package com.tcm.training.admin.app.register.service; import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; import com.fasterxml.jackson.databind.ObjectMapper; import com.tcm.training.admin.app.auth.service.AppSmsCodeService; import com.tcm.training.admin.app.auth.vo.AppSmsCodeSendResponse; import com.tcm.training.admin.app.integration.haizhentong.entity.HztAppUser; import com.tcm.training.admin.app.integration.haizhentong.entity.HztClinic; import com.tcm.training.admin.app.integration.haizhentong.mapper.HztAppUserMapper; import com.tcm.training.admin.app.integration.haizhentong.mapper.HztClinicMapper; import com.tcm.training.admin.app.register.dto.AppRegisterOperatorCompleteRequest; import com.tcm.training.admin.app.register.dto.AppRegisterOperatorPrepareRequest; import com.tcm.training.admin.app.register.dto.AppRegisterUserRequest; import com.tcm.training.admin.app.register.vo.AppRegisterClinicInfoVO; import com.tcm.training.admin.app.register.vo.AppRegisterOperatorPrepareResponse; import com.tcm.training.admin.app.register.vo.AppRegisterRegionDatasetVO; import com.tcm.training.admin.app.register.vo.AppRegisterRegionOptionVO; import com.tcm.training.admin.app.register.vo.AppRegisterResultVO; import com.tcm.training.admin.common.PhoneMasker; import com.tcm.training.admin.live.entity.Clinic; import com.tcm.training.admin.live.mapper.ClinicMapper; import com.tcm.training.admin.member.entity.AppUser; import com.tcm.training.admin.member.mapper.UserMapper; import org.springframework.data.redis.core.StringRedisTemplate; import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; import java.util.List; import java.util.UUID; import java.util.concurrent.ThreadLocalRandom; import java.util.concurrent.TimeUnit; import java.util.regex.Pattern; /** * APP 注册服务,负责普通用户、经营者注册与海诊通资料同步。 */ @Service public class AppRegisterService { private static final Pattern PHONE_PATTERN = Pattern.compile("^1\\d{10}$"); private static final Pattern PASSWORD_PATTERN = Pattern.compile("^(?![0-9]+$)(?![a-zA-Z]+$)(?![^0-9a-zA-Z]+$).{8,32}$"); private static final String REGISTER_TICKET_CACHE_PREFIX = "app:register:operator:"; private static final String INVITE_CODE_CHARS = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789"; private static final int INVITE_CODE_LENGTH = 6; private static final long REGISTER_TICKET_EXPIRE_MINUTES = 10L; private final UserMapper userMapper; private final ClinicMapper clinicMapper; private final BCryptPasswordEncoder passwordEncoder; private final AppSmsCodeService appSmsCodeService; private final AppRegisterRegionService appRegisterRegionService; private final HztAppUserMapper hztAppUserMapper; private final HztClinicMapper hztClinicMapper; private final StringRedisTemplate stringRedisTemplate; private final ObjectMapper objectMapper; /** * 注入注册链路所需的本地库、海诊通库、验证码与缓存能力。 */ public AppRegisterService(UserMapper userMapper, ClinicMapper clinicMapper, BCryptPasswordEncoder passwordEncoder, AppSmsCodeService appSmsCodeService, AppRegisterRegionService appRegisterRegionService, HztAppUserMapper hztAppUserMapper, HztClinicMapper hztClinicMapper, StringRedisTemplate stringRedisTemplate, ObjectMapper objectMapper) { this.userMapper = userMapper; this.clinicMapper = clinicMapper; this.passwordEncoder = passwordEncoder; this.appSmsCodeService = appSmsCodeService; this.appRegisterRegionService = appRegisterRegionService; this.hztAppUserMapper = hztAppUserMapper; this.hztClinicMapper = hztClinicMapper; this.stringRedisTemplate = stringRedisTemplate; this.objectMapper = objectMapper; } /** * 为未注册或待注册手机号发送注册验证码。 */ public AppSmsCodeSendResponse sendRegisterCode(String rawPhone) { String phone = normalizePhone(rawPhone); ensurePhoneAvailable(phone); return appSmsCodeService.createRegisterCode(phone); } /** * 提供 APP 注册页使用的省市区三级联动选项。 */ public List listRegions(String parentCode) { return appRegisterRegionService.listRegions(parentCode); } /** * 提供 APP 滚轮地区选择器使用的完整数据集。 */ public AppRegisterRegionDatasetVO getRegionDataset() { return appRegisterRegionService.buildRegionDataset(); } /** * 完成普通用户注册,并按需绑定上级诊所用户。 * 普通用户注册会先查询海诊通数据源,因此这里不包裹本地事务,避免事务先绑定 master 导致跨库查询失效。 */ public AppRegisterResultVO registerUser(AppRegisterUserRequest request) { String phone = normalizePhone(request.getPhone()); String password = normalizePassword(request.getPassword()); String inviteCode = normalizeInviteCode(request.getInviteCode()); ensurePhoneAvailable(phone); appSmsCodeService.validateRegisterCode(phone, normalizeCode(request.getCode())); AppUser user = new AppUser(); user.setPhone(phone); user.setPassword(passwordEncoder.encode(password)); user.setType(0); user.setStatus(1); // 优先同步海诊通同手机号用户资料,未命中时使用手机号兜底昵称。 HztAppUser hztAppUser = findHztAppUserByPhone(phone); populateUserProfile(user, hztAppUser, phone); // 邀请码命中时绑定上级诊所用户,并继承该诊所关系。 bindSuperiorClinicIfNecessary(user, inviteCode); userMapper.insert(user); return new AppRegisterResultVO(user.getId(), user.getType(), user.getClinicId(), null); } /** * 校验经营者第一步,并返回海诊通诊所预填资料与注册凭证。 */ public AppRegisterOperatorPrepareResponse prepareOperatorRegister(AppRegisterOperatorPrepareRequest request) { String phone = normalizePhone(request.getPhone()); String password = normalizePassword(request.getPassword()); ensurePhoneAvailable(phone); appSmsCodeService.validateRegisterCode(phone, normalizeCode(request.getCode())); HztClinic hztClinic = findHztClinicByPhone(phone); OperatorRegisterDraft draft = buildOperatorDraft(phone, password, hztClinic); String registerTicket = createRegisterTicket(); cacheDraft(registerTicket, draft); AppRegisterOperatorPrepareResponse response = new AppRegisterOperatorPrepareResponse(); response.setRegisterTicket(registerTicket); response.setClinicMatched(hztClinic != null); response.setClinicInfo(toClinicInfo(draft)); return response; } /** * 完成经营者第二步,创建本地诊所与诊所用户。 */ @Transactional public AppRegisterResultVO completeOperatorRegister(AppRegisterOperatorCompleteRequest request) { OperatorRegisterDraft draft = loadDraft(normalizeRegisterTicket(request.getRegisterTicket())); ensurePhoneAvailable(draft.getPhone()); Clinic clinic = new Clinic(); clinic.setName(trimRequired(request.getInstitutionName(), "请输入机构名称")); clinic.setContactName(trimToNull(draft.getContactName())); clinic.setContactPhone(draft.getPhone()); clinic.setProvinceCode(trimToNull(request.getProvinceCode())); clinic.setProvinceName(trimRequired(request.getProvinceName(), "请选择省份")); clinic.setCityCode(trimToNull(request.getCityCode())); clinic.setCityName(trimRequired(request.getCityName(), "请选择城市")); clinic.setDistrictCode(trimToNull(request.getDistrictCode())); clinic.setDistrictName(trimRequired(request.getDistrictName(), "请选择区县")); clinic.setAddress(trimRequired(request.getDetailAddress(), "请输入详细地址")); clinic.setBusinessLicenseUrl(trimToNull(request.getBusinessLicenseUrl())); clinic.setInviteCode(generateInviteCode()); clinic.setStatus(1); clinicMapper.insert(clinic); AppUser user = new AppUser(); user.setNickname(clinic.getName()); user.setPhone(draft.getPhone()); user.setPassword(draft.getEncodedPassword()); user.setType(1); user.setStatus(1); user.setClinicId(clinic.getId()); userMapper.insert(user); clearDraft(request.getRegisterTicket()); return new AppRegisterResultVO(user.getId(), user.getType(), clinic.getId(), clinic.getInviteCode()); } /** * 使用海诊通用户快照填充本地用户资料。 */ private void populateUserProfile(AppUser user, HztAppUser hztAppUser, String phone) { if (hztAppUser == null) { user.setNickname(resolveFallbackNickname(phone)); return; } String nickname = trimToNull(hztAppUser.getNickName()); user.setNickname(nickname == null ? resolveFallbackNickname(phone) : nickname); user.setAvatar(trimToNull(hztAppUser.getAvatar())); user.setGender(mapGender(hztAppUser.getSex())); } /** * 按邀请码绑定本地上级诊所用户与诊所归属。 */ private void bindSuperiorClinicIfNecessary(AppUser user, String inviteCode) { if (inviteCode == null) { return; } Clinic clinic = clinicMapper.selectOne(new LambdaQueryWrapper() .eq(Clinic::getInviteCode, inviteCode) .eq(Clinic::getDeleted, 0) .last("limit 1")); if (clinic == null) { throw new IllegalArgumentException("邀请码无效"); } AppUser clinicUser = userMapper.selectOne(new LambdaQueryWrapper() .eq(AppUser::getClinicId, clinic.getId()) .eq(AppUser::getType, 1) .eq(AppUser::getDeleted, 0) .last("limit 1")); if (clinicUser == null) { throw new IllegalArgumentException("邀请码暂不可用,请联系诊所管理员"); } user.setClinicId(clinicUser.getClinicId()); user.setSuperiorClinicUserId(clinicUser.getId()); } /** * 按手机号查询海诊通普通用户资料。 */ private HztAppUser findHztAppUserByPhone(String phone) { return hztAppUserMapper.selectOne(new LambdaQueryWrapper() .eq(HztAppUser::getPhone, phone) .eq(HztAppUser::getDisabled, 0) .orderByDesc(HztAppUser::getCreateTime) .orderByDesc(HztAppUser::getId) .last("limit 1")); } /** * 按手机号查询海诊通诊所资料。 */ private HztClinic findHztClinicByPhone(String phone) { return hztClinicMapper.selectOne(new LambdaQueryWrapper() .eq(HztClinic::getPhone, phone) .eq(HztClinic::getDisabled, 0) .orderByDesc(HztClinic::getCreateTime) .orderByDesc(HztClinic::getId) .last("limit 1")); } /** * 构造经营者注册草稿,避免第二步再次输入验证码与密码。 */ private OperatorRegisterDraft buildOperatorDraft(String phone, String password, HztClinic hztClinic) { OperatorRegisterDraft draft = new OperatorRegisterDraft(); draft.setPhone(phone); draft.setEncodedPassword(passwordEncoder.encode(password)); if (hztClinic == null) { return draft; } AppRegisterRegionService.RegionSelection regionSelection = appRegisterRegionService.resolveRegionSelection( hztClinic.getProvinceCode(), hztClinic.getProvinceName(), hztClinic.getCityCode(), hztClinic.getCityName(), hztClinic.getDistrictCode(), hztClinic.getDistrictName() ); draft.setContactName(trimToNull(hztClinic.getPersonChargeName())); draft.setInstitutionName(trimToNull(hztClinic.getClinicName())); draft.setProvinceCode(trimToNull(regionSelection.getProvinceCode())); draft.setProvinceName(trimToNull(regionSelection.getProvinceName())); draft.setCityCode(trimToNull(regionSelection.getCityCode())); draft.setCityName(trimToNull(regionSelection.getCityName())); draft.setDistrictCode(trimToNull(regionSelection.getDistrictCode())); draft.setDistrictName(trimToNull(regionSelection.getDistrictName())); draft.setDetailAddress(trimToNull(hztClinic.getClinicAddress())); return draft; } /** * 将缓存草稿转换为前端回显结构。 */ private AppRegisterClinicInfoVO toClinicInfo(OperatorRegisterDraft draft) { AppRegisterClinicInfoVO clinicInfo = new AppRegisterClinicInfoVO(); clinicInfo.setInstitutionName(trimToNull(draft.getInstitutionName())); clinicInfo.setContactName(trimToNull(draft.getContactName())); clinicInfo.setProvinceCode(trimToNull(draft.getProvinceCode())); clinicInfo.setProvinceName(trimToNull(draft.getProvinceName())); clinicInfo.setCityCode(trimToNull(draft.getCityCode())); clinicInfo.setCityName(trimToNull(draft.getCityName())); clinicInfo.setDistrictCode(trimToNull(draft.getDistrictCode())); clinicInfo.setDistrictName(trimToNull(draft.getDistrictName())); clinicInfo.setDetailAddress(trimToNull(draft.getDetailAddress())); return clinicInfo; } /** * 将经营者第一步草稿缓存到 Redis。 */ private void cacheDraft(String registerTicket, OperatorRegisterDraft draft) { try { stringRedisTemplate.opsForValue().set( buildRegisterTicketKey(registerTicket), objectMapper.writeValueAsString(draft), REGISTER_TICKET_EXPIRE_MINUTES, TimeUnit.MINUTES ); } catch (Exception ex) { throw new IllegalArgumentException("经营者注册暂不可用,请稍后重试"); } } /** * 从 Redis 读取经营者注册草稿。 */ private OperatorRegisterDraft loadDraft(String registerTicket) { String cacheValue = stringRedisTemplate.opsForValue().get(buildRegisterTicketKey(registerTicket)); if (cacheValue == null || cacheValue.trim().isEmpty()) { throw new IllegalArgumentException("注册凭证已失效,请重新填写"); } try { return objectMapper.readValue(cacheValue, OperatorRegisterDraft.class); } catch (Exception ex) { throw new IllegalArgumentException("注册凭证已失效,请重新填写"); } } /** * 清理已经完成的经营者注册草稿。 */ private void clearDraft(String registerTicket) { stringRedisTemplate.delete(buildRegisterTicketKey(normalizeRegisterTicket(registerTicket))); } /** * 构造经营者注册 ticket 的缓存 key。 */ private String buildRegisterTicketKey(String registerTicket) { return REGISTER_TICKET_CACHE_PREFIX + registerTicket; } /** * 生成经营者第二步提交所需的注册 ticket。 */ private String createRegisterTicket() { return UUID.randomUUID().toString().replace("-", ""); } /** * 生成本地诊所邀请码,并确保在主库内唯一。 */ private String generateInviteCode() { for (int i = 0; i < 20; i++) { String candidate = randomInviteCode(); Clinic exists = clinicMapper.selectOne(new LambdaQueryWrapper() .eq(Clinic::getInviteCode, candidate) .eq(Clinic::getDeleted, 0) .last("limit 1")); if (exists == null) { return candidate; } } throw new IllegalStateException("邀请码生成失败,请稍后重试"); } /** * 生成一次随机邀请码候选值。 */ private String randomInviteCode() { StringBuilder builder = new StringBuilder(INVITE_CODE_LENGTH); for (int i = 0; i < INVITE_CODE_LENGTH; i++) { int index = ThreadLocalRandom.current().nextInt(INVITE_CODE_CHARS.length()); builder.append(INVITE_CODE_CHARS.charAt(index)); } return builder.toString(); } /** * 校验手机号在本地用户表中尚未注册。 */ private void ensurePhoneAvailable(String phone) { AppUser exists = userMapper.selectOne(new LambdaQueryWrapper() .eq(AppUser::getPhone, phone) .eq(AppUser::getDeleted, 0) .last("limit 1")); if (exists != null) { throw new IllegalArgumentException("该手机号已注册"); } } /** * 将海诊通性别枚举映射到本地用户性别定义。 */ private Integer mapGender(Integer sex) { if (sex == null) { return null; } if (sex == 1) { return 1; } if (sex == 2) { return 0; } return null; } /** * 生成普通用户未命中海诊通时的默认昵称。 */ private String resolveFallbackNickname(String phone) { String masked = PhoneMasker.mask(phone); return masked == null ? phone : masked; } /** * 归一化并校验手机号格式。 */ private String normalizePhone(String phone) { String normalized = trimToNull(phone); if (normalized == null || !PHONE_PATTERN.matcher(normalized).matches()) { throw new IllegalArgumentException("请输入正确的手机号"); } return normalized; } /** * 归一化并校验验证码输入。 */ private String normalizeCode(String code) { String normalized = trimToNull(code); if (normalized == null) { throw new IllegalArgumentException("请输入验证码"); } return normalized; } /** * 归一化并校验注册密码规则。 */ private String normalizePassword(String password) { String normalized = trimToNull(password); if (normalized == null) { throw new IllegalArgumentException("请输入密码"); } if (!PASSWORD_PATTERN.matcher(normalized).matches()) { throw new IllegalArgumentException("密码需为8-32位,且包含数字、字母、符号至少2种"); } return normalized; } /** * 归一化邀请码,统一转为大写便于匹配。 */ private String normalizeInviteCode(String inviteCode) { String normalized = trimToNull(inviteCode); return normalized == null ? null : normalized.toUpperCase(); } /** * 归一化经营者注册凭证。 */ private String normalizeRegisterTicket(String registerTicket) { String normalized = trimToNull(registerTicket); if (normalized == null) { throw new IllegalArgumentException("注册凭证已失效,请重新填写"); } return normalized; } /** * 要求字段非空并返回裁剪后的值。 */ private String trimRequired(String value, String message) { String normalized = trimToNull(value); if (normalized == null) { throw new IllegalArgumentException(message); } return normalized; } /** * 将空白字符串统一归一化为 null。 */ private String trimToNull(String value) { if (value == null) { return null; } String trimmed = value.trim(); return trimmed.isEmpty() ? null : trimmed; } /** * 经营者注册第一步的缓存草稿。 */ @lombok.Getter @lombok.Setter @lombok.NoArgsConstructor public static class OperatorRegisterDraft { private String phone; private String encodedPassword; private String contactName; private String institutionName; private String provinceCode; private String provinceName; private String cityCode; private String cityName; private String districtCode; private String districtName; private String detailAddress; } }