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
package cn.iocoder.yudao.module.system.service.auth;
 
import cn.hutool.core.util.ObjectUtil;
import cn.iocoder.yudao.framework.common.enums.CommonStatusEnum;
import cn.iocoder.yudao.framework.common.enums.UserTypeEnum;
import cn.iocoder.yudao.framework.common.util.monitor.TracerUtils;
import cn.iocoder.yudao.framework.common.util.object.BeanUtils;
import cn.iocoder.yudao.framework.common.util.servlet.ServletUtils;
import cn.iocoder.yudao.framework.common.util.validation.ValidationUtils;
import cn.iocoder.yudao.framework.datapermission.core.annotation.DataPermission;
import cn.iocoder.yudao.module.system.api.logger.dto.LoginLogCreateReqDTO;
import cn.iocoder.yudao.module.system.api.sms.SmsCodeApi;
import cn.iocoder.yudao.module.system.api.sms.dto.code.SmsCodeUseReqDTO;
import cn.iocoder.yudao.module.system.api.social.dto.SocialUserBindReqDTO;
import cn.iocoder.yudao.module.system.api.social.dto.SocialUserRespDTO;
import cn.iocoder.yudao.module.system.controller.admin.auth.vo.*;
import cn.iocoder.yudao.module.system.convert.auth.AuthConvert;
import cn.iocoder.yudao.module.system.dal.dataobject.oauth2.OAuth2AccessTokenDO;
import cn.iocoder.yudao.module.system.dal.dataobject.permission.RoleDO;
import cn.iocoder.yudao.module.system.dal.dataobject.user.AdminUserDO;
import cn.iocoder.yudao.module.system.dal.mysql.user.AdminUserMapper;
import cn.iocoder.yudao.module.system.enums.logger.LoginLogTypeEnum;
import cn.iocoder.yudao.module.system.enums.logger.LoginResultEnum;
import cn.iocoder.yudao.module.system.enums.oauth2.OAuth2ClientConstants;
import cn.iocoder.yudao.module.system.enums.sms.SmsSceneEnum;
import cn.iocoder.yudao.module.system.event.SocialLoginEvent;
import cn.iocoder.yudao.module.system.service.logger.LoginLogService;
import cn.iocoder.yudao.module.system.service.member.MemberService;
import cn.iocoder.yudao.module.system.service.oauth2.OAuth2TokenService;
import cn.iocoder.yudao.module.system.service.permission.PermissionService;
import cn.iocoder.yudao.module.system.service.permission.RoleService;
import cn.iocoder.yudao.module.system.service.social.SocialUserService;
import cn.iocoder.yudao.module.system.service.user.AdminUserService;
import com.anji.captcha.model.common.ResponseModel;
import com.anji.captcha.model.vo.CaptchaVO;
import com.anji.captcha.service.CaptchaService;
import com.google.common.annotations.VisibleForTesting;
import jakarta.annotation.Resource;
import jakarta.validation.Validator;
import lombok.Setter;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.ApplicationEventPublisher;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
 
import java.util.Objects;
import java.util.Set;
import java.util.UUID;
 
import static cn.iocoder.yudao.framework.common.exception.util.ServiceExceptionUtil.exception;
import static cn.iocoder.yudao.framework.common.util.servlet.ServletUtils.getClientIP;
import static cn.iocoder.yudao.module.system.enums.ErrorCodeConstants.*;
 
/**
 * Auth Service 实现类
 *
 * @author 超级管理员
 */
@Service
@Slf4j
public class AdminAuthServiceImpl implements AdminAuthService {
 
    /**
     * 企业微信社交类型,对应 {@link cn.iocoder.yudao.module.system.enums.social.SocialTypeEnum#WECHAT_ENTERPRISE}
     */
    private static final Integer WECHAT_ENTERPRISE_TYPE = 30;
 
    /**
     * 企业微信扫码自动注册的账号,默认绑定的角色编码。
     *
     * 需要在「系统管理 → 角色管理」里建好该编码的角色并勾选菜单,否则新账号登录后没有任何菜单。
     * 编码而非角色编号:角色编号在本地/测试/生产各库不一致,写死会在换库后静默失效。
     */
    private static final String WXCP_DEFAULT_ROLE_CODE = "wxcp_user";
 
