longz
2026-07-13 5882ba8863d77fdcc168b176c3ffb30e8a20556a
backend/src/main/java/com/sinata/appointment/service/MiniAuthApplicationService.java
@@ -6,18 +6,24 @@
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,17 +34,31 @@
@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) {
            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)
@@ -61,6 +81,45 @@
        }
        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);
    }
@@ -121,6 +180,38 @@
            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());
    }
@@ -199,6 +290,61 @@
        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) {
@@ -301,17 +447,13 @@
            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)
@@ -333,4 +475,6 @@
        }
        return trimmed;
    }
    private record OfficialBindSession(String openId, String unionId) {}
}