Przeglądaj źródła

运营后台和分开管理优化

liuchengsen 1 miesiąc temu
rodzic
commit
597b976696

+ 33 - 0
zhijiayun-common/src/main/java/com/xuekairui/common/RandomUtil.java

@@ -0,0 +1,33 @@
+package com.xuekairui.common;
+
+import java.security.SecureRandom;
+
+/**
+ * 随机字符串工具(统一全项目随机数生成逻辑)
+ */
+public final class RandomUtil {
+
+    /** 字符集:去掉易混淆的 I/l/O/0/1 */
+    private static final String CHARS = "ABCDEFGHJKMNPQRSTUVWXYZabcdefghjkmnpqrstuvwxyz23456789";
+    private static final SecureRandom RANDOM = new SecureRandom();
+
+    private RandomUtil() {}
+
+    /** 生成指定长度的随机字符串 */
+    public static String generateCode(int length) {
+        StringBuilder sb = new StringBuilder(length);
+        for (int i = 0; i < length; i++) {
+            sb.append(CHARS.charAt(RANDOM.nextInt(CHARS.length())));
+        }
+        return sb.toString();
+    }
+
+    /** 生成指定长度的纯数字随机码 */
+    public static String generateDigits(int length) {
+        StringBuilder sb = new StringBuilder(length);
+        for (int i = 0; i < length; i++) {
+            sb.append(RANDOM.nextInt(10));
+        }
+        return sb.toString();
+    }
+}

+ 5 - 34
zhijiayun-common/src/main/java/com/xuekairui/common/TraceIdGenerator.java

@@ -1,45 +1,16 @@
 package com.xuekairui.common;
 
-import java.util.Random;
-
 /**
- * 全链路 Trace ID 生成器
- * <p>
- * 提供统一的 traceId 生成规则,避免各过滤器/装饰器各自维护随机逻辑。
- * 格式:8位随机字符(字母+数字,去掉易混淆的 0/O/1/I/o/l)
- * 示例:6k3Fa9B2
- * </p>
+ * 全链路 Trace ID 生成器,委托 RandomUtil 统一生成。
+ * 格式:8位随机字符(字母+数字,去掉易混淆字符)
  */
 public final class TraceIdGenerator {
 
-    private TraceIdGenerator() {
-        // 工具类禁止实例化
-    }
-
-    /** 随机字符集(大小写字母+数字,去掉易混淆的 0/O/1/I/o/l) */
-    private static final String ID_CHARS = "ABCDEFGHJKLMNPQRSTUVWXYZabcdefghjkmnpqrstuvwxyz23456789";
+    private static final int LENGTH = 8;
 
-    private static final Random RANDOM = new Random();
+    private TraceIdGenerator() {}
 
-    private static final int SEGMENT_LENGTH = 4;
-    private static final int SEGMENT_COUNT = 2;
-
-    /**
-     * 生成新的 traceId
-     */
     public static String generate() {
-        StringBuilder sb = new StringBuilder(SEGMENT_LENGTH * SEGMENT_COUNT);
-        for (int i = 0; i < SEGMENT_COUNT; i++) {
-            sb.append(randomSegment());
-        }
-        return sb.toString();
-    }
-
-    private static String randomSegment() {
-        StringBuilder sb = new StringBuilder(SEGMENT_LENGTH);
-        for (int i = 0; i < SEGMENT_LENGTH; i++) {
-            sb.append(ID_CHARS.charAt(RANDOM.nextInt(ID_CHARS.length())));
-        }
-        return sb.toString();
+        return RandomUtil.generateCode(LENGTH);
     }
 }

+ 1 - 8
zhijiayun-invite/src/main/java/com/xuekairui/invite/service/InviteService.java

