13404089107
2026-05-06 fbe1f9a9e383f9546a7a14063f6c91b697a2933b
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
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
package com.tcm.training.admin.app.auth.service;
 
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.tcm.training.admin.app.auth.dto.AppPasswordLoginRequest;
import com.tcm.training.admin.app.auth.dto.AppPasswordResetRequest;
import com.tcm.training.admin.app.auth.dto.AppPasswordResetVerifyRequest;
import com.tcm.training.admin.app.auth.dto.AppAppleLoginRequest;
import com.tcm.training.admin.app.auth.dto.AppSmsCodeSendRequest;
import com.tcm.training.admin.app.auth.dto.AppSmsLoginRequest;
import com.tcm.training.admin.app.auth.dto.AppWechatLoginRequest;
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.AppPasswordResetVerifyResponse;
import com.tcm.training.admin.app.auth.vo.AppSmsCodeSendResponse;
import com.tcm.training.admin.app.share.service.AppShareService;
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.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.stereotype.Service;
 
import java.time.LocalDateTime;
import java.util.UUID;
import java.util.concurrent.TimeUnit;
import java.util.regex.Pattern;
 
/**
 * APP 登录认证服务,负责短信登录、密码登录、微信登录与忘记密码流程。
 */
@Service
public class AppAuthService {
 
    private static final Pattern PHONE_PATTERN = Pattern.compile("^1\\d{10}$");
    private static final Pattern PASSWORD_PATTERN = Pattern.compile("^(?![0-9]+$)(?![a-zA-Z]+$)(?![^0-9a-zA-Z]+$).{8,32}$");
    private static final String PASSWORD_RESET_TICKET_CACHE_PREFIX = "app:password-reset:ticket:";
    private static final long PASSWORD_RESET_TICKET_EXPIRE_MINUTES = 10L;
    private final UserMapper userMapper;
    private final AppSmsCodeService appSmsCodeService;
    private final BCryptPasswordEncoder passwordEncoder;
    private final AppAuthSessionService appAuthSessionService;
    private final AppWechatAuthClient appWechatAuthClient;
    private final AppAppleAuthClient appAppleAuthClient;
    private final AppShareService appShareService;
    private final StringRedisTemplate stringRedisTemplate;
 
    /**
     * 注入 APP 用户查询能力与短信验证码服务。
     */
    public AppAuthService(UserMapper userMapper,
                          AppSmsCodeService appSmsCodeService,
                          BCryptPasswordEncoder passwordEncoder,
                          AppAuthSessionService appAuthSessionService,
                          AppWechatAuthClient appWechatAuthClient,
                          AppAppleAuthClient appAppleAuthClient,
                          AppShareService appShareService,
                          StringRedisTemplate stringRedisTemplate) {
        this.userMapper = userMapper;
        this.appSmsCodeService = appSmsCodeService;
        this.passwordEncoder = passwordEncoder;
        this.appAuthSessionService = appAuthSessionService;
        this.appWechatAuthClient = appWechatAuthClient;
        this.appAppleAuthClient = appAppleAuthClient;
        this.appShareService = appShareService;
        this.stringRedisTemplate = stringRedisTemplate;
    }
 
    /**
     * 为已注册 APP 用户生成登录验证码。
     */
    public AppSmsCodeSendResponse sendLoginCode(AppSmsCodeSendRequest request) {
        String phone = normalizePhone(request.getPhone());
        requireEnabledUserByPhone(phone);
        return appSmsCodeService.createLoginCode(phone);
    }
 
    /**
     * 为已注册 APP 用户生成忘记密码验证码。
     */
    public AppSmsCodeSendResponse sendPasswordResetCode(AppSmsCodeSendRequest request) {
        String phone = normalizePhone(request.getPhone());
        requireEnabledUserByPhone(phone);
        return appSmsCodeService.createPasswordResetCode(phone);
    }
 
    /**
     * 使用短信验证码完成 APP 用户登录。
     */
    public AppLoginResponse smsLogin(AppSmsLoginRequest request) {
        String phone = normalizePhone(request.getPhone());
        AppUser user = requireEnabledUserByPhone(phone);
        appSmsCodeService.validateLoginCode(phone, normalizeCode(request.getCode()));
        return createLoginResponse(user);
    }
 
