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