From 2a3c3cdbf2398695d61633e072f98ee2757e9e27 Mon Sep 17 00:00:00 2001
From: longz <longzhaoz@qq.com>
Date: 星期二, 11 八月 2026 11:20:26 +0800
Subject: [PATCH] feat(appointment): 添加访客预约系统解除绑定功能

---
 backend/src/main/java/com/sinata/appointment/service/MiniAuthApplicationService.java |  376 ++++++++++++++++++++++++++++++++++++++++++-----------
 1 files changed, 294 insertions(+), 82 deletions(-)

diff --git a/backend/src/main/java/com/sinata/appointment/service/MiniAuthApplicationService.java b/backend/src/main/java/com/sinata/appointment/service/MiniAuthApplicationService.java
index 256ea59..64c9a4d 100644
--- a/backend/src/main/java/com/sinata/appointment/service/MiniAuthApplicationService.java
+++ b/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) {}
 }

--
Gitblit v1.7.1