- 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:
@@ -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());
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user