    @Resource
    private AdminUserService userService;
    @Resource
    private AdminUserMapper userMapper;
    @Resource
    private LoginLogService loginLogService;
    @Resource
    private OAuth2TokenService oauth2TokenService;
    @Resource
    private SocialUserService socialUserService;
    @Resource
    private MemberService memberService;
    @Resource
    private Validator validator;
    @Resource
    private CaptchaService captchaService;
    @Resource
    private SmsCodeApi smsCodeApi;
    @Resource
    private PasswordEncoder passwordEncoder;
    @Resource
    private ApplicationEventPublisher eventPublisher;
    @Resource
    private RoleService roleService;
    @Resource
    private PermissionService permissionService;
 
    /**
     * 验证码的开关,默认为 true
     */
    @Value("${yudao.captcha.enable:true}")
    @Setter // 为了单测:开启或者关闭验证码
    private Boolean captchaEnable;
 
    @Override
    public AdminUserDO authenticate(String username, String password) {
        final LoginLogTypeEnum logTypeEnum = LoginLogTypeEnum.LOGIN_USERNAME;
        // 校验账号是否存在
        AdminUserDO user = userService.getUserByUsername(username);
        if (user == null) {
            createLoginLog(null, username, logTypeEnum, LoginResultEnum.BAD_CREDENTIALS);
            throw exception(AUTH_LOGIN_BAD_CREDENTIALS);
        }
        if (!userService.isPasswordMatch(password, user.getPassword())) {
            createLoginLog(user.getId(), username, logTypeEnum, LoginResultEnum.BAD_CREDENTIALS);
            throw exception(AUTH_LOGIN_BAD_CREDENTIALS);
        }
        // 校验是否禁用
        if (CommonStatusEnum.isDisable(user.getStatus())) {
            createLoginLog(user.getId(), username, logTypeEnum, LoginResultEnum.USER_DISABLED);
            throw exception(AUTH_LOGIN_USER_DISABLED);
        }
        return user;
    }
 
    @Override
    @DataPermission(enable = false)
    public AuthLoginRespVO login(AuthLoginReqVO reqVO) {
        // 校验验证码
        validateCaptcha(reqVO);
 
        // 使用账号密码,进行登录
        AdminUserDO user = authenticate(reqVO.getUsername(), reqVO.getPassword());
 
        // 如果 socialType 非空,说明需要绑定社交用户
        if (reqVO.getSocialType() != null) {
            socialUserService.bindSocialUser(new SocialUserBindReqDTO(user.getId(), getUserType().getValue(),
                    reqVO.getSocialType(), reqVO.getSocialCode(), reqVO.getSocialState()));
        }
        // 创建 Token 令牌,记录登录日志
        AuthLoginRespVO result = createTokenAfterLoginSuccess(user.getId(), reqVO.getUsername(), LoginLogTypeEnum.LOGIN_USERNAME);
 
        // 发布社交登录事件(绑定新社交账号时)
        if (reqVO.getSocialType() != null) {
            eventPublisher.publishEvent(new SocialLoginEvent(
                    reqVO.getSocialType(), user.getId(), user.getUsername(), "账号密码绑定"));
        }
 
        return result;
    }
 
    @Override
    public void sendSmsCode(AuthSmsSendReqVO reqVO) {
        // 如果是重置密码场景,需要校验图形验证码是否正确
        if (Objects.equals(SmsSceneEnum.ADMIN_MEMBER_RESET_PASSWORD.getScene(), reqVO.getScene())) {
            ResponseModel response = doValidateCaptcha(reqVO);
            if (!response.isSuccess()) {
                throw exception(AUTH_REGISTER_CAPTCHA_CODE_ERROR, response.getRepMsg());
            }
        }
 
        // 登录场景,验证是否存在
        if (userService.getUserByMobile(reqVO.getMobile()) == null) {
            throw exception(AUTH_MOBILE_NOT_EXISTS);
        }
        // 发送验证码
        smsCodeApi.sendSmsCode(AuthConvert.INSTANCE.convert(reqVO).setCreateIp(getClientIP()));
    }
 
