longz
2026-08-11 2a3c3cdbf2398695d61633e072f98ee2757e9e27
backend/src/main/java/com/sinata/appointment/service/MiniAuthApplicationService.java
@@ -2,22 +2,28 @@
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.sinata.appointment.constants.BizErrorCode;
import com.sinata.appointment.constants.MiniLoginIntent;
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;
@@ -28,87 +34,95 @@
@Service
public class MiniAuthApplicationService {
    private static final String H5_PHONE_OPEN_ID_PREFIX = "H5_PHONE_";
    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 static final String UNBOUND_OPEN_ID_PREFIX = "UNBOUND_";
    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) {
            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();
        String phoneRaw = wechatMiniGateway.getPhoneNumber(req.getPhoneCode());
        String phone = PhoneNormalize.mainland(phoneRaw);
        MpUser user =
                mpUserMapper.selectOne(
                        new LambdaQueryWrapper<MpUser>()
                                .eq(MpUser::getOpenId, openId)
                                .eq(MpUser::getDelFlag, 0));
        if (MiniLoginIntent.EMPLOYEE.equals(req.getLoginIntent())) {
            if (user == null) {
                user = tryBindPreRegisteredEmployee(openId, session.getUnionId(), phone, phoneRaw);
        if (user == null) {
            if (!StringUtils.hasText(req.getPhoneCode())) {
                throw new BizException(BizErrorCode.MINI_PHONE_AUTH_REQUIRED, "请先授权手机号完成登录");
            }
            if (user == null) {
                throw new BizException(BizErrorCode.MINI_STAFF_NOT_BOUND, "员工未登记,请联系管理员开通后再登录");
            }
            assertAccountEnabled(user);
            if (!MiniUserType.EMPLOYEE.equals(user.getUserType())) {
                throw new BizException(
                        BizErrorCode.MINI_LOGIN_INTENT_MISMATCH, "当前微信为访客身份,请使用访客入口登录");
            }
            if (user.getDeptId() == null) {
                throw new BizException(BizErrorCode.MINI_STAFF_NOT_BOUND, "员工未分配部门");
            }
            assertPhoneMatchesStored(user, phone);
            syncMpUserMobile(user, phoneRaw);
            if (user.getUnionId() == null && session.getUnionId() != null) {
                user.setUnionId(session.getUnionId());
                mpUserMapper.updateById(user);
            }
            MiniStpUtil.login(user.getId());
            return buildResponse(user);
            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);
    }
        if (MiniLoginIntent.VISITOR.equals(req.getLoginIntent())) {
            if (user == null) {
                MpUser created = new MpUser();
                created.setOpenId(openId);
                created.setUnionId(session.getUnionId());
                created.setUserType(MiniUserType.VISITOR);
                created.setMobile(phoneRaw);
                created.setStatus(1);
                mpUserMapper.insert(created);
                user = mpUserMapper.selectById(created.getId());
            } else {
                assertAccountEnabled(user);
                if (!MiniUserType.VISITOR.equals(user.getUserType())) {
                    throw new BizException(
                            BizErrorCode.MINI_LOGIN_INTENT_MISMATCH, "当前微信已登记为员工,请使用员工入口登录");
                }
                assertPhoneMatchesStored(user, phone);
                syncMpUserMobile(user, phoneRaw);
                if (user.getUnionId() == null && session.getUnionId() != null) {
                    user.setUnionId(session.getUnionId());
                    mpUserMapper.updateById(user);
                }
            }
            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();
    }
        throw new BizException(BizErrorCode.AUTH_INVALID_CREDENTIALS, "loginIntent 无效");
    @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)
@@ -130,15 +144,76 @@
                        .orElse(null);
        if (user == null) {
            user = registerVisitorForPhoneLogin(mobileRaw);
        } else {
            assertAccountEnabled(user);
        }
        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, "员工未分配部门");
        }
        ensureOpenIdForPhoneLogin(user);
        MiniStpUtil.login(user.getId());
        return buildResponse(user);
        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());
    }
    /**
@@ -171,16 +246,6 @@
        return null;
    }
    private void assertPhoneMatchesStored(MpUser user, String normalizedPhone) {
        if (user.getMobile() == null || user.getMobile().isBlank()) {
            return;
        }
        String stored = PhoneNormalize.mainland(user.getMobile());
        if (!stored.isBlank() && !stored.equals(normalizedPhone)) {
            throw new BizException(BizErrorCode.MINI_PHONE_MISMATCH, "手机号与登记信息不一致");
        }
    }
    private void syncMpUserMobile(MpUser user, String phoneRaw) {
        boolean needUpdate = false;
        if (user.getMobile() == null || user.getMobile().isBlank()) {
@@ -190,6 +255,113 @@
        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)
                && !isUnboundOpenId(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) {
@@ -223,14 +395,6 @@
        return 2;
    }
    private void ensureOpenIdForPhoneLogin(MpUser user) {
        if (StringUtils.hasText(user.getOpenId())) {
            return;
        }
        user.setOpenId(H5_PHONE_OPEN_ID_PREFIX + user.getId());
        mpUserMapper.updateById(user);
    }
    private MpUser registerVisitorForPhoneLogin(String mobileRaw) {
        MpUser created = new MpUser();
        created.setUserType(MiniUserType.VISITOR);
@@ -250,8 +414,41 @@
        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();
    }
    @Transactional(rollbackFor = Exception.class)
    public void unbindMiniOpenIdAndLogout() {
        MpUser user = currentMpUser();
        user.setOpenId(UNBOUND_OPEN_ID_PREFIX + UUID.randomUUID().toString().replace("-", ""));
        mpUserMapper.updateById(user);
        MiniStpUtil.logout(user.getId());
    }
    private boolean isUnboundOpenId(String openId) {
        return StringUtils.hasText(openId) && openId.startsWith(UNBOUND_OPEN_ID_PREFIX);
    }
    private MiniLoginResponse buildResponse(MpUser user) {
@@ -264,19 +461,34 @@
            SysDept dept = sysDeptMapper.selectById(user.getDeptId());
            deptName = dept == null ? null : dept.getDeptName();
        }
        Boolean leader =
                MiniUserType.EMPLOYEE.equals(user.getUserType())
                        ? Boolean.TRUE.equals(user.getDeptLeaderFlag())
                        : null;
        return MiniLoginResponse.builder()
                .token(MiniStpUtil.getTokenValue())
                .mpUserId(user.getId())
                .userType(user.getUserType())
                .deptId(user.getDeptId())
                .deptName(deptName)
                .deptLeader(leader)
                .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) {}
}