@@ -951,8 +951,6 @@ public class InviteService {
     // ==========================================
 
     /** 邀请码字符集(32个,去掉易混淆的 I/O/0/1) */
-    private static final String CODE_CHARS = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789";
-
     /** 邀请码正则片段:8位不含易混淆字符 */
     private static final String CODE_RE = "[A-HJ-NP-Z2-9]{8}";
 
@@ -979,14 +977,9 @@ public class InviteService {
      * 32^8 ≈ 1.1万亿种组合,正常不会碰撞,此处为防御性设计。
      */
     private String generateUniqueCode() {
-        java.security.SecureRandom random = new java.security.SecureRandom();
         int maxRetries = 5;
         for (int attempt = 0; attempt < maxRetries; attempt++) {
-            StringBuilder sb = new StringBuilder(8);
-            for (int i = 0; i < 8; i++) {
-                sb.append(CODE_CHARS.charAt(random.nextInt(CODE_CHARS.length())));
-            }
-            String code = sb.toString();
+            String code = com.xuekairui.common.RandomUtil.generateCode(8);
             if (inviteCodeMapper.selectCount(
                     new LambdaQueryWrapper<InviteCode>().eq(InviteCode::getCode, code)) == 0) {
                 return code;

+ 63 - 8
zhijiayun-user/src/main/java/com/xuekairui/user/controller/AdminAccountController.java

@@ -2,6 +2,7 @@ package com.xuekairui.user.controller;
 
 import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
 import com.xuekairui.common.Result;
+import com.xuekairui.user.dto.AdminUpsertRequest;
 import com.xuekairui.user.dto.AdminUserResponse;
 import com.xuekairui.user.enums.OperationType;
 import com.xuekairui.user.service.AdminUserService;
@@ -10,10 +11,10 @@ import jakarta.servlet.http.HttpServletRequest;
 import lombok.RequiredArgsConstructor;
 import org.springframework.web.bind.annotation.*;
 
+import java.util.Map;
+
 /**
- * 管理员账号管理接口(运营端)
- * <p>
- * 管理员列表查询、踢出等,操作 t_admin 表。
+ * 管理员账号 CRUD(运营端,操作 t_admin 表)
  */
 @RestController
 @RequestMapping("/api/admin/admins")
@@ -22,6 +23,7 @@ public class AdminAccountController {
 
     private final AdminUserService adminUserService;
     private final OperationAuditLogService auditLogService;
+    private final com.xuekairui.user.service.AdminAuthService adminAuthService;
 
     @GetMapping
     public Result<Page<AdminUserResponse>> listAdmins(
@@ -30,14 +32,67 @@ public class AdminAccountController {
         return Result.success(adminUserService.listAdmins(page, size));
     }
 
+    @GetMapping("/{id}")
+    public Result<AdminUserResponse> getAdmin(@PathVariable Long id) {
+        return Result.success(adminUserService.getAdmin(id));
+    }
+
+    @PostMapping
+    public Result<AdminUserResponse> createAdmin(@RequestBody AdminUpsertRequest request,
+                                                  HttpServletRequest httpRequest) {
+        Long operatorId = (Long) httpRequest.getAttribute("userId");
+        AdminUserResponse resp = adminUserService.createAdmin(request);
+        auditLogService.logSuccess(operatorId, "管理员", getOperatorRole(httpRequest),
+                OperationType.USER_UPDATE, "ADMIN_CREATE:" + resp.getId(), resp.getId());
+        return Result.success(resp);
+    }
+
+    @PutMapping("/{id}")
+    public Result<AdminUserResponse> updateAdmin(@PathVariable Long id,
+                                                  @RequestBody AdminUpsertRequest request,
+                                                  HttpServletRequest httpRequest) {
+        Long operatorId = (Long) httpRequest.getAttribute("userId");
+        AdminUserResponse resp = adminUserService.updateAdmin(id, request);
+        auditLogService.logSuccess(operatorId, "管理员", getOperatorRole(httpRequest),
+                OperationType.USER_UPDATE, "ADMIN_UPDATE:" + id, id);
+        return Result.success(resp);
+    }
+
+    @DeleteMapping("/{id}")
+    public Result<String> deleteAdmin(@PathVariable Long id,
+                                       HttpServletRequest httpRequest) {
+        Long operatorId = (Long) httpRequest.getAttribute("userId");
+        adminUserService.deleteAdmin(id);
+        auditLogService.logSuccess(operatorId, "管理员", getOperatorRole(httpRequest),
+                OperationType.USER_UPDATE, "ADMIN_DELETE:" + id, id);
+        return Result.success("管理员已禁用");
+    }
+
+    @PutMapping("/{id}/password")
+    public Result<String> resetPassword(@PathVariable Long id,
+                                         HttpServletRequest httpRequest) {
+        Long operatorId = (Long) httpRequest.getAttribute("userId");
+        String newPwd = adminAuthService.resetPassword(id);
+        auditLogService.logSuccess(operatorId, "管理员", getOperatorRole(httpRequest),
+                OperationType.USER_UPDATE, "ADMIN_RESET_PWD:" + id, id);
+        return Result.success(newPwd);
+    }
+
+    @PutMapping("/password")
+    public Result<String> changePassword(@RequestBody Map<String, String> body,
+                                          HttpServletRequest httpRequest) {
+        Long adminId = (Long) httpRequest.getAttribute("userId");
+        adminAuthService.setPassword(adminId,
+                body.get("oldPassword"), body.get("newPassword"));
+        return Result.success("密码修改成功");
+    }
+
     @PostMapping("/{adminId}/kick")
-    public Result<String> kickAdmin(
-            @PathVariable Long adminId,
-            HttpServletRequest httpRequest) {
+    public Result<String> kickAdmin(@PathVariable Long adminId,
+                                     HttpServletRequest httpRequest) {
         Long operatorId = (Long) httpRequest.getAttribute("userId");
-        String operatorRole = getOperatorRole(httpRequest);
         adminUserService.kickAdmin(adminId, operatorId);
-        auditLogService.logSuccess(operatorId, "管理员", operatorRole,
+        auditLogService.logSuccess(operatorId, "管理员", getOperatorRole(httpRequest),
                 OperationType.USER_UPDATE, "ADMIN_KICK:" + adminId, adminId);
         return Result.success("管理员已被强制退出");
     }

+ 1 - 1
zhijiayun-user/src/main/java/com/xuekairui/user/controller/AdminUserController.java

@@ -38,7 +38,7 @@ public class AdminUserController {
             @RequestParam(required = false) String phone,
             @RequestParam(required = false) String nickname,
             @RequestParam(required = false) String role) {
-        return Result.success(adminUserService.listUsers(page, size, userId, phone, nickname, role));
+        return Result.success(adminUserService.listUsers(page, size, userId, phone, nickname));
     }
 
     /**

+ 19 - 0
zhijiayun-user/src/main/java/com/xuekairui/user/dto/AdminUpsertRequest.java

@@ -0,0 +1,19 @@
+package com.xuekairui.user.dto;
+
+import lombok.Data;
+
+/**
+ * 管理员创建/更新请求
+ */
+@Data
+public class AdminUpsertRequest {
+
+    private String username;
+
+    private String password;
+
+    private String phone;
+
+    /** 角色:ADMIN / SUPER_ADMIN */
+    private String role;
+}

+ 16 - 103
zhijiayun-user/src/main/java/com/xuekairui/user/service/AdminAuthService.java

@@ -2,6 +2,7 @@ package com.xuekairui.user.service;
 
 import com.xuekairui.common.BusinessException;
 import com.xuekairui.common.ErrorCode;
+import com.xuekairui.common.RandomUtil;
 import com.xuekairui.user.dto.LoginResponse;
 import com.xuekairui.user.entity.Admin;
 import com.xuekairui.user.mapper.AdminMapper;
@@ -28,6 +29,8 @@ public class AdminAuthService {
     private final PasswordUtil passwordUtil;
     private final TokenVersionCache tokenVersionCache;
 
+    private static final int RESET_LEN = 10;
+
     /**
      * 管理员密码登录(按用户名或手机号)
      */
@@ -90,126 +93,36 @@ public class AdminAuthService {
     }
 
     /**
-     * 管理员心跳续期
-     */
-    public LoginResponse heartbeat(Long adminId, String role) {
-        String roleCode = role != null ? role : "ADMIN";
-        Long currentVersion = tokenVersionCache.getVersion(adminId, true);
-        if (currentVersion == null) {
-            Admin admin = adminMapper.selectById(adminId);
-            currentVersion = admin != null && admin.getTokenVersion() != null
-                    ? admin.getTokenVersion() : 0L;
-            if (admin != null) {
-                tokenVersionCache.putVersion(adminId, true, currentVersion);
-            }
-        }
-
-        String newAccessToken = jwtUtil.generateAccessToken(adminId, roleCode, currentVersion);
-        String newRefreshToken = jwtUtil.generateRefreshToken(adminId);
-
-        Admin updateAdmin = new Admin();
-        updateAdmin.setId(adminId);
-        updateAdmin.setRefreshTokenHash(JwtUtil.hashToken(newRefreshToken));
-        updateAdmin.setRefreshTokenStatus("ACTIVE");
-        adminMapper.updateById(updateAdmin);
-
-        return LoginResponse.builder()
-                .userId(adminId)
-                .accessToken(newAccessToken)
-                .refreshToken(newRefreshToken)
-                .expiresIn(jwtUtil.getExpirationInSeconds(roleCode))
-                .needBindPhone(false)
-                .role(roleCode)
-                .build();
-    }
-
-    /**
-     * Refresh Token 旋转 + 重用检测
+     * 管理员自行修改密码(需验旧密码)
      */
     @Transactional
-    public LoginResponse refreshToken(String refreshToken) {
-        String tokenType = jwtUtil.getTokenType(refreshToken);
-        if (!"refresh".equals(tokenType)) {
-            throw new BusinessException(ErrorCode.REFRESH_TOKEN_INVALID);
-        }
-
-        Long adminId = jwtUtil.getUserId(refreshToken);
+    public void setPassword(Long adminId, String oldPassword, String newPassword) {
         Admin admin = adminMapper.selectById(adminId);
         if (admin == null) {
             throw new BusinessException(ErrorCode.USER_NOT_FOUND);
         }
-
-        String tokenHash = JwtUtil.hashToken(refreshToken);
-        String storedHash = admin.getRefreshTokenHash();
-        String storedStatus = admin.getRefreshTokenStatus();
-
-        // 重用检测
-        if (tokenHash.equals(storedHash) && "USED".equals(storedStatus)) {
-            log.warn("管理员 {} 的Refresh Token被重用(疑似被盗),踢出所有设备", adminId);
-            long newVersion = (admin.getTokenVersion() != null ? admin.getTokenVersion() : 0L) + 1;
-            admin.setTokenVersion(newVersion);
-            admin.setRefreshTokenHash(null);
-            admin.setRefreshTokenStatus(null);
-            adminMapper.updateById(admin);
-            tokenVersionCache.putVersion(adminId, true, newVersion);
-            throw new BusinessException(ErrorCode.TOKEN_KICKED);
-        }
-
-        if (!tokenHash.equals(storedHash) || !"ACTIVE".equals(storedStatus)) {
-            throw new BusinessException(ErrorCode.REFRESH_TOKEN_INVALID);
+        if (admin.getPassword() != null && !admin.getPassword().isEmpty()
+                && oldPassword != null && !passwordUtil.matches(oldPassword, admin.getPassword())) {
+            throw new BusinessException(ErrorCode.OLD_PASSWORD_INCORRECT);
         }
-
-        // 旋转
-        admin.setRefreshTokenStatus("USED");
-        String roleCode = admin.getRole() != null ? admin.getRole() : "ADMIN";
-        long currentVersion = admin.getTokenVersion() != null ? admin.getTokenVersion() : 0L;
-
-        String newAccessToken = jwtUtil.generateAccessToken(admin.getId(), roleCode, currentVersion);
-        String newRefreshToken = jwtUtil.generateRefreshToken(admin.getId());
-
-        admin.setRefreshTokenHash(JwtUtil.hashToken(newRefreshToken));
-        admin.setRefreshTokenStatus("ACTIVE");
+        admin.setPassword(passwordUtil.encode(newPassword));
         adminMapper.updateById(admin);
-
-        return LoginResponse.builder()
-                .userId(admin.getId())
-                .accessToken(newAccessToken)
-                .refreshToken(newRefreshToken)
-                .expiresIn(jwtUtil.getExpirationInSeconds(roleCode))
-                .needBindPhone(false)
-                .newUser(false)
-                .role(roleCode)
-                .build();
     }
 
     /**
-     * 修改管理员密码
+     * 重置管理员密码为随机密码,返回明文
      */
     @Transactional
-    public void setPassword(Long adminId, String oldPassword, String newPassword) {
+    public String resetPassword(Long adminId) {
         Admin admin = adminMapper.selectById(adminId);
         if (admin == null) {
             throw new BusinessException(ErrorCode.USER_NOT_FOUND);
         }
-        if (admin.getPassword() != null && !admin.getPassword().isEmpty()
-                && oldPassword != null && !passwordUtil.matches(oldPassword, admin.getPassword())) {
-            throw new BusinessException(ErrorCode.OLD_PASSWORD_INCORRECT);
-        }
-        admin.setPassword(passwordUtil.encode(newPassword));
+        String rawPwd = RandomUtil.generateCode(RESET_LEN);
+        admin.setPassword(passwordUtil.encode(rawPwd));
         adminMapper.updateById(admin);
-    }
-
-    /**
-     * 获取管理员信息
-     */
-    public Admin getById(Long adminId) {
-        return adminMapper.selectById(adminId);
-    }
-
-    /**
-     * 按用户名查询
-     */
-    public Admin getByUsername(String username) {
-        return adminMapper.selectByUsername(username);
+        tokenVersionCache.putVersion(adminId, true,
+                (admin.getTokenVersion() != null ? admin.getTokenVersion() : 0L) + 1);
+        return rawPwd;
     }
 }

+ 65 - 11
zhijiayun-user/src/main/java/com/xuekairui/user/service/AdminUserService.java

@@ -18,6 +18,7 @@ import org.springframework.stereotype.Service;
 import org.springframework.transaction.annotation.Transactional;
 
 import java.util.Objects;
+import java.util.Optional;
 
 /**
  * 运营用户管理服务
@@ -36,7 +37,7 @@ public class AdminUserService {
      * 分页查询前端用户列表(仅 USER 角色,不含管理员)
      */
     public Page<AdminUserResponse> listUsers(Integer pageNum, Integer pageSize,
-                                              Long userId, String phone, String nickname, String role) {
+                                              Long userId, String phone, String nickname) {
         Page<User> page = new Page<>(pageNum, pageSize);
         LambdaQueryWrapper<User> wrapper = new LambdaQueryWrapper<>();
         if (userId != null) {
@@ -55,7 +56,7 @@ public class AdminUserService {
         Page<AdminUserResponse> resultPage = new Page<>(userPage.getCurrent(), userPage.getSize(), userPage.getTotal());
         resultPage.setRecords(userPage.getRecords().stream()
                 .map(this::toResponse)
-                .map(r -> { r.setRole("USER"); return r; })
+                .peek(r -> r.setRole("USER"))
                 .toList());
         return resultPage;
     }
@@ -70,18 +71,64 @@ public class AdminUserService {
         Page<Admin> adminPage = adminMapper.selectPage(page, wrapper);
         Page<AdminUserResponse> resultPage = new Page<>(adminPage.getCurrent(), adminPage.getSize(), adminPage.getTotal());
         resultPage.setRecords(adminPage.getRecords().stream()
-                .map(a -> AdminUserResponse.builder()
-                        .id(a.getId())
-                        .phone(a.getPhone())
-                        .nickname(a.getUsername())
-                        .role(a.getRole())
-                        .status(a.getStatus())
-                        .createTime(a.getCreateTime())
-                        .build())
-                .toList());
+                .map(this::buildAdminResponse).toList());
         return resultPage;
     }
 
+    public AdminUserResponse getAdmin(Long id) {
+        Admin admin = adminMapper.selectById(id);
+        if (admin == null) {
+            throw new BusinessException(ErrorCode.USER_NOT_FOUND);
+        }
+        return buildAdminResponse(admin);
+    }
+
+    @Transactional
+    public AdminUserResponse createAdmin(com.xuekairui.user.dto.AdminUpsertRequest req) {
+        if (req.getPassword() == null || req.getPassword().isBlank()) {
+            throw new BusinessException(ErrorCode.PARAM_ERROR, "密码不能为空");
+        }
+        Admin admin = Admin.builder()
+                .username(req.getUsername()).password(passwordUtil.encode(req.getPassword()))
+                .phone(req.getPhone()).role(req.getRole() != null ? req.getRole() : "ADMIN")
+                .status(1).build();
+        adminMapper.insert(admin);
+        return buildAdminResponse(admin);
+    }
+
+    @Transactional
+    public AdminUserResponse updateAdmin(Long id, com.xuekairui.user.dto.AdminUpsertRequest req) {
+        Admin admin = adminMapper.selectById(id);
+        if (admin == null) {
+            throw new BusinessException(ErrorCode.USER_NOT_FOUND);
+        }
+        Optional.ofNullable(req.getUsername()).filter(s -> !s.isBlank()).ifPresent(admin::setUsername);
+        Optional.ofNullable(req.getPhone()).ifPresent(admin::setPhone);
+        Optional.ofNullable(req.getPassword()).filter(s -> !s.isBlank())
+                .map(passwordUtil::encode).ifPresent(admin::setPassword);
+        Optional.ofNullable(req.getRole()).filter(s -> !s.isBlank()).ifPresent(admin::setRole);
+        adminMapper.updateById(admin);
+        return buildAdminResponse(admin);
+    }
+
+    @Transactional
+    public void deleteAdmin(Long id) {
+        Admin admin = adminMapper.selectById(id);
+        if (admin == null) {
+            throw new BusinessException(ErrorCode.USER_NOT_FOUND);
+        }
+        if ("SUPER_ADMIN".equals(admin.getRole())) {
+            long count = adminMapper.selectCount(
+                    new LambdaQueryWrapper<Admin>().eq(Admin::getRole, "SUPER_ADMIN"));
+            if (count <= 1) {
+                throw new BusinessException(ErrorCode.BUSINESS_ERROR, "系统中至少保留一个超级管理员");
+            }
+        }
+        admin.setStatus(0);
+        adminMapper.updateById(admin);
+        tokenVersionCache.clear();
+    }
+
     /**
      * 修改用户角色(仅超级管理员可调用)
      * 升级为 ADMIN/SUPER_ADMIN → 插入 t_admin;降级为 USER → 删除 t_admin 记录
@@ -184,6 +231,13 @@ public class AdminUserService {
         log.info("管理员{}强制踢出管理员{},新tokenVersion={}", operatorId, adminId, newVersion);
     }
 
+    private AdminUserResponse buildAdminResponse(Admin a) {
+        return AdminUserResponse.builder()
+                .id(a.getId()).phone(a.getPhone()).nickname(a.getUsername())
+                .role(a.getRole()).status(a.getStatus()).createTime(a.getCreateTime())
+                .build();
+    }
+
     private AdminUserResponse toResponse(User user) {
         return AdminUserResponse.builder()
                 .id(user.getId())

+ 2 - 10
zhijiayun-user/src/main/java/com/xuekairui/user/service/VerificationCodeService.java

@@ -9,7 +9,7 @@ import org.springframework.beans.factory.annotation.Value;
 import org.springframework.data.redis.core.RedisTemplate;
 import org.springframework.stereotype.Service;
 
-import java.security.SecureRandom;
+import com.xuekairui.common.RandomUtil;
 import java.util.concurrent.TimeUnit;
 
 /**
@@ -23,9 +23,6 @@ public class VerificationCodeService {
     private final RedisTemplate<String, Object> redisTemplate;
     private final AliyunSmsClient aliyunSmsClient;
 
-    /** 安全随机数生成器 */
-    private static final SecureRandom SECURE_RANDOM = new SecureRandom();
-
     @Value("${sms.expire-minutes:5}")
     private Integer expireMinutes;
 
@@ -138,13 +135,8 @@ public class VerificationCodeService {
         redisTemplate.delete(codeKey);
     }
 
-    /**
-     * 生成6位数字验证码(使用 SecureRandom 保证安全性)
-     */
     private String generateCode() {
-        // 生成 100000 ~ 999999 之间的6位数字
-        int code = SECURE_RANDOM.nextInt(900000) + 100000;
-        return String.valueOf(code);
+        return RandomUtil.generateDigits(6);
     }
 
     /**