“mitao”
2026-04-27 b82daad16c59c9770f6a0492e1a89514d41e6f71
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
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;
    }
}