feat(oapi): 新增移动端渠道绑定开放接口
build / build-push (push) Successful in 59s

- GET /oapi/channel/user 按 (channel, openid) 查已绑定用户,未绑定返回 null
- POST /oapi/channel/bind 凭证绑定:账号密码本服务校验 + 三层查重(openid/账号/unionid)
- POST /oapi/channel/bindByPhone 手机号证明绑定(归属由渠道验签器证明)
- POST /oapi/channel/unbind 按登录账号 + 渠道删绑定行
- 抢绑防护:openid/unionId 仅由服务端从一次性 loginTicket 获取,前端不接触
- 新增 sys_user_channel_rel 实体/仓储/服务,写操作经 @OapiLog 记审计
- 补 ChannelBindServiceTest
This commit is contained in:
chenxing
2026-10-21 08:52:52 +08:00
parent cb40524b8e
commit ddaa3a6f42
8 changed files with 664 additions and 0 deletions
@@ -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());
}
}
@@ -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;
}
@@ -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);
}
@@ -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,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());
}
}