    /**
     * 使用手机号密码完成 APP 用户登录。
     */
    public AppLoginResponse passwordLogin(AppPasswordLoginRequest request) {
        String phone = normalizePhone(request.getPhone());
        String password = normalizeLoginPassword(request.getPassword());
        AppUser user = requireEnabledUserByPhone(phone);
        if (!passwordEncoder.matches(password, user.getPassword())) {
            throw new IllegalArgumentException("账号或密码错误");
        }
        return createLoginResponse(user);
    }
 
    /**
     * 使用微信授权结果完成 APP 登录,首次登录自动创建普通用户。
     */
    public AppLoginResponse wechatLogin(AppWechatLoginRequest request) {
        AppWechatUserProfile profile = appWechatAuthClient.resolveUserProfile(request);
        AppUser user = findUserByWechatOpenid(profile.getOpenid());
        if (user == null) {
            user = buildWechatUser(profile);
            bindInviteRelationIfAbsent(user, request.getInviteCode());
            userMapper.insert(user);
            return createLoginResponse(user);
        }
 
        requireEnabledUser(user);
        // 登录时同步微信专用资料,并仅在本地昵称头像为空时补齐展示字段。
        applyWechatProfile(user, profile, false);
        userMapper.updateById(user);
        return createLoginResponse(user);
    }
 
    /**
     * 使用苹果授权结果完成 APP 登录,首次登录自动创建普通用户。
     */
    public AppLoginResponse appleLogin(AppAppleLoginRequest request) {
        AppAppleUserProfile profile = appAppleAuthClient.resolveUserProfile(request);
        AppUser user = findUserByAppleSubject(profile.getSubject());
        if (user == null) {
            user = buildAppleUser(profile);
            bindInviteRelationIfAbsent(user, request.getInviteCode());
            userMapper.insert(user);
            return createLoginResponse(user);
        }
 
        requireEnabledUser(user);
        // 登录时同步苹果绑定标识,避免历史账号缺少最新 subject 或绑定时间。
        applyAppleProfile(user, profile, false);
        userMapper.updateById(user);
        return createLoginResponse(user);
    }
 
    /**
     * 校验忘记密码验证码并生成一次性重置凭证。
     */
    public AppPasswordResetVerifyResponse verifyPasswordResetCode(AppPasswordResetVerifyRequest request) {
        String phone = normalizePhone(request.getPhone());
        requireEnabledUserByPhone(phone);
        appSmsCodeService.validatePasswordResetCode(phone, normalizeCode(request.getCode()));
 
        String resetTicket = createResetTicket();
        cacheResetTicket(resetTicket, phone);
        return new AppPasswordResetVerifyResponse(resetTicket);
    }
 
    /**
     * 使用一次性重置凭证更新 APP 用户密码。
     */
    public void resetPassword(AppPasswordResetRequest request) {
        String resetTicket = normalizeResetTicket(request.getResetTicket());
        String password = normalizeNewPassword(request.getPassword());
        String confirmPassword = normalizeConfirmPassword(request.getConfirmPassword());
        validatePasswordConfirmation(password, confirmPassword);
 
        String phone = loadPhoneByResetTicket(resetTicket);
        AppUser user = requireEnabledUserByPhone(phone);
        user.setPassword(passwordEncoder.encode(password));
        userMapper.updateById(user);
        stringRedisTemplate.delete(buildResetTicketKey(resetTicket));
    }
 
    /**
     * 注销当前 APP 登录态。
     */
    public void logout() {
        appAuthSessionService.logout();
    }
 
    /**
     * 刷新当前 APP 登录态,并重新签发一枚新的 token。
     */
    public AppLoginResponse refreshToken() {
        Long userId = appAuthSessionService.requireLoginUserId();
        AppUser user = requireEnabledUserById(userId);
        // 当前 Sa-Token 版本不支持按 token 原地续期,这里直接为当前用户重新签发新 token。
        appAuthSessionService.login(user.getId());
        return buildLoginResponse(user, appAuthSessionService.getTokenInfo());
    }
 
