| | |
| | | |
| | | 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; |
| | |
| | | @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 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) |
| | |
| | | .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()); |
| | | } |
| | | |
| | | /** |
| | |
| | | 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()) { |
| | |
| | | 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) { |
| | |
| | | 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); |
| | |
| | | } |
| | | 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() { |
| | |
| | | .deptName(deptName) |
| | | .deptLeader(leader) |
| | | .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) {} |
| | | } |