“mitao”
2026-05-12 4f367056ab0e73d044e920d44b1d7dd0c8b65d80
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
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
package com.tcm.training.admin.app.register.service;
 
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.auth.service.AppSmsCodeService;
import com.tcm.training.admin.app.auth.vo.AppLoginResponse;
import com.tcm.training.admin.app.auth.vo.AppLoginUserVO;
import com.tcm.training.admin.app.auth.vo.AppSmsCodeSendResponse;
import com.tcm.training.admin.app.integration.haizhentong.entity.HztAppUser;
import com.tcm.training.admin.app.integration.haizhentong.entity.HztClinic;
import com.tcm.training.admin.app.integration.haizhentong.mapper.HztAppUserMapper;
import com.tcm.training.admin.app.integration.haizhentong.mapper.HztClinicMapper;
import com.tcm.training.admin.app.share.service.AppShareService;
import com.tcm.training.admin.app.register.dto.AppRegisterOperatorCompleteRequest;
import com.tcm.training.admin.app.register.dto.AppRegisterOperatorPrepareRequest;
import com.tcm.training.admin.app.register.dto.AppRegisterUserRequest;
import com.tcm.training.admin.app.register.vo.AppRegisterClinicInfoVO;
import com.tcm.training.admin.app.register.vo.AppRegisterOperatorPrepareResponse;
import com.tcm.training.admin.app.register.vo.AppRegisterRegionDatasetVO;
import com.tcm.training.admin.app.register.vo.AppRegisterRegionOptionVO;
import com.tcm.training.admin.common.PhoneMasker;
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 cn.dev33.satoken.stp.SaTokenInfo;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
 
import java.util.List;
import java.util.UUID;
import java.util.concurrent.ThreadLocalRandom;
import java.util.concurrent.TimeUnit;
import java.util.regex.Pattern;
 
/**
 * APP 注册服务,负责普通用户、经营者注册与海诊通资料同步。
 */
@Service
public class AppRegisterService {
 
    private static final Pattern PHONE_PATTERN = Pattern.compile("^1\\d{10}$");
    private static final String REGISTER_TICKET_CACHE_PREFIX = "app:register:operator:";
    private static final String INVITE_CODE_CHARS = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789";
    private static final int INVITE_CODE_LENGTH = 6;
    private static final long REGISTER_TICKET_EXPIRE_MINUTES = 10L;
 
    private final UserMapper userMapper;
    private final ClinicMapper clinicMapper;
    private final AppSmsCodeService appSmsCodeService;
    private final AppAuthSessionService appAuthSessionService;
    private final AppRegisterRegionService appRegisterRegionService;
    private final HztAppUserMapper hztAppUserMapper;
    private final HztClinicMapper hztClinicMapper;
    private final StringRedisTemplate stringRedisTemplate;
    private final ObjectMapper objectMapper;
    private final AppShareService appShareService;
 
    /**
     * 注入注册链路所需的本地库、海诊通库、验证码与缓存能力。
     */
    public AppRegisterService(UserMapper userMapper,
                              ClinicMapper clinicMapper,
                              AppSmsCodeService appSmsCodeService,
                              AppAuthSessionService appAuthSessionService,
                              AppRegisterRegionService appRegisterRegionService,
                              HztAppUserMapper hztAppUserMapper,
                              HztClinicMapper hztClinicMapper,
                              StringRedisTemplate stringRedisTemplate,
                              ObjectMapper objectMapper,
                              AppShareService appShareService) {
        this.userMapper = userMapper;
        this.clinicMapper = clinicMapper;
        this.appSmsCodeService = appSmsCodeService;
        this.appAuthSessionService = appAuthSessionService;
        this.appRegisterRegionService = appRegisterRegionService;
        this.hztAppUserMapper = hztAppUserMapper;
        this.hztClinicMapper = hztClinicMapper;
        this.stringRedisTemplate = stringRedisTemplate;
        this.objectMapper = objectMapper;
        this.appShareService = appShareService;
    }
 
    /**
     * 为未注册或待注册手机号发送注册验证码。
     */
    public AppSmsCodeSendResponse sendRegisterCode(String rawPhone) {
        String phone = normalizePhone(rawPhone);
        ensurePhoneAvailable(phone);
        return appSmsCodeService.createRegisterCode(phone);
    }
 
