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