Compare commits

..
4 Commits
Author SHA1 Message Date
chenxing ddaa3a6f42 feat(oapi): 新增移动端渠道绑定开放接口
build / build-push (push) Successful in 59s
- GET /oapi/channel/user 按 (channel, openid) 查已绑定用户,未绑定返回 null
- POST /oapi/channel/bind 凭证绑定:账号密码本服务校验 + 三层查重(openid/账号/unionid)
- POST /oapi/channel/bindByPhone 手机号证明绑定(归属由渠道验签器证明)
- POST /oapi/channel/unbind 按登录账号 + 渠道删绑定行
- 抢绑防护:openid/unionId 仅由服务端从一次性 loginTicket 获取,前端不接触
- 新增 sys_user_channel_rel 实体/仓储/服务,写操作经 @OapiLog 记审计
- 补 ChannelBindServiceTest
2026-10-21 08:52:52 +08:00
chenxing cb40524b8e feat(oapi): user/modules 新增可选 appCode,支持查询指定应用的模块树
- 传 appCode => 按 application_info.app_code 解析目标应用(供托管型应用代渲染导航)
- 未传 => 保持原行为,按验签 appKey 解析调用方应用
- 应用解析失败一律返回空列表(安全优先,禁止意外泄露其他应用模块)
- 参数用 appCode 而非整数 appId:跨环境稳定,与 form-center 的 /app/{appCode}/ 同键
2026-10-21 08:52:42 +08:00
chenxing 195936236d feat(module): 子模块支持按钮权限型,custom_id 放开为可空
- AppModule.customId 去掉 @NotBlank / nullable=false;AppModuleRequest 同步放开必填校验
- AppModuleService 空白归一为 null,仅在有值时做应用内唯一校验;path 同样归一 + trim
- 必须存 NULL 而非 '':'' 在 uk_app_module_custom(app_id, custom_id) 下每应用只能有一条
- 新增幂等 DDL sql/app_module_custom_id_nullable.sql(ddl-auto=update 不会改列属性)
- 补 AppModuleServiceButtonModuleTest
2026-10-21 08:52:34 +08:00
chenxing ad434dbc36 refactor(path): API 前缀收敛为 /api,退役 /{platform} 通配
- Config.API_PATH 由 "/{platform}" 改为 "/api",另留 MOBI_API_PATH 常量做对齐
- ManageAccessInterceptor 白名单改显式 /api 前缀,路径从通配变闭集
- DWebMvcConfig 拦截器注册由 /{platform}/** 改 /api/**,堵掉「任意前缀绕过白名单」的洞
2026-10-21 08:52:26 +08:00
18 changed files with 863 additions and 32 deletions
@@ -19,7 +19,7 @@ import java.util.List;
* 白名单:GetUserInfo / GetUserScope / GetSysDict / RedirectTo(仅登录即可访问); * 白名单:GetUserInfo / GetUserScope / GetSysDict / RedirectTo(仅登录即可访问);
* env-config(免登录,SPA 启动阶段在 CAS 登录前就要拉运行时配置,且内容为非敏感前端配置, * env-config(免登录,SPA 启动阶段在 CAS 登录前就要拉运行时配置,且内容为非敏感前端配置,
* 端点由平台 yh-env-config-spring-boot-starter 提供,旁路过滤器见 EnvConfigSsoBypassFilter)。<br> * 端点由平台 yh-env-config-spring-boot-starter 提供,旁路过滤器见 EnvConfigSsoBypassFilter)。<br>
* 注册于 /{platform}/**(api/manage/mobiapi 三前缀均命中),排除 /oapi/**(服务间签名调用,行为不变)。<br> * 注册于 /api/**(显式前缀,路径闭集),排除 /oapi/**(服务间签名调用,行为不变)。<br>
* SUPER/BUREAU/SCHOOL 之间的细粒度差异由各 Controller 内 guard 实现。 * SUPER/BUREAU/SCHOOL 之间的细粒度差异由各 Controller 内 guard 实现。
* *
* @author chenx * @author chenx
@@ -29,17 +29,18 @@ import java.util.List;
public class ManageAccessInterceptor implements HandlerInterceptor { public class ManageAccessInterceptor implements HandlerInterceptor {
/** /**
* 白名单(Ant 风格,* 匹配 platform 前缀): * 白名单(显式 /api 前缀,路径闭集):
* 登录后骨架所需的 4 个只读接口——用户信息、访问范围、系统字典、跳转代理; * 登录后骨架所需的 4 个只读接口——用户信息、访问范围、系统字典、跳转代理;
* appearance 外观偏好读(仅登录即可读自身偏好,平台 yh-appearance-spring-boot-starter 端点);
* env-config 免登录(端点与默认路径见平台 yh-env-config-spring-boot-starter)。 * env-config 免登录(端点与默认路径见平台 yh-env-config-spring-boot-starter)。
*/ */
private static final List<String> WHITELIST = Arrays.asList( private static final List<String> WHITELIST = Arrays.asList(
"/*/base/GetUserInfo", "/api/base/GetUserInfo",
"/*/base/GetUserScope", "/api/base/GetUserScope",
"/*/base/GetSysDict", "/api/base/GetSysDict",
"/*/base/RedirectTo/*", "/api/base/RedirectTo/*",
"/api/appearance", "/api/appearance",
"/*/env-config"); "/api/env-config");
private static final AntPathMatcher MATCHER = new AntPathMatcher(); private static final AntPathMatcher MATCHER = new AntPathMatcher();
@@ -13,9 +13,10 @@ public enum Config
Inst; Inst;
// 末尾不要带斜杠:各控制器用 API_PATH + "/xxx/" 拼路径,若这里带尾斜杠会变成 // 末尾不要带斜杠:各控制器用 API_PATH + "/xxx/" 拼路径,若这里带尾斜杠会变成
// "/{platform}//xxx/" 这种字面双斜杠;Boot 2.6+ 默认 PathPatternParser 不合并连续斜杠, // "/api//xxx/" 这种字面双斜杠;Boot 2.6+ 默认 PathPatternParser 不合并连续斜杠,
// 会导致 /api/xxx 这类正常请求匹配不上而 404(旧 AntPathMatcher 会自动合并,故以前不报错)。 // 会导致 /api/xxx 这类正常请求匹配不上而 404(旧 AntPathMatcher 会自动合并,故以前不报错)。
public static final String API_PATH = "/{platform}"; // manage / api / mobiapi public static final String API_PATH = "/api"; // PC 管理端(CAS 会话),路径闭集:/manage 等旧前缀随之 404
public static final String MOBI_API_PATH = "/mobiapi"; // 移动端(平台 token;UserBase 自身不挂 mobiapi 控制器,仅供常量对齐)
public static final String OPEN_API_PATH = "/oapi/"; public static final String OPEN_API_PATH = "/oapi/";
public String getString(String key) public String getString(String key)
@@ -38,11 +38,12 @@ public class DWebMvcConfig implements WebMvcConfigurer
.addPathPatterns("/**") .addPathPatterns("/**")
.excludePathPatterns("/login", "/logout", "/static/**", "/error", "/upload/**"); .excludePathPatterns("/login", "/logout", "/static/**", "/error", "/upload/**");
// 管理端统一防线:NONE 级 403(白名单见拦截器内)。 // 管理端统一防线:NONE 级 403(白名单见拦截器内)。
// /{platform}/** 命中 api/manage/mobiapi 三前缀;显式排除 /oapi/**(服务间签名调用不受影响)。 // 注册 /api/**(显式前缀,路径闭集:/manage、/{platform} 通配退役,旧前缀随之 404,
// 同时堵掉"任意前缀绕过白名单"的洞);显式排除 /oapi/**(服务间签名调用不受影响)。
// /actuator/**:健康探针与 prometheus 抓取无登录态(projman 部署健康检查依赖,误拦会触发回滚); // /actuator/**:健康探针与 prometheus 抓取无登录态(projman 部署健康检查依赖,误拦会触发回滚);
// /v3/api-docs、/swagger-ui:接口文档工具链。 // /v3/api-docs、/swagger-ui:接口文档工具链。
registry.addInterceptor(manageAccessInterceptor) registry.addInterceptor(manageAccessInterceptor)
.addPathPatterns("/{platform}/**") .addPathPatterns("/api/**")
.excludePathPatterns("/oapi/**", "/login", "/logout", "/static/**", "/error", "/upload/**", .excludePathPatterns("/oapi/**", "/login", "/logout", "/static/**", "/error", "/upload/**",
"/actuator/**", "/v3/api-docs/**", "/swagger-ui/**", "/swagger-ui.html", "/doc.html", "/actuator/**", "/v3/api-docs/**", "/swagger-ui/**", "/swagger-ui.html", "/doc.html",
"/webjars/**"); "/webjars/**");
@@ -27,7 +27,7 @@ import java.util.TreeSet;
/** /**
* 缓存注册表对账接口(方案 C 的运行时可观测面)。<br><br> * 缓存注册表对账接口(方案 C 的运行时可观测面)。<br><br>
* *
* {@code GET /{platform}/cache/registry} 输出三块内容:<br> * {@code GET /api/cache/registry} 输出三块内容:<br>
* <ul> * <ul>
* <li><b>caches</b>:注册表({@link CacheNames#REGISTRY})逐项声明(TTL/抖动/存放/key 规则/失效责任/依赖表) * <li><b>caches</b>:注册表({@link CacheNames#REGISTRY})逐项声明(TTL/抖动/存放/key 规则/失效责任/依赖表)
* × 运行时是否已出现 × Caffeine 命中率统计(已 recordStats);</li> * × 运行时是否已出现 × Caffeine 命中率统计(已 recordStats);</li>
@@ -0,0 +1,71 @@
package com.yunhai.userbase.controller.oapi;
import com.yunhai.platform.utils.model.BoaUser;
import com.yunhai.userbase.common.annotation.OapiLog;
import com.yunhai.userbase.config.Config;
import com.yunhai.userbase.model.channel.ChannelBindByPhoneRequest;
import com.yunhai.userbase.model.channel.ChannelBindRequest;
import com.yunhai.userbase.model.channel.ChannelUnbindRequest;
import com.yunhai.userbase.service.impl.ChannelBindService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
/**
* 移动端渠道绑定开放接口(sys_user_channel_rel,移动端接入 步 3,方案 §3.13)。<br>
* <br>
* 供 SDK 登录端点(门户 / 各子应用)经 yh-app-auth 签名调用({@code /oapi/channel/...});
* 签名由 {@code OpenApiAuthEnforceConfiguration} 在过滤器层强制。<br>
* 绑定身份以服务端校验为准:openid/unionId 来自一次性 loginTicket,账号密码/手机号在本服务校验,
* 前端永不接触 openid/unionid(抢绑防护,方案 §3.4)。<br>
* POST 写操作由 {@code OapiLogAspect} 自动记录审计日志。
*
* @author chenx
* @date 2026/9/25
*/
@RestController
@RequestMapping(Config.OPEN_API_PATH + "channel/")
public class ChannelBindController {
@Autowired
private ChannelBindService channelBindService;
/**
* 按 (channel, openid) 查已绑定用户。
*
* @return 已绑定时返回用户明细(含 currentRole);未绑定返回 null(data 为 null,code=0)
*/
@GetMapping("user")
public BoaUser user(@RequestParam("channel") String channel,
@RequestParam("openid") String openid) {
return channelBindService.findBoundUser(channel, openid);
}
/** 凭证绑定:账号密码校验 + 三层查重(openid/账号/unionid)通过后写绑定关系 */
@OapiLog
@PostMapping("bind")
public BoaUser bind(@RequestBody @Validated ChannelBindRequest request) {
return channelBindService.bindByCredential(request.getChannel(), request.getOpenid(),
request.getUnionId(), request.getAccount(), request.getPassword());
}
/** 手机号证明绑定(小程序一键手机号):手机号归属由渠道验签器证明 */
@OapiLog
@PostMapping("bindByPhone")
public BoaUser bindByPhone(@RequestBody @Validated ChannelBindByPhoneRequest request) {
return channelBindService.bindByPhone(request.getChannel(), request.getOpenid(),
request.getUnionId(), request.getPhone());
}
/** 解绑:按登录账号 + 渠道删绑定行(account 来自 token payload,服务端定位) */
@OapiLog
@PostMapping("unbind")
public void unbind(@RequestBody @Validated ChannelUnbindRequest request) {
channelBindService.unbind(request.getChannel(), request.getAccount());
}
}
@@ -10,6 +10,7 @@ import com.yunhai.platform.utils.model.BoaUser;
import com.yunhai.userbase.common.annotation.OapiLog; import com.yunhai.userbase.common.annotation.OapiLog;
import com.yunhai.userbase.common.validation.ValidationGroups; import com.yunhai.userbase.common.validation.ValidationGroups;
import com.yunhai.userbase.config.Config; import com.yunhai.userbase.config.Config;
import com.yunhai.userbase.entity.ApplicationInfo;
import com.yunhai.userbase.model.sysuser.BatchChangeStatusRequest; import com.yunhai.userbase.model.sysuser.BatchChangeStatusRequest;
import com.yunhai.userbase.model.sysuser.SysUserChangeStatusRequest; import com.yunhai.userbase.model.sysuser.SysUserChangeStatusRequest;
import com.yunhai.userbase.model.sysuser.SysUserContactRequest; import com.yunhai.userbase.model.sysuser.SysUserContactRequest;
@@ -91,30 +92,49 @@ public class UserController {
} }
/** /**
* 查询用户在<b>调用方应用</b>下、指定学校(上下文)里的已授权模块列表。<br> * 查询用户在<b>指定应用</b>下、指定学校(上下文)里的已授权模块列表。<br>
* <br> * <br>
* 与管理端「权限一览」返回<b>全应用聚合</b>不同,本方法严格按调用方应用维度过滤:<br> * 与管理端「权限一览」返回<b>全应用聚合</b>不同,本方法严格按单一应用维度过滤。应用来源两态:<br>
* ① 通过 {@link AppAuthFilter#APP_AUTH_ATTR} 拿到验签时的 appKey(RequireAppSignFilter * ① 传了可选参数 appCode → 按 application_info.app_code 解析<b>目标应用</b>(供托管型应用
* 已保证非空);<br> * 如 form-center 代快应用渲染导航时查询目标应用的模块清单;查不到返回空列表并 warn,
* ② 查 application_info 映射到具体 appId;<br> * 口径同现有兜底);<br>
* ② 未传 appCode → 现行为不变:通过 {@link AppAuthFilter#APP_AUTH_ATTR} 拿到验签时的
* appKey(RequireAppSignFilter 已保证非空),查 application_info 映射到具体 appId;<br>
* ③ 调 Service 专用方法:所有 SQL/集合构建均限定在该 appId 范围内(数据库层面过滤)。<br> * ③ 调 Service 专用方法:所有 SQL/集合构建均限定在该 appId 范围内(数据库层面过滤)。<br>
* <br> * <br>
* 边界:调用方 appKey 查不到对应应用 → 直接返回空列表(安全优先,禁止意外返回其他应用的模块)。<br> * 边界:应用解析失败(appCode/appKey 查不到对应应用)→ 直接返回空列表(安全优先,禁止意外
* 返回其他应用的模块)。<br>
* 记账:本参数暴露面=模块名/路径/图标级低敏数据;如需收紧可后续限定仅 BASE 应用调用
* (暂放开,表单中心建设方案 §6.2 已记)。参数用 appCode(字符串)而非整数 appKey:
* 跨环境稳定,与 form-center 分发路由 /app/{appCode}/ 同键。
* *
* @param request 用于取 AppAuthFilter 设置的 appKey * @param request 用于取 AppAuthFilter 设置的 appKey(未传 appCode 时使用)
* @param userId 用户ID(必填) * @param userId 用户ID(必填)
* @param schId 学校ID/组ID(可选;用于限定「学校上下文」:非空时仅取该学校子树内的真实归属组参与判定, * @param schId 学校ID/组ID(可选;用于限定「学校上下文」:非空时仅取该学校子树内的真实归属组参与判定,
* 为空时取用户全部真实归属组,与权限一览一致) * 为空时取用户全部真实归属组,与权限一览一致)
* @param appCode 目标应用技术标识(可选;传=查该应用的模块树,不传=按签名调用方解析)
* @return 该应用下用户已授权模块(扁平化;无权限时返回空列表) * @return 该应用下用户已授权模块(扁平化;无权限时返回空列表)
*/ */
@GetMapping("modules") @GetMapping("modules")
public List<BoaModule> modules(HttpServletRequest request, public List<BoaModule> modules(HttpServletRequest request,
@RequestParam("userId") Integer userId, @RequestParam("userId") Integer userId,
@RequestParam(value = "schId", required = false) Integer schId) { @RequestParam(value = "schId", required = false) Integer schId,
@RequestParam(value = "appCode", required = false) String appCode) {
if (userId == null) { if (userId == null) {
return Collections.emptyList(); return Collections.emptyList();
} }
Integer appId = resolveCallerAppId(request); Integer appId;
if (appCode != null && !appCode.trim().isEmpty()) {
ApplicationInfo app = applicationInfoService.getByAppCode(appCode.trim());
if (app == null) {
log.warn("[oapi:user:modules] appCode={} not found in application_info, uri={}",
appCode, request.getRequestURI());
return Collections.emptyList();
}
appId = app.getId();
} else {
appId = resolveCallerAppId(request);
}
if (appId == null) { if (appId == null) {
return Collections.emptyList(); return Collections.emptyList();
} }
@@ -55,9 +55,9 @@ public class AppModule extends BaseEntity {
/** /**
* 自定义ID(应用内唯一,业务编码,如 "user-mgmt")。<br> * 自定义ID(应用内唯一,业务编码,如 "user-mgmt")。<br>
* 按钮权限型子模块(无路由无编码)为 null;NULL 在唯一键 uk_app_module_custom 下可多行并存。
*/ */
@NotBlank @Column(name = "custom_id", nullable = true, length = 64)
@Column(name = "custom_id", nullable = false, length = 64)
private String customId; private String customId;
/** /**
@@ -0,0 +1,71 @@
package com.yunhai.userbase.entity;
import lombok.Data;
import lombok.EqualsAndHashCode;
import lombok.NoArgsConstructor;
import javax.persistence.Column;
import javax.persistence.Entity;
import javax.persistence.GeneratedValue;
import javax.persistence.GenerationType;
import javax.persistence.Id;
import javax.persistence.Index;
import javax.persistence.Table;
import javax.persistence.UniqueConstraint;
import java.time.LocalDateTime;
/**
* 用户-移动渠道绑定关系(移动端接入 步 3,方案 §3.13)。
*
* <p>openid / unionid ↔ sys_user.id 的绑定属 UserBase 身份域;SDK 登录端点经
* {@code /oapi/channel/**}(应用签名)读写。channel 取值与前端 MobileChannel 对齐:
* wxgzh / miniapp / qywx / dingtalk。</p>
*
* <p>⚠ uk_user_channel 只约束半边:同一 unionid 仍可绑到两个账号(公众号一个、小程序一个),
* 应用层在 {@code ChannelBindService} 绑定时用 idx_unionid 查重补齐,勿仅依赖唯一约束。</p>
*
* @author chenx
* @date 2026/9/25
*/
@Data
@NoArgsConstructor
@Entity
@Table(
name = "sys_user_channel_rel",
uniqueConstraints = {
@UniqueConstraint(columnNames = {"channel", "openid"}),
@UniqueConstraint(columnNames = {"user_id", "channel"})
},
indexes = {@Index(columnList = "unionid")}
)
@EqualsAndHashCode(onlyExplicitlyIncluded = true, callSuper = false)
public class SysUserChannelRel {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
/** sys_user.id */
@Column(name = "user_id", nullable = false)
private Integer userId;
/** wxgzh / miniapp / qywx / dingtalk */
@Column(nullable = false, length = 20)
private String channel;
/** 渠道内用户标识 */
@Column(nullable = false, length = 64)
private String openid;
/** 同一开放平台下跨端一致;可空 */
@Column(name = "unionid", length = 64)
private String unionId;
/** 绑定时间(服务端 now() 写入) */
@Column(name = "bound_at", nullable = false)
private LocalDateTime boundAt;
/** 最近一次经该渠道登录时间(token 签发时更新,可空) */
@Column(name = "last_login_at")
private LocalDateTime lastLoginAt;
}
@@ -26,10 +26,13 @@ public class AppModuleRequest {
@NotBlank(groups = {ValidationGroups.Create.class, ValidationGroups.Update.class}, message = "模块名称不能为空") @NotBlank(groups = {ValidationGroups.Create.class, ValidationGroups.Update.class}, message = "模块名称不能为空")
private String name; private String name;
@NotBlank(groups = {ValidationGroups.Create.class, ValidationGroups.Update.class}, message = "自定义ID不能为空") /**
* 自定义ID(应用内唯一,业务编码)。子模块双定位(树节点/按钮权限):<br>
* 按钮权限型子模块可不填,服务层把空白归一为 null(仅在有值时做应用内唯一校验)。<br>
*/
private String customId; private String customId;
/** 模块路径(前端路由/资源路径)。<br> */ /** 模块路径(前端路由/资源路径);按钮权限型子模块可不填。<br> */
private String path; private String path;
/** 模块图标(YschIcons 图标名;非必填,留空则开放接口返回 null)。<br> */ /** 模块图标(YschIcons 图标名;非必填,留空则开放接口返回 null)。<br> */
@@ -0,0 +1,30 @@
package com.yunhai.userbase.model.channel;
import lombok.Data;
import javax.validation.constraints.NotBlank;
/**
* 渠道绑定(手机号证明模式)开放接口入参(/oapi/channel/bindByPhone)。
* 供小程序「一键手机号」使用:手机号归属由渠道验签器(phoneCode → phone)证明,无密码。
*
* @author chenx
* @date 2026/9/25
*/
@Data
public class ChannelBindByPhoneRequest {
/** 渠道码:miniapp */
@NotBlank(message = "渠道不能为空")
private String channel;
@NotBlank(message = "openid 不能为空")
private String openid;
/** 同一开放平台下跨端一致标识;可空 */
private String unionId;
/** 渠道验签器解析出的已验证手机号 */
@NotBlank(message = "手机号不能为空")
private String phone;
}
@@ -0,0 +1,33 @@
package com.yunhai.userbase.model.channel;
import lombok.Data;
import javax.validation.constraints.NotBlank;
/**
* 渠道绑定(凭证模式)开放接口入参(/oapi/channel/bind)。
* openid/unionId 由 SDK 登录端点从一次性 loginTicket 解出后传入,前端永不直传。
*
* @author chenx
* @date 2026/9/25
*/
@Data
public class ChannelBindRequest {
/** 渠道码:wxgzh / miniapp / qywx / dingtalk */
@NotBlank(message = "渠道不能为空")
private String channel;
@NotBlank(message = "openid 不能为空")
private String openid;
/** 同一开放平台下跨端一致标识;可空 */
private String unionId;
/** 登录账号(学生学号 / 教师手机号) */
@NotBlank(message = "账号不能为空")
private String account;
@NotBlank(message = "密码不能为空")
private String password;
}
@@ -0,0 +1,24 @@
package com.yunhai.userbase.model.channel;
import lombok.Data;
import javax.validation.constraints.NotBlank;
/**
* 解绑开放接口入参(/oapi/channel/unbind)。
* account 由 SDK 登录端点从 token payload 解出后传入(服务端定位用户),非客户端自报。
*
* @author chenx
* @date 2026/9/25
*/
@Data
public class ChannelUnbindRequest {
/** 渠道码:wxgzh / miniapp / qywx / dingtalk */
@NotBlank(message = "渠道不能为空")
private String channel;
/** 登录账号(token payload 内 account) */
@NotBlank(message = "账号不能为空")
private String account;
}
@@ -0,0 +1,39 @@
package com.yunhai.userbase.repository;
import com.yunhai.userbase.entity.SysUserChannelRel;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.JpaSpecificationExecutor;
import org.springframework.data.jpa.repository.Modifying;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
import java.util.List;
import java.util.Optional;
/**
* 用户-移动渠道绑定仓储(sys_user_channel_rel,移动端接入 步 3)。
*
* @author chenx
* @date 2026/9/25
*/
public interface SysUserChannelRelRepository extends JpaRepository<SysUserChannelRel, Long>, JpaSpecificationExecutor<SysUserChannelRel> {
/** 按 (channel, openid) 查绑定(uk_channel_openid 唯一) */
Optional<SysUserChannelRel> findByChannelAndOpenid(String channel, String openid);
/** 按用户 + 渠道查绑定(uk_user_channel 唯一):判"该账号在此渠道是否已绑过" */
Optional<SysUserChannelRel> findByUserIdAndChannel(Integer userId, String channel);
/** unionid 应用层查重(uk_user_channel 只约束半边,方案 §3.13 ⚠):同 unionid 已存在的绑定行 */
List<SysUserChannelRel> findByUnionId(String unionId);
/** 解绑(按用户 + 渠道) */
@Modifying
@Query("DELETE FROM SysUserChannelRel r WHERE r.userId = :userId AND r.channel = :channel")
void deleteByUserIdAndChannel(@Param("userId") Integer userId, @Param("channel") String channel);
/** 级联清理:删除用户时清残留绑定行 */
@Modifying
@Query("DELETE FROM SysUserChannelRel r WHERE r.userId = :userId")
void deleteByUserIdBulk(@Param("userId") Integer userId);
}
@@ -58,12 +58,16 @@ public class AppModuleService implements IAppModuleService {
if (!applicationInfoRepository.existsById(request.getAppId())) { if (!applicationInfoRepository.existsById(request.getAppId())) {
throw new DException("所属应用不存在"); throw new DException("所属应用不存在");
} }
if (appModuleRepository.existsByAppIdAndCustomId(request.getAppId(), request.getCustomId())) {
throw new DException("自定义ID在该应用下已存在");
}
AppModule entity = new AppModule(); AppModule entity = new AppModule();
BeanUtils.copyProperties(request, entity); BeanUtils.copyProperties(request, entity);
entity.setId(null); entity.setId(null);
// 按钮权限型子模块可不带编码:空白归一为 null,仅在有值时做应用内唯一校验
entity.setCustomId(normalizeBlankToNull(request.getCustomId()));
if (entity.getCustomId() != null
&& appModuleRepository.existsByAppIdAndCustomId(request.getAppId(), entity.getCustomId())) {
throw new DException("自定义ID在该应用下已存在");
}
entity.setPath(normalizeBlankToNull(request.getPath()));
entity.setLevel(resolveLevel(request.getAppId(), request.getParentId())); entity.setLevel(resolveLevel(request.getAppId(), request.getParentId()));
// sortNo 默认为空(由前端编辑时带入),不自动递增 // sortNo 默认为空(由前端编辑时带入),不自动递增
appModuleRepository.save(entity); appModuleRepository.save(entity);
@@ -77,13 +81,15 @@ public class AppModuleService implements IAppModuleService {
Objects.requireNonNull(request, "模块编辑入参不能为空"); Objects.requireNonNull(request, "模块编辑入参不能为空");
AppModule entity = appModuleRepository.findById(id) AppModule entity = appModuleRepository.findById(id)
.orElseThrow(() -> new RuntimeException("模块不存在: " + id)); .orElseThrow(() -> new RuntimeException("模块不存在: " + id));
if (appModuleRepository.existsByAppIdAndCustomIdAndIdNot(entity.getAppId(), request.getCustomId(), id)) { // 可编辑字段:name/customId/path/icon/sortNo;id、appId 不可改
String customId = normalizeBlankToNull(request.getCustomId());
if (customId != null
&& appModuleRepository.existsByAppIdAndCustomIdAndIdNot(entity.getAppId(), customId, id)) {
throw new DException("自定义ID在该应用下已存在"); throw new DException("自定义ID在该应用下已存在");
} }
// 可编辑字段:name/customId/path/icon/sortNo;id、appId 不可改
entity.setName(request.getName()); entity.setName(request.getName());
entity.setCustomId(request.getCustomId()); entity.setCustomId(customId);
entity.setPath(request.getPath()); entity.setPath(normalizeBlankToNull(request.getPath()));
entity.setIcon(request.getIcon()); entity.setIcon(request.getIcon());
entity.setSortNo(request.getSortNo()); entity.setSortNo(request.getSortNo());
// parentId 变更即移动子树:校验层级上限与环,并级联刷新后代层级 // parentId 变更即移动子树:校验层级上限与环,并级联刷新后代层级
@@ -278,6 +284,19 @@ public class AppModuleService implements IAppModuleService {
} }
} }
/**
* 按钮权限型子模块可不带编码/路径:空白(null/空串/纯空格)统一归一为 null。<br>
* customId 归一是硬性要求:'' 在唯一键 uk_app_module_custom(app_id, custom_id) 下每个应用只能有一条,
* 多个按钮型子模块会互相撞键;NULL 在 MySQL 唯一索引中可并存。
*/
private static String normalizeBlankToNull(String value) {
if (value == null) {
return null;
}
String trimmed = value.trim();
return trimmed.isEmpty() ? null : trimmed;
}
private AppModuleVo convertToVo(AppModule entity) { private AppModuleVo convertToVo(AppModule entity) {
AppModuleVo vo = new AppModuleVo(); AppModuleVo vo = new AppModuleVo();
BeanUtils.copyProperties(entity, vo); BeanUtils.copyProperties(entity, vo);
@@ -0,0 +1,146 @@
package com.yunhai.userbase.service.impl;
import com.yunhai.platform.utils.exception.DException;
import com.yunhai.platform.utils.model.BoaUser;
import com.yunhai.userbase.entity.SysUser;
import com.yunhai.userbase.entity.SysUserChannelRel;
import com.yunhai.userbase.enums.StatusEnum;
import com.yunhai.userbase.repository.SysUserChannelRelRepository;
import com.yunhai.userbase.repository.SysUserRepository;
import org.apache.commons.lang3.StringUtils;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.time.LocalDateTime;
import java.util.Optional;
/**
* 移动端渠道绑定服务(sys_user_channel_rel,移动端接入 步 3,方案 §3.13)。<br>
* <br>
* openid / unionid ↔ userId 的绑定属 UserBase 身份域;SDK 登录端点(门户/各子应用)经
* {@code /oapi/channel/**} 应用签名调用本服务。绑定一律以服务端解析出的身份为准
* (openid/unionid 来自一次性 loginTicket,账号密码/手机号在服务端校验),前端永不直传 openid。<br>
* <br>
* 唯一性三层防线:<br>
* ① uk_channel_openid:一个 openid 同渠道只能绑一个账号(DB 兜底 + 应用层前置校验友好报错);<br>
* ② uk_user_channel:一个账号同渠道只能绑一个 openid(换绑须先解绑);<br>
* ③ unionid 应用层查重:uk_user_channel 只约束半边(同一 unionid 仍可绑两个账号——公众号一个、
* 小程序一个),用 idx_unionid 在绑定时补齐(方案 §3.13 ⚠)。
*
* @author chenx
* @date 2026/9/25
*/
@Service
public class ChannelBindService {
@Autowired
private SysUserChannelRelRepository relRepository;
@Autowired
private SysUserRepository sysUserRepository;
@Autowired
private UserCenterService userCenterService;
@Autowired
private PasswordEncoder passwordEncoder;
/**
* 按 (channel, openid) 查已绑定用户(登录端点"已绑定 → 直接发 token"分支)。
*
* @return 已绑定时返回用户明细(含 currentRole);未绑定返回 null
*/
public BoaUser findBoundUser(String channel, String openid) {
return relRepository.findByChannelAndOpenid(channel, openid)
.map(rel -> userCenterService.getUserInfoWithDeptById(rel.getUserId()))
.orElse(null);
}
/**
* 凭证绑定(loginTicket 身份 + 账号密码)。密码 BCrypt 校验失败统一报"账号或密码错误"
* (不区分账号不存在/密码错,防账号枚举);防爆破计数(LoginGuard)在 SDK 侧。
*/
@Transactional
public BoaUser bindByCredential(String channel, String openid, String unionId,
String account, String password) {
requireIdentity(channel, openid);
if (StringUtils.isBlank(account) || StringUtils.isBlank(password)) {
throw new DException("账号与密码不能为空");
}
SysUser user = sysUserRepository.findByAccount(account);
if (user == null || !passwordEncoder.matches(password, user.getPassword())) {
throw new DException("账号或密码错误");
}
return doBind(channel, openid, unionId, user);
}
/**
* 手机号证明绑定(loginTicket 身份 + 渠道验签器已验证的手机号,小程序一键手机号)。
* 手机号归属由渠道验签器证明(phoneCode → phone),无需密码。
*/
@Transactional
public BoaUser bindByPhone(String channel, String openid, String unionId, String phone) {
requireIdentity(channel, openid);
if (StringUtils.isBlank(phone)) {
throw new DException("手机号不能为空");
}
SysUser user = sysUserRepository.findByPhone(phone);
if (user == null) {
throw new DException("未找到该手机号对应的账号");
}
return doBind(channel, openid, unionId, user);
}
/** 解绑:按登录账号 + 渠道删绑定行(服务端据 token 内 account 定位,非客户端自报 openid) */
@Transactional
public void unbind(String channel, String account) {
if (StringUtils.isBlank(channel) || StringUtils.isBlank(account)) {
throw new DException("渠道与账号不能为空");
}
SysUser user = sysUserRepository.findByAccount(account);
if (user == null) {
return;
}
relRepository.deleteByUserIdAndChannel(user.getId(), channel);
}
private void requireIdentity(String channel, String openid) {
if (StringUtils.isBlank(channel) || StringUtils.isBlank(openid)) {
throw new DException("渠道与 openid 不能为空");
}
}
private BoaUser doBind(String channel, String openid, String unionId, SysUser user) {
if (StatusEnum.DISABLED.equals(user.getDisabled())) {
throw new DException("账号已禁用,请联系管理员");
}
// ① openid 已绑定其他账号 → 拒绝(正常流程登录端点会先查绑定直接发 token,此处兜底并发窗口)
Optional<SysUserChannelRel> byOpenid = relRepository.findByChannelAndOpenid(channel, openid);
if (byOpenid.isPresent() && !byOpenid.get().getUserId().equals(user.getId())) {
throw new DException("该微信已绑定其他账号");
}
// ② 账号在此渠道已绑定其他 openid → 拒绝(换绑须先解绑)
Optional<SysUserChannelRel> byUser = relRepository.findByUserIdAndChannel(user.getId(), channel);
if (byUser.isPresent() && !byUser.get().getOpenid().equals(openid)) {
throw new DException("该账号已绑定其他微信,请先解绑");
}
// ③ unionid 应用层查重:防同一 unionid 绑到两个账号(uk_user_channel 只约束半边)
if (StringUtils.isNotBlank(unionId)) {
for (SysUserChannelRel rel : relRepository.findByUnionId(unionId)) {
if (!rel.getUserId().equals(user.getId())) {
throw new DException("该微信已绑定其他账号");
}
}
}
SysUserChannelRel rel = byOpenid.orElseGet(SysUserChannelRel::new);
rel.setUserId(user.getId());
rel.setChannel(channel);
rel.setOpenid(openid);
rel.setUnionId(StringUtils.isBlank(unionId) ? null : unionId);
rel.setBoundAt(LocalDateTime.now());
relRepository.save(rel);
return userCenterService.getUserInfoWithDeptById(user.getId());
}
}
@@ -0,0 +1,14 @@
-- app_module.custom_id 从 NOT NULL 放开为可空:子模块双定位(树节点 / 按钮权限)。
-- 背景:子模块设计上既可作树节点(有路由路径+业务编码),也可作按钮权限(两者皆无)。
-- 路径 path 本就可空;custom_id 原为 NOT NULL + 应用内唯一键,挡死了按钮权限型子模块。
-- 关键:必须存 NULL 不能存 ''——'' 在唯一键 uk_app_module_custom(app_id, custom_id) 下
-- 每个应用只能有一条,第二个按钮型子模块即撞键;NULL 在 MySQL 唯一索引中可多行并存。
-- 执行方式:dev / prod 手工执行(ddl-auto=update 只加列不改既有列属性,不会自动放 nullable)。
-- 幂等:可重复执行,重复 MODIFY 到相同定义是无害 no-op。
ALTER TABLE app_module MODIFY COLUMN custom_id VARCHAR(64) NULL COMMENT '自定义ID(应用内唯一,业务编码);null=按钮权限型子模块无编码';
-- 核对:custom_id 的 IS_NULLABLE 应为 YES
-- SELECT COLUMN_NAME, COLUMN_TYPE, IS_NULLABLE, COLUMN_COMMENT
-- FROM information_schema.COLUMNS
-- WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'app_module' AND COLUMN_NAME = 'custom_id';
@@ -0,0 +1,108 @@
package com.yunhai.userbase.service.impl;
import com.yunhai.platform.utils.exception.DException;
import com.yunhai.userbase.entity.AppModule;
import com.yunhai.userbase.model.appmodule.AppModuleRequest;
import com.yunhai.userbase.repository.AppModuleRepository;
import com.yunhai.userbase.repository.ApplicationInfoRepository;
import com.yunhai.userbase.repository.SysAppRoleModuleRelRepository;
import com.yunhai.userbase.repository.SysGroupModulePermRepository;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.mockito.ArgumentCaptor;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.MockitoAnnotations;
import org.springframework.context.ApplicationEventPublisher;
import java.util.Optional;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNull;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyInt;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
/**
* 按钮权限型子模块(子模块双定位:树节点/按钮权限)——customId/path 可空。<br>
* 覆盖:空白 customId/path 归一为 null;空白 customId 跳过应用内唯一校验;
* 有值 customId 仍查重;编辑清空编码生效。背景见 sql/app_module_custom_id_nullable.sql
* ('' 在 uk_app_module_custom 下每应用仅一条,按钮型必须存 NULL)。
*/
class AppModuleServiceButtonModuleTest {
@InjectMocks
private AppModuleService appModuleService;
@Mock
private AppModuleRepository appModuleRepository;
@Mock
private ApplicationInfoRepository applicationInfoRepository;
@Mock
private SysGroupModulePermRepository sysGroupModulePermRepository;
@Mock
private SysAppRoleModuleRelRepository sysAppRoleModuleRelRepository;
@Mock
private ApplicationEventPublisher eventPublisher;
@BeforeEach
void setUp() {
MockitoAnnotations.openMocks(this);
}
private AppModuleRequest request(String name, String customId, String path) {
AppModuleRequest request = new AppModuleRequest();
request.setAppId(10);
request.setName(name);
request.setCustomId(customId);
request.setPath(path);
return request;
}
@Test
void add_buttonModule_blankCustomIdAndPath_normalizedToNull() {
when(applicationInfoRepository.existsById(10)).thenReturn(true);
appModuleService.add(request("删除按钮", " ", ""));
ArgumentCaptor<AppModule> captor = ArgumentCaptor.forClass(AppModule.class);
verify(appModuleRepository).save(captor.capture());
assertNull(captor.getValue().getCustomId());
assertNull(captor.getValue().getPath());
// 空白编码不进唯一性查询('' 会撞 uk_app_module_custom,必须以 NULL 落库)
verify(appModuleRepository, never()).existsByAppIdAndCustomId(anyInt(), anyString());
}
@Test
void add_withCustomId_stillCheckedForUniqueness() {
when(applicationInfoRepository.existsById(10)).thenReturn(true);
when(appModuleRepository.existsByAppIdAndCustomId(10, "btn-save")).thenReturn(true);
DException ex = assertThrows(DException.class,
() -> appModuleService.add(request("保存按钮", "btn-save", null)));
assertEquals("自定义ID在该应用下已存在", ex.getMessage());
verify(appModuleRepository, never()).save(any(AppModule.class));
}
@Test
void edit_clearCustomIdAndPath_setsNullWithoutUniquenessQuery() {
AppModule entity = new AppModule();
entity.setId(1);
entity.setAppId(10);
entity.setName("编辑按钮");
entity.setCustomId("btn-edit");
entity.setPath("/app/edit");
when(appModuleRepository.findById(1)).thenReturn(Optional.of(entity));
appModuleService.edit(1, request("编辑按钮", "", " "));
assertNull(entity.getCustomId());
assertNull(entity.getPath());
verify(appModuleRepository, never()).existsByAppIdAndCustomIdAndIdNot(anyInt(), anyString(), anyInt());
verify(appModuleRepository).save(entity);
}
}
@@ -0,0 +1,250 @@
package com.yunhai.userbase.service.impl;
import com.yunhai.platform.utils.exception.DException;
import com.yunhai.platform.utils.model.BoaUser;
import com.yunhai.userbase.entity.SysUser;
import com.yunhai.userbase.entity.SysUserChannelRel;
import com.yunhai.userbase.enums.StatusEnum;
import com.yunhai.userbase.repository.SysUserChannelRelRepository;
import com.yunhai.userbase.repository.SysUserRepository;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.mockito.ArgumentCaptor;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.MockitoAnnotations;
import org.springframework.security.crypto.password.PasswordEncoder;
import java.util.Arrays;
import java.util.Optional;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertNull;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
/**
* 移动端渠道绑定服务单测(步 3 门禁):绑定唯一约束(uk_channel_openid / uk_user_channel 对应的
* 应用层前置校验)+ unionid 应用层查重(uk_user_channel 只约束半边,方案 §3.13 ⚠)。
* 纯 Mockito,不依赖数据库。
*/
class ChannelBindServiceTest {
@Mock
private SysUserChannelRelRepository relRepository;
@Mock
private SysUserRepository sysUserRepository;
@Mock
private UserCenterService userCenterService;
@Mock
private PasswordEncoder passwordEncoder;
@InjectMocks
private ChannelBindService service;
private SysUser user;
@BeforeEach
void setUp() {
MockitoAnnotations.openMocks(this);
user = new SysUser();
user.setId(1001);
user.setAccount("s1001");
user.setPassword("$bcrypt$");
user.setDisabled(StatusEnum.NORMAL);
when(passwordEncoder.matches(anyString(), anyString())).thenAnswer(inv -> {
Object[] args = inv.getArguments();
return "right-pwd".equals(args[0]) && "$bcrypt$".equals(args[1]);
});
}
private BoaUser boa(int id, String account) {
return new BoaUser(id, account, "用户" + id);
}
// ==================== findBoundUser ====================
@Test
void findBoundUser_unbound_returnsNull() {
when(relRepository.findByChannelAndOpenid("wxgzh", "o-1")).thenReturn(Optional.empty());
assertNull(service.findBoundUser("wxgzh", "o-1"));
}
@Test
void findBoundUser_bound_returnsBoaUser() {
SysUserChannelRel rel = new SysUserChannelRel();
rel.setUserId(1001);
when(relRepository.findByChannelAndOpenid("wxgzh", "o-1")).thenReturn(Optional.of(rel));
when(userCenterService.getUserInfoWithDeptById(1001)).thenReturn(boa(1001, "s1001"));
BoaUser result = service.findBoundUser("wxgzh", "o-1");
assertNotNull(result);
assertEquals(1001, result.getId());
}
// ==================== bindByCredential:密码与账号 ====================
@Test
void bindByCredential_wrongPassword_rejectedWithoutSaving() {
when(sysUserRepository.findByAccount("s1001")).thenReturn(user);
DException ex = assertThrows(DException.class,
() -> service.bindByCredential("wxgzh", "o-1", "u-1", "s1001", "wrong-pwd"));
assertTrue(ex.getMessage().contains("账号或密码错误"));
verify(relRepository, never()).save(any());
}
@Test
void bindByCredential_unknownAccount_sameErrorAsWrongPassword() {
when(sysUserRepository.findByAccount("nobody")).thenReturn(null);
DException ex = assertThrows(DException.class,
() -> service.bindByCredential("wxgzh", "o-1", "u-1", "nobody", "right-pwd"));
assertTrue(ex.getMessage().contains("账号或密码错误"));
}
@Test
void bindByCredential_success_savesRelWithBoundAt() {
when(sysUserRepository.findByAccount("s1001")).thenReturn(user);
when(relRepository.findByChannelAndOpenid("wxgzh", "o-1")).thenReturn(Optional.empty());
when(relRepository.findByUserIdAndChannel(1001, "wxgzh")).thenReturn(Optional.empty());
when(relRepository.findByUnionId("u-1")).thenReturn(Arrays.asList());
when(userCenterService.getUserInfoWithDeptById(1001)).thenReturn(boa(1001, "s1001"));
BoaUser result = service.bindByCredential("wxgzh", "o-1", "u-1", "s1001", "right-pwd");
assertNotNull(result);
ArgumentCaptor<SysUserChannelRel> captor = ArgumentCaptor.forClass(SysUserChannelRel.class);
verify(relRepository).save(captor.capture());
assertEquals(1001, captor.getValue().getUserId());
assertEquals("wxgzh", captor.getValue().getChannel());
assertEquals("o-1", captor.getValue().getOpenid());
assertEquals("u-1", captor.getValue().getUnionId());
assertNotNull(captor.getValue().getBoundAt());
}
// ==================== bindByCredential:唯一约束前置校验 ====================
@Test
void bindByCredential_openidBoundToAnotherUser_rejected() {
// uk_channel_openid 场景:openid 已绑其他账号(并发窗口兜底)
when(sysUserRepository.findByAccount("s1001")).thenReturn(user);
SysUserChannelRel other = new SysUserChannelRel();
other.setUserId(2002);
other.setChannel("wxgzh");
other.setOpenid("o-1");
when(relRepository.findByChannelAndOpenid("wxgzh", "o-1")).thenReturn(Optional.of(other));
DException ex = assertThrows(DException.class,
() -> service.bindByCredential("wxgzh", "o-1", "u-1", "s1001", "right-pwd"));
assertTrue(ex.getMessage().contains("已绑定"));
verify(relRepository, never()).save(any());
}
@Test
void bindByCredential_userAlreadyBoundAnotherOpenid_rejected() {
// uk_user_channel 场景:账号在此渠道已绑其他 openid(换绑须先解绑)
when(sysUserRepository.findByAccount("s1001")).thenReturn(user);
when(relRepository.findByChannelAndOpenid("wxgzh", "o-1")).thenReturn(Optional.empty());
SysUserChannelRel existing = new SysUserChannelRel();
existing.setUserId(1001);
existing.setChannel("wxgzh");
existing.setOpenid("o-old");
when(relRepository.findByUserIdAndChannel(1001, "wxgzh")).thenReturn(Optional.of(existing));
DException ex = assertThrows(DException.class,
() -> service.bindByCredential("wxgzh", "o-1", "u-1", "s1001", "right-pwd"));
assertTrue(ex.getMessage().contains("请先解绑"));
verify(relRepository, never()).save(any());
}
@Test
void bindByCredential_unionidBoundToAnotherUser_rejected() {
// uk_user_channel 只约束半边:同 unionid 绑两个账号须在应用层拦(方案 §3.13 ⚠)
when(sysUserRepository.findByAccount("s1001")).thenReturn(user);
when(relRepository.findByChannelAndOpenid("wxgzh", "o-1")).thenReturn(Optional.empty());
when(relRepository.findByUserIdAndChannel(1001, "wxgzh")).thenReturn(Optional.empty());
SysUserChannelRel otherUnion = new SysUserChannelRel();
otherUnion.setUserId(2002);
otherUnion.setChannel("miniapp");
otherUnion.setOpenid("o-2");
otherUnion.setUnionId("u-1");
when(relRepository.findByUnionId("u-1")).thenReturn(Arrays.asList(otherUnion));
DException ex = assertThrows(DException.class,
() -> service.bindByCredential("wxgzh", "o-1", "u-1", "s1001", "right-pwd"));
assertTrue(ex.getMessage().contains("已绑定"));
verify(relRepository, never()).save(any());
}
@Test
void bindByCredential_unionidOwnedBySameUser_allowed() {
// 同一用户另一端的绑定(公众号+小程序同 unionid)不拦
when(sysUserRepository.findByAccount("s1001")).thenReturn(user);
when(relRepository.findByChannelAndOpenid("wxgzh", "o-1")).thenReturn(Optional.empty());
when(relRepository.findByUserIdAndChannel(1001, "wxgzh")).thenReturn(Optional.empty());
SysUserChannelRel ownUnion = new SysUserChannelRel();
ownUnion.setUserId(1001);
ownUnion.setChannel("miniapp");
ownUnion.setOpenid("o-2");
ownUnion.setUnionId("u-1");
when(relRepository.findByUnionId("u-1")).thenReturn(Arrays.asList(ownUnion));
when(userCenterService.getUserInfoWithDeptById(1001)).thenReturn(boa(1001, "s1001"));
assertNotNull(service.bindByCredential("wxgzh", "o-1", "u-1", "s1001", "right-pwd"));
}
@Test
void bindByCredential_disabledUser_rejected() {
user.setDisabled(StatusEnum.DISABLED);
when(sysUserRepository.findByAccount("s1001")).thenReturn(user);
DException ex = assertThrows(DException.class,
() -> service.bindByCredential("wxgzh", "o-1", "u-1", "s1001", "right-pwd"));
assertTrue(ex.getMessage().contains("禁用"));
}
// ==================== bindByPhone ====================
@Test
void bindByPhone_success() {
when(sysUserRepository.findByPhone("13800000001")).thenReturn(user);
when(relRepository.findByChannelAndOpenid("miniapp", "o-1")).thenReturn(Optional.empty());
when(relRepository.findByUserIdAndChannel(1001, "miniapp")).thenReturn(Optional.empty());
when(relRepository.findByUnionId(anyString())).thenReturn(Arrays.asList());
when(userCenterService.getUserInfoWithDeptById(1001)).thenReturn(boa(1001, "s1001"));
BoaUser result = service.bindByPhone("miniapp", "o-1", "u-1", "13800000001");
assertNotNull(result);
verify(relRepository).save(any(SysUserChannelRel.class));
}
@Test
void bindByPhone_unknownPhone_rejected() {
when(sysUserRepository.findByPhone("13900000000")).thenReturn(null);
DException ex = assertThrows(DException.class,
() -> service.bindByPhone("miniapp", "o-1", "u-1", "13900000000"));
assertTrue(ex.getMessage().contains("手机号"));
}
// ==================== unbind ====================
@Test
void unbind_deletesByUserIdAndChannel() {
when(sysUserRepository.findByAccount("s1001")).thenReturn(user);
service.unbind("wxgzh", "s1001");
verify(relRepository).deleteByUserIdAndChannel(1001, "wxgzh");
}
@Test
void unbind_unknownAccount_silentNoop() {
when(sysUserRepository.findByAccount("nobody")).thenReturn(null);
service.unbind("wxgzh", "nobody");
verify(relRepository, never()).deleteByUserIdAndChannel(any(), any());
}
}