From ddaa3a6f4257f4895e182339a9b53f0a7e480e00 Mon Sep 17 00:00:00 2001 From: chenxing Date: Wed, 21 Oct 2026 08:52:52 +0800 Subject: [PATCH] =?UTF-8?q?feat(oapi):=20=E6=96=B0=E5=A2=9E=E7=A7=BB?= =?UTF-8?q?=E5=8A=A8=E7=AB=AF=E6=B8=A0=E9=81=93=E7=BB=91=E5=AE=9A=E5=BC=80?= =?UTF-8?q?=E6=94=BE=E6=8E=A5=E5=8F=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- .../oapi/ChannelBindController.java | 71 +++++ .../userbase/entity/SysUserChannelRel.java | 71 +++++ .../channel/ChannelBindByPhoneRequest.java | 30 +++ .../model/channel/ChannelBindRequest.java | 33 +++ .../model/channel/ChannelUnbindRequest.java | 24 ++ .../SysUserChannelRelRepository.java | 39 +++ .../service/impl/ChannelBindService.java | 146 ++++++++++ .../service/impl/ChannelBindServiceTest.java | 250 ++++++++++++++++++ 8 files changed, 664 insertions(+) create mode 100644 src/main/java/com/yunhai/userbase/controller/oapi/ChannelBindController.java create mode 100644 src/main/java/com/yunhai/userbase/entity/SysUserChannelRel.java create mode 100644 src/main/java/com/yunhai/userbase/model/channel/ChannelBindByPhoneRequest.java create mode 100644 src/main/java/com/yunhai/userbase/model/channel/ChannelBindRequest.java create mode 100644 src/main/java/com/yunhai/userbase/model/channel/ChannelUnbindRequest.java create mode 100644 src/main/java/com/yunhai/userbase/repository/SysUserChannelRelRepository.java create mode 100644 src/main/java/com/yunhai/userbase/service/impl/ChannelBindService.java create mode 100644 src/test/java/com/yunhai/userbase/service/impl/ChannelBindServiceTest.java diff --git a/src/main/java/com/yunhai/userbase/controller/oapi/ChannelBindController.java b/src/main/java/com/yunhai/userbase/controller/oapi/ChannelBindController.java new file mode 100644 index 0000000..9ec3d27 --- /dev/null +++ b/src/main/java/com/yunhai/userbase/controller/oapi/ChannelBindController.java @@ -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)。
+ *
+ * 供 SDK 登录端点(门户 / 各子应用)经 yh-app-auth 签名调用({@code /oapi/channel/...}); + * 签名由 {@code OpenApiAuthEnforceConfiguration} 在过滤器层强制。
+ * 绑定身份以服务端校验为准:openid/unionId 来自一次性 loginTicket,账号密码/手机号在本服务校验, + * 前端永不接触 openid/unionid(抢绑防护,方案 §3.4)。
+ * 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()); + } +} diff --git a/src/main/java/com/yunhai/userbase/entity/SysUserChannelRel.java b/src/main/java/com/yunhai/userbase/entity/SysUserChannelRel.java new file mode 100644 index 0000000..c83591c --- /dev/null +++ b/src/main/java/com/yunhai/userbase/entity/SysUserChannelRel.java @@ -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)。 + * + *

openid / unionid ↔ sys_user.id 的绑定属 UserBase 身份域;SDK 登录端点经 + * {@code /oapi/channel/**}(应用签名)读写。channel 取值与前端 MobileChannel 对齐: + * wxgzh / miniapp / qywx / dingtalk。

+ * + *

⚠ uk_user_channel 只约束半边:同一 unionid 仍可绑到两个账号(公众号一个、小程序一个), + * 应用层在 {@code ChannelBindService} 绑定时用 idx_unionid 查重补齐,勿仅依赖唯一约束。

+ * + * @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; +} diff --git a/src/main/java/com/yunhai/userbase/model/channel/ChannelBindByPhoneRequest.java b/src/main/java/com/yunhai/userbase/model/channel/ChannelBindByPhoneRequest.java new file mode 100644 index 0000000..cdeb244 --- /dev/null +++ b/src/main/java/com/yunhai/userbase/model/channel/ChannelBindByPhoneRequest.java @@ -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; +} diff --git a/src/main/java/com/yunhai/userbase/model/channel/ChannelBindRequest.java b/src/main/java/com/yunhai/userbase/model/channel/ChannelBindRequest.java new file mode 100644 index 0000000..684e350 --- /dev/null +++ b/src/main/java/com/yunhai/userbase/model/channel/ChannelBindRequest.java @@ -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; +} diff --git a/src/main/java/com/yunhai/userbase/model/channel/ChannelUnbindRequest.java b/src/main/java/com/yunhai/userbase/model/channel/ChannelUnbindRequest.java new file mode 100644 index 0000000..539bfb6 --- /dev/null +++ b/src/main/java/com/yunhai/userbase/model/channel/ChannelUnbindRequest.java @@ -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; +} diff --git a/src/main/java/com/yunhai/userbase/repository/SysUserChannelRelRepository.java b/src/main/java/com/yunhai/userbase/repository/SysUserChannelRelRepository.java new file mode 100644 index 0000000..8be7728 --- /dev/null +++ b/src/main/java/com/yunhai/userbase/repository/SysUserChannelRelRepository.java @@ -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, JpaSpecificationExecutor { + + /** 按 (channel, openid) 查绑定(uk_channel_openid 唯一) */ + Optional findByChannelAndOpenid(String channel, String openid); + + /** 按用户 + 渠道查绑定(uk_user_channel 唯一):判"该账号在此渠道是否已绑过" */ + Optional findByUserIdAndChannel(Integer userId, String channel); + + /** unionid 应用层查重(uk_user_channel 只约束半边,方案 §3.13 ⚠):同 unionid 已存在的绑定行 */ + List 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); +} diff --git a/src/main/java/com/yunhai/userbase/service/impl/ChannelBindService.java b/src/main/java/com/yunhai/userbase/service/impl/ChannelBindService.java new file mode 100644 index 0000000..13e8b8d --- /dev/null +++ b/src/main/java/com/yunhai/userbase/service/impl/ChannelBindService.java @@ -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)。
+ *
+ * openid / unionid ↔ userId 的绑定属 UserBase 身份域;SDK 登录端点(门户/各子应用)经 + * {@code /oapi/channel/**} 应用签名调用本服务。绑定一律以服务端解析出的身份为准 + * (openid/unionid 来自一次性 loginTicket,账号密码/手机号在服务端校验),前端永不直传 openid。
+ *
+ * 唯一性三层防线:
+ * ① uk_channel_openid:一个 openid 同渠道只能绑一个账号(DB 兜底 + 应用层前置校验友好报错);
+ * ② uk_user_channel:一个账号同渠道只能绑一个 openid(换绑须先解绑);
+ * ③ 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 byOpenid = relRepository.findByChannelAndOpenid(channel, openid); + if (byOpenid.isPresent() && !byOpenid.get().getUserId().equals(user.getId())) { + throw new DException("该微信已绑定其他账号"); + } + // ② 账号在此渠道已绑定其他 openid → 拒绝(换绑须先解绑) + Optional 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()); + } +} diff --git a/src/test/java/com/yunhai/userbase/service/impl/ChannelBindServiceTest.java b/src/test/java/com/yunhai/userbase/service/impl/ChannelBindServiceTest.java new file mode 100644 index 0000000..7855fa8 --- /dev/null +++ b/src/test/java/com/yunhai/userbase/service/impl/ChannelBindServiceTest.java @@ -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 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()); + } +}