hejianhao
2026-09-01 4b4d225c0f8456b8918dc79eec2e093485746d0f
tcm-training-admin-backend/src/main/java/com/tcm/training/admin/app/register/service/AppRegisterService.java
@@ -1,8 +1,12 @@
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;
@@ -16,16 +20,19 @@
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.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 org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
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;
@@ -37,10 +44,10 @@
 * APP 注册服务,负责普通用户、经营者注册与海诊通资料同步。
 */
@Service
@Slf4j
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;
@@ -48,38 +55,41 @@
    private final UserMapper userMapper;
    private final ClinicMapper clinicMapper;
    private final BCryptPasswordEncoder passwordEncoder;
    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,
                              BCryptPasswordEncoder passwordEncoder,
                              AppSmsCodeService appSmsCodeService,
                              AppAuthSessionService appAuthSessionService,
                              AppRegisterRegionService appRegisterRegionService,
                              HztAppUserMapper hztAppUserMapper,
                              HztClinicMapper hztClinicMapper,
                              StringRedisTemplate stringRedisTemplate,
                              ObjectMapper objectMapper,
                              AppShareService appShareService) {
                              AppShareService appShareService,
                              VzanHttpUtil vzanHttpUtil) {
        this.userMapper = userMapper;
        this.clinicMapper = clinicMapper;
        this.passwordEncoder = passwordEncoder;
        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;
    }
    /**
@@ -109,9 +119,8 @@
     * 完成普通用户注册,并按需绑定上级诊所用户。
     * 普通用户注册会先查询海诊通数据源,因此这里不包裹本地事务,避免事务先绑定 master 导致跨库查询失效。
     */
    public AppRegisterResultVO registerUser(AppRegisterUserRequest request) {
    public AppLoginResponse registerUser(AppRegisterUserRequest request) {
        String phone = normalizePhone(request.getPhone());
        String password = normalizePassword(request.getPassword());
        String inviteCode = normalizeInviteCode(request.getInviteCode());
        String bindTicket = normalizeBindTicket(request.getBindTicket());
@@ -120,9 +129,8 @@
        AppUser user = new AppUser();
        user.setPhone(phone);
        user.setPassword(passwordEncoder.encode(password));
        user.setType(0);
        user.setStatus(1);
        user.setType(UserType.DOCTOR.getValue());
        user.setStatus(UserStatus.ENABLED.getValue());
        // 优先同步海诊通同手机号用户资料,未命中时使用手机号兜底昵称。
        HztAppUser hztAppUser = findHztAppUserByPhone(phone);
@@ -132,7 +140,7 @@
        bindSuperiorClinicIfNecessary(user, inviteCode, bindTicket);
        userMapper.insert(user);
        return new AppRegisterResultVO(user.getId(), user.getType(), user.getClinicId(), null);
        return createLoginResponse(user);
    }
    /**
@@ -140,13 +148,12 @@
     */
    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);
        OperatorRegisterDraft draft = buildOperatorDraft(phone, hztClinic);
        String registerTicket = createRegisterTicket();
        cacheDraft(registerTicket, draft);
@@ -160,8 +167,8 @@
    /**
     * 完成经营者第二步,创建本地诊所与诊所用户。
     */
    @Transactional
    public AppRegisterResultVO completeOperatorRegister(AppRegisterOperatorCompleteRequest request) {
    @Transactional(rollbackFor = Exception.class)
    public AppLoginResponse completeOperatorRegister(AppRegisterOperatorCompleteRequest request) {
        OperatorRegisterDraft draft = loadDraft(normalizeRegisterTicket(request.getRegisterTicket()));
        ensurePhoneAvailable(draft.getPhone());
@@ -178,20 +185,53 @@
        clinic.setAddress(trimRequired(request.getDetailAddress(), "请输入详细地址"));
        clinic.setBusinessLicenseUrl(trimToNull(request.getBusinessLicenseUrl()));
        clinic.setInviteCode(generateInviteCode());
        clinic.setStatus(1);
        clinic.setStatus(UserStatus.ENABLED.getValue());
        clinic.setAgentId(4L); // 绑定到默认的省代上面
        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.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 new AppRegisterResultVO(user.getId(), user.getType(), clinic.getId(), clinic.getInviteCode());
        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);
        }
    }
    /**
@@ -218,6 +258,42 @@
        }
        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;
    }
    /**
@@ -258,12 +334,11 @@
    }
    /**
     * 构造经营者注册草稿,避免第二步再次输入验证码与密码。
     * 构造经营者注册草稿,避免第二步再次输入验证码。
     */
    private OperatorRegisterDraft buildOperatorDraft(String phone, String password, HztClinic hztClinic) {
    private OperatorRegisterDraft buildOperatorDraft(String phone, HztClinic hztClinic) {
        OperatorRegisterDraft draft = new OperatorRegisterDraft();
        draft.setPhone(phone);
        draft.setEncodedPassword(passwordEncoder.encode(password));
        if (hztClinic == null) {
            return draft;
        }
@@ -423,6 +498,17 @@
    }
    /**
     * 优先使用用户昵称,缺失时回退到手机号脱敏昵称。
     */
    private String resolveFallbackNickname(AppUser user) {
        String nickname = trimToNull(user.getNickname());
        if (nickname != null) {
            return nickname;
        }
        return resolveFallbackNickname(user.getPhone());
    }
    /**
     * 归一化并校验手机号格式。
     */
    private String normalizePhone(String phone) {
@@ -440,20 +526,6 @@
        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;
    }
@@ -515,8 +587,6 @@
    public static class OperatorRegisterDraft {
        private String phone;
        private String encodedPassword;
        private String contactName;