    @Override
    public AuthLoginRespVO smsLogin(AuthSmsLoginReqVO reqVO) {
        // 校验验证码
        smsCodeApi.useSmsCode(AuthConvert.INSTANCE.convert(reqVO, SmsSceneEnum.ADMIN_MEMBER_LOGIN.getScene(), getClientIP()));
 
        // 获得用户信息
        AdminUserDO user = userService.getUserByMobile(reqVO.getMobile());
        if (user == null) {
            throw exception(USER_NOT_EXISTS);
        }
 
        // 创建 Token 令牌,记录登录日志
        return createTokenAfterLoginSuccess(user.getId(), reqVO.getMobile(), LoginLogTypeEnum.LOGIN_MOBILE);
    }
 
    private void createLoginLog(Long userId, String username,
                                LoginLogTypeEnum logTypeEnum, LoginResultEnum loginResult) {
        // 插入登录日志
        LoginLogCreateReqDTO reqDTO = new LoginLogCreateReqDTO();
        reqDTO.setLogType(logTypeEnum.getType());
        reqDTO.setTraceId(TracerUtils.getTraceId());
        reqDTO.setUserId(userId);
        reqDTO.setUserType(getUserType().getValue());
        reqDTO.setUsername(username);
        reqDTO.setUserAgent(ServletUtils.getUserAgent());
        reqDTO.setUserIp(ServletUtils.getClientIP());
        reqDTO.setResult(loginResult.getResult());
        loginLogService.createLoginLog(reqDTO);
        // 更新最后登录时间
        if (userId != null && Objects.equals(LoginResultEnum.SUCCESS.getResult(), loginResult.getResult())) {
            userService.updateUserLogin(userId, ServletUtils.getClientIP());
        }
    }
 
    @Override
    @Transactional(rollbackFor = Exception.class)
    public AuthLoginRespVO socialLogin(AuthSocialLoginReqVO reqVO) {
        // 1. 使用 code 授权码,获取社交用户信息
        SocialUserRespDTO socialUser = socialUserService.getSocialUserByCode(UserTypeEnum.ADMIN.getValue(), reqVO.getType(),
                reqVO.getCode(), reqVO.getState());
        if (socialUser == null) {
            throw exception(AUTH_THIRD_LOGIN_NOT_BIND);
        }
 
        // 2. 判断是否已有绑定的系统用户
        //
        // 注意:system_social_user_bind 里的 user_id 可能指向「已被删除」的用户 ——
        // AdminUserServiceImpl.deleteUser 只清理了角色和岗位,没有清理这张社交绑定表,
        // 删除后绑定关系会残留成孤儿。所以这里不能只看「有没有绑定」,
        // 还要看「绑定的人还在不在」。
        Long bindUserId = socialUser.getUserId();
        AdminUserDO user = bindUserId != null ? userService.getUser(bindUserId) : null;
 
        if (user == null) {
            // 2.1 首次登录,或原绑定用户已被删除:自动注册新用户并(改)绑定
            //
            // 原绑定用户已删除时必须走这条路:否则该企业成员会永久卡在「用户不存在」——
            // 既登不进来,也不会被重新注册,是一个无法自愈的死锁。
            // bindSocialUser 内部会先删掉该社交用户的旧绑定,所以改绑不会产生重复记录。
            if (bindUserId != null) {
                log.warn("[socialLogin][社交用户({}) 原绑定的用户({}) 已不存在(可能已被删除),按首次登录重新注册]",
                        socialUser.getOpenid(), bindUserId);
            }
            user = autoRegisterUser(socialUser, reqVO.getType());
            // 绑定社交账号
            socialUserService.bindSocialUser(new SocialUserBindReqDTO(user.getId(), getUserType().getValue(),
                    reqVO.getType(), reqVO.getCode(), reqVO.getState()));
            log.info("[socialLogin][社交登录自动注册 userId={} username={} socialType={}]",
                    user.getId(), user.getUsername(), reqVO.getType());
        }
 
        // 3. 创建 Token 令牌,记录登录日志
        AuthLoginRespVO result = createTokenAfterLoginSuccess(user.getId(), user.getUsername(), LoginLogTypeEnum.LOGIN_SOCIAL);
 
        // 4. 发布社交登录事件(供其他模块监听,如发送企业微信通知)
        eventPublisher.publishEvent(new SocialLoginEvent(
                reqVO.getType(), user.getId(), user.getUsername(), "社交扫码"));
 
        return result;
    }
 
