“mitao”
2026-05-12 4f367056ab0e73d044e920d44b1d7dd0c8b65d80
fix:注册流程优化、屏蔽账号密码登录、已登录后token永不过期
15个文件已修改
850 ■■■■■ 已修改文件
tcm-training-admin-backend/src/main/java/com/tcm/training/admin/app/auth/service/AppAuthService.java 145 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
tcm-training-admin-backend/src/main/java/com/tcm/training/admin/app/auth/service/AppAuthSessionService.java 2 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
tcm-training-admin-backend/src/main/java/com/tcm/training/admin/app/auth/support/StpAppUserUtil.java 6 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
tcm-training-admin-backend/src/main/java/com/tcm/training/admin/app/register/controller/AppRegisterController.java 6 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
tcm-training-admin-backend/src/main/java/com/tcm/training/admin/app/register/dto/AppRegisterOperatorPrepareRequest.java 3 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
tcm-training-admin-backend/src/main/java/com/tcm/training/admin/app/register/dto/AppRegisterUserRequest.java 3 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
tcm-training-admin-backend/src/main/java/com/tcm/training/admin/app/register/service/AppRegisterService.java 95 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
tcm-training-admin-backend/src/main/resources/static/app-download/index.html 168 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
tcm-training-admin-backend/src/test/java/com/tcm/training/admin/app/auth/service/AppAuthServiceTest.java 160 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
tcm-training-admin-backend/src/test/java/com/tcm/training/admin/app/register/service/AppRegisterServiceTest.java 112 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
traditionalApp/pages/login/login.vue 81 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
traditionalApp/pages/login/service.js 7 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
traditionalApp/pages/register/register.vue 43 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
traditionalApp/pages/settings/settings.vue 18 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
traditionalApp/utils/auth-session.js 1 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
tcm-training-admin-backend/src/main/java/com/tcm/training/admin/app/auth/service/AppAuthService.java
@@ -21,20 +21,16 @@
import org.springframework.stereotype.Service;
import java.time.LocalDateTime;
import java.util.UUID;
import java.util.concurrent.TimeUnit;
import java.util.regex.Pattern;
/**
 * APP 登录认证服务,负责短信登录、密码登录、微信登录与忘记密码流程。
 * APP 登录认证服务,负责短信登录、第三方登录与登录态刷新流程。
 */