    /**
     * 提供 APP 注册页使用的省市区三级联动选项。
     */
    public List<AppRegisterRegionOptionVO> listRegions(String parentCode) {
        return appRegisterRegionService.listRegions(parentCode);
    }
 
    /**
     * 提供 APP 滚轮地区选择器使用的完整数据集。
     */
    public AppRegisterRegionDatasetVO getRegionDataset() {
        return appRegisterRegionService.buildRegionDataset();
    }
 
    /**
     * 完成普通用户注册,并按需绑定上级诊所用户。
     * 普通用户注册会先查询海诊通数据源,因此这里不包裹本地事务,避免事务先绑定 master 导致跨库查询失效。
     */
    public AppLoginResponse registerUser(AppRegisterUserRequest request) {
        String phone = normalizePhone(request.getPhone());
        String inviteCode = normalizeInviteCode(request.getInviteCode());
        String bindTicket = normalizeBindTicket(request.getBindTicket());
 
        ensurePhoneAvailable(phone);
        appSmsCodeService.validateRegisterCode(phone, normalizeCode(request.getCode()));
 
        AppUser user = new AppUser();
        user.setPhone(phone);
        user.setType(0);
        user.setStatus(1);
 
        // 优先同步海诊通同手机号用户资料,未命中时使用手机号兜底昵称。
        HztAppUser hztAppUser = findHztAppUserByPhone(phone);
        populateUserProfile(user, hztAppUser, phone);
 
        // 绑定票据优先于邀请码,避免安装后丢失或误填导致关系错误。
        bindSuperiorClinicIfNecessary(user, inviteCode, bindTicket);
 
        userMapper.insert(user);
        return createLoginResponse(user);
    }
 
    /**
     * 校验经营者第一步,并返回海诊通诊所预填资料与注册凭证。
     */
    public AppRegisterOperatorPrepareResponse prepareOperatorRegister(AppRegisterOperatorPrepareRequest request) {
        String phone = normalizePhone(request.getPhone());
 
        ensurePhoneAvailable(phone);
        appSmsCodeService.validateRegisterCode(phone, normalizeCode(request.getCode()));
 
        HztClinic hztClinic = findHztClinicByPhone(phone);
        OperatorRegisterDraft draft = buildOperatorDraft(phone, hztClinic);
        String registerTicket = createRegisterTicket();
        cacheDraft(registerTicket, draft);
 
        AppRegisterOperatorPrepareResponse response = new AppRegisterOperatorPrepareResponse();
        response.setRegisterTicket(registerTicket);
        response.setClinicMatched(hztClinic != null);
        response.setClinicInfo(toClinicInfo(draft));
        return response;
    }
 
    /**
     * 完成经营者第二步,创建本地诊所与诊所用户。
     */
    @Transactional
    public AppLoginResponse completeOperatorRegister(AppRegisterOperatorCompleteRequest request) {
        OperatorRegisterDraft draft = loadDraft(normalizeRegisterTicket(request.getRegisterTicket()));
        ensurePhoneAvailable(draft.getPhone());
 
        Clinic clinic = new Clinic();
        clinic.setName(trimRequired(request.getInstitutionName(), "请输入机构名称"));
        clinic.setContactName(trimToNull(draft.getContactName()));
        clinic.setContactPhone(draft.getPhone());
        clinic.setProvinceCode(trimToNull(request.getProvinceCode()));
        clinic.setProvinceName(trimRequired(request.getProvinceName(), "请选择省份"));
        clinic.setCityCode(trimToNull(request.getCityCode()));
        clinic.setCityName(trimRequired(request.getCityName(), "请选择城市"));
        clinic.setDistrictCode(trimToNull(request.getDistrictCode()));
        clinic.setDistrictName(trimRequired(request.getDistrictName(), "请选择区县"));
        clinic.setAddress(trimRequired(request.getDetailAddress(), "请输入详细地址"));
        clinic.setBusinessLicenseUrl(trimToNull(request.getBusinessLicenseUrl()));
        clinic.setInviteCode(generateInviteCode());
        clinic.setStatus(1);
        clinicMapper.insert(clinic);
 
        AppUser user = new AppUser();
        user.setNickname(clinic.getName());
        user.setPhone(draft.getPhone());
        user.setType(1);
        user.setStatus(1);
        user.setClinicId(clinic.getId());
        userMapper.insert(user);
 
        clearDraft(request.getRegisterTicket());
        return createLoginResponse(user);
    }
 
