package com.tcm.training.admin.app.register.service; import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.ObjectMapper; import com.tcm.training.admin.app.auth.service.AppAuthSessionService; import com.tcm.training.admin.app.auth.service.AppSmsCodeService; import com.tcm.training.admin.app.auth.vo.AppLoginResponse; import com.tcm.training.admin.app.auth.vo.AppLoginUserVO; 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.share.service.AppShareService; 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.common.PhoneMasker; import com.tcm.training.admin.utils.VzanHttpUtil; 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 cn.dev33.satoken.stp.SaTokenInfo; import org.springframework.data.redis.core.StringRedisTemplate; import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; import com.tcm.training.admin.member.enums.UserStatus; import com.tcm.training.admin.member.enums.UserType; 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 @Slf4j public class AppRegisterService { private static final Pattern PHONE_PATTERN = Pattern.compile("^1\\d{10}$"); 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 AppSmsCodeService appSmsCodeService; private final AppAuthSessionService appAuthSessionService; private final AppRegisterRegionService appRegisterRegionService; private final HztAppUserMapper hztAppUserMapper; private final HztClinicMapper hztClinicMapper; private final StringRedisTemplate stringRedisTemplate; private final ObjectMapper objectMapper; private final AppShareService appShareService; private final VzanHttpUtil vzanHttpUtil; /** * 注入注册链路所需的本地库、海诊通库、验证码与缓存能力。 */ public AppRegisterService(UserMapper userMapper, ClinicMapper clinicMapper, AppSmsCodeService appSmsCodeService, AppAuthSessionService appAuthSessionService, AppRegisterRegionService appRegisterRegionService, HztAppUserMapper hztAppUserMapper, HztClinicMapper hztClinicMapper, StringRedisTemplate stringRedisTemplate, ObjectMapper objectMapper, AppShareService appShareService, VzanHttpUtil vzanHttpUtil) { this.userMapper = userMapper; this.clinicMapper = clinicMapper; this.appSmsCodeService = appSmsCodeService; this.appAuthSessionService = appAuthSessionService; this.appRegisterRegionService = appRegisterRegionService; this.hztAppUserMapper = hztAppUserMapper; this.hztClinicMapper = hztClinicMapper; this.stringRedisTemplate = stringRedisTemplate; this.objectMapper = objectMapper; this.appShareService = appShareService; this.vzanHttpUtil = vzanHttpUtil; } /** * 为未注册或待注册手机号发送注册验证码。 */ 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 AppLoginResponse registerUser(AppRegisterUserRequest request) { String phone = normalizePhone(request.getPhone()); String inviteCode = normalizeInviteCode(request.getInviteCode()); String bindTicket = normalizeBindTicket(request.getBindTicket()); ensurePhoneAvailable(phone); appSmsCodeService.validateRegisterCode(phone, normalizeCode(request.getCode())); AppUser user = new AppUser(); user.setPhone(phone); user.setType(UserType.DOCTOR.getValue()); user.setStatus(UserStatus.ENABLED.getValue()); // 优先同步海诊通同手机号用户资料,未命中时使用手机号兜底昵称。 HztAppUser hztAppUser = findHztAppUserByPhone(phone); populateUserProfile(user, hztAppUser, phone); // 绑定票据优先于邀请码,避免安装后丢失或误填导致关系错误。 bindSuperiorClinicIfNecessary(user, inviteCode, bindTicket); userMapper.insert(user); return createLoginResponse(user); } /** * 校验经营者第一步,并返回海诊通诊所预填资料与注册凭证。 */ public AppRegisterOperatorPrepareResponse prepareOperatorRegister(AppRegisterOperatorPrepareRequest request) { String phone = normalizePhone(request.getPhone()); ensurePhoneAvailable(phone); appSmsCodeService.validateRegisterCode(phone, normalizeCode(request.getCode())); HztClinic hztClinic = findHztClinicByPhone(phone); OperatorRegisterDraft draft = buildOperatorDraft(phone, 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(rollbackFor = Exception.class) public AppLoginResponse 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(UserStatus.ENABLED.getValue()); clinic.setAgentId(4L); // 绑定到默认的省代上面 clinicMapper.insert(clinic); AppUser user = new AppUser(); user.setNickname(clinic.getName()); user.setPhone(draft.getPhone()); user.setType(UserType.CLINIC.getValue()); user.setStatus(UserStatus.ENABLED.getValue()); user.setClinicId(clinic.getId()); userMapper.insert(user); Integer provinceCode = parseRegionCode(clinic.getProvinceCode(), "省编码"); Integer cityCode = parseRegionCode(clinic.getCityCode(), "市编码"); Integer areaCode = parseRegionCode(clinic.getDistrictCode(), "区县编码"); log.info("调用微赞创建门店,入参 title={}, provinceCode={}, cityCode={}, areaCode={}, address={}, telephone={}" , clinic.getName(), provinceCode, cityCode, areaCode, clinic.getAddress(), clinic.getContactPhone()); try { JsonNode vzanResponse = vzanHttpUtil.createStore(clinic.getName(), provinceCode, cityCode, areaCode, clinic.getAddress(), clinic.getContactPhone()); log.info("微赞创建门店成功,出参={}", vzanResponse); // 注册阶段暂未绑定区域代理,parentMobile 传空,由微赞按一级代理处理。 log.info("调用微赞创建一级代理,入参 agentMobile={}, agentName={}, levelCode=1, storeName={}, parentMobile=18510939636", clinic.getContactPhone(), clinic.getName(), clinic.getName()); JsonNode vzanAgentResponse = vzanHttpUtil.createAgent( clinic.getContactPhone(), clinic.getName(), 1, "18510939636", clinic.getName()); log.info("微赞创建一级代理成功,出参={}", vzanAgentResponse); } catch (RuntimeException ex) { log.error("微赞创建门店或一级代理失败,本地事务将回滚,clinicId={}", clinic.getId(), ex); throw new IllegalStateException("微赞门店或一级代理创建失败", ex); } clearDraft(request.getRegisterTicket()); return createLoginResponse(user); } private Integer parseRegionCode(String code, String label) { String normalized = code == null ? null : code.trim(); if (normalized == null || normalized.isEmpty()) { throw new IllegalArgumentException(label + "不能为空"); } try { return Integer.valueOf(normalized); } catch (NumberFormatException ex) { throw new IllegalArgumentException(label + "格式错误", ex); } } /** * 使用海诊通用户快照填充本地用户资料。 */ 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, String bindTicket) { AppShareService.InviteBindingTarget bindingTarget = resolveInviteBindingTarget(inviteCode, bindTicket); if (bindingTarget == null) { return; } user.setClinicId(bindingTarget.getClinicId()); user.setSuperiorClinicUserId(bindingTarget.getSuperiorClinicUserId()); } /** * 注册成功后立即建立 APP 登录态并返回统一登录响应。 */ private AppLoginResponse createLoginResponse(AppUser user) { appAuthSessionService.login(user.getId()); return buildLoginResponse(user, appAuthSessionService.getTokenInfo()); } /** * 使用 Sa-Token 信息构造 APP 登录响应。 */ private AppLoginResponse buildLoginResponse(AppUser user, SaTokenInfo tokenInfo) { return new AppLoginResponse( tokenInfo.getTokenValue(), "Bearer", tokenInfo.getTokenTimeout(), buildLoginUser(user) ); } /** * 将注册后的 APP 用户实体转换为登录响应用户摘要。 */ private AppLoginUserVO buildLoginUser(AppUser user) { AppLoginUserVO loginUser = new AppLoginUserVO(); loginUser.setId(user.getId()); loginUser.setNickname(resolveFallbackNickname(user)); loginUser.setPhone(user.getPhone()); loginUser.setAvatar(trimToNull(user.getAvatar())); loginUser.setType(user.getType()); loginUser.setStatus(user.getStatus()); loginUser.setClinicId(user.getClinicId()); loginUser.setMemberExpireAt(user.getMemberExpireAt()); return loginUser; } /** * 解析注册阶段应绑定的目标关系,票据优先于邀请码。 */ private AppShareService.InviteBindingTarget resolveInviteBindingTarget(String inviteCode, String bindTicket) { if (bindTicket != null) { return appShareService.consumeBindTicket(bindTicket); } if (inviteCode == null) { return null; } return appShareService.resolveInviteBindingTarget(inviteCode); } /** * 按手机号查询海诊通普通用户资料。 */ 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, HztClinic hztClinic) { OperatorRegisterDraft draft = new OperatorRegisterDraft(); draft.setPhone(phone); 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 resolveFallbackNickname(AppUser user) { String nickname = trimToNull(user.getNickname()); if (nickname != null) { return nickname; } return resolveFallbackNickname(user.getPhone()); } /** * 归一化并校验手机号格式。 */ 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 normalizeInviteCode(String inviteCode) { String normalized = trimToNull(inviteCode); return normalized == null ? null : normalized.toUpperCase(); } /** * 归一化绑定票据。 */ private String normalizeBindTicket(String bindTicket) { return trimToNull(bindTicket); } /** * 归一化经营者注册凭证。 */ 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 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; } }