@Service
public class AppAuthService {
    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 PASSWORD_RESET_TICKET_CACHE_PREFIX = "app:password-reset:ticket:";
    private static final long PASSWORD_RESET_TICKET_EXPIRE_MINUTES = 10L;
    private static final String PASSWORD_AUTH_DISABLED_MESSAGE = "当前版本仅支持验证码登录";
    private final UserMapper userMapper;
    private final AppSmsCodeService appSmsCodeService;
    private final BCryptPasswordEncoder passwordEncoder;
@@ -75,12 +71,11 @@
    }
    /**
     * 为已注册 APP 用户生成忘记密码验证码。
     * 当前版本已下线密码能力,旧客户端调用时返回统一提示。
     */
    public AppSmsCodeSendResponse sendPasswordResetCode(AppSmsCodeSendRequest request) {
        String phone = normalizePhone(request.getPhone());
        requireEnabledUserByPhone(phone);
        return appSmsCodeService.createPasswordResetCode(phone);
        throwPasswordAuthDisabled();
        return null;
    }
    /**
@@ -94,16 +89,11 @@
    }
    /**
     * 使用手机号密码完成 APP 用户登录。
     * 当前版本已下线密码登录,旧客户端调用时返回统一提示。
     */
    public AppLoginResponse passwordLogin(AppPasswordLoginRequest request) {
        String phone = normalizePhone(request.getPhone());
        String password = normalizeLoginPassword(request.getPassword());
        AppUser user = requireEnabledUserByPhone(phone);
        if (!passwordEncoder.matches(password, user.getPassword())) {
            throw new IllegalArgumentException("账号或密码错误");
        }
        return createLoginResponse(user);
        throwPasswordAuthDisabled();
        return null;
    }
    /**
@@ -147,32 +137,18 @@
    }
    /**
     * 校验忘记密码验证码并生成一次性重置凭证。
     * 当前版本已下线密码能力,旧客户端调用时返回统一提示。
     */
    public AppPasswordResetVerifyResponse verifyPasswordResetCode(AppPasswordResetVerifyRequest request) {
        String phone = normalizePhone(request.getPhone());
        requireEnabledUserByPhone(phone);
        appSmsCodeService.validatePasswordResetCode(phone, normalizeCode(request.getCode()));
        String resetTicket = createResetTicket();
        cacheResetTicket(resetTicket, phone);
        return new AppPasswordResetVerifyResponse(resetTicket);
        throwPasswordAuthDisabled();
        return null;
    }
    /**
     * 使用一次性重置凭证更新 APP 用户密码。
     * 当前版本已下线密码能力,旧客户端调用时返回统一提示。
     */
    public void resetPassword(AppPasswordResetRequest request) {
        String resetTicket = normalizeResetTicket(request.getResetTicket());
        String password = normalizeNewPassword(request.getPassword());
        String confirmPassword = normalizeConfirmPassword(request.getConfirmPassword());
        validatePasswordConfirmation(password, confirmPassword);
        String phone = loadPhoneByResetTicket(resetTicket);
        AppUser user = requireEnabledUserByPhone(phone);
        user.setPassword(passwordEncoder.encode(password));
        userMapper.updateById(user);
        stringRedisTemplate.delete(buildResetTicketKey(resetTicket));
        throwPasswordAuthDisabled();
    }
    /**
@@ -445,14 +421,10 @@
    }
    /**
     * 归一化密码登录场景下的密码输入,仅要求非空。
     * 抛出 APP 密码能力下线后的统一业务提示。
     */
    private String normalizeLoginPassword(String password) {
        String normalized = trimToNull(password);
        if (normalized == null) {
            throw new IllegalArgumentException("请输入密码");
        }
        return normalized;
    private void throwPasswordAuthDisabled() {
        throw new IllegalArgumentException(PASSWORD_AUTH_DISABLED_MESSAGE);
    }
    /**
@@ -465,91 +437,6 @@
        }
        String upperInviteCode = normalized.toUpperCase();
        return upperInviteCode.matches("^[A-Z0-9]{6}$") ? upperInviteCode : null;
    }
    /**
     * 归一化并校验忘记密码场景下的新密码格式。
     */
    private String normalizeNewPassword(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 normalizeConfirmPassword(String confirmPassword) {
        String normalized = trimToNull(confirmPassword);
        if (normalized == null) {
            throw new IllegalArgumentException("请再次输入密码");
        }
        if (!PASSWORD_PATTERN.matcher(normalized).matches()) {
            throw new IllegalArgumentException("密码需为8-32位,且包含数字、字母、符号至少2种");
        }
        return normalized;
    }
    /**
     * 归一化并校验一次性重置凭证。
     */
    private String normalizeResetTicket(String resetTicket) {
        String normalized = trimToNull(resetTicket);
        if (normalized == null) {
            throw new IllegalArgumentException("重置凭证已失效,请重新验证");
        }
        return normalized;
    }
    /**
     * 校验两次输入的新密码保持一致。
     */
    private void validatePasswordConfirmation(String password, String confirmPassword) {
        if (!password.equals(confirmPassword)) {
            throw new IllegalArgumentException("两次密码输入不一致");
        }
    }
    /**
     * 生成忘记密码场景使用的一次性重置凭证。
     */
    private String createResetTicket() {
        return UUID.randomUUID().toString().replace("-", "");
    }
    /**
     * 将重置凭证与手机号绑定写入 Redis,供下一步更新密码使用。
     */
    private void cacheResetTicket(String resetTicket, String phone) {
        stringRedisTemplate.opsForValue().set(
                buildResetTicketKey(resetTicket),
                phone,
                PASSWORD_RESET_TICKET_EXPIRE_MINUTES,
                TimeUnit.MINUTES
        );
    }
    /**
     * 从 Redis 读取并校验一次性重置凭证对应的手机号。
     */
    private String loadPhoneByResetTicket(String resetTicket) {
        String phone = stringRedisTemplate.opsForValue().get(buildResetTicketKey(resetTicket));
        if (phone == null || phone.trim().isEmpty()) {
            throw new IllegalArgumentException("重置凭证已失效,请重新验证");
        }
        return phone;
    }
    /**
     * 构造忘记密码重置凭证的 Redis key。
     */
    private String buildResetTicketKey(String resetTicket) {
        return PASSWORD_RESET_TICKET_CACHE_PREFIX + resetTicket;
    }
    /**
tcm-training-admin-backend/src/main/java/com/tcm/training/admin/app/auth/service/AppAuthSessionService.java
@@ -11,7 +11,7 @@
public class AppAuthSessionService {
    /**
     * 为指定 APP 用户建立登录态。
     * 为指定 APP 用户建立永久登录态。
     */
    public void login(Long userId) {
        StpAppUserUtil.login(userId);
tcm-training-admin-backend/src/main/java/com/tcm/training/admin/app/auth/support/StpAppUserUtil.java
@@ -1,5 +1,6 @@
package com.tcm.training.admin.app.auth.support;
import cn.dev33.satoken.stp.SaLoginModel;
import cn.dev33.satoken.stp.SaTokenInfo;
import cn.dev33.satoken.stp.StpLogic;
@@ -9,15 +10,16 @@
public final class StpAppUserUtil {
    private static final StpLogic STP_LOGIC = new StpLogic("app-user");
    private static final long NEVER_EXPIRE_TIMEOUT = -1L;
    private StpAppUserUtil() {
    }
    /**
     * 为 APP 用户建立登录态。
     * 为 APP 用户建立永久登录态。
     */
    public static void login(Object loginId) {
        STP_LOGIC.login(loginId);
        STP_LOGIC.login(loginId, new SaLoginModel().setTimeout(NEVER_EXPIRE_TIMEOUT));
    }
    /**
tcm-training-admin-backend/src/main/java/com/tcm/training/admin/app/register/controller/AppRegisterController.java
@@ -1,6 +1,7 @@
package com.tcm.training.admin.app.register.controller;
import com.tcm.training.admin.app.auth.dto.AppSmsCodeSendRequest;
import com.tcm.training.admin.app.auth.vo.AppLoginResponse;
import com.tcm.training.admin.app.auth.vo.AppSmsCodeSendResponse;
import com.tcm.training.admin.app.register.dto.AppRegisterOperatorCompleteRequest;
import com.tcm.training.admin.app.register.dto.AppRegisterOperatorPrepareRequest;
@@ -9,7 +10,6 @@
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.ApiResponse;
import com.tcm.training.admin.upload.ObsUploadService;
import com.tcm.training.admin.upload.UploadController;
@@ -82,7 +82,7 @@
     * 提交普通用户注册。
     */
    @PostMapping("/user")
    public ApiResponse<AppRegisterResultVO> registerUser(@Valid @RequestBody AppRegisterUserRequest request) {
    public ApiResponse<AppLoginResponse> registerUser(@Valid @RequestBody AppRegisterUserRequest request) {
        return ApiResponse.ok(appRegisterService.registerUser(request));
    }
@@ -98,7 +98,7 @@
     * 提交经营者第二步并创建本地诊所与诊所用户。
     */
    @PostMapping("/operator/complete")
    public ApiResponse<AppRegisterResultVO> completeOperator(@Valid @RequestBody AppRegisterOperatorCompleteRequest request) {
    public ApiResponse<AppLoginResponse> completeOperator(@Valid @RequestBody AppRegisterOperatorCompleteRequest request) {
        return ApiResponse.ok(appRegisterService.completeOperatorRegister(request));
    }
tcm-training-admin-backend/src/main/java/com/tcm/training/admin/app/register/dto/AppRegisterOperatorPrepareRequest.java
@@ -19,7 +19,4 @@
    @NotBlank(message = "请输入验证码")
    private String code;
    @NotBlank(message = "请输入密码")
    private String password;
}
tcm-training-admin-backend/src/main/java/com/tcm/training/admin/app/register/dto/AppRegisterUserRequest.java
@@ -20,9 +20,6 @@
    @NotBlank(message = "请输入验证码")
    private String code;
    @NotBlank(message = "请输入密码")
    private String password;
    private String inviteCode;
    private String bindTicket;
tcm-training-admin-backend/src/main/java/com/tcm/training/admin/app/register/service/AppRegisterService.java
@@ -2,7 +2,10 @@
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
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,14 +19,13 @@
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 cn.dev33.satoken.stp.SaTokenInfo;
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;
@@ -40,7 +42,6 @@
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,8 +49,8 @@
    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;
@@ -62,8 +63,8 @@
     */
    public AppRegisterService(UserMapper userMapper,
                              ClinicMapper clinicMapper,
                              BCryptPasswordEncoder passwordEncoder,
                              AppSmsCodeService appSmsCodeService,
                              AppAuthSessionService appAuthSessionService,
                              AppRegisterRegionService appRegisterRegionService,
                              HztAppUserMapper hztAppUserMapper,
                              HztClinicMapper hztClinicMapper,
@@ -72,8 +73,8 @@
                              AppShareService appShareService) {
        this.userMapper = userMapper;
        this.clinicMapper = clinicMapper;
        this.passwordEncoder = passwordEncoder;
        this.appSmsCodeService = appSmsCodeService;
        this.appAuthSessionService = appAuthSessionService;
        this.appRegisterRegionService = appRegisterRegionService;
        this.hztAppUserMapper = hztAppUserMapper;
        this.hztClinicMapper = hztClinicMapper;
@@ -109,9 +110,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,7 +120,6 @@
        AppUser user = new AppUser();
        user.setPhone(phone);
        user.setPassword(passwordEncoder.encode(password));
        user.setType(0);
        user.setStatus(1);
@@ -132,7 +131,7 @@
        bindSuperiorClinicIfNecessary(user, inviteCode, bindTicket);
        userMapper.insert(user);
        return new AppRegisterResultVO(user.getId(), user.getType(), user.getClinicId(), null);
        return createLoginResponse(user);
    }
    /**
@@ -140,13 +139,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);
@@ -161,7 +159,7 @@
     * 完成经营者第二步,创建本地诊所与诊所用户。
     */
    @Transactional
    public AppRegisterResultVO completeOperatorRegister(AppRegisterOperatorCompleteRequest request) {
    public AppLoginResponse completeOperatorRegister(AppRegisterOperatorCompleteRequest request) {
        OperatorRegisterDraft draft = loadDraft(normalizeRegisterTicket(request.getRegisterTicket()));
        ensurePhoneAvailable(draft.getPhone());
@@ -184,14 +182,13 @@
        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());
        return createLoginResponse(user);
    }
    /**
@@ -218,6 +215,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 +291,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 +455,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 +483,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 +544,6 @@
    public static class OperatorRegisterDraft {
        private String phone;
        private String encodedPassword;
        private String contactName;
tcm-training-admin-backend/src/main/resources/static/app-download/index.html
@@ -131,98 +131,9 @@
            color: #c63520;
            min-height: 20px;
        }
        .wechat-guide {
            position: fixed;
            top: 0;
            right: 0;
            bottom: 0;
            left: 0;
            z-index: 999;
            display: none;
            background: rgba(47, 29, 18, 0.72);
            padding: 20px 16px 28px;
            overflow-y: auto;
        }
        .wechat-guide.is-visible {
            display: block;
        }
        .wechat-guide__panel {
            width: 100%;
            max-width: 520px;
            margin: 12px auto 0;
            background: linear-gradient(180deg, #fffaf4 0%, #fff3e6 100%);
            border-radius: 24px;
            padding: 22px 18px 18px;
            box-shadow: 0 18px 48px rgba(0, 0, 0, 0.22);
            position: relative;
        }
        .wechat-guide__badge {
            display: inline-flex;
            align-items: center;
            border-radius: 999px;
            background: #ffe8cf;
            color: #b24a11;
            font-size: 12px;
            font-weight: 700;
            padding: 6px 10px;
        }
        .wechat-guide__title {
            margin: 14px 0 10px;
            font-size: 24px;
            line-height: 1.35;
            font-weight: 700;
            color: #2f1d12;
        }
        .wechat-guide__desc {
            margin: 0;
            color: #6f5b4d;
            font-size: 14px;
            line-height: 1.8;
        }
        .wechat-guide__steps {
            margin: 18px 0 0;
            padding: 16px 16px 16px 38px;
            border-radius: 18px;
            background: rgba(255, 255, 255, 0.72);
            color: #5b4638;
            font-size: 14px;
            line-height: 1.9;
        }
        .wechat-guide__hint {
            margin-top: 14px;
            color: #9b5f33;
            font-size: 13px;
            line-height: 1.7;
        }
        .wechat-guide__close {
            margin-top: 18px;
            width: 100%;
            border: none;
            border-radius: 14px;
            background: #c63520;
            color: #fff;
            font-size: 15px;
            font-weight: 600;
            padding: 14px 16px;
            cursor: pointer;
        }
    </style>
</head>
<body>
<div class="wechat-guide" id="wechatGuide">
    <div class="wechat-guide__panel">
        <div class="wechat-guide__badge">微信内暂不支持直接下载</div>
        <h2 class="wechat-guide__title">请使用系统浏览器打开后再下载 APP</h2>
        <p class="wechat-guide__desc">微信内置浏览器会拦截安装包下载。请先通过右上角菜单切换到系统浏览器,再继续安装。</p>
        <ol class="wechat-guide__steps">
            <li>点击右上角“...”。</li>
            <li>选择“在浏览器打开”。</li>
            <li>进入浏览器后,再点击下载按钮。</li>
        </ol>
        <div class="wechat-guide__hint">邀请码会保留在当前页面,切换到浏览器后仍可复制并用于注册绑定诊所。</div>
        <button class="wechat-guide__close" id="wechatGuideClose" type="button">我知道了</button>
    </div>
</div>
<div class="page">
    <div class="card">
        <div class="badge">中医培训 APP 下载</div>
@@ -263,7 +174,6 @@
        inviteCode: '',
        androidDownloadUrl: '',
        iosDownloadUrl: '',
        wechatBrowser: false,
        openAppBaseUrl: '',
        bindTicketLoading: false,
        openAppUrl: '',
@@ -290,8 +200,6 @@
     */
    async function bootstrap() {
        bindActions()
        detectWechatEnvironment()
        bindWechatEnvironmentListeners()
        bindResumeAutoOpenListeners()
        const inviteCode = normalizeInviteCode(readQueryParam('inviteCode'))
        state.inviteCode = inviteCode
@@ -367,20 +275,12 @@
            }
            copyInviteCode(state.inviteCode, true)
        })
        document.getElementById('wechatGuideClose').addEventListener('click', function () {
            hideWechatGuide()
        })
    }
    /**
     * 处理主按钮动作,优先拉起 APP,未安装时自动下载。
     */
    async function handlePrimaryAction(fromResume) {
        if (shouldBlockDirectDownload()) {
            showWechatGuide()
            showStatus('请点击右上角,选择在浏览器中打开后继续')
            return
        }
        if (!state.inviteCode) {
            showStatus('当前二维码未包含邀请码,请复制邀请码后手动注册')
            return
@@ -782,74 +682,6 @@
     */
    function renderInviteCode(inviteCode) {
        document.getElementById('inviteCodeValue').textContent = inviteCode || '--'
    }
    /**
     * 根据当前环境刷新微信内提示。
     */
    function detectWechatEnvironment() {
        const matched = isWechatBrowser()
        state.wechatBrowser = matched
        if (matched) {
            showWechatGuide()
            showStatus('检测到微信内打开,请点击右上角,选择在浏览器中打开后再下载')
        }
    }
    /**
     * 绑定微信桥注入后的补充检测。
     */
    function bindWechatEnvironmentListeners() {
        document.addEventListener('WeixinJSBridgeReady', function () {
            detectWechatEnvironment()
        }, false)
        window.addEventListener('load', function () {
            window.setTimeout(function () {
                detectWechatEnvironment()
            }, 300)
        })
    }
    /**
     * 判断当前是否为微信内置浏览器。
     */
    function isWechatBrowser() {
        if (state.wechatBrowser) {
            return true
        }
        const userAgent = String(window.navigator.userAgent || '').toLowerCase()
        if (userAgent.indexOf('micromessenger') >= 0 || userAgent.indexOf('wechat') >= 0) {
            return true
        }
        if (window.__wxjs_environment === 'miniprogram') {
            return true
        }
        if (typeof window.WeixinJSBridge !== 'undefined') {
            return true
        }
        return false
    }
    /**
     * 判断当前下载动作是否需要被微信环境拦截。
     */
    function shouldBlockDirectDownload() {
        state.wechatBrowser = isWechatBrowser()
        return state.wechatBrowser
    }
    /**
     * 展示微信内打开浏览器指引。
     */
    function showWechatGuide() {
        document.getElementById('wechatGuide').classList.add('is-visible')
    }
    /**
     * 隐藏微信内打开浏览器指引。
     */
    function hideWechatGuide() {
        document.getElementById('wechatGuide').classList.remove('is-visible')
    }
    /**
tcm-training-admin-backend/src/test/java/com/tcm/training/admin/app/auth/service/AppAuthServiceTest.java
@@ -10,8 +10,6 @@
import com.tcm.training.admin.app.auth.dto.AppWechatLoginRequest;
import com.tcm.training.admin.app.auth.vo.AppLoginResponse;
import com.tcm.training.admin.app.auth.vo.AppPasswordResetVerifyResponse;
import com.tcm.training.admin.app.auth.vo.AppSmsCodeSendResponse;
import com.tcm.training.admin.app.auth.exception.InviteCodeRequiredException;
import com.tcm.training.admin.app.share.service.AppShareService;
import com.tcm.training.admin.member.entity.AppUser;
import com.tcm.training.admin.member.mapper.UserMapper;
@@ -26,13 +24,9 @@
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.data.redis.core.ValueOperations;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import java.util.concurrent.TimeUnit;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertNull;
import static org.mockito.ArgumentMatchers.any;
@@ -79,9 +73,6 @@
    @Mock
    private StringRedisTemplate stringRedisTemplate;
    @Mock
    private ValueOperations<String, String> valueOperations;
    private AppAuthService appAuthService;
    @Captor
@@ -105,128 +96,69 @@
    }
    /**
     * 密码登录成功时应返回统一登录响应并建立 APP 登录态。
     * 密码登录能力下线后旧客户端调用应返回统一提示。
     */
    @Test
    void passwordLoginShouldReturnTokenWhenPasswordMatches() {
    void passwordLoginShouldReturnDisabledMessage() {
        AppPasswordLoginRequest request = new AppPasswordLoginRequest();
        request.setPhone("13800138000");
        request.setPassword("abc123!!");
        AppUser user = buildEnabledUser("13800138000", "ENCODED");
        SaTokenInfo tokenInfo = new SaTokenInfo();
        tokenInfo.setTokenValue("token-value");
        tokenInfo.setTokenTimeout(7200L);
        when(userMapper.selectOne(any())).thenReturn(user);
        when(passwordEncoder.matches("abc123!!", "ENCODED")).thenReturn(true);
        when(appAuthSessionService.getTokenInfo()).thenReturn(tokenInfo);
        AppLoginResponse response = appAuthService.passwordLogin(request);
        verify(appAuthSessionService).login(1L);
        assertEquals("token-value", response.getToken());
        assertEquals("Bearer", response.getTokenType());
        assertEquals(7200L, response.getExpiresIn());
        assertEquals(Long.valueOf(1L), response.getUser().getId());
        assertEquals("13800138000", response.getUser().getPhone());
    }
    /**
     * 密码错误时应返回统一错误文案。
     */
    @Test
    void passwordLoginShouldFailWhenPasswordMismatch() {
        AppPasswordLoginRequest request = new AppPasswordLoginRequest();
        request.setPhone("13800138000");
        request.setPassword("bad-password");
        AppUser user = buildEnabledUser("13800138000", "ENCODED");
        when(userMapper.selectOne(any())).thenReturn(user);
        when(passwordEncoder.matches("bad-password", "ENCODED")).thenReturn(false);
        IllegalArgumentException exception = assertThrows(IllegalArgumentException.class,
                () -> appAuthService.passwordLogin(request));
        assertEquals("账号或密码错误", exception.getMessage());
        assertEquals("当前版本仅支持验证码登录", exception.getMessage());
        verify(passwordEncoder, never()).matches(anyString(), anyString());
        verify(appAuthSessionService, never()).login(anyLong());
    }
    /**
     * 忘记密码发码仅允许已启用用户发送。
     * 忘记密码发码能力下线后旧客户端调用应返回统一提示。
     */
    @Test
    void sendPasswordResetCodeShouldReturnExpireSeconds() {
    void sendPasswordResetCodeShouldReturnDisabledMessage() {
        AppSmsCodeSendRequest request = new AppSmsCodeSendRequest();
        request.setPhone("13800138001");
        when(userMapper.selectOne(any())).thenReturn(buildEnabledUser("13800138001", "ENCODED"));
        when(appSmsCodeService.createPasswordResetCode("13800138001")).thenReturn(new AppSmsCodeSendResponse(300));
        IllegalArgumentException exception = assertThrows(IllegalArgumentException.class,
                () -> appAuthService.sendPasswordResetCode(request));
        AppSmsCodeSendResponse response = appAuthService.sendPasswordResetCode(request);
        assertEquals(300, response.getExpireInSeconds());
        assertEquals("当前版本仅支持验证码登录", exception.getMessage());
        verify(appSmsCodeService, never()).createPasswordResetCode(anyString());
    }
    /**
     * 忘记密码验证码校验成功后应签发一次性重置凭证并写入缓存。
     * 忘记密码验证码校验能力下线后旧客户端调用应返回统一提示。
     */
    @Test
    void verifyPasswordResetCodeShouldCacheTicket() {
    void verifyPasswordResetCodeShouldReturnDisabledMessage() {
        AppPasswordResetVerifyRequest request = new AppPasswordResetVerifyRequest();
        request.setPhone("13800138002");
        request.setCode("666666");
        when(userMapper.selectOne(any())).thenReturn(buildEnabledUser("13800138002", "ENCODED"));
        when(stringRedisTemplate.opsForValue()).thenReturn(valueOperations);
        doNothing().when(appSmsCodeService).validatePasswordResetCode("13800138002", "666666");
        IllegalArgumentException exception = assertThrows(IllegalArgumentException.class,
                () -> appAuthService.verifyPasswordResetCode(request));
        AppPasswordResetVerifyResponse response = appAuthService.verifyPasswordResetCode(request);
        assertNotNull(response.getResetTicket());
        verify(valueOperations).set(anyString(), anyString(), anyLong(), any(TimeUnit.class));
        assertEquals("当前版本仅支持验证码登录", exception.getMessage());
        verify(appSmsCodeService, never()).validatePasswordResetCode(anyString(), anyString());
    }
    /**
     * 使用有效重置凭证时应更新数据库中的密码并销毁凭证。
     * 重置密码能力下线后旧客户端调用应返回统一提示。
     */
    @Test
    void resetPasswordShouldUpdatePasswordAndDeleteTicket() {
    void resetPasswordShouldReturnDisabledMessage() {
        AppPasswordResetRequest request = new AppPasswordResetRequest();
        request.setResetTicket("ticket-1");
        request.setPassword("abc123!!");
        request.setConfirmPassword("abc123!!");
        AppUser user = buildEnabledUser("13800138003", "OLD");
        when(stringRedisTemplate.opsForValue()).thenReturn(valueOperations);
        when(valueOperations.get(anyString())).thenReturn("13800138003");
        when(userMapper.selectOne(any())).thenReturn(user);
        when(passwordEncoder.encode("abc123!!")).thenReturn("NEW-ENCODED");
        appAuthService.resetPassword(request);
        verify(userMapper).updateById(appUserCaptor.capture());
        verify(stringRedisTemplate).delete(anyString());
        assertEquals("NEW-ENCODED", appUserCaptor.getValue().getPassword());
    }
    /**
     * 重置凭证失效时应拒绝更新密码。
     */
    @Test
    void resetPasswordShouldFailWhenTicketExpired() {
        AppPasswordResetRequest request = new AppPasswordResetRequest();
        request.setResetTicket("expired-ticket");
        request.setPassword("abc123!!");
        request.setConfirmPassword("abc123!!");
        when(stringRedisTemplate.opsForValue()).thenReturn(valueOperations);
        when(valueOperations.get(anyString())).thenReturn(null);
        IllegalArgumentException exception = assertThrows(IllegalArgumentException.class,
                () -> appAuthService.resetPassword(request));
        assertEquals("重置凭证已失效,请重新验证", exception.getMessage());
        assertEquals("当前版本仅支持验证码登录", exception.getMessage());
        verify(passwordEncoder, never()).encode(anyString());
        verify(userMapper, never()).updateById(any(AppUser.class));
    }
    /**
@@ -310,10 +242,10 @@
    }
    /**
     * 微信首次登录未携带邀请码时应提示联系管理员获取。
     * 微信首次登录未携带邀请码时应创建待绑定手机号的临时用户。
     */
    @Test
    void wechatLoginShouldRequireInviteCodeWhenOpenidNotExists() {
    void wechatLoginShouldCreatePendingUserWhenInviteCodeMissing() {
        AppWechatLoginRequest request = new AppWechatLoginRequest();
        request.setAccessToken("access-token");
        request.setOpenid("openid-new");
@@ -323,12 +255,19 @@
        when(appWechatAuthClient.resolveUserProfile(request)).thenReturn(profile);
        when(userMapper.selectOne(any())).thenReturn(null);
        when(userMapper.insert(any(AppUser.class))).thenAnswer(invocation -> {
            AppUser inserted = invocation.getArgument(0);
            inserted.setId(2001L);
            return 1;
        });
        when(appAuthSessionService.getTokenInfo()).thenReturn(buildTokenInfo("wechat-pending-token", 7200L));
        InviteCodeRequiredException exception = assertThrows(InviteCodeRequiredException.class,
                () -> appAuthService.wechatLogin(request));
        AppLoginResponse response = appAuthService.wechatLogin(request);
        assertEquals("暂无邀请码,请联系管理员获取", exception.getMessage());
        verify(userMapper, never()).insert(any());
        verify(userMapper).insert(appUserCaptor.capture());
        assertNull(appUserCaptor.getValue().getPhone());
        assertEquals("openid-new", appUserCaptor.getValue().getWechatOpenid());
        assertEquals("wechat-pending-token", response.getToken());
    }
    /**
@@ -405,10 +344,10 @@
    }
    /**
     * 苹果首次登录未携带邀请码时应提示联系管理员获取。
     * 苹果首次登录未携带邀请码时应创建待绑定手机号的临时用户。
     */
    @Test
    void appleLoginShouldRequireInviteCodeWhenSubjectNotExists() {
    void appleLoginShouldCreatePendingUserWhenInviteCodeMissing() {
        AppAppleLoginRequest request = new AppAppleLoginRequest();
        request.setIdentityToken("identity-token");
        request.setUserIdentifier("apple-user-new");
@@ -419,12 +358,19 @@
        when(appAppleAuthClient.resolveUserProfile(request)).thenReturn(profile);
        when(userMapper.selectOne(any())).thenReturn(null);
        when(userMapper.insert(any(AppUser.class))).thenAnswer(invocation -> {
            AppUser inserted = invocation.getArgument(0);
            inserted.setId(3001L);
            return 1;
        });
        when(appAuthSessionService.getTokenInfo()).thenReturn(buildTokenInfo("apple-pending-token", 7200L));
        InviteCodeRequiredException exception = assertThrows(InviteCodeRequiredException.class,
                () -> appAuthService.appleLogin(request));
        AppLoginResponse response = appAuthService.appleLogin(request);
        assertEquals("暂无邀请码,请联系管理员获取", exception.getMessage());
        verify(userMapper, never()).insert(any());
        verify(userMapper).insert(appUserCaptor.capture());
        assertNull(appUserCaptor.getValue().getPhone());
        assertEquals("apple-subject-new", appUserCaptor.getValue().getAppleIdentitySubject());
        assertEquals("apple-pending-token", response.getToken());
    }
    /**
@@ -456,6 +402,16 @@
    }
    /**
     * 构造测试用 token 信息。
     */
    private SaTokenInfo buildTokenInfo(String tokenValue, long timeout) {
        SaTokenInfo tokenInfo = new SaTokenInfo();
        tokenInfo.setTokenValue(tokenValue);
        tokenInfo.setTokenTimeout(timeout);
        return tokenInfo;
    }
    /**
     * 统一构造启用中的 APP 用户测试桩。
     */
    private AppUser buildEnabledUser(String phone, String password) {
tcm-training-admin-backend/src/test/java/com/tcm/training/admin/app/register/service/AppRegisterServiceTest.java
@@ -3,20 +3,23 @@
import com.baomidou.mybatisplus.core.MybatisConfiguration;
import com.baomidou.mybatisplus.core.metadata.TableInfoHelper;
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.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.AppRegisterOperatorPrepareResponse;
import com.tcm.training.admin.app.register.vo.AppRegisterResultVO;
import com.tcm.training.admin.app.share.service.AppShareService;
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.apache.ibatis.builder.MapperBuilderAssistant;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.BeforeEach;
@@ -28,7 +31,6 @@
import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.data.redis.core.ValueOperations;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import java.util.concurrent.TimeUnit;
@@ -40,6 +42,7 @@
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyLong;
import static org.mockito.Mockito.doNothing;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
@@ -65,10 +68,10 @@
    private ClinicMapper clinicMapper;
    @Mock
    private BCryptPasswordEncoder passwordEncoder;
    private AppSmsCodeService appSmsCodeService;
    @Mock
    private AppSmsCodeService appSmsCodeService;
    private AppAuthSessionService appAuthSessionService;
    @Mock
    private AppRegisterRegionService appRegisterRegionService;
@@ -99,8 +102,8 @@
        appRegisterService = new AppRegisterService(
                userMapper,
                clinicMapper,
                passwordEncoder,
                appSmsCodeService,
                appAuthSessionService,
                appRegisterRegionService,
                hztAppUserMapper,
                hztClinicMapper,
@@ -118,7 +121,6 @@
        AppRegisterUserRequest request = new AppRegisterUserRequest();
        request.setPhone("13800138000");
        request.setCode("666666");
        request.setPassword("abc123!!");
        request.setInviteCode("abc123");
        HztAppUser hztAppUser = new HztAppUser();
@@ -133,29 +135,36 @@
        clinicUser.setId(88L);
        clinicUser.setClinicId(19L);
        when(passwordEncoder.encode("abc123!!")).thenReturn("ENCODED");
        AppShareService.InviteBindingTarget bindingTarget = new AppShareService.InviteBindingTarget();
        bindingTarget.setClinicId(19L);
        bindingTarget.setSuperiorClinicUserId(88L);
        when(appShareService.resolveInviteBindingTarget("ABC123")).thenReturn(bindingTarget);
        when(hztAppUserMapper.selectOne(any())).thenReturn(hztAppUser);
        when(clinicMapper.selectOne(any())).thenReturn(clinic);
        when(userMapper.selectOne(any())).thenReturn(null, clinicUser);
        when(userMapper.selectOne(any())).thenReturn(null);
        doNothing().when(appSmsCodeService).validateRegisterCode("13800138000", "666666");
        when(userMapper.insert(any(AppUser.class))).thenAnswer(invocation -> {
            AppUser inserted = invocation.getArgument(0);
            inserted.setId(1001L);
            return 1;
        });
        when(appAuthSessionService.getTokenInfo()).thenReturn(buildTokenInfo("register-token", 7200L));
        AppRegisterResultVO result = appRegisterService.registerUser(request);
        AppLoginResponse result = appRegisterService.registerUser(request);
        verify(userMapper).insert(appUserCaptor.capture());
        verify(appAuthSessionService).login(1001L);
        AppUser inserted = appUserCaptor.getValue();
        assertEquals("海诊通昵称", inserted.getNickname());
        assertEquals("https://cdn.test/avatar.png", inserted.getAvatar());
        assertEquals(Integer.valueOf(0), inserted.getGender());
        assertEquals(Long.valueOf(19L), inserted.getClinicId());
        assertEquals(Long.valueOf(88L), inserted.getSuperiorClinicUserId());
        assertEquals("ENCODED", inserted.getPassword());
        assertEquals(Long.valueOf(1001L), result.getUserId());
        assertEquals(Integer.valueOf(0), result.getUserType());
        assertNull(inserted.getPassword());
        assertEquals("register-token", result.getToken());
        assertEquals("Bearer", result.getTokenType());
        assertEquals(7200L, result.getExpiresIn());
        assertEquals(Long.valueOf(1001L), result.getUser().getId());
        assertEquals(Integer.valueOf(0), result.getUser().getType());
    }
    /**
@@ -166,9 +175,7 @@
        AppRegisterUserRequest request = new AppRegisterUserRequest();
        request.setPhone("13800138123");
        request.setCode("666666");
        request.setPassword("abc123!!");
        when(passwordEncoder.encode("abc123!!")).thenReturn("ENCODED");
        when(userMapper.selectOne(any())).thenReturn(null);
        when(hztAppUserMapper.selectOne(any())).thenReturn(null);
        doNothing().when(appSmsCodeService).validateRegisterCode("13800138123", "666666");
@@ -177,16 +184,19 @@
            inserted.setId(1002L);
            return 1;
        });
        when(appAuthSessionService.getTokenInfo()).thenReturn(buildTokenInfo("fallback-token", 3600L));
        AppRegisterResultVO result = appRegisterService.registerUser(request);
        AppLoginResponse result = appRegisterService.registerUser(request);
        verify(userMapper).insert(appUserCaptor.capture());
        verify(appAuthSessionService).login(1002L);
        AppUser inserted = appUserCaptor.getValue();
        assertEquals("138****8123", inserted.getNickname());
        assertNull(inserted.getAvatar());
        assertNull(inserted.getGender());
        assertEquals(Long.valueOf(1002L), result.getUserId());
        assertEquals(Integer.valueOf(0), result.getUserType());
        assertNull(inserted.getPassword());
        assertEquals(Long.valueOf(1002L), result.getUser().getId());
        assertEquals(Integer.valueOf(0), result.getUser().getType());
    }
    /**
@@ -197,9 +207,7 @@
        AppRegisterOperatorPrepareRequest request = new AppRegisterOperatorPrepareRequest();
        request.setPhone("13800138001");
        request.setCode("666666");
        request.setPassword("abc123!!");
        when(passwordEncoder.encode("abc123!!")).thenReturn("ENCODED");
        when(userMapper.selectOne(any())).thenReturn(null);
        when(hztClinicMapper.selectOne(any())).thenReturn(null);
        when(stringRedisTemplate.opsForValue()).thenReturn(valueOperations);
@@ -222,7 +230,6 @@
        AppRegisterOperatorPrepareRequest request = new AppRegisterOperatorPrepareRequest();
        request.setPhone("13800138002");
        request.setCode("666666");
        request.setPassword("abc123!!");
        HztClinic hztClinic = new HztClinic();
        hztClinic.setClinicName("海诊通诊所");
@@ -239,7 +246,6 @@
        regionSelection.setDistrictCode("330106");
        regionSelection.setDistrictName("西湖区");
        when(passwordEncoder.encode("abc123!!")).thenReturn("ENCODED");
        when(userMapper.selectOne(any())).thenReturn(null);
        when(hztClinicMapper.selectOne(any())).thenReturn(hztClinic);
        when(appRegisterRegionService.resolveRegionSelection(null, "浙江省", null, "杭州市", null, "西湖区")).thenReturn(regionSelection);
@@ -255,4 +261,66 @@
        assertEquals("330106", response.getClinicInfo().getDistrictCode());
        assertEquals("文三路 1 号", response.getClinicInfo().getDetailAddress());
    }
    /**
     * 经营者第二步完成注册后应直接返回登录响应。
     */
    @Test
    void completeOperatorShouldReturnLoginResponse() throws Exception {
        AppRegisterOperatorCompleteRequest request = new AppRegisterOperatorCompleteRequest();
        request.setRegisterTicket("ticket-1");
        request.setInstitutionName("本地诊所");
        request.setProvinceName("浙江省");
        request.setCityName("杭州市");
        request.setDistrictName("西湖区");
        request.setDetailAddress("文三路 2 号");
        request.setBusinessLicenseUrl("https://cdn.test/license.png");
        AppRegisterService.OperatorRegisterDraft draft = new AppRegisterService.OperatorRegisterDraft();
        draft.setPhone("13800138003");
        draft.setContactName("负责人");
        Clinic clinicExists = null;
        when(stringRedisTemplate.opsForValue()).thenReturn(valueOperations);
        when(valueOperations.get("app:register:operator:ticket-1")).thenReturn(new ObjectMapper().writeValueAsString(draft));
        when(userMapper.selectOne(any())).thenReturn(null);
        when(clinicMapper.selectOne(any())).thenReturn(clinicExists);
        when(clinicMapper.insert(any(Clinic.class))).thenAnswer(invocation -> {
            Clinic inserted = invocation.getArgument(0);
            inserted.setId(2001L);
            inserted.setInviteCode("ABC123");
            return 1;
        });
        when(userMapper.insert(any(AppUser.class))).thenAnswer(invocation -> {
            AppUser inserted = invocation.getArgument(0);
            inserted.setId(3001L);
            return 1;
        });
        when(appAuthSessionService.getTokenInfo()).thenReturn(buildTokenInfo("operator-token", 7200L));
        AppLoginResponse response = appRegisterService.completeOperatorRegister(request);
        verify(userMapper).insert(appUserCaptor.capture());
        verify(appAuthSessionService).login(3001L);
        AppUser inserted = appUserCaptor.getValue();
        assertEquals("本地诊所", inserted.getNickname());
        assertEquals("13800138003", inserted.getPhone());
        assertNull(inserted.getPassword());
        assertEquals(Integer.valueOf(1), inserted.getType());
        assertEquals(Long.valueOf(2001L), inserted.getClinicId());
        assertEquals("operator-token", response.getToken());
        assertEquals(Long.valueOf(3001L), response.getUser().getId());
        assertEquals(Integer.valueOf(1), response.getUser().getType());
        assertEquals(Long.valueOf(2001L), response.getUser().getClinicId());
    }
    /**
     * 构造测试用登录 token 信息。
     */
    private SaTokenInfo buildTokenInfo(String tokenValue, long timeout) {
        SaTokenInfo tokenInfo = new SaTokenInfo();
        tokenInfo.setTokenValue(tokenValue);
        tokenInfo.setTokenTimeout(timeout);
        return tokenInfo;
    }
}
traditionalApp/pages/login/login.vue
@@ -3,27 +3,18 @@
        <image class="close-icon" src="@/static/image/login/close@2x.png" mode="aspectFit" @tap="handleClose"></image>
        <view class="title">欢迎登录</view>
        <view class="tabs">
            <view class="tab" :class="loginType === 'code' ? 'active' : 'normal'" @tap="switchLoginType('code')">验证码登录
            </view>
            <view class="tab" :class="loginType === 'password' ? 'active' : 'normal'"
                @tap="switchLoginType('password')">密码登录</view>
        </view>
        <view class="form">
            <view class="input-group">
                <input class="input" type="number" maxlength="11" v-model="phone" placeholder="输入手机号"
                    placeholder-class="placeholder-style" />
                <view class="get-code-btn" :class="{ disabled: countdown > 0 }" v-if="loginType === 'code'"
                    @tap="handleSendCode">
                <view class="get-code-btn" :class="{ disabled: countdown > 0 }" @tap="handleSendCode">
                    {{ countdown > 0 ? `${countdown}s` : '获取验证码' }}
                </view>
            </view>
            <view class="input-group">
                <input class="input" :type="loginType === 'code' ? 'number' : 'text'"
                    :password="loginType === 'password'" :maxlength="loginType === 'code' ? 6 : 32" v-model="credential"
                    :placeholder="loginType === 'code' ? '输入验证码' : '输入密码'" placeholder-class="placeholder-style" />
                <input class="input" type="number" maxlength="6" v-model="credential" placeholder="输入验证码"
                    placeholder-class="placeholder-style" />
            </view>
        </view>
@@ -38,10 +29,8 @@
        <view class="login-btn" @tap="handleLogin">{{ loading ? '登录中...' : '登录' }}</view>
        <view class="action-links"
            :class="{ 'center': loginType === 'code', 'space-between': loginType === 'password' }">
        <view class="action-links center">
            <text class="register-link" @tap="goRegister">没有账号?点击注册</text>
            <text class="forgot-password" v-if="loginType === 'password'" @tap="goForgotPassword">忘记密码?</text>
        </view>
        <view class="oauth-actions">
@@ -60,12 +49,11 @@
<script setup lang="ts">
    import { onLoad } from '@dcloudio/uni-app'
    import { computed, onUnmounted, ref } from 'vue'
    import { appleLogin, passwordLogin, sendSmsCode, smsLogin, wechatLogin } from './service.js'
    import { appleLogin, sendSmsCode, smsLogin, wechatLogin } from './service.js'
    import { saveAuthSession, savePendingBindSession } from '@/utils/auth-session.js'
    import { syncJPushAliasWithCurrentUser } from '@/utils/push.js'
    import { clearCachedInviteCode, getCachedInviteCode } from '@/utils/invite-cache.js'
    const loginType = ref<'code' | 'password'>('code')
    const phone = ref('')
    const credential = ref('')
    const isAgreed = ref(false)
@@ -178,14 +166,6 @@
    })
    /**
     * 切换登录方式时重置当前输入态。
     */
    const switchLoginType = (type : 'code' | 'password') => {
        loginType.value = type
        credential.value = ''
    }
    /**
     * 关闭登录页,优先返回上一页,没有页面栈时回首页。
     */
    const handleClose = () => {
@@ -204,15 +184,6 @@
    const goRegister = () => {
        uni.navigateTo({
            url: '/pages/register/register'
        })
    }
    /**
     * 跳转到忘记密码页。
     */
    const goForgotPassword = () => {
        uni.navigateTo({
            url: '/pages/forgotPassword/forgotPassword'
        })
    }
@@ -583,7 +554,7 @@
    }
    /**
     * 执行登录动作,根据当前 tab 走验证码登录或密码登录。
     * 执行短信验证码登录动作。
     */
    const handleLogin = async () => {
        if (loading.value) {
@@ -598,9 +569,7 @@
        }
        loading.value = true
        try {
            const res = loginType.value === 'code'
                ? await loginWithCode(normalizedPhone)
                : await loginWithPassword(normalizedPhone)
            const res = await loginWithCode(normalizedPhone)
            if (!res) {
                return
            }
@@ -633,20 +602,6 @@
        return smsLogin({
            phone: normalizedPhone,
            code: normalizedCode
        })
    }
    /**
     * 使用手机号密码完成登录。
     */
    const loginWithPassword = (normalizedPhone : string) => {
        const normalizedPassword = normalizePassword()
        if (!normalizedPassword) {
            return null
        }
        return passwordLogin({
            phone: normalizedPhone,
            password: normalizedPassword
        })
    }
@@ -719,21 +674,6 @@
            return ''
        }
        return normalizedCode
    }
    /**
     * 校验密码输入是否合法。
     */
    const normalizePassword = () => {
        const normalizedPassword = (credential.value || '').trim()
        if (!normalizedPassword) {
            uni.showToast({
                title: '请输入密码',
                icon: 'none'
            })
            return ''
        }
        return normalizedPassword
    }
    /**
@@ -963,12 +903,7 @@
            justify-content: center;
        }
        &.space-between {
            justify-content: space-between;
        }
        .register-link,
        .forgot-password {
        .register-link {
            font-size: 28rpx;
            color: #C63520;
        }
traditionalApp/pages/login/service.js
@@ -29,13 +29,6 @@
}
/**
 * 使用手机号密码完成 APP 登录。
 */
export const passwordLogin = (data) => {
    return request.post('/api/app/auth/password-login', data)
}
/**
 * 刷新当前 APP 登录态有效期。
 */
export const refreshAuthToken = () => {
traditionalApp/pages/register/register.vue
@@ -46,21 +46,6 @@
                <view class="line"></view>
            </view>
            <view class="form-item">
                <view class="label">密码</view>
                <view class="input-wrapper">
                    <input
                        class="input"
                        type="text"
                        password
                        placeholder="8-32位数字、字母、符号至少2种或以上"
                        placeholder-class="placeholder-style"
                        v-model="form.password"
                    />
                </view>
                <view class="line"></view>
            </view>
            <template v-if="registerType === 'user'">
                <view class="form-item">
                    <view class="label">邀请码</view>
@@ -179,6 +164,8 @@
import { reportOpeninstallRegister } from '@/utils/openinstall.js'
import { cacheInviteCode, clearCachedInviteCode, getCachedInviteCode } from '@/utils/invite-cache.js'
import { confirmCameraForInviteCodeScan, confirmMediaForLicense } from '@/utils/permission.js'
import { saveAuthSession } from '@/utils/auth-session.js'
import { syncJPushAliasWithCurrentUser } from '@/utils/push.js'
interface RegionItem {
    label?: string
@@ -215,8 +202,6 @@
    clinicInfo?: AppRegisterClinicInfo
}
const PASSWORD_REGEX = /^(?![0-9]+$)(?![a-zA-Z]+$)(?![^0-9a-zA-Z]+$).{8,32}$/
const registerType = ref<'user' | 'operator'>('user')
const currentStep = ref(1)
const isAgreed = ref(false)
@@ -243,7 +228,6 @@
const form = reactive({
    phone: '',
    code: '',
    password: '',
    inviteCode: '',
    institutionName: '',
    provinceCode: '',
@@ -504,14 +488,13 @@
    const res = await registerUser({
        phone: form.phone.trim(),
        code: form.code.trim(),
        password: form.password,
        inviteCode: form.inviteCode.trim().toUpperCase(),
        bindTicket: bindTicket.value
    })
    if (!isSuccessResponse(res)) {
        return
    }
    await handleRegisterSuccess('注册成功')
    await handleRegisterSuccess('注册成功', res.data)
}
/**
@@ -520,8 +503,7 @@
const prepareOperatorStep = async () => {
    const res = await prepareOperatorRegister({
        phone: form.phone.trim(),
        code: form.code.trim(),
        password: form.password
        code: form.code.trim()
    })
    if (!isSuccessResponse(res)) {
        return
@@ -558,7 +540,7 @@
    if (!isSuccessResponse(res)) {
        return
    }
    await handleRegisterSuccess('经营者注册成功')
    await handleRegisterSuccess('经营者注册成功', res.data)
}
/**
@@ -571,10 +553,6 @@
    }
    if (!form.code.trim()) {
        showToast('请输入验证码')
        return false
    }
    if (!PASSWORD_REGEX.test(form.password)) {
        showToast('密码需为8-32位,且包含数字、字母、符号至少2种')
        return false
    }
    return true
@@ -822,9 +800,9 @@
}
/**
 * 注册成功后提示并返回登录页。
 * 注册成功后保存登录态并进入首页。
 */
const handleRegisterSuccess = async (message: string) => {
const handleRegisterSuccess = async (message: string, loginData: any) => {
    // 注册成功后通知 openinstall,用于统计推广链路的注册转化。
    reportOpeninstallRegister()
    clearCachedInviteCode()
@@ -832,9 +810,13 @@
        title: message,
        icon: 'success'
    })
    saveAuthSession(loginData)
    syncJPushAliasWithCurrentUser()
    resetForm()
    setTimeout(() => {
        goLogin()
        uni.switchTab({
            url: '/pages/home/home'
        })
    }, 800)
}
@@ -916,7 +898,6 @@
    bindTicket.value = ''
    form.phone = ''
    form.code = ''
    form.password = ''
    form.inviteCode = ''
    form.institutionName = ''
    form.provinceCode = ''
traditionalApp/pages/settings/settings.vue
@@ -35,15 +35,6 @@
                        <image class="edit-icon" src="/static/image/mine/edit.png" mode="aspectFit" @tap="editPhone"></image>
                    </view>
                </view>
                <view class="list-item">
                    <view class="list-left">
                        <image class="list-icon" src="/static/image/mine/password.png" mode="aspectFit"></image>
                        <text class="list-title">登录密码</text>
                    </view>
                    <view class="list-right">
                        <image class="edit-icon" src="/static/image/mine/edit.png" mode="aspectFit" @tap="editPassword"></image>
                    </view>
                </view>
                <view class="list-item" @tap="swapWechat">
                    <view class="list-left">
                        <image class="list-icon" src="/static/image/mine/weixinIcon.png" mode="aspectFit"></image>
@@ -206,15 +197,6 @@
function editPhone() {
    uni.navigateTo({
        url: `/pages/modifyPhone/modifyPhone?phone=${encodeURIComponent(normalizeOptionalText(profile.phone))}`,
    })
}
/**
 * 跳转修改密码页。
 */
function editPassword() {
    uni.navigateTo({
        url: '/pages/modifyPassword/modifyPassword',
    })
}
traditionalApp/utils/auth-session.js
@@ -115,6 +115,7 @@
 */
export function persistTokenExpireAt(expiresIn) {
    const expireSeconds = Number(expiresIn)
    // 后端 APP 永久 token 返回 -1,此时不保存过期时间,后续请求也不会触发定时刷新。
    if (!Number.isFinite(expireSeconds) || expireSeconds <= 0) {
        uni.removeStorageSync(TOKEN_EXPIRE_AT_KEY)
        return