“mitao”
2026-04-27 b82daad16c59c9770f6a0492e1a89514d41e6f71
fix:安卓推广码绑定
12个文件已修改
473 ■■■■■ 已修改文件
tcm-training-admin-backend/src/main/java/com/tcm/training/admin/app/register/dto/AppRegisterUserRequest.java 2 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
tcm-training-admin-backend/src/main/java/com/tcm/training/admin/app/register/service/AppRegisterService.java 51 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
tcm-training-admin-backend/src/main/java/com/tcm/training/admin/app/share/config/AppShareProperties.java 19 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
tcm-training-admin-backend/src/main/java/com/tcm/training/admin/app/share/controller/OpenAppInviteController.java 23 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
tcm-training-admin-backend/src/main/java/com/tcm/training/admin/app/share/service/AppShareService.java 171 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
tcm-training-admin-backend/src/main/java/com/tcm/training/admin/app/share/vo/AppInviteLandingVO.java 2 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
tcm-training-admin-backend/src/main/resources/static/app-download/index.html 61 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
traditionalApp/App.vue 50 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
traditionalApp/manifest.json 3 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
traditionalApp/pages/register/register.vue 54 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
traditionalApp/pages/register/service.js 7 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
traditionalApp/utils/utils.js 30 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
tcm-training-admin-backend/src/main/java/com/tcm/training/admin/app/register/dto/AppRegisterUserRequest.java
@@ -24,4 +24,6 @@
    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
