From b82daad16c59c9770f6a0492e1a89514d41e6f71 Mon Sep 17 00:00:00 2001
From: “mitao” <“2763622819@qq.com”>
Date: 星期一, 27 四月 2026 18:58:06 +0800
Subject: [PATCH] fix:安卓推广码绑定

---
 tcm-training-admin-backend/src/main/java/com/tcm/training/admin/app/share/service/AppShareService.java |  171 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++
 1 files changed, 170 insertions(+), 1 deletions(-)

diff --git a/tcm-training-admin-backend/src/main/java/com/tcm/training/admin/app/share/service/AppShareService.java b/tcm-training-admin-backend/src/main/java/com/tcm/training/admin/app/share/service/AppShareService.java
index 0877e3e..f81fe5d 100644
--- a/tcm-training-admin-backend/src/main/java/com/tcm/training/admin/app/share/service/AppShareService.java
+++ b/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;
+    }
 }

--
Gitblit v1.7.1