    /**
     * 社交登录自动注册用户
     *
     * 当用户首次通过社交账号(如企业微信)扫码且未绑定时,自动创建系统用户
     *
     * @param socialUser 社交用户信息
     * @param socialType 社交类型
     * @return 新创建的用户
     */
    private AdminUserDO autoRegisterUser(SocialUserRespDTO socialUser, Integer socialType) {
        // 1. 自动生成用户名(wx_ + 8位随机十六进制)
        String rawUsername = "wx_" + UUID.randomUUID().toString().replaceAll("-", "").substring(0, 8);
 
        // 2. 确保用户名唯一(如果冲突则重试)
        String username = rawUsername;
        int retry = 0;
        while (userService.getUserByUsername(username) != null && retry < 5) {
            username = "wx_" + UUID.randomUUID().toString().replaceAll("-", "").substring(0, 8);
            retry++;
        }
        if (retry >= 5) {
            throw exception(USER_USERNAME_EXISTS);
        }
 
        // 3. 生成随机密码
        String password = UUID.randomUUID().toString().replaceAll("-", "").substring(0, 16);
 
        // 4. 创建用户
        AdminUserDO user = new AdminUserDO();
        user.setUsername(username);
        user.setNickname(socialUser.getNickname() != null ? socialUser.getNickname() : username);
        user.setPassword(passwordEncoder.encode(password));
        user.setStatus(CommonStatusEnum.ENABLE.getStatus());
        userMapper.insert(user);
 
        // 5. 绑定默认角色
        //    不绑的话账号能登录,但 get-permission-info 返回空菜单,进去一片空白;
        //    这种"能进但什么都看不到"的状态不会报错,很难定位,所以注册时就绑上
        bindDefaultRole(user);
 
        return user;
    }
 
    /**
     * 给自动注册的账号绑定默认角色
     *
     * 角色不存在时只打警告、不抛异常:一个配置遗漏不应该让扫码登录整条链路不可用,
     * 而且警告日志里已经写明了去哪儿补。
     *
     * @param user 自动注册出来的用户
     */
    private void bindDefaultRole(AdminUserDO user) {
        RoleDO role = roleService.getRoleByCode(WXCP_DEFAULT_ROLE_CODE);
        if (role == null) {
            log.warn("[bindDefaultRole][角色编码({}) 不存在,用户({}) 将没有任何菜单权限;" +
                            "请在「系统管理 → 角色管理」新建编码为 {} 的角色并分配菜单]",
                    WXCP_DEFAULT_ROLE_CODE, user.getUsername(), WXCP_DEFAULT_ROLE_CODE);
            return;
        }
        permissionService.assignUserRole(user.getId(), Set.of(role.getId()));
        log.info("[bindDefaultRole][用户({}) 已绑定默认角色({}/{})]",
                user.getUsername(), role.getCode(), role.getName());
    }
 
    @VisibleForTesting
    void validateCaptcha(AuthLoginReqVO reqVO) {
        ResponseModel response = doValidateCaptcha(reqVO);
        // 校验验证码
        if (!response.isSuccess()) {
            // 创建登录失败日志(验证码不正确)
            createLoginLog(null, reqVO.getUsername(), LoginLogTypeEnum.LOGIN_USERNAME, LoginResultEnum.CAPTCHA_CODE_ERROR);
            throw exception(AUTH_LOGIN_CAPTCHA_CODE_ERROR, response.getRepMsg());
        }
    }
 
    private ResponseModel doValidateCaptcha(CaptchaVerificationReqVO reqVO) {
        // 如果验证码关闭,则不进行校验
        if (!captchaEnable) {
            return ResponseModel.success();
        }
        ValidationUtils.validate(validator, reqVO, CaptchaVerificationReqVO.CodeEnableGroup.class);
        CaptchaVO captchaVO = new CaptchaVO();
        captchaVO.setCaptchaVerification(reqVO.getCaptchaVerification());
        return captchaService.verification(captchaVO);
    }
 