    /**
     * 为成功登录的 APP 用户建立登录态并拼装统一响应。
     */
    private AppLoginResponse createLoginResponse(AppUser user) {
        appAuthSessionService.login(user.getId());
        return buildLoginResponse(user, appAuthSessionService.getTokenInfo());
    }
 
    /**
     * 统一构造 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(resolveNickname(user));
        loginUser.setPhone(user.getPhone());
        loginUser.setAvatar(resolveAvatar(user));
        loginUser.setType(user.getType());
        loginUser.setStatus(user.getStatus());
        loginUser.setClinicId(user.getClinicId());
        loginUser.setMemberExpireAt(user.getMemberExpireAt());
        return loginUser;
    }
 
    /**
     * 按手机号查询 APP 用户,不存在时抛出统一业务异常。
     */
    private AppUser requireExistingUserByPhone(String phone) {
        AppUser user = userMapper.selectOne(new LambdaQueryWrapper<AppUser>()
                .eq(AppUser::getPhone, phone)
                .eq(AppUser::getDeleted, 0)
                .last("limit 1"));
        if (user == null) {
            throw new IllegalArgumentException("账号不存在,请先注册");
        }
        return user;
    }
 
    /**
     * 按手机号查询并校验 APP 用户状态。
     */
    private AppUser requireEnabledUserByPhone(String phone) {
        AppUser user = requireExistingUserByPhone(phone);
        requireEnabledUser(user);
        return user;
    }
 
    /**
     * 按用户主键查询并校验 APP 用户状态。
     */
    private AppUser requireEnabledUserById(Long userId) {
        AppUser user = userMapper.selectById(userId);
        if (user == null || user.getDeleted() == null || user.getDeleted().intValue() != 0) {
            throw new IllegalArgumentException("用户不存在");
        }
        requireEnabledUser(user);
        return user;
    }
 
    /**
     * 校验给定 APP 用户是否处于启用状态。
     */
    private void requireEnabledUser(AppUser user) {
        if (user.getStatus() == null || user.getStatus() != 1) {
            throw new IllegalArgumentException("账号已被禁用");
        }
    }
 
    /**
     * 按微信 openid 查询本地 APP 用户,重复脏数据场景优先返回启用且已绑定手机号的账号。
     */
    private AppUser findUserByWechatOpenid(String openid) {
        return userMapper.selectOne(new LambdaQueryWrapper<AppUser>()
                .eq(AppUser::getWechatOpenid, openid)
                .eq(AppUser::getDeleted, 0)
                .last("ORDER BY CASE WHEN status = 1 THEN 0 ELSE 1 END, "
                        + "CASE WHEN phone IS NOT NULL AND TRIM(phone) <> '' THEN 0 ELSE 1 END, "
                        + "updated_at DESC, id DESC LIMIT 1"));
    }
 
    /**
     * 按苹果 subject 查询本地 APP 用户,重复脏数据场景优先返回启用且已绑定手机号的账号。
     */
    private AppUser findUserByAppleSubject(String subject) {
        return userMapper.selectOne(new LambdaQueryWrapper<AppUser>()
                .eq(AppUser::getAppleIdentitySubject, subject)
                .eq(AppUser::getDeleted, 0)
                .last("ORDER BY CASE WHEN status = 1 THEN 0 ELSE 1 END, "
                        + "CASE WHEN phone IS NOT NULL AND TRIM(phone) <> '' THEN 0 ELSE 1 END, "
                        + "updated_at DESC, id DESC LIMIT 1"));
    }
 
    /**
     * 基于微信资料构造首次登录时的本地普通用户。
     */
    private AppUser buildWechatUser(AppWechatUserProfile profile) {
        AppUser user = new AppUser();
        user.setType(0);
        user.setStatus(1);
        applyWechatProfile(user, profile, true);
        return user;
    }
 
