package com.ruoyi.auth.service; import java.util.Map; import java.util.concurrent.TimeUnit; import java.util.regex.Pattern; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.stereotype.Service; import com.ruoyi.auth.enums.SmsSceneEnum; import com.ruoyi.common.core.constant.CacheConstants; import com.ruoyi.common.core.constant.SecurityConstants; import com.ruoyi.common.core.domain.R; import com.ruoyi.common.core.enums.UserStatus; import com.ruoyi.common.core.exception.ServiceException; import com.ruoyi.common.core.utils.StringUtils; import com.ruoyi.common.redis.service.RedisService; import com.ruoyi.common.security.service.TokenService; import com.ruoyi.system.api.RemoteAccountUserService; import com.ruoyi.system.api.domain.AccountMember; import com.ruoyi.system.api.domain.SysUser; import com.ruoyi.system.api.model.AccountRegisterRequest; import com.ruoyi.system.api.model.LoginUser; /** * C端(用户端小程序)登录与注册编排服务 * * @author hugray */ @Service public class AccountLoginService { private static final Logger log = LoggerFactory.getLogger(AccountLoginService.class); /** 手机号正则 */ private static final Pattern PHONE_PATTERN = Pattern.compile("^1[3-9]\\d{9}$"); /** 注册分布式锁持有时长(秒) */ private static final long REGISTER_LOCK_SECONDS = 10L; @Autowired private RedisService redisService; @Autowired private SmsCodeService smsCodeService; @Autowired private WeChatMiniappService weChatMiniappService; @Autowired private RemoteAccountUserService remoteAccountUserService; @Autowired private TokenService tokenService; /** * 手机号一键登录 * * @param code 微信 getPhoneNumber 回调返回的动态令牌 * @return 令牌信息(access_token、expires_in) */ public Map phoneLogin(String code) { // STEP 1: 授权凭证非空校验 if (StringUtils.isEmpty(code)) { throw new ServiceException("微信授权凭证不能为空"); } // STEP 2-3: 微信凭证换取手机号(内部含 access_token 获取与异常兜底) String phone = weChatMiniappService.getPhoneNumber(code); // STEP 4: 查询会员并校验状态 AccountMember member = getRegisteredMember(phone); checkMemberStatus(member); // STEP 5: 组装登录态并签发令牌 return issueToken(member, "phone code-login"); } /** * 手机号验证码登录(短信验证码一次性消费) * * @param phone 手机号 * @param code 短信验证码 * @return 令牌信息(access_token、expires_in) */ public Map phoneCodeLogin(String phone, String code) { // STEP 1: 手机号格式校验(Controller 已校验,此处兜底) if (StringUtils.isEmpty(phone) || !PHONE_PATTERN.matcher(phone).matches()) { throw new ServiceException("请输入正确手机号"); } // STEP 2: 先查询会员与状态,避免无谓消费验证码 AccountMember member = getRegisteredMember(phone); checkMemberStatus(member); // STEP 3: 校验并消费短信验证码(一次性) smsCodeService.verifyAndConsume(phone, SmsSceneEnum.LOGIN.getCode(), code); // STEP 4: 组装登录态并签发令牌 return issueToken(member, "phone sms-login"); } /** * C端会员注册 * * @param phone 手机号 * @param inputCode 短信验证码 * @param referralCode 推荐码 */ public void accountRegister(String phone, String inputCode, String referralCode) { // STEP 1: 手机号与推荐码格式校验(Controller 已校验,此处兜底) if (StringUtils.isEmpty(phone) || !PHONE_PATTERN.matcher(phone).matches()) { throw new ServiceException("请输入正确手机号"); } if (StringUtils.isEmpty(referralCode)) { throw new ServiceException("请输入推荐人码"); } // STEP 2: 校验并消费短信验证码(一次性) smsCodeService.verifyAndConsume(phone, SmsSceneEnum.REGISTER.getCode(), inputCode); // STEP 3: 获取注册分布式锁,防并发与重复提交 String lockKey = CacheConstants.ACCOUNT_REGISTER_LOCK_KEY + phone; boolean locked = redisService.setCacheObjectIfAbsent(lockKey, "1", REGISTER_LOCK_SECONDS, TimeUnit.SECONDS); if (!locked) { throw new ServiceException("操作过于频繁,请稍后重试"); } try { // STEP 4: 锁内二次校验手机号未被注册 if (StringUtils.isNotNull(getMemberOrNull(phone))) { throw new ServiceException("手机号已注册,请前往登录"); } // STEP 5: 调用 ruoyi-user 落库注册 AccountRegisterRequest request = new AccountRegisterRequest(); request.setPhone(phone); request.setReferralCode(referralCode); R registerResult = remoteAccountUserService.registerMember(request, SecurityConstants.INNER); if (R.FAIL == registerResult.getCode()) { throw new ServiceException(registerResult.getMsg()); } log.info("[ACCOUNT_REGISTER] register success, phone={}", maskPhone(phone)); } finally { // STEP 6: 释放锁 redisService.deleteObject(lockKey); } } /** * 按手机号查询会员,查询失败抛出异常,不存在返回 null * * @param phone 手机号 * @return 会员信息或 null */ private AccountMember getMemberOrNull(String phone) { R memberResult = remoteAccountUserService.getMemberByPhone(phone, SecurityConstants.INNER); if (R.FAIL == memberResult.getCode()) { throw new ServiceException(memberResult.getMsg()); } return memberResult.getData(); } /** * 按手机号查询会员,不存在时抛出「未注册」业务异常 * * @param phone 手机号 * @return 会员信息 */ private AccountMember getRegisteredMember(String phone) { AccountMember member = getMemberOrNull(phone); if (StringUtils.isNull(member)) { throw new ServiceException("该手机号未注册,请先注册"); } return member; } /** * 校验会员状态,停用账号拒绝登录 * * @param member C端会员信息 */ private void checkMemberStatus(AccountMember member) { if (UserStatus.DISABLE.getCode().equals(member.getStatus())) { throw new ServiceException("账号已停用,请联系管理员"); } } /** * 组装登录态并签发令牌 * * @param member C端会员信息 * @param scene 登录场景(仅用于日志标记) * @return 令牌信息(access_token、expires_in) */ private Map issueToken(AccountMember member, String scene) { LoginUser loginUser = buildLoginUser(member); log.info("[ACCOUNT_LOGIN] {} success, userId={}", scene, member.getUserId()); return tokenService.createToken(loginUser); } /** * 组装登录用户对象(以 SysUser 作为令牌载体,兼容 TokenService) * * @param member C端会员信息 * @return 登录用户对象 */ private LoginUser buildLoginUser(AccountMember member) { SysUser sysUser = new SysUser(); sysUser.setUserId(member.getUserId()); // TokenService 依赖 userName 生成令牌,缺失时回退为手机号 sysUser.setUserName(StringUtils.isNotEmpty(member.getUserName()) ? member.getUserName() : member.getPhone()); sysUser.setNickName(member.getNickname()); sysUser.setPhonenumber(member.getPhone()); sysUser.setAvatar(member.getAvatar()); sysUser.setStatus(member.getStatus()); LoginUser loginUser = new LoginUser(); loginUser.setSysUser(sysUser); return loginUser; } /** * 手机号脱敏,形如 138****0000 */ private String maskPhone(String phone) { if (StringUtils.isEmpty(phone) || phone.length() < 7) { return phone; } return phone.substring(0, 3) + "****" + phone.substring(phone.length() - 4); } }