@@ -8,6 +8,7 @@
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;
@@ -54,6 +55,7 @@
    private final HztClinicMapper hztClinicMapper;
    private final StringRedisTemplate stringRedisTemplate;
    private final ObjectMapper objectMapper;
    private final AppShareService appShareService;
    /**
     * 注入注册链路所需的本地库、海诊通库、验证码与缓存能力。
@@ -66,7 +68,8 @@
                              HztAppUserMapper hztAppUserMapper,
                              HztClinicMapper hztClinicMapper,
                              StringRedisTemplate stringRedisTemplate,
                              ObjectMapper objectMapper) {
                              ObjectMapper objectMapper,
                              AppShareService appShareService) {
        this.userMapper = userMapper;
        this.clinicMapper = clinicMapper;
        this.passwordEncoder = passwordEncoder;
@@ -76,6 +79,7 @@
        this.hztClinicMapper = hztClinicMapper;
        this.stringRedisTemplate = stringRedisTemplate;
        this.objectMapper = objectMapper;
        this.appShareService = appShareService;
    }
    /**
@@ -109,6 +113,7 @@
        String phone = normalizePhone(request.getPhone());
        String password = normalizePassword(request.getPassword());
        String inviteCode = normalizeInviteCode(request.getInviteCode());
        String bindTicket = normalizeBindTicket(request.getBindTicket());
        ensurePhoneAvailable(phone);
        appSmsCodeService.validateRegisterCode(phone, normalizeCode(request.getCode()));
@@ -123,8 +128,8 @@
        HztAppUser hztAppUser = findHztAppUserByPhone(phone);
        populateUserProfile(user, hztAppUser, phone);
        // 邀请码命中时绑定上级诊所用户,并继承该诊所关系。
        bindSuperiorClinicIfNecessary(user, inviteCode);
        // 绑定票据优先于邀请码,避免安装后丢失或误填导致关系错误。
        bindSuperiorClinicIfNecessary(user, inviteCode, bindTicket);
        userMapper.insert(user);
        return new AppRegisterResultVO(user.getId(), user.getType(), user.getClinicId(), null);
@@ -206,27 +211,26 @@
    /**
     * 按邀请码绑定本地上级诊所用户与诊所归属。
     */
    private void bindSuperiorClinicIfNecessary(AppUser user, String inviteCode) {
        if (inviteCode == null) {
    private void bindSuperiorClinicIfNecessary(AppUser user, String inviteCode, String bindTicket) {
        AppShareService.InviteBindingTarget bindingTarget = resolveInviteBindingTarget(inviteCode, bindTicket);
        if (bindingTarget == null) {
            return;
        }
        Clinic clinic = clinicMapper.selectOne(new LambdaQueryWrapper<Clinic>()
                .eq(Clinic::getInviteCode, inviteCode)
                .eq(Clinic::getDeleted, 0)
                .last("limit 1"));
        if (clinic == null) {
            throw new IllegalArgumentException("邀请码无效");
        user.setClinicId(bindingTarget.getClinicId());
        user.setSuperiorClinicUserId(bindingTarget.getSuperiorClinicUserId());
    }
    /**
     * 解析注册阶段应绑定的目标关系,票据优先于邀请码。
     */
    private AppShareService.InviteBindingTarget resolveInviteBindingTarget(String inviteCode, String bindTicket) {
        if (bindTicket != null) {
            return appShareService.consumeBindTicket(bindTicket);
        }
        AppUser clinicUser = userMapper.selectOne(new LambdaQueryWrapper<AppUser>()
                .eq(AppUser::getClinicId, clinic.getId())
                .eq(AppUser::getType, 1)
                .eq(AppUser::getDeleted, 0)
                .last("limit 1"));
        if (clinicUser == null) {
            throw new IllegalArgumentException("邀请码暂不可用,请联系诊所管理员");
        if (inviteCode == null) {
            return null;
        }
        user.setClinicId(clinicUser.getClinicId());
        user.setSuperiorClinicUserId(clinicUser.getId());
        return appShareService.resolveInviteBindingTarget(inviteCode);
    }
    /**
@@ -463,6 +467,13 @@
    }
    /**
     * 归一化绑定票据。
     */
    private String normalizeBindTicket(String bindTicket) {
        return trimToNull(bindTicket);
    }
    /**
     * 归一化经营者注册凭证。
     */
    private String normalizeRegisterTicket(String registerTicket) {
tcm-training-admin-backend/src/main/java/com/tcm/training/admin/app/share/config/AppShareProperties.java
@@ -16,6 +16,11 @@
    private String landingBaseUrl = "https://haizhentong.com/app-download/";
    /**
     * 安卓 App 自定义 Scheme 基础地址。
     */
    private String androidAppSchemeUrl = "haizhentong://register";
    /**
     * 获取邀请下载页基础地址。
     */
    public String getLandingBaseUrl() {
@@ -28,4 +33,18 @@
    public void setLandingBaseUrl(String landingBaseUrl) {
        this.landingBaseUrl = landingBaseUrl;
    }
    /**
     * 获取安卓 App 自定义 Scheme 基础地址。
     */
    public String getAndroidAppSchemeUrl() {
        return androidAppSchemeUrl;
    }
    /**
     * 设置安卓 App 自定义 Scheme 基础地址。
     */
    public void setAndroidAppSchemeUrl(String androidAppSchemeUrl) {
        this.androidAppSchemeUrl = androidAppSchemeUrl;
    }
}
tcm-training-admin-backend/src/main/java/com/tcm/training/admin/app/share/controller/OpenAppInviteController.java
@@ -1,13 +1,20 @@
package com.tcm.training.admin.app.share.controller;
import com.tcm.training.admin.app.share.dto.AppInviteBindTicketCreateRequest;
import com.tcm.training.admin.app.share.service.AppShareService;
import com.tcm.training.admin.app.share.vo.AppInviteBindTicketVO;
import com.tcm.training.admin.app.share.vo.AppInviteBindTicketValidateVO;
import com.tcm.training.admin.app.share.vo.AppInviteLandingVO;
import com.tcm.training.admin.common.ApiResponse;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import javax.validation.Valid;
/**
 * 邀请下载页公开接口。
@@ -33,4 +40,20 @@
    public ApiResponse<AppInviteLandingVO> getInviteLanding(@RequestParam(value = "inviteCode", required = false) String inviteCode) {
        return ApiResponse.ok(appShareService.getInviteLanding(inviteCode));
    }
    /**
     * 生成下载页继续注册使用的一次性绑定票据。
     */
    @PostMapping("/bind-ticket")
    public ApiResponse<AppInviteBindTicketVO> createBindTicket(@Valid @RequestBody AppInviteBindTicketCreateRequest request) {
        return ApiResponse.ok(appShareService.createBindTicket(request.getInviteCode()));
    }
    /**
     * 校验绑定票据是否仍可用于注册绑定。
     */
    @GetMapping("/bind-ticket/validate")
    public ApiResponse<AppInviteBindTicketValidateVO> validateBindTicket(@RequestParam("bindTicket") String bindTicket) {
        return ApiResponse.ok(appShareService.validateBindTicket(bindTicket));
    }
}
tcm-training-admin-backend/src/main/java/com/tcm/training/admin/app/share/service/AppShareService.java
@@ -4,8 +4,11 @@
import cn.hutool.core.img.ImgUtil;
import cn.hutool.extra.qrcode.QrCodeUtil;
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.share.config.AppShareProperties;
import com.tcm.training.admin.app.share.vo.AppInviteBindTicketVO;
import com.tcm.training.admin.app.share.vo.AppInviteBindTicketValidateVO;
import com.tcm.training.admin.app.share.vo.AppInviteLandingVO;
import com.tcm.training.admin.app.share.vo.AppShareBoundUserItemVO;
import com.tcm.training.admin.app.share.vo.AppShareQrcodeVO;
@@ -16,14 +19,18 @@
import com.tcm.training.admin.system.entity.AppVersionConfig;
import com.tcm.training.admin.system.service.AppVersionSettingsAppService;
import com.tcm.training.admin.system.vo.AppVersionCheckVO;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.stereotype.Service;
import org.springframework.util.StringUtils;
import java.awt.image.BufferedImage;
import java.io.ByteArrayOutputStream;
import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;
import java.util.ArrayList;
import java.util.List;
import java.util.UUID;
import java.util.concurrent.TimeUnit;
/**
 * APP 邀请分享服务。
@@ -35,12 +42,17 @@
    private static final int USER_TYPE_PERSONAL = 0;
    private static final int USER_STATUS_ENABLED = 1;
    private static final int DEFAULT_VERSION_CODE = 0;
    private static final String BIND_TICKET_CACHE_PREFIX = "app:invite:bind-ticket:";
    private static final long BIND_TICKET_EXPIRE_HOURS = 24L;
    private static final DateTimeFormatter DATE_TIME_FORMATTER = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss");
    private final AppAuthSessionService appAuthSessionService;
    private final UserMapper userMapper;
    private final ClinicMapper clinicMapper;
    private final AppVersionSettingsAppService appVersionSettingsAppService;
    private final AppShareProperties appShareProperties;
    private final StringRedisTemplate stringRedisTemplate;
    private final ObjectMapper objectMapper;
    /**
     * 注入邀请分享相关依赖。
@@ -49,12 +61,16 @@
                           UserMapper userMapper,
                           ClinicMapper clinicMapper,
                           AppVersionSettingsAppService appVersionSettingsAppService,
                           AppShareProperties appShareProperties) {
                           AppShareProperties appShareProperties,
                           StringRedisTemplate stringRedisTemplate,
                           ObjectMapper objectMapper) {
        this.appAuthSessionService = appAuthSessionService;
        this.userMapper = userMapper;
        this.clinicMapper = clinicMapper;
        this.appVersionSettingsAppService = appVersionSettingsAppService;
        this.appShareProperties = appShareProperties;
        this.stringRedisTemplate = stringRedisTemplate;
        this.objectMapper = objectMapper;
    }
    /**
@@ -98,7 +114,59 @@
        // 安卓与 iOS 版本配置共用同一条最新记录,这里优先回显安卓,再回退 iOS。
        result.setLatestVersionNo(resolveLatestVersionNo(androidVersion, iosVersion));
        result.setUpdateNotice(resolveUpdateNotice(androidVersion, iosVersion));
        result.setOpenAppBaseUrl(trimToEmpty(appShareProperties.getAndroidAppSchemeUrl()));
        return result;
    }
    /**
     * 为落地页生成一次性绑定票据。
     */
    public AppInviteBindTicketVO createBindTicket(String inviteCode) {
        InviteBindingTarget bindingTarget = resolveInviteBindingTarget(inviteCode);
        String bindTicket = UUID.randomUUID().toString().replace("-", "");
        LocalDateTime expireAt = LocalDateTime.now().plusHours(BIND_TICKET_EXPIRE_HOURS);
        InviteBindTicketCacheValue cacheValue = new InviteBindTicketCacheValue();
        cacheValue.setInviteCode(bindingTarget.getInviteCode());
        cacheValue.setClinicId(bindingTarget.getClinicId());
        cacheValue.setSuperiorClinicUserId(bindingTarget.getSuperiorClinicUserId());
        cacheValue.setCreatedAt(LocalDateTime.now().format(DATE_TIME_FORMATTER));
        try {
            stringRedisTemplate.opsForValue().set(
                    buildBindTicketKey(bindTicket),
                    objectMapper.writeValueAsString(cacheValue),
                    BIND_TICKET_EXPIRE_HOURS,
                    TimeUnit.HOURS
            );
        } catch (Exception ex) {
            throw new IllegalArgumentException("绑定凭证生成失败,请稍后重试");
        }
        AppInviteBindTicketVO result = new AppInviteBindTicketVO();
        result.setBindTicket(bindTicket);
        result.setExpireAt(expireAt.format(DATE_TIME_FORMATTER));
        result.setOpenAppUrl(buildOpenAppUrl(bindTicket));
        return result;
    }
    /**
     * 校验绑定票据是否仍然有效。
     */
    public AppInviteBindTicketValidateVO validateBindTicket(String bindTicket) {
        loadBindTicket(bindTicket, false);
        AppInviteBindTicketValidateVO result = new AppInviteBindTicketValidateVO();
        result.setValid(true);
        return result;
    }
    /**
     * 读取注册阶段绑定票据对应的目标关系。
     */
    public InviteBindingTarget consumeBindTicket(String bindTicket) {
        InviteBindTicketCacheValue cacheValue = loadBindTicket(bindTicket, true);
        InviteBindingTarget target = new InviteBindingTarget();
        target.setInviteCode(trimToNull(cacheValue.getInviteCode()));
        target.setClinicId(cacheValue.getClinicId());
        target.setSuperiorClinicUserId(cacheValue.getSuperiorClinicUserId());
        return target;
    }
    /**
@@ -155,6 +223,33 @@
                .eq(Clinic::getInviteCode, normalizedInviteCode)
                .eq(Clinic::getDeleted, 0)
                .last("limit 1"));
    }
    /**
     * 将邀请码解析为可绑定的诊所与经营者关系。
     */
    public InviteBindingTarget resolveInviteBindingTarget(String inviteCode) {
        String normalizedInviteCode = trimToNull(inviteCode);
        if (normalizedInviteCode == null) {
            throw new IllegalArgumentException("邀请码不能为空");
        }
        Clinic clinic = findClinicByInviteCode(normalizedInviteCode);
        if (clinic == null) {
            throw new IllegalArgumentException("邀请码无效");
        }
        AppUser clinicUser = userMapper.selectOne(new LambdaQueryWrapper<AppUser>()
                .eq(AppUser::getClinicId, clinic.getId())
                .eq(AppUser::getType, USER_TYPE_DOCTOR)
                .eq(AppUser::getDeleted, 0)
                .last("limit 1"));
        if (clinicUser == null) {
            throw new IllegalArgumentException("邀请码暂不可用,请联系诊所管理员");
        }
        InviteBindingTarget target = new InviteBindingTarget();
        target.setInviteCode(normalizedInviteCode);
        target.setClinicId(clinicUser.getClinicId());
        target.setSuperiorClinicUserId(clinicUser.getId());
        return target;
    }
    /**
@@ -276,6 +371,18 @@
    }
    /**
     * 构造安卓 App 打开注册页的深链地址。
     */
    private String buildOpenAppUrl(String bindTicket) {
        String schemeUrl = trimToNull(appShareProperties.getAndroidAppSchemeUrl());
        if (schemeUrl == null) {
            throw new IllegalArgumentException("App 打开地址暂未配置");
        }
        String queryJoiner = schemeUrl.contains("?") ? "&" : "?";
        return schemeUrl + queryJoiner + "bindTicket=" + bindTicket;
    }
    /**
     * 优先使用昵称,没有时回退手机号。
     */
    private String resolveNickname(AppUser user) {
@@ -309,6 +416,36 @@
    }
    /**
     * 按需读取并消费绑定票据。
     */
    private InviteBindTicketCacheValue loadBindTicket(String bindTicket, boolean consume) {
        String normalizedBindTicket = trimToNull(bindTicket);
        if (normalizedBindTicket == null) {
            throw new IllegalArgumentException("绑定凭证已失效,请重新扫码");
        }
        String cacheValue = stringRedisTemplate.opsForValue().get(buildBindTicketKey(normalizedBindTicket));
        if (cacheValue == null || cacheValue.trim().isEmpty()) {
            throw new IllegalArgumentException("绑定凭证已失效,请重新扫码");
        }
        try {
            InviteBindTicketCacheValue parsedValue = objectMapper.readValue(cacheValue, InviteBindTicketCacheValue.class);
            if (consume) {
                stringRedisTemplate.delete(buildBindTicketKey(normalizedBindTicket));
            }
            return parsedValue;
        } catch (Exception ex) {
            throw new IllegalArgumentException("绑定凭证已失效,请重新扫码");
        }
    }
    /**
     * 构造绑定票据缓存 key。
     */
    private String buildBindTicketKey(String bindTicket) {
        return BIND_TICKET_CACHE_PREFIX + bindTicket;
    }
    /**
     * 将可空文本归一化为空字符串。
     */
    private String trimToEmpty(String value) {
@@ -328,4 +465,36 @@
        String trimmed = value.trim();
        return trimmed.isEmpty() ? null : trimmed;
    }
    /**
     * 邀请绑定目标关系。
     */
    @lombok.Getter
    @lombok.Setter
    @lombok.NoArgsConstructor
    public static class InviteBindingTarget {
        private String inviteCode;
        private Long clinicId;
        private Long superiorClinicUserId;
    }
    /**
     * 绑定票据缓存结构。
     */
    @lombok.Getter
    @lombok.Setter
    @lombok.NoArgsConstructor
    public static class InviteBindTicketCacheValue {
        private String inviteCode;
        private Long clinicId;
        private Long superiorClinicUserId;
        private String createdAt;
    }
}
tcm-training-admin-backend/src/main/java/com/tcm/training/admin/app/share/vo/AppInviteLandingVO.java
@@ -21,4 +21,6 @@
    private String latestVersionNo;
    private String updateNotice;
    private String openAppBaseUrl;
}
tcm-training-admin-backend/src/main/resources/static/app-download/index.html
@@ -232,12 +232,13 @@
        <div class="invite-box">
            <div class="invite-label">邀请码</div>
            <div class="invite-code" id="inviteCodeValue">--</div>
            <div class="copy-tip">系统会自动尝试复制邀请码,安装后打开 APP 注册时将自动带出。</div>
            <div class="copy-tip">安装完成后请返回本页,点击“打开 App 继续注册”完成绑定。</div>
        </div>
        <div class="btn-group">
            <button class="download-btn" id="androidDownloadBtn" type="button">下载 Android 版</button>
            <button class="download-btn secondary" id="iosDownloadBtn" type="button">下载 iOS 版</button>
            <button class="download-btn secondary" id="openAppBtn" type="button">打开 App 继续注册</button>
            <button class="copy-btn" id="copyInviteBtn" type="button">复制邀请码</button>
        </div>
@@ -247,7 +248,7 @@
            <div class="section-title">安装说明</div>
            <ol class="notice">
                <li>点击对应平台下载按钮,下载安装 APP。</li>
                <li>若未自动跳转,请手动复制邀请码并在注册页完成注册。</li>
                <li>安装完成后返回本页,点击“打开 App 继续注册”。</li>
                <li>普通用户注册成功后,将自动关联邀请医生所属诊所。</li>
            </ol>
        </div>
@@ -263,7 +264,9 @@
        inviteCode: '',
        androidDownloadUrl: '',
        iosDownloadUrl: '',
        wechatBrowser: false
        wechatBrowser: false,
        openAppBaseUrl: '',
        bindTicketLoading: false
    }
    /**
@@ -277,9 +280,6 @@
        state.inviteCode = inviteCode
        renderInviteCode(inviteCode)
        await loadInviteLanding(inviteCode)
        if (inviteCode) {
            copyInviteCode(inviteCode, false)
        }
    }
    /**
@@ -307,6 +307,7 @@
    function applyLandingData(data, inviteCode) {
        state.androidDownloadUrl = normalizeOptionalText(data.androidDownloadUrl)
        state.iosDownloadUrl = normalizeOptionalText(data.iosDownloadUrl)
        state.openAppBaseUrl = normalizeOptionalText(data.openAppBaseUrl)
        const clinicName = normalizeOptionalText(data.clinicName)
        document.getElementById('clinicTitle').textContent = clinicName
            ? `${clinicName}邀请您加入`
@@ -349,13 +350,16 @@
            }
            copyInviteCode(state.inviteCode, true)
        })
        document.getElementById('openAppBtn').addEventListener('click', function () {
            void handleOpenApp()
        })
        document.getElementById('wechatGuideClose').addEventListener('click', function () {
            hideWechatGuide()
        })
    }
    /**
     * 处理下载动作,并在跳转前再次复制邀请码。
     * 处理下载动作,仅负责下载安装包。
     */
    function handleDownload(url, platform) {
        if (shouldBlockDirectDownload()) {
@@ -368,13 +372,50 @@
            showStatus(platform === 'ANDROID' ? 'Android 安装包暂未配置' : 'iOS 安装包暂未配置')
            return
        }
        if (state.inviteCode) {
            copyInviteCode(state.inviteCode, false)
        }
        window.location.href = normalizedUrl
    }
    /**
     * 生成绑定票据并通过深链打开 App 继续注册。
     */
    async function handleOpenApp() {
        if (state.bindTicketLoading) {
            return
        }
        if (!state.inviteCode) {
            showStatus('当前二维码未包含邀请码,请改用手动填写邀请码')
            return
        }
        if (!state.openAppBaseUrl) {
            showStatus('App 打开地址暂未配置')
            return
        }
        state.bindTicketLoading = true
        try {
            const response = await fetch('/api/open/app-invite/bind-ticket', {
                method: 'POST',
                headers: {
                    'Content-Type': 'application/json'
                },
                body: JSON.stringify({
                    inviteCode: state.inviteCode
                })
            })
            const payload = await response.json()
            if (!payload || payload.code !== 0 || !payload.data || !payload.data.openAppUrl) {
                showStatus(payload && (payload.msg || payload.message) ? payload.msg || payload.message : '绑定凭证生成失败,请稍后重试')
                return
            }
            window.location.href = payload.data.openAppUrl
            showStatus('若未自动打开 App,请安装后返回本页再次点击')
        } catch (error) {
            showStatus('打开 App 失败,请安装后返回本页再次点击')
        } finally {
            state.bindTicketLoading = false
        }
    }
    /**
     * 根据当前接口数据刷新下载按钮状态。
     */
    function updateButtonState() {
traditionalApp/App.vue
@@ -1,32 +1,48 @@
<script setup>
    import {
        onLaunch
        onLaunch,
        onShow
    } from '@dcloudio/uni-app'
    import { cacheAppInviteCode, extractInviteCode } from '@/utils/utils.js'
    import { extractBindTicket } from '@/utils/utils.js'
    let lastHandledBindTicket = ''
    /**
     * 应用启动时尝试读取系统剪贴板中的邀请码。
     * 应用启动时保留钩子,当前不再依赖系统剪贴板承接推广关系。
     */
    onLaunch(() => {
        readInviteCodeFromClipboard()
    })
    /**
     * 从系统剪贴板读取邀请码,供注册页自动带出。
     * App 被外部 Scheme 拉起时,解析 bindTicket 并跳转注册页。
     */
    function readInviteCodeFromClipboard() {
        uni.getClipboardData({
            success: (result) => {
                const inviteCode = extractInviteCode(result && result.data)
                if (!inviteCode) {
                    return
                }
                cacheAppInviteCode(inviteCode)
            },
            fail: () => {
                // 首次启动或系统限制时允许静默失败,不影响主流程。
            }
    onShow(() => {
        handleOpenByScheme()
    })
    /**
     * 读取原生启动参数中的绑定票据,并在命中时跳转注册页。
     */
    function handleOpenByScheme() {
        // #ifdef APP-PLUS
        const bindTicket = extractBindTicket(typeof plus !== 'undefined' && plus.runtime ? plus.runtime.arguments : '')
        if (!bindTicket || bindTicket === lastHandledBindTicket) {
            return
        }
        lastHandledBindTicket = bindTicket
        const currentPages = getCurrentPages()
        const currentRoute = currentPages && currentPages.length > 0 ? currentPages[currentPages.length - 1].route : ''
        const targetUrl = `/pages/register/register?bindTicket=${encodeURIComponent(bindTicket)}`
        if (currentRoute === 'pages/register/register') {
            uni.redirectTo({
                url: targetUrl
            })
            return
        }
        uni.navigateTo({
            url: targetUrl
        })
        // #endif
    }
</script>
traditionalApp/manifest.json
@@ -28,6 +28,9 @@
        "distribute" : {
            /* android打包配置 */
            "android" : {
                "schemes" : [
                    "haizhentong"
                ],
                "permissions" : [
                    "<uses-permission android:name=\"android.permission.CHANGE_NETWORK_STATE\"/>",
                    "<uses-permission android:name=\"android.permission.MOUNT_UNMOUNT_FILESYSTEMS\"/>",
traditionalApp/pages/register/register.vue
@@ -172,9 +172,10 @@
    prepareOperatorRegister,
    registerUser,
    sendRegisterSmsCode,
    uploadRegisterLicense
    uploadRegisterLicense,
    validateInviteBindTicket
} from './service.js'
import { consumeAppInviteCode, extractInviteCode, normalizeOptionalText } from '@/utils/utils.js'
import { extractBindTicket, extractInviteCode, normalizeOptionalText } from '@/utils/utils.js'
interface RegionItem {
    label?: string
@@ -223,6 +224,7 @@
const uploadingLicense = ref(false)
const operatorClinicMatched = ref(true)
const registerTicket = ref('')
const bindTicket = ref('')
const regionDatasetLoading = ref(false)
const regionDatasetLoaded = ref(false)
@@ -255,9 +257,10 @@
})
/**
 * 页面加载时优先消费路由或全局缓存中的邀请码。
 * 页面加载时优先处理绑定票据,并兼容邀请码路由带入。
 */
onLoad((options) => {
    void applyBindTicket(options?.bindTicket)
    applyAutoInviteCode(options?.inviteCode)
})
@@ -489,7 +492,8 @@
        phone: form.phone.trim(),
        code: form.code.trim(),
        password: form.password,
        inviteCode: form.inviteCode.trim().toUpperCase()
        inviteCode: form.inviteCode.trim().toUpperCase(),
        bindTicket: bindTicket.value
    })
    if (!isSuccessResponse(res)) {
        return
@@ -602,7 +606,7 @@
}
/**
 * 尝试从路由参数或全局缓存中自动带出邀请码。
 * 尝试从路由参数中自动带出邀请码。
 */
const applyAutoInviteCode = (routeInviteCode?: unknown) => {
    if (form.inviteCode.trim()) {
@@ -611,17 +615,30 @@
    const routeCode = normalizeInviteCode(routeInviteCode)
    if (routeCode) {
        form.inviteCode = routeCode
    }
}
/**
 * 处理路由带入的绑定票据,失效时明确提示并回退到手动邀请码方案。
 */
const applyBindTicket = async (routeBindTicket?: unknown) => {
    const normalizedBindTicket = normalizeBindTicket(routeBindTicket)
    if (!normalizedBindTicket) {
        bindTicket.value = ''
        return
    }
    const cachedInviteCode = normalizeInviteCode(consumeAppInviteCode())
    if (!cachedInviteCode) {
        return
    try {
        const res = await validateInviteBindTicket(normalizedBindTicket)
        if (!isSuccessResponse(res)) {
            bindTicket.value = ''
            showToast(res?.msg || res?.message || '推广关系已失效,请重新扫码')
            return
        }
        bindTicket.value = normalizedBindTicket
    } catch (error: any) {
        bindTicket.value = ''
        showToast(error?.message || '推广关系已失效,请重新扫码')
    }
    form.inviteCode = cachedInviteCode
    uni.showToast({
        title: '已自动识别邀请码',
        icon: 'none'
    })
}
/**
@@ -823,6 +840,16 @@
}
/**
 * 将绑定票据归一化为非空字符串。
 */
const normalizeBindTicket = (value: unknown) => {
    if (typeof value === 'string' && value.includes('bindTicket=')) {
        return extractBindTicket(value)
    }
    return normalizeOptionalText(value)
}
/**
 * 判断当前响应是否为统一成功态。
 */
const isSuccessResponse = (response: any) => {
@@ -864,6 +891,7 @@
    currentStep.value = 1
    operatorClinicMatched.value = true
    registerTicket.value = ''
    bindTicket.value = ''
    form.phone = ''
    form.code = ''
    form.password = ''
traditionalApp/pages/register/service.js
@@ -44,6 +44,13 @@
}
/**
 * 校验下载页继续注册使用的绑定票据。
 */
export const validateInviteBindTicket = (bindTicket) => {
    return request.get('/api/open/app-invite/bind-ticket/validate', { bindTicket }, false, false)
}
/**
 * 上传注册阶段的营业执照图片。
 */
export const uploadRegisterLicense = (filePath) => {
traditionalApp/utils/utils.js
@@ -225,7 +225,7 @@
}
/**
 * 从 URL、纯文本或剪贴板内容中提取 6 位邀请码。
 * 从 URL、纯文本或扫码内容中提取 6 位邀请码。
 */
export const extractInviteCode = (value) => {
    const normalizedValue = normalizeOptionalText(value)
@@ -241,27 +241,17 @@
    return matchedInviteCode ? matchedInviteCode[0] : ''
}
const APP_INVITE_CODE_STORAGE_KEY = 'appAutoInviteCode'
/**
 * 缓存自动识别的邀请码,供注册页优先读取。
 * 从 URL 中提取 bindTicket 参数。
 */
export const cacheAppInviteCode = (inviteCode) => {
    const normalizedInviteCode = extractInviteCode(inviteCode)
    if (!/^[A-Z0-9]{6}$/.test(normalizedInviteCode)) {
        return
    }
    uni.setStorageSync(APP_INVITE_CODE_STORAGE_KEY, normalizedInviteCode)
}
/**
 * 读取并消费自动识别的邀请码。
 */
export const consumeAppInviteCode = () => {
    const inviteCode = extractInviteCode(uni.getStorageSync(APP_INVITE_CODE_STORAGE_KEY))
    if (!/^[A-Z0-9]{6}$/.test(inviteCode)) {
export const extractBindTicket = (value) => {
    const normalizedValue = normalizeOptionalText(value)
    if (!normalizedValue) {
        return ''
    }
    uni.removeStorageSync(APP_INVITE_CODE_STORAGE_KEY)
    return inviteCode
    const queryMatchedBindTicket = normalizedValue.match(/[?&]bindTicket=([^&#]+)/i)
    if (!queryMatchedBindTicket || !queryMatchedBindTicket[1]) {
        return ''
    }
    return decodeURIComponent(queryMatchedBindTicket[1]).trim()
}