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() }