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.setInviteCode(bindingTarget.getInviteCode()); result.setExpireAt(expireAt.format(DATE_TIME_FORMATTER)); result.setOpenAppUrl(buildOpenAppUrl(bindTicket, bindingTarget.getInviteCode())); 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() .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() .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 listBoundUsers(AppUser doctorUser) { List users = userMapper.selectList(new LambdaQueryWrapper() .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 result = new ArrayList(); 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 qrcodeImageBaseUrl = trimToNull(appShareProperties.getQrcodeImageBaseUrl()); if (qrcodeImageBaseUrl == null) { throw new IllegalArgumentException("分享二维码图片地址暂未配置"); } String normalizedInviteCode = trimToNull(inviteCode); if (!StringUtils.hasText(normalizedInviteCode)) { throw new IllegalArgumentException("邀请码不能为空"); } String queryJoiner = qrcodeImageBaseUrl.contains("?") ? "&" : "?"; return qrcodeImageBaseUrl + queryJoiner + "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(resolveBoundUserPhone(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 inviteCode) { String schemeUrl = trimToNull(appShareProperties.getAndroidAppSchemeUrl()); if (schemeUrl == null) { throw new IllegalArgumentException("App 打开地址暂未配置"); } String queryJoiner = schemeUrl.contains("?") ? "&" : "?"; return schemeUrl + queryJoiner + "bindTicket=" + bindTicket + "&inviteCode=" + inviteCode; } /** * 绑定用户列表按业务要求直接在昵称字段展示完整手机号。 */ private String resolveBoundUserPhone(AppUser user) { 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; } }