| | |
| | | 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 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) |
| | |
| | | } |
| | | 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); |
| | | } |
| | |
| | | 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()); |
| | | } |
| | | |
| | | /** |
| | | * 管理端预建档员工(openId 为 PLHD_ 占位):按手机号绑定当前微信 openId。 |
| | | */ |
| | |
| | | String currentOpenId = user.getOpenId(); |
| | | if (StringUtils.hasText(currentOpenId) |
| | | && !Objects.equals(currentOpenId, openId) |
| | | && !MpStaffConstants.isPlaceholderOpenId(currentOpenId)) { |
| | | && !MpStaffConstants.isPlaceholderOpenId(currentOpenId) |
| | | && !isUnboundOpenId(currentOpenId)) { |
| | | throw new BizException(BizErrorCode.AUTH_FORBIDDEN, "该手机号已绑定其他微信账号"); |
| | | } |
| | | if (MiniUserType.EMPLOYEE.equals(user.getUserType()) |
| | |
| | | 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) { |
| | |
| | | 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) { |
| | | String name = |
| | | user.getNickName() != null && !user.getNickName().isBlank() |
| | |
| | | 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) |
| | |
| | | } |
| | | return trimmed; |
| | | } |
| | | |
| | | private record OfficialBindSession(String openId, String unionId) {} |
| | | } |