    /**
     * 用户尚未绑定诊所关系时,根据医生邀请码补齐上下级关系。
     */
    private void bindInviteRelationIfAbsent(AppUser user, String inviteCode) {
        if (user.getClinicId() != null || user.getSuperiorClinicUserId() != null) {
            return;
        }
        String normalizedInviteCode = normalizeInviteCode(inviteCode);
        if (normalizedInviteCode == null) {
            return;
        }
        AppShareService.InviteBindingTarget bindingTarget = appShareService.resolveInviteBindingTarget(normalizedInviteCode);
        user.setClinicId(bindingTarget.getClinicId());
        user.setSuperiorClinicUserId(bindingTarget.getSuperiorClinicUserId());
    }
 
    /**
     * 基于苹果资料构造首次登录时的本地普通用户。
     */
    private AppUser buildAppleUser(AppAppleUserProfile profile) {
        AppUser user = new AppUser();
        user.setType(0);
        user.setStatus(1);
        applyAppleProfile(user, profile, true);
        return user;
    }
 
    /**
     * 将微信资料回填到本地用户,必要时补齐昵称头像展示字段。
     */
    private void applyWechatProfile(AppUser user, AppWechatUserProfile profile, boolean syncLocalProfile) {
        String nickname = trimToNull(profile.getNickname());
        String avatar = trimToNull(profile.getAvatar());
        String currentNickname = trimToNull(user.getNickname());
 
        user.setWechatOpenid(profile.getOpenid());
        user.setWechatNickname(nickname);
        user.setWechatAvatar(avatar);
        user.setWechatBoundAt(LocalDateTime.now());
 
        if (shouldRefreshLocalNickname(currentNickname, profile.getOpenid(), nickname, syncLocalProfile)) {
            user.setNickname(nickname == null ? profile.getOpenid() : nickname);
        }
        if (syncLocalProfile || trimToNull(user.getAvatar()) == null) {
            user.setAvatar(avatar);
        }
        if (syncLocalProfile || user.getGender() == null) {
            user.setGender(resolveWechatGender(profile.getSex()));
        }
    }
 
    /**
     * 仅在首次建号、昵称为空、openid 兜底值或历史乱码场景下刷新本地昵称,避免覆盖用户手动修改结果。
     */
    private boolean shouldRefreshLocalNickname(String currentNickname,
                                               String openid,
                                               String latestWechatNickname,
                                               boolean syncLocalProfile) {
        if (syncLocalProfile) {
            return true;
        }
        if (trimToNull(latestWechatNickname) == null) {
            return false;
        }
        String normalizedCurrentNickname = trimToNull(currentNickname);
        if (normalizedCurrentNickname == null) {
            return true;
        }
        String normalizedOpenid = trimToNull(openid);
        if (normalizedOpenid != null && normalizedOpenid.equals(normalizedCurrentNickname)) {
            return true;
        }
        return containsNicknameMojibake(normalizedCurrentNickname);
    }
 
    /**
     * 识别历史微信昵称乱码的常见特征,限定为已知乱码模式,避免误伤正常昵称。
     */
    private boolean containsNicknameMojibake(String nickname) {
        return nickname.contains("ð")
                || nickname.contains("Ã")
                || nickname.contains("â")
                || nickname.contains("�");
    }
 
    /**
     * 将苹果资料回填到本地用户,并在首次自动建号时补齐基础展示昵称。
     */
    private void applyAppleProfile(AppUser user, AppAppleUserProfile profile, boolean syncLocalProfile) {
        user.setAppleUserIdentifier(trimToNull(profile.getUserIdentifier()));
        user.setAppleIdentitySubject(trimToNull(profile.getSubject()));
        user.setAppleBoundAt(LocalDateTime.now());
 
        if (syncLocalProfile || trimToNull(user.getNickname()) == null) {
            user.setNickname("苹果用户");
        }
    }
 
    /**
     * 将微信性别值转换为本地用户性别枚举。
     */
    private Integer resolveWechatGender(Integer sex) {
        if (sex == null) {
            return null;
        }
        if (sex == 1) {
            return 1;
        }
        if (sex == 2) {
            return 0;
        }
        return null;
    }
 
    /**
     * 归一化并校验手机号格式。
     */
    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 normalizeLoginPassword(String password) {
        String normalized = trimToNull(password);
        if (normalized == null) {
            throw new IllegalArgumentException("请输入密码");
        }
        return normalized;
    }
 