    /**
     * 使用海诊通用户快照填充本地用户资料。
     */
    private void populateUserProfile(AppUser user, HztAppUser hztAppUser, String phone) {
        if (hztAppUser == null) {
            user.setNickname(resolveFallbackNickname(phone));
            return;
        }
        String nickname = trimToNull(hztAppUser.getNickName());
        user.setNickname(nickname == null ? resolveFallbackNickname(phone) : nickname);
        user.setAvatar(trimToNull(hztAppUser.getAvatar()));
        user.setGender(mapGender(hztAppUser.getSex()));
    }
 
    /**
     * 按邀请码绑定本地上级诊所用户与诊所归属。
     */
    private void bindSuperiorClinicIfNecessary(AppUser user, String inviteCode, String bindTicket) {
        AppShareService.InviteBindingTarget bindingTarget = resolveInviteBindingTarget(inviteCode, bindTicket);
        if (bindingTarget == null) {
            return;
        }
        user.setClinicId(bindingTarget.getClinicId());
        user.setSuperiorClinicUserId(bindingTarget.getSuperiorClinicUserId());
    }
 
    /**
     * 注册成功后立即建立 APP 登录态并返回统一登录响应。
     */
    private AppLoginResponse createLoginResponse(AppUser user) {
        appAuthSessionService.login(user.getId());
        return buildLoginResponse(user, appAuthSessionService.getTokenInfo());
    }
 
    /**
     * 使用 Sa-Token 信息构造 APP 登录响应。
     */
    private AppLoginResponse buildLoginResponse(AppUser user, SaTokenInfo tokenInfo) {
        return new AppLoginResponse(
                tokenInfo.getTokenValue(),
                "Bearer",
                tokenInfo.getTokenTimeout(),
                buildLoginUser(user)
        );
    }
 
    /**
     * 将注册后的 APP 用户实体转换为登录响应用户摘要。
     */
    private AppLoginUserVO buildLoginUser(AppUser user) {
        AppLoginUserVO loginUser = new AppLoginUserVO();
        loginUser.setId(user.getId());
        loginUser.setNickname(resolveFallbackNickname(user));
        loginUser.setPhone(user.getPhone());
        loginUser.setAvatar(trimToNull(user.getAvatar()));
        loginUser.setType(user.getType());
        loginUser.setStatus(user.getStatus());
        loginUser.setClinicId(user.getClinicId());
        loginUser.setMemberExpireAt(user.getMemberExpireAt());
        return loginUser;
    }
 
    /**
     * 解析注册阶段应绑定的目标关系,票据优先于邀请码。
     */
    private AppShareService.InviteBindingTarget resolveInviteBindingTarget(String inviteCode, String bindTicket) {
        if (bindTicket != null) {
            return appShareService.consumeBindTicket(bindTicket);
        }
        if (inviteCode == null) {
            return null;
        }
        return appShareService.resolveInviteBindingTarget(inviteCode);
    }
 
    /**
     * 按手机号查询海诊通普通用户资料。
     */
    private HztAppUser findHztAppUserByPhone(String phone) {
        return hztAppUserMapper.selectOne(new LambdaQueryWrapper<HztAppUser>()
                .eq(HztAppUser::getPhone, phone)
                .eq(HztAppUser::getDisabled, 0)
                .orderByDesc(HztAppUser::getCreateTime)
                .orderByDesc(HztAppUser::getId)
                .last("limit 1"));
    }
 
    /**
     * 按手机号查询海诊通诊所资料。
     */
    private HztClinic findHztClinicByPhone(String phone) {
        return hztClinicMapper.selectOne(new LambdaQueryWrapper<HztClinic>()
                .eq(HztClinic::getPhone, phone)
                .eq(HztClinic::getDisabled, 0)
                .orderByDesc(HztClinic::getCreateTime)
                .orderByDesc(HztClinic::getId)
                .last("limit 1"));
    }
 