    private AuthLoginRespVO createTokenAfterLoginSuccess(Long userId, String username, LoginLogTypeEnum logType) {
        // 插入登陆日志
        createLoginLog(userId, username, logType, LoginResultEnum.SUCCESS);
        // 创建访问令牌
        OAuth2AccessTokenDO accessTokenDO = oauth2TokenService.createAccessToken(userId, getUserType().getValue(),
                OAuth2ClientConstants.CLIENT_ID_DEFAULT, null);
        // 构建返回结果
        return BeanUtils.toBean(accessTokenDO, AuthLoginRespVO.class);
    }
 
    @Override
    public AuthLoginRespVO refreshToken(String refreshToken) {
        OAuth2AccessTokenDO accessTokenDO = oauth2TokenService.refreshAccessToken(refreshToken, OAuth2ClientConstants.CLIENT_ID_DEFAULT);
        return BeanUtils.toBean(accessTokenDO, AuthLoginRespVO.class);
    }
 
    @Override
    public void logout(String token, Integer logType) {
        // 删除访问令牌
        OAuth2AccessTokenDO accessTokenDO = oauth2TokenService.removeAccessToken(token);
        if (accessTokenDO == null) {
            return;
        }
        // 删除成功,则记录登出日志
        createLogoutLog(accessTokenDO.getUserId(), accessTokenDO.getUserType(), logType);
    }
 
    private void createLogoutLog(Long userId, Integer userType, Integer logType) {
        LoginLogCreateReqDTO reqDTO = new LoginLogCreateReqDTO();
        reqDTO.setLogType(logType);
        reqDTO.setTraceId(TracerUtils.getTraceId());
        reqDTO.setUserId(userId);
        reqDTO.setUserType(userType);
        if (ObjectUtil.equal(getUserType().getValue(), userType)) {
            reqDTO.setUsername(getUsername(userId));
        } else {
            reqDTO.setUsername(memberService.getMemberUserMobile(userId));
        }
        reqDTO.setUserAgent(ServletUtils.getUserAgent());
        reqDTO.setUserIp(ServletUtils.getClientIP());
        reqDTO.setResult(LoginResultEnum.SUCCESS.getResult());
        loginLogService.createLoginLog(reqDTO);
    }
 
    private String getUsername(Long userId) {
        if (userId == null) {
            return null;
        }
        AdminUserDO user = userService.getUser(userId);
        return user != null ? user.getUsername() : null;
    }
 
    private UserTypeEnum getUserType() {
        return UserTypeEnum.ADMIN;
    }
 
    @Override
    public AuthLoginRespVO register(AuthRegisterReqVO registerReqVO) {
        // 1. 校验验证码
        validateCaptcha(registerReqVO);
 
        // 2. 校验用户名是否已存在
        Long userId = userService.registerUser(registerReqVO);
 
        // 3. 创建 Token 令牌,记录登录日志
        return createTokenAfterLoginSuccess(userId, registerReqVO.getUsername(), LoginLogTypeEnum.LOGIN_USERNAME);
    }
 
    @VisibleForTesting
    void validateCaptcha(AuthRegisterReqVO reqVO) {
        ResponseModel response = doValidateCaptcha(reqVO);
        // 验证不通过
        if (!response.isSuccess()) {
            throw exception(AUTH_REGISTER_CAPTCHA_CODE_ERROR, response.getRepMsg());
        }
    }
 
    @Override
    @Transactional(rollbackFor = Exception.class)
    public void resetPassword(AuthResetPasswordReqVO reqVO) {
        AdminUserDO userByMobile = userService.getUserByMobile(reqVO.getMobile());
        if (userByMobile == null) {
            throw exception(USER_MOBILE_NOT_EXISTS);
        }
 
        smsCodeApi.useSmsCode(new SmsCodeUseReqDTO()
                .setCode(reqVO.getCode())
                .setMobile(reqVO.getMobile())
                .setScene(SmsSceneEnum.ADMIN_MEMBER_RESET_PASSWORD.getScene())
                .setUsedIp(getClientIP())
        );
 
        userService.updateUserPassword(userByMobile.getId(), reqVO.getPassword());
    }
}