Compare commits
4
Commits
035df02f63
...
ddaa3a6f42
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ddaa3a6f42 | ||
|
|
cb40524b8e | ||
|
|
195936236d | ||
|
|
ad434dbc36 |
@@ -19,7 +19,7 @@ import java.util.List;
|
||||
* 白名单:GetUserInfo / GetUserScope / GetSysDict / RedirectTo(仅登录即可访问);
|
||||
* env-config(免登录,SPA 启动阶段在 CAS 登录前就要拉运行时配置,且内容为非敏感前端配置,
|
||||
* 端点由平台 yh-env-config-spring-boot-starter 提供,旁路过滤器见 EnvConfigSsoBypassFilter)。<br>
|
||||
* 注册于 /{platform}/**(api/manage/mobiapi 三前缀均命中),排除 /oapi/**(服务间签名调用,行为不变)。<br>
|
||||
* 注册于 /api/**(显式前缀,路径闭集),排除 /oapi/**(服务间签名调用,行为不变)。<br>
|
||||
* SUPER/BUREAU/SCHOOL 之间的细粒度差异由各 Controller 内 guard 实现。
|
||||
*
|
||||
* @author chenx
|
||||
@@ -29,17 +29,18 @@ import java.util.List;
|
||||
public class ManageAccessInterceptor implements HandlerInterceptor {
|
||||
|
||||
/**
|
||||
* 白名单(Ant 风格,* 匹配 platform 前缀):
|
||||
* 白名单(显式 /api 前缀,路径闭集):
|
||||
* 登录后骨架所需的 4 个只读接口——用户信息、访问范围、系统字典、跳转代理;
|
||||
* appearance 外观偏好读(仅登录即可读自身偏好,平台 yh-appearance-spring-boot-starter 端点);
|
||||
* env-config 免登录(端点与默认路径见平台 yh-env-config-spring-boot-starter)。
|
||||
*/
|
||||
private static final List<String> WHITELIST = Arrays.asList(
|
||||
"/*/base/GetUserInfo",
|
||||
"/*/base/GetUserScope",
|
||||
"/*/base/GetSysDict",
|
||||
"/*/base/RedirectTo/*",
|
||||
"/api/base/GetUserInfo",
|
||||
"/api/base/GetUserScope",
|
||||
"/api/base/GetSysDict",
|
||||
"/api/base/RedirectTo/*",
|
||||
"/api/appearance",
|
||||
"/*/env-config");
|
||||
"/api/env-config");
|
||||
|
||||
private static final AntPathMatcher MATCHER = new AntPathMatcher();
|
||||
|
||||
|
||||
@@ -13,9 +13,10 @@ public enum Config
|
||||
Inst;
|
||||
|
||||
// 末尾不要带斜杠:各控制器用 API_PATH + "/xxx/" 拼路径,若这里带尾斜杠会变成
|
||||
// "/{platform}//xxx/" 这种字面双斜杠;Boot 2.6+ 默认 PathPatternParser 不合并连续斜杠,
|
||||
// "/api//xxx/" 这种字面双斜杠;Boot 2.6+ 默认 PathPatternParser 不合并连续斜杠,
|
||||
// 会导致 /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 String getString(String key)
|
||||
|
||||
@@ -38,11 +38,12 @@ public class DWebMvcConfig implements WebMvcConfigurer
|
||||
.addPathPatterns("/**")
|
||||
.excludePathPatterns("/login", "/logout", "/static/**", "/error", "/upload/**");
|
||||
// 管理端统一防线:NONE 级 403(白名单见拦截器内)。
|
||||
// /{platform}/** 命中 api/manage/mobiapi 三前缀;显式排除 /oapi/**(服务间签名调用不受影响)。
|
||||
// 注册 /api/**(显式前缀,路径闭集:/manage、/{platform} 通配退役,旧前缀随之 404,
|
||||
// 同时堵掉"任意前缀绕过白名单"的洞);显式排除 /oapi/**(服务间签名调用不受影响)。
|
||||
// /actuator/**:健康探针与 prometheus 抓取无登录态(projman 部署健康检查依赖,误拦会触发回滚);
|
||||
// /v3/api-docs、/swagger-ui:接口文档工具链。
|
||||
registry.addInterceptor(manageAccessInterceptor)
|
||||
.addPathPatterns("/{platform}/**")
|
||||
.addPathPatterns("/api/**")
|
||||
.excludePathPatterns("/oapi/**", "/login", "/logout", "/static/**", "/error", "/upload/**",
|
||||
"/actuator/**", "/v3/api-docs/**", "/swagger-ui/**", "/swagger-ui.html", "/doc.html",
|
||||
"/webjars/**");
|
||||
|
||||
@@ -27,7 +27,7 @@ import java.util.TreeSet;
|
||||
/**
|
||||
* 缓存注册表对账接口(方案 C 的运行时可观测面)。<br><br>
|
||||
*
|
||||
* {@code GET /{platform}/cache/registry} 输出三块内容:<br>
|
||||
* {@code GET /api/cache/registry} 输出三块内容:<br>
|
||||
* <ul>
|
||||
* <li><b>caches</b>:注册表({@link CacheNames#REGISTRY})逐项声明(TTL/抖动/存放/key 规则/失效责任/依赖表)
|
||||
* × 运行时是否已出现 × 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.validation.ValidationGroups;
|
||||
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.SysUserChangeStatusRequest;
|
||||
import com.yunhai.userbase.model.sysuser.SysUserContactRequest;
|
||||
@@ -91,30 +92,49 @@ public class UserController {
|
||||
}
|
||||
|
||||
/**
|
||||
* 查询用户在<b>调用方应用</b>下、指定学校(上下文)里的已授权模块列表。<br>
|
||||
* 查询用户在<b>指定应用</b>下、指定学校(上下文)里的已授权模块列表。<br>
|
||||
* <br>
|
||||
* 与管理端「权限一览」返回<b>全应用聚合</b>不同,本方法严格按调用方应用维度过滤:<br>
|
||||
* ① 通过 {@link AppAuthFilter#APP_AUTH_ATTR} 拿到验签时的 appKey(RequireAppSignFilter
|
||||
* 已保证非空);<br>
|
||||
* ② 查 application_info 映射到具体 appId;<br>
|
||||
* 与管理端「权限一览」返回<b>全应用聚合</b>不同,本方法严格按单一应用维度过滤。应用来源两态:<br>
|
||||
* ① 传了可选参数 appCode → 按 application_info.app_code 解析<b>目标应用</b>(供托管型应用
|
||||
* 如 form-center 代快应用渲染导航时查询目标应用的模块清单;查不到返回空列表并 warn,
|
||||
* 口径同现有兜底);<br>
|
||||
* ② 未传 appCode → 现行为不变:通过 {@link AppAuthFilter#APP_AUTH_ATTR} 拿到验签时的
|
||||
* appKey(RequireAppSignFilter 已保证非空),查 application_info 映射到具体 appId;<br>
|
||||
* ③ 调 Service 专用方法:所有 SQL/集合构建均限定在该 appId 范围内(数据库层面过滤)。<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 schId 学校ID/组ID(可选;用于限定「学校上下文」:非空时仅取该学校子树内的真实归属组参与判定,
|
||||
* 为空时取用户全部真实归属组,与权限一览一致)
|
||||
* @param appCode 目标应用技术标识(可选;传=查该应用的模块树,不传=按签名调用方解析)
|
||||
* @return 该应用下用户已授权模块(扁平化;无权限时返回空列表)
|
||||
*/
|
||||
@GetMapping("modules")
|
||||
public List<BoaModule> modules(HttpServletRequest request,
|
||||
@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) {
|
||||
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) {
|
||||
return Collections.emptyList();
|
||||
}
|
||||
|
||||
@@ -55,9 +55,9 @@ public class AppModule extends BaseEntity {
|
||||
|
||||
/**
|
||||
* 自定义ID(应用内唯一,业务编码,如 "user-mgmt")。<br>
|
||||
* 按钮权限型子模块(无路由无编码)为 null;NULL 在唯一键 uk_app_module_custom 下可多行并存。
|
||||
*/
|
||||
@NotBlank
|
||||
@Column(name = "custom_id", nullable = false, length = 64)
|
||||
@Column(name = "custom_id", nullable = true, length = 64)
|
||||
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 = "模块名称不能为空")
|
||||
private String name;
|
||||
|
||||
@NotBlank(groups = {ValidationGroups.Create.class, ValidationGroups.Update.class}, message = "自定义ID不能为空")
|
||||
/**
|
||||
* 自定义ID(应用内唯一,业务编码)。子模块双定位(树节点/按钮权限):<br>
|
||||
* 按钮权限型子模块可不填,服务层把空白归一为 null(仅在有值时做应用内唯一校验)。<br>
|
||||
*/
|
||||
private String customId;
|
||||
|
||||
/** 模块路径(前端路由/资源路径)。<br> */
|
||||
/** 模块路径(前端路由/资源路径);按钮权限型子模块可不填。<br> */
|
||||
private String path;
|
||||
|
||||
/** 模块图标(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())) {
|
||||
throw new DException("所属应用不存在");
|
||||
}
|
||||
if (appModuleRepository.existsByAppIdAndCustomId(request.getAppId(), request.getCustomId())) {
|
||||
throw new DException("自定义ID在该应用下已存在");
|
||||
}
|
||||
AppModule entity = new AppModule();
|
||||
BeanUtils.copyProperties(request, entity);
|
||||
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()));
|
||||
// sortNo 默认为空(由前端编辑时带入),不自动递增
|
||||
appModuleRepository.save(entity);
|
||||
@@ -77,13 +81,15 @@ public class AppModuleService implements IAppModuleService {
|
||||
Objects.requireNonNull(request, "模块编辑入参不能为空");
|
||||
AppModule entity = appModuleRepository.findById(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在该应用下已存在");
|
||||
}
|
||||
// 可编辑字段:name/customId/path/icon/sortNo;id、appId 不可改
|
||||
entity.setName(request.getName());
|
||||
entity.setCustomId(request.getCustomId());
|
||||
entity.setPath(request.getPath());
|
||||
entity.setCustomId(customId);
|
||||
entity.setPath(normalizeBlankToNull(request.getPath()));
|
||||
entity.setIcon(request.getIcon());
|
||||
entity.setSortNo(request.getSortNo());
|
||||
// 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) {
|
||||
AppModuleVo vo = new AppModuleVo();
|
||||
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());
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user