    /**
     * 归一化医生邀请码,非法时忽略以保持第三方登录主流程可用。
     */
    private String normalizeInviteCode(String inviteCode) {
        String normalized = trimToNull(inviteCode);
        if (normalized == null) {
            return null;
        }
        String upperInviteCode = normalized.toUpperCase();
        return upperInviteCode.matches("^[A-Z0-9]{6}$") ? upperInviteCode : null;
    }
 
    /**
     * 归一化并校验忘记密码场景下的新密码格式。
     */
    private String normalizeNewPassword(String password) {
        String normalized = trimToNull(password);
        if (normalized == null) {
            throw new IllegalArgumentException("请输入新密码");
        }
        if (!PASSWORD_PATTERN.matcher(normalized).matches()) {
            throw new IllegalArgumentException("密码需为8-32位,且包含数字、字母、符号至少2种");
        }
        return normalized;
    }
 
    /**
     * 归一化并校验确认密码输入。
     */
    private String normalizeConfirmPassword(String confirmPassword) {
        String normalized = trimToNull(confirmPassword);
        if (normalized == null) {
            throw new IllegalArgumentException("请再次输入密码");
        }
        if (!PASSWORD_PATTERN.matcher(normalized).matches()) {
            throw new IllegalArgumentException("密码需为8-32位,且包含数字、字母、符号至少2种");
        }
        return normalized;
    }
 
    /**
     * 归一化并校验一次性重置凭证。
     */
    private String normalizeResetTicket(String resetTicket) {
        String normalized = trimToNull(resetTicket);
        if (normalized == null) {
            throw new IllegalArgumentException("重置凭证已失效,请重新验证");
        }
        return normalized;
    }
 
    /**
     * 校验两次输入的新密码保持一致。
     */
    private void validatePasswordConfirmation(String password, String confirmPassword) {
        if (!password.equals(confirmPassword)) {
            throw new IllegalArgumentException("两次密码输入不一致");
        }
    }
 
    /**
     * 生成忘记密码场景使用的一次性重置凭证。
     */
    private String createResetTicket() {
        return UUID.randomUUID().toString().replace("-", "");
    }
 
    /**
     * 将重置凭证与手机号绑定写入 Redis,供下一步更新密码使用。
     */
    private void cacheResetTicket(String resetTicket, String phone) {
        stringRedisTemplate.opsForValue().set(
                buildResetTicketKey(resetTicket),
                phone,
                PASSWORD_RESET_TICKET_EXPIRE_MINUTES,
                TimeUnit.MINUTES
        );
    }
 
    /**
     * 从 Redis 读取并校验一次性重置凭证对应的手机号。
     */
    private String loadPhoneByResetTicket(String resetTicket) {
        String phone = stringRedisTemplate.opsForValue().get(buildResetTicketKey(resetTicket));
        if (phone == null || phone.trim().isEmpty()) {
            throw new IllegalArgumentException("重置凭证已失效,请重新验证");
        }
        return phone;
    }
 
    /**
     * 构造忘记密码重置凭证的 Redis key。
     */
    private String buildResetTicketKey(String resetTicket) {
        return PASSWORD_RESET_TICKET_CACHE_PREFIX + resetTicket;
    }
 
    /**
     * 优先使用昵称,缺失时回退到手机号作为展示名称。
     */
    private String resolveNickname(AppUser user) {
        String nickname = trimToNull(user.getNickname());
        if (nickname != null) {
            return nickname;
        }
        String wechatNickname = trimToNull(user.getWechatNickname());
        if (wechatNickname != null) {
            return wechatNickname;
        }
        return user.getPhone();
    }
 
    /**
     * 优先使用本地头像,缺失时回退到微信头像。
     */
    private String resolveAvatar(AppUser user) {
        String avatar = trimToNull(user.getAvatar());
        if (avatar != null) {
            return avatar;
        }
        return trimToNull(user.getWechatAvatar());
    }
 
    /**
     * 将空白字符串归一化为 null。
     */
    private String trimToNull(String value) {
        if (value == null) {
            return null;
        }
        String trimmed = value.trim();
        return trimmed.isEmpty() ? null : trimmed;
    }
}