package com.tcm.training.admin.app.share.service;
|
|
import cn.hutool.core.codec.Base64;
|
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.live.entity.Clinic;
|
import com.tcm.training.admin.live.mapper.ClinicMapper;
|
import com.tcm.training.admin.member.entity.AppUser;
|
import com.tcm.training.admin.member.mapper.UserMapper;
|
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 邀请分享服务。
|
*/
|
@Service
|
public class AppShareService {
|
|
private static final int USER_TYPE_DOCTOR = 1;
|
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;
|
|
/**
|
* 注入邀请分享相关依赖。
|
*/
|
public AppShareService(AppAuthSessionService appAuthSessionService,
|
UserMapper userMapper,
|
ClinicMapper clinicMapper,
|
AppVersionSettingsAppService appVersionSettingsAppService,
|
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;
|
}
|
|
/**
|
* 获取当前医生的分享二维码页聚合数据。
|
*/
|
public AppShareQrcodeVO getCurrentUserShareQrcode() {
|
AppUser doctorUser = requireDoctorUser(appAuthSessionService.requireLoginUserId());
|
Clinic clinic = requireClinic(doctorUser.getClinicId());
|
String inviteCode = requireInviteCode(clinic);
|
String shareLandingUrl = buildShareLandingUrl(inviteCode);
|
|
AppShareQrcodeVO result = new AppShareQrcodeVO();
|
result.setClinicName(trimToEmpty(clinic.getName()));
|
result.setInviteCode(inviteCode);
|
result.setShareLandingUrl(shareLandingUrl);
|
result.setQrCodeImageUrl(buildQrCodeImageUrl(inviteCode));
|
result.setQrCodeDataUrl(buildQrCodeDataUrl(shareLandingUrl));
|
result.setBoundUsers(listBoundUsers(doctorUser));
|
return result;
|
}
|
|
/**
|
* 获取公开邀请下载页数据。
|
*/
|
public AppInviteLandingVO getInviteLanding(String inviteCode) {
|
AppVersionCheckVO androidVersion = appVersionSettingsAppService.checkUpdate(AppVersionConfig.PLATFORM_ANDROID, DEFAULT_VERSION_CODE);
|
AppVersionCheckVO iosVersion = appVersionSettingsAppService.checkUpdate(AppVersionConfig.PLATFORM_IOS, DEFAULT_VERSION_CODE);
|
|
String androidDownloadUrl = trimToNull(androidVersion == null ? null : androidVersion.getDownloadUrl());
|
String iosDownloadUrl = trimToNull(iosVersion == null ? null : iosVersion.getDownloadUrl());
|
if (androidDownloadUrl == null && iosDownloadUrl == null) {
|
throw new IllegalArgumentException("APP安装包暂未配置");
|
}
|
|
AppInviteLandingVO result = new AppInviteLandingVO();
|
Clinic clinic = findClinicByInviteCode(inviteCode);
|
result.setClinicName(clinic == null ? "" : trimToEmpty(clinic.getName()));
|
result.setInviteCode(trimToEmpty(inviteCode));
|
result.setAndroidDownloadUrl(androidDownloadUrl);
|
result.setIosDownloadUrl(iosDownloadUrl);
|
// 安卓与 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;
|
}
|
|
/**
|
* 校验当前登录用户为有效医生。
|
*/
|
private AppUser requireDoctorUser(Long userId) {
|
AppUser user = userMapper.selectById(userId);
|
if (user == null || user.getDeleted() == null || user.getDeleted().intValue() != 0) {
|
throw new IllegalArgumentException("用户不存在");
|
}
|
if (user.getStatus() == null || user.getStatus().intValue() != USER_STATUS_ENABLED) {
|
throw new IllegalArgumentException("用户已禁用");
|
}
|
if (user.getType() == null || user.getType().intValue() != USER_TYPE_DOCTOR) {
|
throw new IllegalArgumentException("仅经营者可分享");
|
}
|
if (user.getClinicId() == null) {
|
throw new IllegalArgumentException("当前账号未绑定诊所");
|
}
|
return user;
|
}
|
|
/**
|
* 读取当前医生绑定的诊所,不存在时阻止分享。
|
*/
|
private Clinic requireClinic(Long clinicId) {
|
Clinic clinic = clinicMapper.selectById(clinicId);
|
if (clinic == null || clinic.getDeleted() == null || clinic.getDeleted().intValue() != 0) {
|
throw new IllegalArgumentException("当前账号未绑定有效诊所");
|
}
|
return clinic;
|
}
|
|
/**
|
* 读取诊所邀请码,未配置时阻止分享。
|
*/
|
private String requireInviteCode(Clinic clinic) {
|
String inviteCode = trimToNull(clinic == null ? null : clinic.getInviteCode());
|
if (inviteCode == null) {
|
throw new IllegalArgumentException("当前诊所暂未生成邀请码");
|
}
|
return inviteCode;
|
}
|
|
/**
|
* 根据邀请码查询诊所,未命中时返回空,供公开下载页做降级展示。
|
*/
|
private Clinic findClinicByInviteCode(String inviteCode) {
|
String normalizedInviteCode = trimToNull(inviteCode);
|
if (normalizedInviteCode == null) {
|
return null;
|
}
|
return clinicMapper.selectOne(new LambdaQueryWrapper<Clinic>()
|
.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;
|
}
|
|
/**
|
* 查询当前医生直接邀请绑定的普通用户。
|
*/
|
private List<AppShareBoundUserItemVO> listBoundUsers(AppUser doctorUser) {
|
List<AppUser> users = userMapper.selectList(new LambdaQueryWrapper<AppUser>()
|
.eq(AppUser::getClinicId, doctorUser.getClinicId())
|
.eq(AppUser::getSuperiorClinicUserId, doctorUser.getId())
|
.eq(AppUser::getType, USER_TYPE_PERSONAL)
|
.eq(AppUser::getDeleted, 0)
|
.orderByDesc(AppUser::getCreatedAt)
|
.orderByDesc(AppUser::getId));
|
List<AppShareBoundUserItemVO> result = new ArrayList<AppShareBoundUserItemVO>();
|
for (AppUser user : users) {
|
result.add(toBoundUserItem(user));
|
}
|
return result;
|
}
|
|
/**
|
* 组装邀请下载页完整地址。
|
*/
|
private String buildShareLandingUrl(String inviteCode) {
|
String landingBaseUrl = trimToNull(appShareProperties.getLandingBaseUrl());
|
if (landingBaseUrl == null) {
|
throw new IllegalArgumentException("邀请下载页地址暂未配置");
|
}
|
String queryJoiner = landingBaseUrl.contains("?") ? "&" : "?";
|
return landingBaseUrl + queryJoiner + "inviteCode=" + inviteCode;
|
}
|
|
/**
|
* 将邀请下载地址编码为二维码 base64 图片,便于 APP 直接展示与保存。
|
*/
|
private String buildQrCodeDataUrl(String shareLandingUrl) {
|
try {
|
BufferedImage image = QrCodeUtil.generate(shareLandingUrl, 400, 400);
|
ByteArrayOutputStream outputStream = new ByteArrayOutputStream();
|
ImgUtil.write(image, "png", outputStream);
|
return "data:image/png;base64," + Base64.encode(outputStream.toByteArray());
|
} catch (Exception ex) {
|
throw new IllegalArgumentException("生成分享二维码失败");
|
}
|
}
|
|
/**
|
* 构建二维码图片公开访问地址。
|
*/
|
private String buildQrCodeImageUrl(String inviteCode) {
|
String landingBaseUrl = trimToNull(appShareProperties.getLandingBaseUrl());
|
if (landingBaseUrl == null) {
|
throw new IllegalArgumentException("邀请下载页地址暂未配置");
|
}
|
int schemeIndex = landingBaseUrl.indexOf("://");
|
if (schemeIndex < 0) {
|
throw new IllegalArgumentException("邀请下载页地址格式不正确");
|
}
|
int hostEndIndex = landingBaseUrl.indexOf('/', schemeIndex + 3);
|
String origin = hostEndIndex >= 0 ? landingBaseUrl.substring(0, hostEndIndex) : landingBaseUrl;
|
String normalizedInviteCode = trimToNull(inviteCode);
|
if (!StringUtils.hasText(normalizedInviteCode)) {
|
throw new IllegalArgumentException("邀请码不能为空");
|
}
|
return origin + "/api/open/app-share/qrcode-image?inviteCode=" + normalizedInviteCode;
|
}
|
|
/**
|
* 生成二维码 PNG 图片字节流。
|
*/
|
public byte[] generateQrcodeImage(String inviteCode) {
|
Clinic clinic = findClinicByInviteCode(inviteCode);
|
if (clinic == null) {
|
throw new IllegalArgumentException("邀请码无效");
|
}
|
String shareLandingUrl = buildShareLandingUrl(requireInviteCode(clinic));
|
try {
|
BufferedImage image = QrCodeUtil.generate(shareLandingUrl, 400, 400);
|
ByteArrayOutputStream outputStream = new ByteArrayOutputStream();
|
ImgUtil.write(image, "png", outputStream);
|
return outputStream.toByteArray();
|
} catch (Exception ex) {
|
throw new IllegalArgumentException("生成分享二维码失败");
|
}
|
}
|
|
/**
|
* 构建绑定用户展示项。
|
*/
|
private AppShareBoundUserItemVO toBoundUserItem(AppUser user) {
|
AppShareBoundUserItemVO item = new AppShareBoundUserItemVO();
|
item.setId(user.getId());
|
item.setNickname(resolveNickname(user));
|
item.setAvatar(resolveAvatar(user));
|
item.setRegisteredAt(resolveRegisteredAt(user));
|
return item;
|
}
|
|
/**
|
* 解析下载页需要展示的最新版本号。
|
*/
|
private String resolveLatestVersionNo(AppVersionCheckVO androidVersion, AppVersionCheckVO iosVersion) {
|
String androidVersionNo = trimToNull(androidVersion == null ? null : androidVersion.getLatestVersionNo());
|
if (androidVersionNo != null) {
|
return androidVersionNo;
|
}
|
return trimToEmpty(iosVersion == null ? null : iosVersion.getLatestVersionNo());
|
}
|
|
/**
|
* 解析下载页需要展示的更新说明。
|
*/
|
private String resolveUpdateNotice(AppVersionCheckVO androidVersion, AppVersionCheckVO iosVersion) {
|
String androidUpdateNotice = trimToNull(androidVersion == null ? null : androidVersion.getUpdateNotice());
|
if (androidUpdateNotice != null) {
|
return androidUpdateNotice;
|
}
|
return trimToEmpty(iosVersion == null ? null : iosVersion.getUpdateNotice());
|
}
|
|
/**
|
* 构造安卓 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) {
|
String nickname = trimToNull(user == null ? null : user.getNickname());
|
if (nickname != null) {
|
return nickname;
|
}
|
String wechatNickname = trimToNull(user == null ? null : user.getWechatNickname());
|
if (wechatNickname != null) {
|
return wechatNickname;
|
}
|
return trimToEmpty(user == null ? null : user.getPhone());
|
}
|
|
/**
|
* 优先使用头像,没有时回退微信头像。
|
*/
|
private String resolveAvatar(AppUser user) {
|
String avatar = trimToNull(user == null ? null : user.getAvatar());
|
if (avatar != null) {
|
return avatar;
|
}
|
return trimToEmpty(user == null ? null : user.getWechatAvatar());
|
}
|
|
/**
|
* 读取用户注册时间,缺失时回退当前时间避免前端空白。
|
*/
|
private LocalDateTime resolveRegisteredAt(AppUser user) {
|
return user == null || user.getCreatedAt() == null ? LocalDateTime.now() : user.getCreatedAt();
|
}
|
|
/**
|
* 按需读取并消费绑定票据。
|
*/
|
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) {
|
if (value == null) {
|
return "";
|
}
|
return value.trim();
|
}
|
|
/**
|
* 将可空文本归一化为空值。
|
*/
|
private String trimToNull(String value) {
|
if (value == null) {
|
return null;
|
}
|
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;
|
}
|
}
|