“mitao”
2026-04-24 b620af46945757c8c578a9f8d3ef472e40544d70
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
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.tcm.training.admin.app.auth.service.AppAuthSessionService;
import com.tcm.training.admin.app.share.config.AppShareProperties;
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.stereotype.Service;
import org.springframework.util.StringUtils;
 
import java.awt.image.BufferedImage;
import java.io.ByteArrayOutputStream;
import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.List;
 
/**
 * 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 final AppAuthSessionService appAuthSessionService;
    private final UserMapper userMapper;
    private final ClinicMapper clinicMapper;
    private final AppVersionSettingsAppService appVersionSettingsAppService;
    private final AppShareProperties appShareProperties;
 
    /**
     * 注入邀请分享相关依赖。
     */
    public AppShareService(AppAuthSessionService appAuthSessionService,
                           UserMapper userMapper,
                           ClinicMapper clinicMapper,
                           AppVersionSettingsAppService appVersionSettingsAppService,
                           AppShareProperties appShareProperties) {
        this.appAuthSessionService = appAuthSessionService;
        this.userMapper = userMapper;
        this.clinicMapper = clinicMapper;
        this.appVersionSettingsAppService = appVersionSettingsAppService;
        this.appShareProperties = appShareProperties;
    }
 
    /**
     * 获取当前医生的分享二维码页聚合数据。
     */
    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));
        return result;
    }
 
    /**
     * 校验当前登录用户为有效医生。
     */
    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"));
    }
 
    /**
     * 查询当前医生直接邀请绑定的普通用户。
     */
    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());
    }
 
    /**
     * 优先使用昵称,没有时回退手机号。
     */
    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 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;
    }
}