    /**
     * 构造经营者注册草稿,避免第二步再次输入验证码。
     */
    private OperatorRegisterDraft buildOperatorDraft(String phone, HztClinic hztClinic) {
        OperatorRegisterDraft draft = new OperatorRegisterDraft();
        draft.setPhone(phone);
        if (hztClinic == null) {
            return draft;
        }
        AppRegisterRegionService.RegionSelection regionSelection = appRegisterRegionService.resolveRegionSelection(
                hztClinic.getProvinceCode(),
                hztClinic.getProvinceName(),
                hztClinic.getCityCode(),
                hztClinic.getCityName(),
                hztClinic.getDistrictCode(),
                hztClinic.getDistrictName()
        );
        draft.setContactName(trimToNull(hztClinic.getPersonChargeName()));
        draft.setInstitutionName(trimToNull(hztClinic.getClinicName()));
        draft.setProvinceCode(trimToNull(regionSelection.getProvinceCode()));
        draft.setProvinceName(trimToNull(regionSelection.getProvinceName()));
        draft.setCityCode(trimToNull(regionSelection.getCityCode()));
        draft.setCityName(trimToNull(regionSelection.getCityName()));
        draft.setDistrictCode(trimToNull(regionSelection.getDistrictCode()));
        draft.setDistrictName(trimToNull(regionSelection.getDistrictName()));
        draft.setDetailAddress(trimToNull(hztClinic.getClinicAddress()));
        return draft;
    }
 
    /**
     * 将缓存草稿转换为前端回显结构。
     */
    private AppRegisterClinicInfoVO toClinicInfo(OperatorRegisterDraft draft) {
        AppRegisterClinicInfoVO clinicInfo = new AppRegisterClinicInfoVO();
        clinicInfo.setInstitutionName(trimToNull(draft.getInstitutionName()));
        clinicInfo.setContactName(trimToNull(draft.getContactName()));
        clinicInfo.setProvinceCode(trimToNull(draft.getProvinceCode()));
        clinicInfo.setProvinceName(trimToNull(draft.getProvinceName()));
        clinicInfo.setCityCode(trimToNull(draft.getCityCode()));
        clinicInfo.setCityName(trimToNull(draft.getCityName()));
        clinicInfo.setDistrictCode(trimToNull(draft.getDistrictCode()));
        clinicInfo.setDistrictName(trimToNull(draft.getDistrictName()));
        clinicInfo.setDetailAddress(trimToNull(draft.getDetailAddress()));
        return clinicInfo;
    }
 
    /**
     * 将经营者第一步草稿缓存到 Redis。
     */
    private void cacheDraft(String registerTicket, OperatorRegisterDraft draft) {
        try {
            stringRedisTemplate.opsForValue().set(
                    buildRegisterTicketKey(registerTicket),
                    objectMapper.writeValueAsString(draft),
                    REGISTER_TICKET_EXPIRE_MINUTES,
                    TimeUnit.MINUTES
            );
        } catch (Exception ex) {
            throw new IllegalArgumentException("经营者注册暂不可用,请稍后重试");
        }
    }
 
    /**
     * 从 Redis 读取经营者注册草稿。
     */
    private OperatorRegisterDraft loadDraft(String registerTicket) {
        String cacheValue = stringRedisTemplate.opsForValue().get(buildRegisterTicketKey(registerTicket));
        if (cacheValue == null || cacheValue.trim().isEmpty()) {
            throw new IllegalArgumentException("注册凭证已失效,请重新填写");
        }
        try {
            return objectMapper.readValue(cacheValue, OperatorRegisterDraft.class);
        } catch (Exception ex) {
            throw new IllegalArgumentException("注册凭证已失效,请重新填写");
        }
    }
 
    /**
     * 清理已经完成的经营者注册草稿。
     */
    private void clearDraft(String registerTicket) {
        stringRedisTemplate.delete(buildRegisterTicketKey(normalizeRegisterTicket(registerTicket)));
    }
 
    /**
     * 构造经营者注册 ticket 的缓存 key。
     */
    private String buildRegisterTicketKey(String registerTicket) {
        return REGISTER_TICKET_CACHE_PREFIX + registerTicket;
    }
 
    /**
     * 生成经营者第二步提交所需的注册 ticket。
     */
    private String createRegisterTicket() {
        return UUID.randomUUID().toString().replace("-", "");
    }
 
