| | |
| | | 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; |
| | |
| | | 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 邀请分享服务。 |
| | |
| | | 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; |
| | | |
| | | /** |
| | | * 注入邀请分享相关依赖。 |
| | |
| | | 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; |
| | | } |
| | | |
| | | /** |
| | |
| | | // 安卓与 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; |
| | | } |
| | | |
| | | /** |
| | |
| | | .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; |
| | | } |
| | | |
| | | /** |
| | |
| | | } |
| | | |
| | | /** |
| | | * 构造安卓 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) { |
| | |
| | | } |
| | | |
| | | /** |
| | | * 按需读取并消费绑定票据。 |
| | | */ |
| | | 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) { |
| | |
| | | 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; |
| | | } |
| | | } |