“mitao”
2026-04-27 b82daad16c59c9770f6a0492e1a89514d41e6f71
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;
    }
}