    /**
     * 生成本地诊所邀请码,并确保在主库内唯一。
     */
    private String generateInviteCode() {
        for (int i = 0; i < 20; i++) {
            String candidate = randomInviteCode();
            Clinic exists = clinicMapper.selectOne(new LambdaQueryWrapper<Clinic>()
                    .eq(Clinic::getInviteCode, candidate)
                    .eq(Clinic::getDeleted, 0)
                    .last("limit 1"));
            if (exists == null) {
                return candidate;
            }
        }
        throw new IllegalStateException("邀请码生成失败,请稍后重试");
    }
 
    /**
     * 生成一次随机邀请码候选值。
     */
    private String randomInviteCode() {
        StringBuilder builder = new StringBuilder(INVITE_CODE_LENGTH);
        for (int i = 0; i < INVITE_CODE_LENGTH; i++) {
            int index = ThreadLocalRandom.current().nextInt(INVITE_CODE_CHARS.length());
            builder.append(INVITE_CODE_CHARS.charAt(index));
        }
        return builder.toString();
    }
 
    /**
     * 校验手机号在本地用户表中尚未注册。
     */
    private void ensurePhoneAvailable(String phone) {
        AppUser exists = userMapper.selectOne(new LambdaQueryWrapper<AppUser>()
                .eq(AppUser::getPhone, phone)
                .eq(AppUser::getDeleted, 0)
                .last("limit 1"));
        if (exists != null) {
            throw new IllegalArgumentException("该手机号已注册");
        }
    }
 
    /**
     * 将海诊通性别枚举映射到本地用户性别定义。
     */
    private Integer mapGender(Integer sex) {
        if (sex == null) {
            return null;
        }
        if (sex == 1) {
            return 1;
        }
        if (sex == 2) {
            return 0;
        }
        return null;
    }
 
    /**
     * 生成普通用户未命中海诊通时的默认昵称。
     */
    private String resolveFallbackNickname(String phone) {
        String masked = PhoneMasker.mask(phone);
        return masked == null ? phone : masked;
    }
 
    /**
     * 优先使用用户昵称,缺失时回退到手机号脱敏昵称。
     */
    private String resolveFallbackNickname(AppUser user) {
        String nickname = trimToNull(user.getNickname());
        if (nickname != null) {
            return nickname;
        }
        return resolveFallbackNickname(user.getPhone());
    }
 
    /**
     * 归一化并校验手机号格式。
     */
    private String normalizePhone(String phone) {
        String normalized = trimToNull(phone);
        if (normalized == null || !PHONE_PATTERN.matcher(normalized).matches()) {
            throw new IllegalArgumentException("请输入正确的手机号");
        }
        return normalized;
    }
 
    /**
     * 归一化并校验验证码输入。
     */
    private String normalizeCode(String code) {
        String normalized = trimToNull(code);
        if (normalized == null) {
            throw new IllegalArgumentException("请输入验证码");
        }
        return normalized;
    }
 
    /**
     * 归一化邀请码,统一转为大写便于匹配。
     */
    private String normalizeInviteCode(String inviteCode) {
        String normalized = trimToNull(inviteCode);
        return normalized == null ? null : normalized.toUpperCase();
    }
 
    /**
     * 归一化绑定票据。
     */
    private String normalizeBindTicket(String bindTicket) {
        return trimToNull(bindTicket);
    }
 
    /**
     * 归一化经营者注册凭证。
     */
    private String normalizeRegisterTicket(String registerTicket) {
        String normalized = trimToNull(registerTicket);
        if (normalized == null) {
            throw new IllegalArgumentException("注册凭证已失效,请重新填写");
        }
        return normalized;
    }
 
    /**
     * 要求字段非空并返回裁剪后的值。
     */
    private String trimRequired(String value, String message) {
        String normalized = trimToNull(value);
        if (normalized == null) {
            throw new IllegalArgumentException(message);
        }
        return normalized;
    }
 
    /**
     * 将空白字符串统一归一化为 null。
     */
    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 OperatorRegisterDraft {
 
        private String phone;
 
        private String contactName;
 
        private String institutionName;
 
        private String provinceCode;
 
        private String provinceName;
 
        private String cityCode;
 
        private String cityName;
 
        private String districtCode;
 
        private String districtName;
 
        private String detailAddress;
    }
}