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());
+ }
+}