package com.sinata.appointment.service;
|
|
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
|
import com.sinata.appointment.constants.BizErrorCode;
|
import com.sinata.appointment.constants.MiniUserType;
|
import com.sinata.appointment.constants.MpStaffConstants;
|
import com.sinata.appointment.config.MiniStpUtil;
|
import com.sinata.appointment.dto.MiniLoginResponse;
|
import com.sinata.appointment.dto.MiniOfficialLoginResponse;
|
import com.sinata.appointment.dto.MiniProfileUpdateRequest;
|
import com.sinata.appointment.dto.MiniWxLoginRequest;
|
import com.sinata.appointment.entity.MpUser;
|
import com.sinata.appointment.entity.SysDept;
|
import com.sinata.appointment.exception.BizException;
|
import com.sinata.appointment.integration.WechatMiniGateway;
|
import com.sinata.appointment.integration.WechatOfficialAccountGateway;
|
import com.sinata.appointment.integration.WxOfficialOAuthResult;
|
import com.sinata.appointment.integration.WxCode2SessionResult;
|
import com.sinata.appointment.mapper.SysDeptMapper;
|
import com.sinata.appointment.mapper.MpUserMapper;
|
import java.time.Duration;
|
import java.util.List;
|
import java.util.Objects;
|
import java.util.UUID;
|
import com.sinata.appointment.util.PhoneNormalize;
|
import org.springframework.data.redis.core.StringRedisTemplate;
|
import org.springframework.stereotype.Service;
|
import org.springframework.transaction.annotation.Transactional;
|
import org.springframework.util.StringUtils;
|
|
/**
|
* 小程序登录:js_code → openId;phoneCode + access_token → 手机号;访客/员工与平台 sys_user 无关联。
|
*/
|
@Service
|
public class MiniAuthApplicationService {
|
|
private static final String OFFICIAL_BIND_TOKEN_PREFIX = "official:bind:";
|
private static final Duration OFFICIAL_BIND_TOKEN_TTL = Duration.ofMinutes(10);
|
private static final String OFFICIAL_OPEN_ID_PLACEHOLDER_PREFIX = "OA_PLACEHOLDER_";
|
|
private final MpUserMapper mpUserMapper;
|
private final SysDeptMapper sysDeptMapper;
|
private final WechatMiniGateway wechatMiniGateway;
|
private final WechatOfficialAccountGateway wechatOfficialAccountGateway;
|
private final StringRedisTemplate stringRedisTemplate;
|
|
public MiniAuthApplicationService(
|
MpUserMapper mpUserMapper,
|
SysDeptMapper sysDeptMapper,
|
WechatMiniGateway wechatMiniGateway,
|
WechatOfficialAccountGateway wechatOfficialAccountGateway,
|
StringRedisTemplate stringRedisTemplate) {
|
this.mpUserMapper = mpUserMapper;
|
this.sysDeptMapper = sysDeptMapper;
|
this.wechatMiniGateway = wechatMiniGateway;
|
this.wechatOfficialAccountGateway = wechatOfficialAccountGateway;
|
this.stringRedisTemplate = stringRedisTemplate;
|
}
|
|
public String buildOfficialOAuthUrl(String redirectUri, String state) {
|
return wechatOfficialAccountGateway.buildSilentOAuthUrl(redirectUri, state);
|
}
|
|
@Transactional(rollbackFor = Exception.class)
|
public MiniLoginResponse loginByWx(MiniWxLoginRequest req) {
|
WxCode2SessionResult session = wechatMiniGateway.code2Session(req.getWxCode());
|
String openId = session.getOpenId();
|
|
MpUser user =
|
mpUserMapper.selectOne(
|
new LambdaQueryWrapper<MpUser>()
|
.eq(MpUser::getOpenId, openId)
|
.eq(MpUser::getDelFlag, 0));
|
if (user == null) {
|
if (!StringUtils.hasText(req.getPhoneCode())) {
|
throw new BizException(BizErrorCode.MINI_PHONE_AUTH_REQUIRED, "请先授权手机号完成登录");
|
}
|
String phoneRaw = wechatMiniGateway.getPhoneNumber(req.getPhoneCode());
|
String phone = PhoneNormalize.mainland(phoneRaw);
|
user = resolveUserByPhoneForWxLogin(openId, session.getUnionId(), phone, phoneRaw);
|
}
|
assertUserCanLogin(user);
|
syncUnionIdIfNeeded(user, session.getUnionId());
|
MiniStpUtil.login(user.getId());
|
return buildResponse(user);
|
}
|
|
@Transactional(rollbackFor = Exception.class)
|
public MiniOfficialLoginResponse loginByOfficialCode(String code) {
|
WxOfficialOAuthResult session = wechatOfficialAccountGateway.exchangeCode(code);
|
MpUser user =
|
mpUserMapper.selectOne(
|
new LambdaQueryWrapper<MpUser>()
|
.eq(MpUser::getOfficialOpenId, session.getOpenId())
|
.eq(MpUser::getDelFlag, 0)
|
.last("limit 1"));
|
if (user == null) {
|
return MiniOfficialLoginResponse.builder()
|
.mobileRequired(true)
|
.bindToken(createOfficialBindToken(session.getOpenId(), session.getUnionId()))
|
.login(null)
|
.build();
|
}
|
assertUserCanLogin(user);
|
syncUnionIdIfNeeded(user, session.getUnionId());
|
MiniStpUtil.login(user.getId());
|
return MiniOfficialLoginResponse.builder()
|
.mobileRequired(false)
|
.bindToken(null)
|
.login(buildResponse(user))
|
.build();
|
}
|
|
@Transactional(rollbackFor = Exception.class)
|
public MiniLoginResponse bindOfficialPhoneAndLogin(String bindToken, String mobileRaw) {
|
OfficialBindSession session = consumeOfficialBindToken(bindToken);
|
String phone = PhoneNormalize.mainland(mobileRaw);
|
if (!phone.matches("^\\d{11}$")) {
|
throw new BizException(BizErrorCode.AUTH_INVALID_CREDENTIALS, "手机号格式不正确");
|
}
|
MpUser user = resolveUserByPhoneForOfficialLogin(session.openId(), session.unionId(), phone, mobileRaw);
|
assertUserCanLogin(user);
|
MiniStpUtil.login(user.getId());
|
return buildResponse(user);
|
}
|
|
@Transactional(rollbackFor = Exception.class)
|
public MiniLoginResponse loginByPhone(String mobileRaw) {
|
String phone = PhoneNormalize.mainland(mobileRaw);
|
if (!phone.matches("^\\d{11}$")) {
|
throw new BizException(BizErrorCode.AUTH_INVALID_CREDENTIALS, "手机号格式不正确");
|
}
|
List<MpUser> users =
|
mpUserMapper.selectList(
|
new LambdaQueryWrapper<MpUser>()
|
.eq(MpUser::getDelFlag, 0));
|
MpUser user =
|
users.stream()
|
.filter(Objects::nonNull)
|
.filter(u -> phone.equals(PhoneNormalize.mainland(u.getMobile())))
|
.sorted(this::compareByLoginPriority)
|
.findFirst()
|
.orElse(null);
|
if (user == null) {
|
user = registerVisitorForPhoneLogin(mobileRaw);
|
}
|
assertUserCanLogin(user);
|
MiniStpUtil.login(user.getId());
|
return buildResponse(user);
|
}
|
|
private MpUser resolveUserByPhoneForWxLogin(
|
String openId, String unionId, String normalizedPhone, String phoneRaw) {
|
if (!normalizedPhone.matches("^\\d{11}$")) {
|
throw new BizException(BizErrorCode.AUTH_INVALID_CREDENTIALS, "手机号格式不正确");
|
}
|
List<MpUser> users =
|
mpUserMapper.selectList(
|
new LambdaQueryWrapper<MpUser>()
|
.eq(MpUser::getDelFlag, 0));
|
MpUser user =
|
users.stream()
|
.filter(Objects::nonNull)
|
.filter(u -> normalizedPhone.equals(PhoneNormalize.mainland(u.getMobile())))
|
.sorted(this::compareByLoginPriority)
|
.findFirst()
|
.orElse(null);
|
if (user == null) {
|
MpUser created = new MpUser();
|
created.setOpenId(openId);
|
created.setUnionId(unionId);
|
created.setUserType(MiniUserType.VISITOR);
|
created.setMobile(phoneRaw);
|
created.setStatus(1);
|
mpUserMapper.insert(created);
|
return mpUserMapper.selectById(created.getId());
|
}
|
assertAccountEnabled(user);
|
if (MiniUserType.EMPLOYEE.equals(user.getUserType()) && user.getDeptId() == null) {
|
throw new BizException(BizErrorCode.MINI_STAFF_NOT_BOUND, "员工未分配部门");
|
}
|
bindOpenIdIfNeeded(user, openId, unionId, phoneRaw);
|
return mpUserMapper.selectById(user.getId());
|
}
|
|
private MpUser resolveUserByPhoneForOfficialLogin(
|
String officialOpenId, String unionId, String normalizedPhone, String phoneRaw) {
|
List<MpUser> users =
|
mpUserMapper.selectList(
|
new LambdaQueryWrapper<MpUser>()
|
.eq(MpUser::getDelFlag, 0));
|
MpUser user =
|
users.stream()
|
.filter(Objects::nonNull)
|
.filter(u -> normalizedPhone.equals(PhoneNormalize.mainland(u.getMobile())))
|
.sorted(this::compareByLoginPriority)
|
.findFirst()
|
.orElse(null);
|
if (user == null) {
|
MpUser created = new MpUser();
|
created.setOpenId(OFFICIAL_OPEN_ID_PLACEHOLDER_PREFIX + UUID.randomUUID());
|
created.setOfficialOpenId(officialOpenId);
|
created.setUnionId(unionId);
|
created.setUserType(MiniUserType.VISITOR);
|
created.setMobile(phoneRaw);
|
created.setStatus(1);
|
mpUserMapper.insert(created);
|
return mpUserMapper.selectById(created.getId());
|
}
|
assertAccountEnabled(user);
|
if (MiniUserType.EMPLOYEE.equals(user.getUserType()) && user.getDeptId() == null) {
|
throw new BizException(BizErrorCode.MINI_STAFF_NOT_BOUND, "员工未分配部门");
|
}
|
bindOfficialOpenIdIfNeeded(user, officialOpenId, unionId, phoneRaw);
|
return mpUserMapper.selectById(user.getId());
|
}
|
|
/**
|
* 管理端预建档员工(openId 为 PLHD_ 占位):按手机号绑定当前微信 openId。
|
*/
|
private MpUser tryBindPreRegisteredEmployee(
|
String openId, String unionId, String normalizedPhone, String phoneRaw) {
|
List<MpUser> candidates =
|
mpUserMapper.selectList(
|
new LambdaQueryWrapper<MpUser>()
|
.eq(MpUser::getUserType, MiniUserType.EMPLOYEE)
|
.eq(MpUser::getDelFlag, 0)
|
.likeRight(MpUser::getOpenId, MpStaffConstants.PLACEHOLDER_OPEN_ID_PREFIX));
|
for (MpUser u : candidates) {
|
if (u.getOpenId() == null || !MpStaffConstants.isPlaceholderOpenId(u.getOpenId())) {
|
continue;
|
}
|
String stored = PhoneNormalize.mainland(u.getMobile());
|
if (stored.isBlank() || !stored.equals(normalizedPhone)) {
|
continue;
|
}
|
u.setOpenId(openId);
|
if (unionId != null) {
|
u.setUnionId(unionId);
|
}
|
syncMpUserMobile(u, phoneRaw);
|
mpUserMapper.updateById(u);
|
return mpUserMapper.selectById(u.getId());
|
}
|
return null;
|
}
|
|
private void syncMpUserMobile(MpUser user, String phoneRaw) {
|
boolean needUpdate = false;
|
if (user.getMobile() == null || user.getMobile().isBlank()) {
|
user.setMobile(phoneRaw);
|
needUpdate = true;
|
}
|
if (needUpdate) {
|
mpUserMapper.updateById(user);
|
}
|
}
|
|
private void bindOpenIdIfNeeded(MpUser user, String openId, String unionId, String phoneRaw) {
|
if (user == null) {
|
return;
|
}
|
String currentOpenId = user.getOpenId();
|
if (StringUtils.hasText(currentOpenId)
|
&& !Objects.equals(currentOpenId, openId)
|
&& !MpStaffConstants.isPlaceholderOpenId(currentOpenId)) {
|
throw new BizException(BizErrorCode.AUTH_FORBIDDEN, "该手机号已绑定其他微信账号");
|
}
|
if (MiniUserType.EMPLOYEE.equals(user.getUserType())
|
&& MpStaffConstants.isPlaceholderOpenId(currentOpenId)) {
|
MpUser bound = tryBindPreRegisteredEmployee(openId, unionId, PhoneNormalize.mainland(phoneRaw), phoneRaw);
|
if (bound == null) {
|
throw new BizException(BizErrorCode.MINI_STAFF_NOT_BOUND, "员工未登记,请联系管理员开通后再登录");
|
}
|
return;
|
}
|
boolean needUpdate = false;
|
if (!Objects.equals(currentOpenId, openId)) {
|
user.setOpenId(openId);
|
needUpdate = true;
|
}
|
if (!StringUtils.hasText(user.getUnionId()) && StringUtils.hasText(unionId)) {
|
user.setUnionId(unionId);
|
needUpdate = true;
|
}
|
if (!StringUtils.hasText(user.getMobile()) && StringUtils.hasText(phoneRaw)) {
|
user.setMobile(phoneRaw);
|
needUpdate = true;
|
}
|
if (needUpdate) {
|
mpUserMapper.updateById(user);
|
}
|
}
|
|
private void bindOfficialOpenIdIfNeeded(
|
MpUser user, String officialOpenId, String unionId, String phoneRaw) {
|
if (user == null) {
|
return;
|
}
|
String currentOfficialOpenId = user.getOfficialOpenId();
|
if (StringUtils.hasText(currentOfficialOpenId)
|
&& !Objects.equals(currentOfficialOpenId, officialOpenId)) {
|
throw new BizException(BizErrorCode.AUTH_FORBIDDEN, "该手机号已绑定其他微信账号");
|
}
|
boolean needUpdate = false;
|
if (!Objects.equals(currentOfficialOpenId, officialOpenId)) {
|
user.setOfficialOpenId(officialOpenId);
|
needUpdate = true;
|
}
|
if (!StringUtils.hasText(user.getUnionId()) && StringUtils.hasText(unionId)) {
|
user.setUnionId(unionId);
|
needUpdate = true;
|
}
|
if (!StringUtils.hasText(user.getMobile()) && StringUtils.hasText(phoneRaw)) {
|
user.setMobile(phoneRaw);
|
needUpdate = true;
|
}
|
if (needUpdate) {
|
mpUserMapper.updateById(user);
|
}
|
}
|
|
private String createOfficialBindToken(String openId, String unionId) {
|
String token = UUID.randomUUID().toString();
|
String value = openId + "\n" + (unionId == null ? "" : unionId);
|
stringRedisTemplate.opsForValue()
|
.set(OFFICIAL_BIND_TOKEN_PREFIX + token, value, OFFICIAL_BIND_TOKEN_TTL);
|
return token;
|
}
|
|
private OfficialBindSession consumeOfficialBindToken(String bindToken) {
|
if (!StringUtils.hasText(bindToken)) {
|
throw new BizException(BizErrorCode.AUTH_INVALID_CREDENTIALS, "绑定凭证不能为空");
|
}
|
String key = OFFICIAL_BIND_TOKEN_PREFIX + bindToken.trim();
|
String value = stringRedisTemplate.opsForValue().get(key);
|
if (!StringUtils.hasText(value)) {
|
throw new BizException(BizErrorCode.AUTH_INVALID_CREDENTIALS, "绑定凭证已失效,请重新登录");
|
}
|
stringRedisTemplate.delete(key);
|
String[] parts = value.split("\\n", 2);
|
String openId = parts.length > 0 ? parts[0] : "";
|
String unionId = parts.length > 1 ? parts[1] : null;
|
if (!StringUtils.hasText(openId)) {
|
throw new BizException(BizErrorCode.AUTH_INVALID_CREDENTIALS, "绑定凭证无效,请重新登录");
|
}
|
return new OfficialBindSession(openId, StringUtils.hasText(unionId) ? unionId : null);
|
}
|
|
private void assertUserCanLogin(MpUser user) {
|
assertAccountEnabled(user);
|
if (MiniUserType.EMPLOYEE.equals(user.getUserType()) && user.getDeptId() == null) {
|
throw new BizException(BizErrorCode.MINI_STAFF_NOT_BOUND, "员工未分配部门");
|
}
|
}
|
|
private void syncUnionIdIfNeeded(MpUser user, String unionId) {
|
if (user == null || !StringUtils.hasText(unionId) || StringUtils.hasText(user.getUnionId())) {
|
return;
|
}
|
user.setUnionId(unionId);
|
mpUserMapper.updateById(user);
|
}
|
|
private void assertAccountEnabled(MpUser user) {
|
if (user == null || user.getStatus() == null || user.getStatus() != 0) {
|
return;
|
}
|
if (MiniUserType.EMPLOYEE.equals(user.getUserType())) {
|
throw new BizException(BizErrorCode.AUTH_FORBIDDEN, "员工账号已禁用");
|
}
|
throw new BizException(BizErrorCode.AUTH_FORBIDDEN, "账号已被禁用");
|
}
|
|
private int compareByLoginPriority(MpUser a, MpUser b) {
|
int pa = loginPriority(a);
|
int pb = loginPriority(b);
|
if (pa != pb) {
|
return Integer.compare(pa, pb);
|
}
|
Long aid = a.getId() == null ? Long.MAX_VALUE : a.getId();
|
Long bid = b.getId() == null ? Long.MAX_VALUE : b.getId();
|
return Long.compare(aid, bid);
|
}
|
|
private int loginPriority(MpUser u) {
|
if (MiniUserType.EMPLOYEE.equals(u.getUserType())) {
|
return 0;
|
}
|
if (MiniUserType.VISITOR.equals(u.getUserType())) {
|
return 1;
|
}
|
return 2;
|
}
|
|
private MpUser registerVisitorForPhoneLogin(String mobileRaw) {
|
MpUser created = new MpUser();
|
created.setUserType(MiniUserType.VISITOR);
|
created.setMobile(mobileRaw);
|
created.setStatus(1);
|
mpUserMapper.insert(created);
|
return mpUserMapper.selectById(created.getId());
|
}
|
|
public MpUser currentMpUser() {
|
long id = MiniStpUtil.getLoginIdAsLong();
|
MpUser u = mpUserMapper.selectById(id);
|
if (u == null || u.getDelFlag() != null && u.getDelFlag() == 1) {
|
throw new BizException(BizErrorCode.AUTH_FORBIDDEN, "用户不存在或已禁用");
|
}
|
assertAccountEnabled(u);
|
return u;
|
}
|
|
@Transactional(rollbackFor = Exception.class)
|
public MpUser updateProfile(MiniProfileUpdateRequest request) {
|
MpUser user = currentMpUser();
|
String nickName = trimRequired(request.getNickName(), "姓名不能为空");
|
if (nickName.length() > 30) {
|
throw new BizException(BizErrorCode.AUTH_INVALID_CREDENTIALS, "姓名长度不能超过30个字符");
|
}
|
String gender = trimToNull(request.getGender());
|
if (StringUtils.hasText(gender) && !"男".equals(gender) && !"女".equals(gender)) {
|
throw new BizException(BizErrorCode.AUTH_INVALID_CREDENTIALS, "性别仅支持男或女");
|
}
|
String companyName = trimToNull(request.getCompanyName());
|
String avatarUrl = trimToNull(request.getAvatarUrl());
|
user.setNickName(nickName);
|
user.setGender(gender);
|
user.setCompanyName(companyName);
|
user.setAvatarUrl(avatarUrl);
|
mpUserMapper.updateById(user);
|
return mpUserMapper.selectById(user.getId());
|
}
|
|
public void logout() {
|
MiniStpUtil.logout();
|
}
|
|
private MiniLoginResponse buildResponse(MpUser user) {
|
String name =
|
user.getNickName() != null && !user.getNickName().isBlank()
|
? user.getNickName()
|
: (MiniUserType.EMPLOYEE.equals(user.getUserType()) ? "员工" : "访客");
|
String deptName = null;
|
if (MiniUserType.EMPLOYEE.equals(user.getUserType()) && user.getDeptId() != null) {
|
SysDept dept = sysDeptMapper.selectById(user.getDeptId());
|
deptName = dept == null ? null : dept.getDeptName();
|
}
|
return MiniLoginResponse.builder()
|
.token(MiniStpUtil.getTokenValue())
|
.mpUserId(user.getId())
|
.userType(user.getUserType())
|
.deptId(user.getDeptId())
|
.deptName(deptName)
|
.deptLeader(MiniUserType.EMPLOYEE.equals(user.getUserType()) ? false : null)
|
.avatarUrl(user.getAvatarUrl())
|
.companyName(user.getCompanyName())
|
.displayName(name)
|
.build();
|
}
|
|
private static String trimToNull(String value) {
|
if (!StringUtils.hasText(value)) {
|
return null;
|
}
|
String trimmed = value.trim();
|
return trimmed.isEmpty() ? null : trimmed;
|
}
|
|
private static String trimRequired(String value, String message) {
|
String trimmed = trimToNull(value);
|
if (!StringUtils.hasText(trimmed)) {
|
throw new BizException(BizErrorCode.AUTH_INVALID_CREDENTIALS, message);
|
}
|
return trimmed;
|
}
|
|
private record OfficialBindSession(String openId, String unionId) {}
|
}
|