Преглед изворни кода

运营后台和普通用户的登录分开

liuchengsen пре 1 месец
родитељ
комит
d1123b35a6
39 измењених фајлова са 1320 додато и 197 уклоњено
  1. 562 0
      test-run-new.txt
  2. 51 0
      zhijiayun-gateway/src/main/resources/db/migration-v6.sql
  3. 21 1
      zhijiayun-gateway/src/main/resources/db/schema.sql
  4. 1 1
      zhijiayun-gateway/src/test/java/com/xuekairui/gateway/AdminControllerTest.java
  5. 1 1
      zhijiayun-gateway/src/test/java/com/xuekairui/gateway/AdminLicenseTest.java
  6. 5 2
      zhijiayun-gateway/src/test/java/com/xuekairui/gateway/CouponControllerTest.java
  7. 1 7
      zhijiayun-gateway/src/test/java/com/xuekairui/gateway/EndToEndBusinessFlowTest.java
  8. 9 2
      zhijiayun-gateway/src/test/java/com/xuekairui/gateway/GatewayBaseTest.java
  9. 8 6
      zhijiayun-gateway/src/test/java/com/xuekairui/gateway/MembershipControllerTest.java
  10. 1 1
      zhijiayun-gateway/src/test/java/com/xuekairui/gateway/OperationAuditLogTest.java
  11. 3 3
      zhijiayun-gateway/src/test/java/com/xuekairui/gateway/PaymentControllerTest.java
  12. 1 1
      zhijiayun-gateway/src/test/java/com/xuekairui/gateway/TrialQuotaConfigTest.java
  13. 27 5
      zhijiayun-gateway/src/test/resources/test-schema.sql
  14. 4 4
      zhijiayun-invite/src/main/java/com/xuekairui/invite/dto/InviteRewardResponse.java
  15. 3 6
      zhijiayun-invite/src/main/java/com/xuekairui/invite/dto/MyInviterResponse.java
  16. 5 3
      zhijiayun-payment/src/main/java/com/xuekairui/payment/dto/AgreementResponse.java
  17. 3 4
      zhijiayun-payment/src/main/java/com/xuekairui/payment/dto/AgreementSignResponse.java
  18. 4 3
      zhijiayun-payment/src/main/java/com/xuekairui/payment/dto/AnxinCardResponse.java
  19. 3 3
      zhijiayun-payment/src/main/java/com/xuekairui/payment/dto/PaymentPlanResponse.java
  20. 24 15
      zhijiayun-test/src/main/resources/static/invite-test.html
  21. 33 53
      zhijiayun-user/src/main/java/com/xuekairui/user/config/SuperAdminInitializer.java
  22. 49 0
      zhijiayun-user/src/main/java/com/xuekairui/user/controller/AdminAccountController.java
  23. 4 1
      zhijiayun-user/src/main/java/com/xuekairui/user/dto/AdminUserResponse.java
  24. 4 1
      zhijiayun-user/src/main/java/com/xuekairui/user/dto/CrawlerUserDetailResponse.java
  25. 4 1
      zhijiayun-user/src/main/java/com/xuekairui/user/dto/LoginResponse.java
  26. 4 1
      zhijiayun-user/src/main/java/com/xuekairui/user/dto/MembershipResponse.java
  27. 3 4
      zhijiayun-user/src/main/java/com/xuekairui/user/dto/UserCouponResponse.java
  28. 6 2
      zhijiayun-user/src/main/java/com/xuekairui/user/dto/UserInfoResponse.java
  29. 3 1
      zhijiayun-user/src/main/java/com/xuekairui/user/dto/UserLevelResponse.java
  30. 4 1
      zhijiayun-user/src/main/java/com/xuekairui/user/dto/WatchlistResponse.java
  31. 52 0
      zhijiayun-user/src/main/java/com/xuekairui/user/entity/Admin.java
  32. 2 2
      zhijiayun-user/src/main/java/com/xuekairui/user/entity/UserMembership.java
  33. 35 0
      zhijiayun-user/src/main/java/com/xuekairui/user/enums/AdminRole.java
  34. 20 0
      zhijiayun-user/src/main/java/com/xuekairui/user/mapper/AdminMapper.java
  35. 22 16
      zhijiayun-user/src/main/java/com/xuekairui/user/security/JwtAuthenticationFilter.java
  36. 19 19
      zhijiayun-user/src/main/java/com/xuekairui/user/security/TokenVersionCache.java
  37. 215 0
      zhijiayun-user/src/main/java/com/xuekairui/user/service/AdminAuthService.java
  38. 96 22
      zhijiayun-user/src/main/java/com/xuekairui/user/service/AdminUserService.java
  39. 8 5
      zhijiayun-user/src/main/java/com/xuekairui/user/service/AuthService.java

Разлика између датотеке није приказан због своје велике величине
+ 562 - 0
test-run-new.txt


+ 51 - 0
zhijiayun-gateway/src/main/resources/db/migration-v6.sql

@@ -0,0 +1,51 @@
+-- ============================================
+-- 迁移 v6:管理员表隔离
+-- 将 ADMIN/SUPER_ADMIN 从 t_user 分离到 t_admin
+-- ============================================
+
+-- 1. 创建管理员表
+CREATE TABLE IF NOT EXISTS `t_admin` (
+    `id`                BIGINT        NOT NULL AUTO_INCREMENT COMMENT '主键ID',
+    `username`          VARCHAR(64)   NOT NULL COMMENT '用户名',
+    `password`          VARCHAR(128)  NOT NULL COMMENT '密码(BCrypt加密)',
+    `phone`             VARCHAR(20)   DEFAULT NULL COMMENT '手机号',
+    `role`              VARCHAR(20)   NOT NULL DEFAULT 'ADMIN' COMMENT '角色:ADMIN/SUPER_ADMIN',
+    `status`            INT           NOT NULL DEFAULT 1 COMMENT '状态:0-禁用 1-正常',
+    `token_version`     BIGINT        DEFAULT 0 COMMENT 'Token版本号',
+    `refresh_token_hash`   VARCHAR(64)   DEFAULT NULL COMMENT 'Refresh Token SHA-256哈希',
+    `refresh_token_status` VARCHAR(10)   DEFAULT NULL COMMENT 'Refresh Token状态:ACTIVE/USED',
+    `last_login_time`   DATETIME      DEFAULT NULL COMMENT '最后登录时间',
+    `last_login_ip`     VARCHAR(45)   DEFAULT NULL COMMENT '最后登录IP',
+    `create_time`       DATETIME      DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
+    `update_time`       DATETIME      DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
+    PRIMARY KEY (`id`),
+    UNIQUE KEY `uk_username` (`username`),
+    UNIQUE KEY `uk_phone` (`phone`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='管理员表';
+
+-- 2. 从 t_user 迁移现有管理员数据到 t_admin
+--    username 使用 nickname 作为默认值(如果没有则用 phone)
+INSERT INTO `t_admin` (`id`, `username`, `password`, `phone`, `role`, `status`,
+    `token_version`, `refresh_token_hash`, `refresh_token_status`,
+    `last_login_time`, `last_login_ip`, `create_time`, `update_time`)
+SELECT
+    `id`,
+    COALESCE(`nickname`, `phone`, CONCAT('admin_', `id`)) AS `username`,
+    COALESCE(`password`, '') AS `password`,
+    `phone`,
+    `role`,
+    `status`,
+    COALESCE(`token_version`, 0) AS `token_version`,
+    `refresh_token_hash`,
+    `refresh_token_status`,
+    `last_login_time`,
+    `last_login_ip`,
+    `create_time`,
+    `update_time`
+FROM `t_user`
+WHERE `role` IN ('ADMIN', 'SUPER_ADMIN');
+
+-- 3. 迁移后清理 t_user 中的管理员数据(保留记录但清除 role)
+--    注意:不删除 t_user 中的行,因为管理员可能同时也是用户(有业务数据关联)
+--    但 role 改为 USER,因为 t_user 不再区分 ADMIN/SUPER_ADMIN
+UPDATE `t_user` SET `role` = 'USER' WHERE `role` IN ('ADMIN', 'SUPER_ADMIN');

+ 21 - 1
zhijiayun-gateway/src/main/resources/db/schema.sql

@@ -49,12 +49,32 @@ CREATE TABLE IF NOT EXISTS `t_user` (
     KEY `idx_unionid` (`wechat_union_id`)
 ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='用户表';
 
+-- 管理员表
+CREATE TABLE IF NOT EXISTS `t_admin` (
+    `id`                BIGINT        NOT NULL AUTO_INCREMENT COMMENT '主键ID',
+    `username`          VARCHAR(64)   NOT NULL COMMENT '用户名',
+    `password`          VARCHAR(128)  NOT NULL COMMENT '密码(BCrypt加密)',
+    `phone`             VARCHAR(20)   DEFAULT NULL COMMENT '手机号',
+    `role`              VARCHAR(20)   NOT NULL DEFAULT 'ADMIN' COMMENT '角色:ADMIN/SUPER_ADMIN',
+    `status`            INT           NOT NULL DEFAULT 1 COMMENT '状态:0-禁用 1-正常',
+    `token_version`     BIGINT        DEFAULT 0 COMMENT 'Token版本号',
+    `refresh_token_hash`   VARCHAR(64)   DEFAULT NULL COMMENT 'Refresh Token SHA-256哈希',
+    `refresh_token_status` VARCHAR(10)   DEFAULT NULL COMMENT 'Refresh Token状态:ACTIVE/USED',
+    `last_login_time`   DATETIME      DEFAULT NULL COMMENT '最后登录时间',
+    `last_login_ip`     VARCHAR(45)   DEFAULT NULL COMMENT '最后登录IP',
+    `create_time`       DATETIME      DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
+    `update_time`       DATETIME      DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
+    PRIMARY KEY (`id`),
+    UNIQUE KEY `uk_username` (`username`),
+    UNIQUE KEY `uk_phone` (`phone`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='管理员表';
+
 -- 用户会员记录表
 CREATE TABLE IF NOT EXISTS `t_user_membership` (
     `id`                BIGINT        NOT NULL AUTO_INCREMENT COMMENT '主键ID',
     `user_id`           BIGINT        NOT NULL COMMENT '用户ID',
     `level`             VARCHAR(20)   NOT NULL COMMENT '会员等级:PLUS/PRO/ULTRA',
-    `source`            VARCHAR(20)   NOT NULL DEFAULT 'ADMIN_GRANT' COMMENT '来源:ADMIN_GRANT-运营发放 RECHARGE-充值购买 SYSTEM-系统赠送',
+    `source`            VARCHAR(20)   NOT NULL DEFAULT 'SYSTEM' COMMENT '来源:SYSTEM-系统默认 RECHARGE-充值购买 INVITE-邀请奖励 ADMIN_GRANT-运营发放 LICENSE-营业执照',
     `effective_from`    DATETIME      DEFAULT CURRENT_TIMESTAMP COMMENT '生效开始时间',
     `effective_to`      DATETIME      DEFAULT NULL COMMENT '生效结束时间(NULL表示永久)',
     `status`            INT           NOT NULL DEFAULT 1 COMMENT '状态:1-有效 0-过期/停用',

+ 1 - 1
zhijiayun-gateway/src/test/java/com/xuekairui/gateway/AdminControllerTest.java

@@ -15,7 +15,7 @@ import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.
 class AdminControllerTest extends GatewayBaseTest {
 
     private String adminToken() {
-        return generateAccessToken(1L, "13800138000");
+        return generateAccessToken(100L, "13900000000", "ADMIN");
     }
 
     // ==================== 邀请配置管理 ====================

+ 1 - 1
zhijiayun-gateway/src/test/java/com/xuekairui/gateway/AdminLicenseTest.java

@@ -15,7 +15,7 @@ import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.
 class AdminLicenseTest extends GatewayBaseTest {
 
     private String adminToken() {
-        return generateAccessToken(1L, "13800138000");
+        return generateAccessToken(100L, "13900000000", "ADMIN");
     }
 
     private Long uploadedLicenseId;

+ 5 - 2
zhijiayun-gateway/src/test/java/com/xuekairui/gateway/CouponControllerTest.java

@@ -18,6 +18,10 @@ class CouponControllerTest extends GatewayBaseTest {
         return generateAccessToken(1L, "13800138000");
     }
 
+    private String adminToken() {
+        return generateAdminToken();
+    }
+
     // ==================== 优惠券发放(管理) ====================
 
     @Test
@@ -31,12 +35,11 @@ class CouponControllerTest extends GatewayBaseTest {
                 }
                 """;
         mockMvc.perform(post("/api/coupon/issue")
-                        .header("Authorization", "Bearer " + userToken())
+                        .header("Authorization", "Bearer " + adminToken())
                         .contentType(MediaType.APPLICATION_JSON)
                         .content(body))
                 .andExpect(status().isOk())
                 .andExpect(jsonPath("$.code").isNumber());
-        // 优惠券不存在返回业务错误码,HTTP仍为200
     }
 
     @Test

+ 1 - 7
zhijiayun-gateway/src/test/java/com/xuekairui/gateway/EndToEndBusinessFlowTest.java

@@ -246,13 +246,7 @@ public class EndToEndBusinessFlowTest extends GatewayBaseTest {
     public void stage7_AdminReviewLicense() throws Exception {
         log.info("\n========== 阶段7:管理员审核 ==========");
 
-        MvcResult adminLoginResult = mockMvc.perform(post("/api/auth/sms/login")
-                .contentType(MediaType.APPLICATION_JSON)
-                .content("{\"phone\":\"13900000000\",\"code\":\"123456\"}"))
-                .andExpect(status().isOk())
-                .andReturn();
-        JsonNode adminData = objectMapper.readTree(adminLoginResult.getResponse().getContentAsString()).get("data");
-        adminToken = adminData.get("accessToken").asText();
+        adminToken = generateAdminToken();
 
         // 7.1 查询待审核执照列表
         mockMvc.perform(get("/api/admin/license/review?reviewStatus=PENDING")

+ 9 - 2
zhijiayun-gateway/src/test/java/com/xuekairui/gateway/GatewayBaseTest.java

@@ -103,10 +103,17 @@ public abstract class GatewayBaseTest {
     }
 
     /**
-     * 生成测试用 Access Token(默认 ADMIN 角色
+     * 生成测试用 Access Token(默认 USER 角色,管理员用 generateAdminToken
      */
     protected String generateAccessToken(Long userId, String phone) {
-        return generateAccessToken(userId, phone, "ADMIN");
+        return generateAccessToken(userId, phone, "USER");
+    }
+
+    /**
+     * 生成管理员测试 Token(用户 100 + ADMIN 角色)
+     */
+    protected String generateAdminToken() {
+        return generateAccessToken(100L, "13900000000", "ADMIN");
     }
 
     /**

+ 8 - 6
zhijiayun-gateway/src/test/java/com/xuekairui/gateway/MembershipControllerTest.java

@@ -13,13 +13,15 @@ import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.
 @TestMethodOrder(MethodOrderer.OrderAnnotation.class)
 class MembershipControllerTest extends GatewayBaseTest {
 
+    private String adminToken() { return generateAdminToken(); }
+
     // ==================== 我的会员 ====================
 
     @Test
     @Order(1)
     @DisplayName("POST /api/admin/membership/grant - 先给用户发放PLUS会员")
     void setup_grantPlusMembership() throws Exception {
-        String token = generateAccessToken(1L, "13800138000");
+        String token = generateAdminToken();
         mockMvc.perform(post("/api/admin/membership/grant")
                         .header("Authorization", "Bearer " + token)
                         .contentType(MediaType.APPLICATION_JSON)
@@ -107,7 +109,7 @@ class MembershipControllerTest extends GatewayBaseTest {
     @Order(10)
     @DisplayName("POST /api/admin/membership/grant - 管理员给用户发放PRO会员")
     void grantMembership_pro_shouldSucceed() throws Exception {
-        String token = generateAccessToken(1L, "13800138000");
+        String token = generateAdminToken();
         mockMvc.perform(post("/api/admin/membership/grant")
                         .header("Authorization", "Bearer " + token)
                         .contentType(MediaType.APPLICATION_JSON)
@@ -123,7 +125,7 @@ class MembershipControllerTest extends GatewayBaseTest {
     @Order(11)
     @DisplayName("POST /api/admin/membership/grant - 无效等级应返回错误")
     void grantMembership_invalidLevel_shouldReturnError() throws Exception {
-        String token = generateAccessToken(1L, "13800138000");
+        String token = generateAdminToken();
         mockMvc.perform(post("/api/admin/membership/grant")
                         .header("Authorization", "Bearer " + token)
                         .contentType(MediaType.APPLICATION_JSON)
@@ -148,7 +150,7 @@ class MembershipControllerTest extends GatewayBaseTest {
     @Order(20)
     @DisplayName("GET /api/admin/membership/memberships - 查询所有会员记录")
     void listMemberships_shouldReturnPage() throws Exception {
-        String token = generateAccessToken(1L, "13800138000");
+        String token = generateAdminToken();
         mockMvc.perform(get("/api/admin/membership/memberships?page=1&size=10")
                         .header("Authorization", "Bearer " + token))
                 .andExpect(status().isOk())
@@ -160,7 +162,7 @@ class MembershipControllerTest extends GatewayBaseTest {
     @Order(21)
     @DisplayName("GET /api/admin/membership/memberships - 按用户ID筛选")
     void listMemberships_byUserId_shouldFilter() throws Exception {
-        String token = generateAccessToken(1L, "13800138000");
+        String token = generateAdminToken();
         mockMvc.perform(get("/api/admin/membership/memberships?page=1&size=10&userId=1")
                         .header("Authorization", "Bearer " + token))
                 .andExpect(status().isOk())
@@ -172,7 +174,7 @@ class MembershipControllerTest extends GatewayBaseTest {
     @Order(22)
     @DisplayName("GET /api/admin/membership/memberships - 按等级筛选PLUS")
     void listMemberships_byLevel_shouldFilter() throws Exception {
-        String token = generateAccessToken(1L, "13800138000");
+        String token = generateAdminToken();
         mockMvc.perform(get("/api/admin/membership/memberships?page=1&size=10&level=PLUS")
                         .header("Authorization", "Bearer " + token))
                 .andExpect(status().isOk())

+ 1 - 1
zhijiayun-gateway/src/test/java/com/xuekairui/gateway/OperationAuditLogTest.java

@@ -15,7 +15,7 @@ import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.
 class OperationAuditLogTest extends GatewayBaseTest {
 
     private String adminToken() {
-        return generateAccessToken(1L, "13800138000");
+        return generateAccessToken(100L, "13900000000", "ADMIN");
     }
 
     // ==================== 分页查询 ====================

+ 3 - 3
zhijiayun-gateway/src/test/java/com/xuekairui/gateway/PaymentControllerTest.java

@@ -211,7 +211,7 @@ class PaymentControllerTest extends GatewayBaseTest {
     @Order(20)
     @DisplayName("POST /api/admin/payment/plans - 管理员创建支付方案")
     void createPlan_asAdmin_shouldSucceed() throws Exception {
-        String token = generateAccessToken(1L, "13800138000");
+        String token = generateAdminToken();
         mockMvc.perform(post("/api/admin/payment/plans")
                         .header("Authorization", "Bearer " + token)
                         .contentType(MediaType.APPLICATION_JSON)
@@ -226,7 +226,7 @@ class PaymentControllerTest extends GatewayBaseTest {
     @Order(21)
     @DisplayName("PUT /api/admin/payment/plans/{id} - 管理员更新支付方案")
     void updatePlan_asAdmin_shouldSucceed() throws Exception {
-        String token = generateAccessToken(1L, "13800138000");
+        String token = generateAdminToken();
         mockMvc.perform(put("/api/admin/payment/plans/1")
                         .header("Authorization", "Bearer " + token)
                         .contentType(MediaType.APPLICATION_JSON)
@@ -240,7 +240,7 @@ class PaymentControllerTest extends GatewayBaseTest {
     @Order(22)
     @DisplayName("PUT /api/admin/payment/plans/{id}/status - 切换方案上架/下架")
     void togglePlanStatus_asAdmin_shouldSucceed() throws Exception {
-        String token = generateAccessToken(1L, "13800138000");
+        String token = generateAdminToken();
         mockMvc.perform(put("/api/admin/payment/plans/1/status")
                         .header("Authorization", "Bearer " + token)
                         .contentType(MediaType.APPLICATION_JSON)

+ 1 - 1
zhijiayun-gateway/src/test/java/com/xuekairui/gateway/TrialQuotaConfigTest.java

@@ -24,7 +24,7 @@ class TrialQuotaConfigTest extends GatewayBaseTest {
     private static Long deleteConfigId;
 
     private String adminToken() {
-        return generateAccessToken(1L, "13800138000");
+        return generateAccessToken(100L, "13900000000", "ADMIN");
     }
 
     // ==================== 用户端:获取活跃配置 ====================

+ 27 - 5
zhijiayun-gateway/src/test/resources/test-schema.sql

@@ -53,11 +53,29 @@ CREATE TABLE IF NOT EXISTS t_user (
     CONSTRAINT uk_phone UNIQUE (phone)
 );
 
+CREATE TABLE IF NOT EXISTS t_admin (
+    id              BIGINT AUTO_INCREMENT PRIMARY KEY,
+    username        VARCHAR(64)   NOT NULL,
+    password        VARCHAR(128)  NOT NULL DEFAULT '',
+    phone           VARCHAR(20)   DEFAULT NULL,
+    role            VARCHAR(20)   NOT NULL DEFAULT 'ADMIN',
+    status          INT           NOT NULL DEFAULT 1,
+    token_version   BIGINT        DEFAULT 0,
+    refresh_token_hash   VARCHAR(64)  DEFAULT NULL,
+    refresh_token_status VARCHAR(10) DEFAULT NULL,
+    last_login_time DATETIME      DEFAULT NULL,
+    last_login_ip   VARCHAR(45)   DEFAULT NULL,
+    create_time     DATETIME      DEFAULT CURRENT_TIMESTAMP,
+    update_time     DATETIME      DEFAULT CURRENT_TIMESTAMP,
+    CONSTRAINT uk_username UNIQUE (username),
+    CONSTRAINT uk_phone_admin UNIQUE (phone)
+);
+
 CREATE TABLE IF NOT EXISTS t_user_membership (
     id              BIGINT AUTO_INCREMENT PRIMARY KEY,
     user_id         BIGINT        NOT NULL,
     level           VARCHAR(20)   NOT NULL,
-    source          VARCHAR(20)   NOT NULL DEFAULT 'ADMIN_GRANT',
+    source          VARCHAR(20)   NOT NULL DEFAULT 'SYSTEM',
     effective_from  TIMESTAMP     DEFAULT CURRENT_TIMESTAMP,
     effective_to    TIMESTAMP     DEFAULT NULL,
     status          INT           NOT NULL DEFAULT 1,
@@ -341,15 +359,19 @@ ON DUPLICATE KEY UPDATE
     status = VALUES(status),
     membership_level = VALUES(membership_level);
 
--- 初始化管理员账号(用于运营后台接口测试)
-INSERT IGNORE INTO t_user (id, phone, nickname, status, role, membership_level)
+-- 初始化管理员账号(用于运营后台接口测试,已迁至 t_admin)
+INSERT IGNORE INTO t_admin (id, username, password, phone, role, status)
+VALUES
+    (100, 'testadmin', '', '13900000000', 'ADMIN', 1);
+
+-- 管理员也保留 t_user 记录(用于 ID 关联,但不设 role 为 ADMIN)
+INSERT IGNORE INTO t_user (id, phone, nickname, status, membership_level)
 VALUES
-    (100, '13900000000', '测试管理员', 1, 'ADMIN', 'PLUS')
+    (100, '13900000000', '测试管理员', 1, 'PLUS')
 ON DUPLICATE KEY UPDATE
     phone = VALUES(phone),
     nickname = VALUES(nickname),
     status = VALUES(status),
-    role = VALUES(role),
     membership_level = VALUES(membership_level);
 
 -- 初始化邀请配置

+ 4 - 4
zhijiayun-invite/src/main/java/com/xuekairui/invite/dto/InviteRewardResponse.java

@@ -1,23 +1,23 @@
 package com.xuekairui.invite.dto;
 
+import com.fasterxml.jackson.databind.annotation.JsonSerialize;
+import com.fasterxml.jackson.databind.ser.std.ToStringSerializer;
 import lombok.Builder;
 import lombok.Data;
 
 /**
  * 邀请记录响应
- * 展示每条邀请的详情:谁、是否注册、获得奖励
  */
 @Data
 @Builder
 public class InviteRewardResponse {
 
-    /** 邀请人ID */
+    @JsonSerialize(using = ToStringSerializer.class)
     private Long inviterId;
 
-    /** 邀请人昵称 */
     private String inviterNickname;
 
-    /** 被邀请人ID */
+    @JsonSerialize(using = ToStringSerializer.class)
     private Long inviteeId;
 
     /** 被邀请人昵称(未注册时显示"待注册") */

+ 3 - 6
zhijiayun-invite/src/main/java/com/xuekairui/invite/dto/MyInviterResponse.java

@@ -1,20 +1,17 @@
 package com.xuekairui.invite.dto;
 
+import com.fasterxml.jackson.databind.annotation.JsonSerialize;
+import com.fasterxml.jackson.databind.ser.std.ToStringSerializer;
 import lombok.Builder;
 import lombok.Data;
 
-/**
- * 我的邀请人响应
- * 被邀请人查询是谁邀请了自己
- */
 @Data
 @Builder
 public class MyInviterResponse {
 
-    /** 是否被邀请过 */
     private Boolean invited;
 
-    /** 邀请人ID */
+    @JsonSerialize(using = ToStringSerializer.class)
     private Long inviterId;
 
     /** 邀请人昵称 */

+ 5 - 3
zhijiayun-payment/src/main/java/com/xuekairui/payment/dto/AgreementResponse.java

@@ -1,20 +1,22 @@
 package com.xuekairui.payment.dto;
 
+import com.fasterxml.jackson.databind.annotation.JsonSerialize;
+import com.fasterxml.jackson.databind.ser.std.ToStringSerializer;
 import lombok.Builder;
 import lombok.Data;
 
 import java.math.BigDecimal;
 import java.time.LocalDateTime;
 
-/**
- * 签约协议详情响应
- */
 @Data
 @Builder
 public class AgreementResponse {
 
+    @JsonSerialize(using = ToStringSerializer.class)
     private Long id;
+    @JsonSerialize(using = ToStringSerializer.class)
     private Long userId;
+    @JsonSerialize(using = ToStringSerializer.class)
     private Long planId;
     private String planName;
     private String channel;

+ 3 - 4
zhijiayun-payment/src/main/java/com/xuekairui/payment/dto/AgreementSignResponse.java

@@ -1,16 +1,15 @@
 package com.xuekairui.payment.dto;
 
+import com.fasterxml.jackson.databind.annotation.JsonSerialize;
+import com.fasterxml.jackson.databind.ser.std.ToStringSerializer;
 import lombok.Builder;
 import lombok.Data;
 
-/**
- * 签约响应
- */
 @Data
 @Builder
 public class AgreementSignResponse {
 
-    /** 协议ID(本地记录) */
+    @JsonSerialize(using = ToStringSerializer.class)
     private Long agreementId;
 
     /** 商家自定义协议号 */

+ 4 - 3
zhijiayun-payment/src/main/java/com/xuekairui/payment/dto/AnxinCardResponse.java

@@ -1,19 +1,20 @@
 package com.xuekairui.payment.dto;
 
+import com.fasterxml.jackson.databind.annotation.JsonSerialize;
+import com.fasterxml.jackson.databind.ser.std.ToStringSerializer;
 import lombok.Builder;
 import lombok.Data;
 
 import java.math.BigDecimal;
 import java.time.LocalDateTime;
 
-/**
- * 安心付卡模板响应
- */
 @Data
 @Builder
 public class AnxinCardResponse {
 
+    @JsonSerialize(using = ToStringSerializer.class)
     private Long id;
+    @JsonSerialize(using = ToStringSerializer.class)
     private Long planId;
     private String planName;
     private String cardId;

+ 3 - 3
zhijiayun-payment/src/main/java/com/xuekairui/payment/dto/PaymentPlanResponse.java

@@ -1,19 +1,19 @@
 package com.xuekairui.payment.dto;
 
+import com.fasterxml.jackson.databind.annotation.JsonSerialize;
+import com.fasterxml.jackson.databind.ser.std.ToStringSerializer;
 import lombok.Data;
 import lombok.Builder;
 import lombok.NoArgsConstructor;
 import lombok.AllArgsConstructor;
 
-/**
- * 支付方案响应
- */
 @Data
 @Builder
 @NoArgsConstructor
 @AllArgsConstructor
 public class PaymentPlanResponse {
 
+    @JsonSerialize(using = ToStringSerializer.class)
     private Long id;
 
     private String planName;

+ 24 - 15
zhijiayun-test/src/main/resources/static/invite-test.html

@@ -198,21 +198,12 @@
         </div>
         <div class="toolbar admin-login-area">
             <div class="login-section">
-                <span class="login-title">管理员短信登录</span>
-                <label>手机号</label>
-                <label for="adminPhone"></label><input type="text" id="adminPhone" value="" placeholder="管理员手机号" style="width:150px;">
-                <label>验证码</label>
-                <label for="adminSmsCode"></label><input type="text" id="adminSmsCode" placeholder="123456" value="123456" style="width:100px;">
-                <button class="btn btn-primary btn-sm" onclick="sendSms()">发送验证码</button>
-                <button class="btn btn-warning btn-sm" onclick="smsLogin()">管理员短信登录</button>
-            </div>
-            <div class="login-section">
-                <span class="login-title">管理员密码登录</span>
+                <span class="login-title">管理员登录(t_admin表)</span>
                 <label>用户名</label>
                 <label for="adminPwdUsername"></label><input type="text" id="adminPwdUsername" value="admin" placeholder="管理员用户名" style="width:150px;">
                 <label>密码</label>
                 <label for="adminPwdPassword"></label><input type="password" id="adminPwdPassword" value="admin@123" placeholder="管理员密码" style="width:140px;">
-                <button class="btn btn-warning btn-sm" onclick="passwordLogin()">管理员密码登录</button>
+                <button class="btn btn-warning btn-sm" onclick="adminPasswordLogin()">管理员登录</button>
             </div>
         </div>
     </div>
@@ -524,6 +515,21 @@ function updateAdminRoleBadge(role) {
     }
 }
 
+async function adminPasswordLogin() {
+    const username = document.getElementById('adminPwdUsername').value;
+    const password = document.getElementById('adminPwdPassword').value;
+    if (!username || !password) { alert('请填写用户名和密码'); return; }
+    const payload = { username, password };
+    const res = await api('POST', '/api/auth/password/login', payload, false);
+    if (res.data?.code === 200 && res.data?.data?.accessToken) {
+        document.getElementById('adminToken').value = res.data.data.accessToken;
+        updateAdminRoleBadge(res.data.data.role);
+        alert('管理员登录成功!\n用户ID: ' + res.data.data.userId + '\n角色: ' + (res.data.data.role || 'ADMIN'));
+    } else {
+        alert('登录失败: ' + (res.data?.message || '未知错误'));
+    }
+}
+
 function copyToken() {
     const tok = TOKEN();
     if (!tok) { alert('请先登录'); return; }
@@ -1680,16 +1686,19 @@ function renderAdminTrialQuotaModule() {
 function renderAdminUserModule() {
     return `
     <div class="batch-buttons"><button class="btn btn-warning btn-sm" onclick="runAdminUserTests()">运行全部用户管理测试</button></div>
-    ${testSection('admin-user-list', '用户列表', 'GET', 'get', '/api/admin/users?page={page}&size={size}&phone={phone}&role={role}', '需认证', 'auth-required',
+    ${testSection('admin-user-list', '前端用户列表', 'GET', 'get', '/api/admin/users?page={page}&size={size}&phone={phone}&nickname={nickname}', '需认证', 'auth-required',
         [{label:'页码(page)', name:'page', placeholder:'1', default:'1'},
          {label:'每页条数(size)', name:'size', placeholder:'10', default:'10'},
          {label:'手机号(phone)', name:'phone', placeholder:'138', default:''},
-         {label:'角色(role)', name:'role', placeholder:'USER/ADMIN/SUPER_ADMIN', default:''}], true, null, '超级管理员查询用户列表')}
+         {label:'昵称(nickname)', name:'nickname', placeholder:'测试', default:''}], true, null, '查询前端注册用户列表(仅USER角色)')}
+    ${testSection('admin-admins-list', '管理员列表', 'GET', 'get', '/api/admin/admins?page=1&size=10', '需认证', 'auth-required', null, true, null, '查询管理员列表(t_admin表)')}
     ${testSection('admin-user-role', '修改用户角色', 'PUT', 'put', '/api/admin/users/{userId}/role', '需认证', 'auth-required',
         [{label:'用户ID(userId)', name:'userId', placeholder:'1', default:'1'},
-         {label:'目标角色(role)', name:'role', placeholder:'USER/ADMIN/SUPER_ADMIN', default:'ADMIN'}], false, null, '超级管理员修改用户角色')}
+         {label:'目标角色(role)', name:'role', placeholder:'ADMIN/SUPER_ADMIN/USER', default:'ADMIN'}], false, null, '升级用户为管理员(写入t_admin)/ 降级管理员为用户(删除t_admin记录)')}
     ${testSection('admin-user-kick', '强制踢出用户', 'POST', 'post', '/api/admin/users/{userId}/kick', '需认证', 'auth-required',
-        [{label:'用户ID(userId)', name:'userId', placeholder:'1', default:'1'}], false, null, '强制踢出用户:递增tokenVersion+清空RefreshToken,用户下次请求返回1304')}`;
+        [{label:'用户ID(userId)', name:'userId', placeholder:'1', default:'1'}], false, null, '强制踢出前端用户:递增tokenVersion+清空RefreshToken')}
+    ${testSection('admin-admin-kick', '强制踢出管理员', 'POST', 'post', '/api/admin/admins/{adminId}/kick', '需认证', 'auth-required',
+        [{label:'管理员ID(adminId)', name:'adminId', placeholder:'100', default:'100'}], false, null, '强制踢出管理员:递增tokenVersion+清空RefreshToken')}`;
 }
 
 function renderAdminAuditModule() {

+ 33 - 53
zhijiayun-user/src/main/java/com/xuekairui/user/config/SuperAdminInitializer.java

@@ -1,9 +1,8 @@
 package com.xuekairui.user.config;
 
 import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
-import com.xuekairui.user.entity.User;
-import com.xuekairui.user.enums.UserRole;
-import com.xuekairui.user.mapper.UserMapper;
+import com.xuekairui.user.entity.Admin;
+import com.xuekairui.user.mapper.AdminMapper;
 import com.xuekairui.user.util.PasswordUtil;
 import lombok.RequiredArgsConstructor;
 import lombok.extern.slf4j.Slf4j;
@@ -12,86 +11,67 @@ import org.springframework.boot.CommandLineRunner;
 import org.springframework.stereotype.Component;
 
 /**
- * 系统启动时初始化超级管理员
- * <p>
- * 当数据库中不存在 SUPER_ADMIN 时,使用配置的用户名和密码创建超级管理员账号。
- * 默认使用用户名密码登录(无需手机号),生产环境建议通过环境变量覆盖默认值。
+ * 系统启动时初始化超级管理员(操作 t_admin 表)
  */
 @Slf4j
 @Component
 @RequiredArgsConstructor
 public class SuperAdminInitializer implements CommandLineRunner {
 
-    private final UserMapper userMapper;
+    private final AdminMapper adminMapper;
     private final PasswordUtil passwordUtil;
 
-    @Value("${super-admin.phone:}")
-    private String superAdminPhone;
+    @Value("${super-admin.username:admin}")
+    private String superAdminUsername;
 
     @Value("${super-admin.password:admin@123}")
     private String superAdminPassword;
 
-    @Value("${super-admin.nickname:admin}")
-    private String superAdminNickname;
+    @Value("${super-admin.phone:}")
+    private String superAdminPhone;
 
     @Override
     public void run(String... args) {
-        User existingSuperAdmin = userMapper.selectOne(
-                new LambdaQueryWrapper<User>().eq(User::getRole, UserRole.SUPER_ADMIN).last("LIMIT 1"));
+        Admin existing = adminMapper.selectOne(
+                new LambdaQueryWrapper<Admin>().eq(Admin::getRole, "SUPER_ADMIN").last("LIMIT 1"));
 
-        if (existingSuperAdmin != null) {
-            // 超级管理员已存在:确保账号信息完好,不允许被禁用或丢失关键凭证
+        if (existing != null) {
             boolean needsUpdate = false;
-            if (existingSuperAdmin.getStatus() == null || existingSuperAdmin.getStatus() == 0) {
-                existingSuperAdmin.setStatus(1);
+            if (existing.getStatus() == null || existing.getStatus() == 0) {
+                existing.setStatus(1);
                 needsUpdate = true;
                 log.warn("超级管理员账号已被禁用,已自动恢复启用");
             }
-            if (existingSuperAdmin.getPassword() == null) {
-                existingSuperAdmin.setPassword(passwordUtil.encode(superAdminPassword));
+            if (existing.getPassword() == null || existing.getPassword().isEmpty()) {
+                existing.setPassword(passwordUtil.encode(superAdminPassword));
                 needsUpdate = true;
                 log.warn("超级管理员账号密码缺失,已补充默认密码");
             }
-            if (existingSuperAdmin.getNickname() == null || existingSuperAdmin.getNickname().isBlank()) {
-                existingSuperAdmin.setNickname(superAdminNickname);
-                needsUpdate = true;
-            }
             if (needsUpdate) {
-                userMapper.updateById(existingSuperAdmin);
-                log.info("已修复超级管理员账号信息,用户名:{}", existingSuperAdmin.getNickname());
+                adminMapper.updateById(existing);
+                log.info("已修复超级管理员账号,用户名:{}", existing.getUsername());
             } else {
-                log.info("系统中已存在超级管理员,账号正常(用户名:{})", existingSuperAdmin.getNickname());
+                log.info("超级管理员已存在,账号正常(用户名:{})", existing.getUsername());
             }
             return;
         }
 
-        // 首次创建:优先按昵称查找已有用户(支持用户名密码登录场景)
-        User existUser = userMapper.selectOne(
-                new LambdaQueryWrapper<User>().eq(User::getNickname, superAdminNickname));
-
-        // 如果配置了手机号,也按手机号查找
-        if (existUser == null && superAdminPhone != null && !superAdminPhone.isBlank()) {
-            existUser = userMapper.selectOne(
-                    new LambdaQueryWrapper<User>().eq(User::getPhone, superAdminPhone));
+        // 首次创建:先检查用户名是否被占用
+        Admin byUsername = adminMapper.selectOne(
+                new LambdaQueryWrapper<Admin>().eq(Admin::getUsername, superAdminUsername));
+        if (byUsername != null) {
+            log.info("用户名 {} 已被占用,跳过超级管理员创建", superAdminUsername);
+            return;
         }
 
-        if (existUser != null) {
-            existUser.setRole(UserRole.SUPER_ADMIN);
-            if (existUser.getPassword() == null) {
-                existUser.setPassword(passwordUtil.encode(superAdminPassword));
-            }
-            userMapper.updateById(existUser);
-            log.info("已将用户 '{}' 提升为超级管理员", existUser.getNickname());
-        } else {
-            User superAdmin = User.builder()
-                    .phone(superAdminPhone != null && !superAdminPhone.isBlank() ? superAdminPhone : null)
-                    .nickname(superAdminNickname)
-                    .password(passwordUtil.encode(superAdminPassword))
-                    .role(UserRole.SUPER_ADMIN)
-                    .status(1)
-                    .build();
-            userMapper.insert(superAdmin);
-            log.info("已创建默认超级管理员,用户名:{},默认密码:{},请尽快修改默认密码", superAdminNickname, superAdminPassword);
-        }
+        Admin superAdmin = Admin.builder()
+                .username(superAdminUsername)
+                .phone(superAdminPhone != null && !superAdminPhone.isBlank() ? superAdminPhone : null)
+                .password(passwordUtil.encode(superAdminPassword))
+                .role("SUPER_ADMIN")
+                .status(1)
+                .build();
+        adminMapper.insert(superAdmin);
+        log.info("已创建超级管理员,用户名:{},默认密码:{},请尽快修改", superAdminUsername, superAdminPassword);
     }
 }

+ 49 - 0
zhijiayun-user/src/main/java/com/xuekairui/user/controller/AdminAccountController.java

@@ -0,0 +1,49 @@
+package com.xuekairui.user.controller;
+
+import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
+import com.xuekairui.common.Result;
+import com.xuekairui.user.dto.AdminUserResponse;
+import com.xuekairui.user.enums.OperationType;
+import com.xuekairui.user.service.AdminUserService;
+import com.xuekairui.user.service.OperationAuditLogService;
+import jakarta.servlet.http.HttpServletRequest;
+import lombok.RequiredArgsConstructor;
+import org.springframework.web.bind.annotation.*;
+
+/**
+ * 管理员账号管理接口(运营端)
+ * <p>
+ * 管理员列表查询、踢出等,操作 t_admin 表。
+ */
+@RestController
+@RequestMapping("/api/admin/admins")
+@RequiredArgsConstructor
+public class AdminAccountController {
+
+    private final AdminUserService adminUserService;
+    private final OperationAuditLogService auditLogService;
+
+    @GetMapping
+    public Result<Page<AdminUserResponse>> listAdmins(
+            @RequestParam(defaultValue = "1") Integer page,
+            @RequestParam(defaultValue = "20") Integer size) {
+        return Result.success(adminUserService.listAdmins(page, size));
+    }
+
+    @PostMapping("/{adminId}/kick")
+    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,
+                OperationType.USER_UPDATE, "ADMIN_KICK:" + adminId, adminId);
+        return Result.success("管理员已被强制退出");
+    }
+
+    private String getOperatorRole(HttpServletRequest request) {
+        String role = (String) request.getAttribute("role");
+        return role != null ? role : "ADMIN";
+    }
+}

+ 4 - 1
zhijiayun-user/src/main/java/com/xuekairui/user/dto/AdminUserResponse.java

@@ -1,5 +1,7 @@
 package com.xuekairui.user.dto;
 
+import com.fasterxml.jackson.databind.annotation.JsonSerialize;
+import com.fasterxml.jackson.databind.ser.std.ToStringSerializer;
 import lombok.AllArgsConstructor;
 import lombok.Builder;
 import lombok.Data;
@@ -16,7 +18,8 @@ import java.time.LocalDateTime;
 @AllArgsConstructor
 public class AdminUserResponse {
 
-    /** 用户ID */
+    /** 用户ID(String 序列化,避免前端 JS 大数精度丢失) */
+    @JsonSerialize(using = ToStringSerializer.class)
     private Long id;
 
     /** 手机号 */

+ 4 - 1
zhijiayun-user/src/main/java/com/xuekairui/user/dto/CrawlerUserDetailResponse.java

@@ -1,5 +1,7 @@
 package com.xuekairui.user.dto;
 
+import com.fasterxml.jackson.databind.annotation.JsonSerialize;
+import com.fasterxml.jackson.databind.ser.std.ToStringSerializer;
 import lombok.AllArgsConstructor;
 import lombok.Builder;
 import lombok.Data;
@@ -16,7 +18,8 @@ import java.util.List;
 @AllArgsConstructor
 public class CrawlerUserDetailResponse {
     
-    /** 用户ID */
+    /** 用户ID(String 序列化) */
+    @JsonSerialize(using = ToStringSerializer.class)
     private Long userId;
     
     /** 用户昵称 */

+ 4 - 1
zhijiayun-user/src/main/java/com/xuekairui/user/dto/LoginResponse.java

@@ -1,5 +1,7 @@
 package com.xuekairui.user.dto;
 
+import com.fasterxml.jackson.databind.annotation.JsonSerialize;
+import com.fasterxml.jackson.databind.ser.std.ToStringSerializer;
 import lombok.Data;
 import lombok.Builder;
 import lombok.NoArgsConstructor;
@@ -14,7 +16,8 @@ import lombok.AllArgsConstructor;
 @AllArgsConstructor
 public class LoginResponse {
 
-    /** 用户ID */
+    /** 用户ID(String 序列化,避免前端 JS 大数精度丢失) */
+    @JsonSerialize(using = ToStringSerializer.class)
     private Long userId;
 
     /** 访问令牌 */

+ 4 - 1
zhijiayun-user/src/main/java/com/xuekairui/user/dto/MembershipResponse.java

@@ -1,5 +1,7 @@
 package com.xuekairui.user.dto;
 
+import com.fasterxml.jackson.databind.annotation.JsonSerialize;
+import com.fasterxml.jackson.databind.ser.std.ToStringSerializer;
 import lombok.AllArgsConstructor;
 import lombok.Builder;
 import lombok.Data;
@@ -16,7 +18,8 @@ import java.time.LocalDateTime;
 @AllArgsConstructor
 public class MembershipResponse {
 
-    /** 记录ID */
+    /** 记录ID(String 序列化,避免前端 JS 大数精度丢失) */
+    @JsonSerialize(using = ToStringSerializer.class)
     private Long id;
 
     /** 用户ID */

+ 3 - 4
zhijiayun-user/src/main/java/com/xuekairui/user/dto/UserCouponResponse.java

@@ -1,5 +1,7 @@
 package com.xuekairui.user.dto;
 
+import com.fasterxml.jackson.databind.annotation.JsonSerialize;
+import com.fasterxml.jackson.databind.ser.std.ToStringSerializer;
 import lombok.Data;
 import lombok.Builder;
 import lombok.NoArgsConstructor;
@@ -7,16 +9,13 @@ import lombok.AllArgsConstructor;
 
 import java.time.LocalDateTime;
 
-/**
- * 用户优惠券响应
- */
 @Data
 @Builder
 @NoArgsConstructor
 @AllArgsConstructor
 public class UserCouponResponse {
 
-    /** 用户优惠券ID */
+    @JsonSerialize(using = ToStringSerializer.class)
     private Long id;
 
     /** 优惠券名称 */

+ 6 - 2
zhijiayun-user/src/main/java/com/xuekairui/user/dto/UserInfoResponse.java

@@ -1,6 +1,8 @@
 package com.xuekairui.user.dto;
 
 import com.fasterxml.jackson.annotation.JsonGetter;
+import com.fasterxml.jackson.databind.annotation.JsonSerialize;
+import com.fasterxml.jackson.databind.ser.std.ToStringSerializer;
 import lombok.Data;
 import lombok.Builder;
 import lombok.NoArgsConstructor;
@@ -17,7 +19,8 @@ import java.time.LocalDateTime;
 @AllArgsConstructor
 public class UserInfoResponse {
 
-    /** 用户ID */
+    /** 用户ID(String 序列化,避免前端 JS 大数精度丢失) */
+    @JsonSerialize(using = ToStringSerializer.class)
     private Long id;
 
     /** 手机号(脱敏) */
@@ -62,7 +65,8 @@ public class UserInfoResponse {
     /** 会员等级中文名 */
     private String membershipLevelName;
 
-    /** 邀请人ID(NULL=未被邀请) */
+    /** 邀请人ID(NULL=未被邀请,String 序列化避免大数精度丢失) */
+    @JsonSerialize(using = ToStringSerializer.class)
     private Long inviterId;
 
     /** 邀请人昵称 */

+ 3 - 1
zhijiayun-user/src/main/java/com/xuekairui/user/dto/UserLevelResponse.java

@@ -1,5 +1,7 @@
 package com.xuekairui.user.dto;
 
+import com.fasterxml.jackson.databind.annotation.JsonSerialize;
+import com.fasterxml.jackson.databind.ser.std.ToStringSerializer;
 import lombok.Data;
 import lombok.Builder;
 import lombok.NoArgsConstructor;
@@ -17,7 +19,7 @@ import java.util.List;
 @AllArgsConstructor
 public class UserLevelResponse {
 
-    /** 等级ID */
+    @JsonSerialize(using = ToStringSerializer.class)
     private Long id;
 
     /** 等级名称(如:普通会员) */

+ 4 - 1
zhijiayun-user/src/main/java/com/xuekairui/user/dto/WatchlistResponse.java

@@ -1,5 +1,7 @@
 package com.xuekairui.user.dto;
 
+import com.fasterxml.jackson.databind.annotation.JsonSerialize;
+import com.fasterxml.jackson.databind.ser.std.ToStringSerializer;
 import lombok.Data;
 import lombok.Builder;
 import lombok.NoArgsConstructor;
@@ -17,7 +19,8 @@ import java.time.LocalDateTime;
 @AllArgsConstructor
 public class WatchlistResponse {
 
-    /** 关注ID */
+    /** 关注ID(String 序列化) */
+    @JsonSerialize(using = ToStringSerializer.class)
     private Long id;
 
     /** 药品名称 */

+ 52 - 0
zhijiayun-user/src/main/java/com/xuekairui/user/entity/Admin.java

@@ -0,0 +1,52 @@
+package com.xuekairui.user.entity;
+
+import com.baomidou.mybatisplus.annotation.*;
+import lombok.AllArgsConstructor;
+import lombok.Builder;
+import lombok.Data;
+import lombok.NoArgsConstructor;
+
+import java.time.LocalDateTime;
+
+/**
+ * 管理员实体(独立 t_admin 表,与前端用户隔离)
+ */
+@Data
+@NoArgsConstructor
+@AllArgsConstructor
+@Builder
+@TableName("t_admin")
+public class Admin {
+
+    @TableId(type = IdType.AUTO)
+    private Long id;
+
+    private String username;
+
+    private String password;
+
+    private String phone;
+
+    @Builder.Default
+    private String role = "ADMIN";
+
+    @Builder.Default
+    private Integer status = 1;
+
+    @Builder.Default
+    private Long tokenVersion = 0L;
+
+    private String refreshTokenHash;
+
+    private String refreshTokenStatus;
+
+    private LocalDateTime lastLoginTime;
+
+    private String lastLoginIp;
+
+    @TableField(fill = FieldFill.INSERT)
+    private LocalDateTime createTime;
+
+    @TableField(fill = FieldFill.INSERT_UPDATE)
+    private LocalDateTime updateTime;
+}

+ 2 - 2
zhijiayun-user/src/main/java/com/xuekairui/user/entity/UserMembership.java

@@ -29,9 +29,9 @@ public class UserMembership {
     /** 会员等级:PLUS/PRO/ULTRA */
     private MembershipLevel level;
 
-    /** 来源:ADMIN_GRANT-运营发放 RECHARGE-充值购买 SYSTEM-系统赠送 */
+    /** 来源:SYSTEM-系统默认 RECHARGE-充值购买 INVITE-邀请奖励 ADMIN_GRANT-运营发放 LICENSE-营业执照 */
     @Builder.Default
-    private String source = "ADMIN_GRANT";
+    private String source = "SYSTEM";
 
     /** 生效开始时间 */
     private LocalDateTime effectiveFrom;

+ 35 - 0
zhijiayun-user/src/main/java/com/xuekairui/user/enums/AdminRole.java

@@ -0,0 +1,35 @@
+package com.xuekairui.user.enums;
+
+import com.baomidou.mybatisplus.annotation.EnumValue;
+import com.fasterxml.jackson.annotation.JsonValue;
+import lombok.Getter;
+
+/**
+ * 管理员角色枚举(仅用于 t_admin 表)
+ */
+@Getter
+public enum AdminRole {
+
+    ADMIN("ADMIN", "运营人员"),
+    SUPER_ADMIN("SUPER_ADMIN", "超级管理员");
+
+    @EnumValue
+    @JsonValue
+    private final String code;
+
+    private final String description;
+
+    AdminRole(String code, String description) {
+        this.code = code;
+        this.description = description;
+    }
+
+    public static AdminRole fromCode(String code) {
+        for (AdminRole role : values()) {
+            if (role.code.equals(code)) {
+                return role;
+            }
+        }
+        return null;
+    }
+}

+ 20 - 0
zhijiayun-user/src/main/java/com/xuekairui/user/mapper/AdminMapper.java

@@ -0,0 +1,20 @@
+package com.xuekairui.user.mapper;
+
+import com.baomidou.mybatisplus.core.mapper.BaseMapper;
+import com.xuekairui.user.entity.Admin;
+import org.apache.ibatis.annotations.Mapper;
+import org.apache.ibatis.annotations.Param;
+import org.apache.ibatis.annotations.Select;
+
+/**
+ * 管理员 Mapper(操作 t_admin 表)
+ */
+@Mapper
+public interface AdminMapper extends BaseMapper<Admin> {
+
+    @Select("SELECT * FROM t_admin WHERE username = #{username} AND status = 1 LIMIT 1")
+    Admin selectByUsername(@Param("username") String username);
+
+    @Select("SELECT * FROM t_admin WHERE phone = #{phone} AND status = 1 LIMIT 1")
+    Admin selectByPhone(@Param("phone") String phone);
+}

+ 22 - 16
zhijiayun-user/src/main/java/com/xuekairui/user/security/JwtAuthenticationFilter.java

@@ -47,6 +47,7 @@ public class JwtAuthenticationFilter extends OncePerRequestFilter {
 
     private final JwtUtil jwtUtil;
     private final UserMapper userMapper;
+    private final com.xuekairui.user.mapper.AdminMapper adminMapper;
     private final TokenVersionCache tokenVersionCache;
 
     private static final String AUTH_HEADER = "Authorization";
@@ -92,19 +93,18 @@ public class JwtAuthenticationFilter extends OncePerRequestFilter {
                     Long userId = claims.get("userId", Long.class);
                     Long tokenVer = getTokenVersionFromClaims(claims);
                     String role = claims.get("role", String.class);
+                    boolean isAdmin = "ADMIN".equals(role) || "SUPER_ADMIN".equals(role);
 
-                    // 确保 MDC 中有 uid(extractUidForMdc 可能已设置,此处兜底)
                     if (userId != null) {
                         MDC.put("uid", String.valueOf(userId));
                     }
 
                     if ("access".equals(tokenType)) {
-                        // 尽早设置 userId 到 request attribute,供后续 RequestLoggingFilter 日志使用
                         request.setAttribute("userId", userId);
 
-                        // 单设备登录校验:验证 token 中的版本号与当前最新版本一致
-                        if (!isTokenVersionValid(userId, tokenVer, role)) {
-                            log.info("用户 {} 的Token版本号不匹配(已在其他设备登录),拒绝访问", userId);
+                        if (!isTokenVersionValid(userId, isAdmin, tokenVer, role)) {
+                            log.info("{} {} 的Token版本号不匹配(已在其他设备登录),拒绝访问",
+                                    isAdmin ? "管理员" : "用户", userId);
                             writeKickedResponse(response);
                             return;
                         }
@@ -156,30 +156,36 @@ public class JwtAuthenticationFilter extends OncePerRequestFilter {
      * <p>
      * 优先从内存缓存读取,缓存未命中时查 DB 并回填缓存。
      */
-    private boolean isTokenVersionValid(Long userId, Long tokenVersion, String role) {
-        if (userId == null) {
+    private boolean isTokenVersionValid(Long id, boolean isAdmin, Long tokenVersion, String role) {
+        if (id == null) {
             return false;
         }
         // 从缓存获取当前版本号
-        Long currentVersion = tokenVersionCache.getVersion(userId);
+        Long currentVersion = tokenVersionCache.getVersion(id, isAdmin);
         if (currentVersion == null) {
-            // 缓存未命中,查 DB
-            User user = userMapper.selectById(userId);
-            if (user == null) {
-                return false;
+            // 缓存未命中:按 userType 分流查不同表
+            if (isAdmin) {
+                com.xuekairui.user.entity.Admin admin = adminMapper.selectById(id);
+                if (admin == null) {
+                    return false;
+                }
+                currentVersion = admin.getTokenVersion() != null ? admin.getTokenVersion() : 0L;
+            } else {
+                User user = userMapper.selectById(id);
+                if (user == null) {
+                    return false;
+                }
+                currentVersion = user.getTokenVersion() != null ? user.getTokenVersion() : 0L;
             }
-            currentVersion = user.getTokenVersion() != null ? user.getTokenVersion() : 0L;
-            tokenVersionCache.putVersion(userId, currentVersion);
+            tokenVersionCache.putVersion(id, isAdmin, currentVersion);
         }
 
         long tokenVer = tokenVersion != null ? tokenVersion : 0L;
 
-        // 管理员允许最多 5 台设备:接受最近 5 个版本号
         if ("ADMIN".equals(role) || "SUPER_ADMIN".equals(role)) {
             return tokenVer <= currentVersion && (currentVersion - tokenVer) < 5;
         }
 
-        // 普通用户保持单设备:精确匹配
         return currentVersion.equals(tokenVer);
     }
 

+ 19 - 19
zhijiayun-user/src/main/java/com/xuekairui/user/security/TokenVersionCache.java

@@ -10,45 +10,48 @@ import org.springframework.stereotype.Component;
 /**
  * Token 版本号内存缓存(避免每次请求都查 DB)
  * <p>
- * 用于单设备登录校验:缓存 userId → tokenVersion 的映射,
+ * 用于单设备/多设备登录校验:缓存 "u:{userId}" 或 "a:{adminId}" → tokenVersion 的映射,
  * 登录时更新缓存,过滤器直接从缓存读取版本号与 JWT 中的版本号比对。
  * 缓存未命中时回退到 DB 查询。
  * <p>
- * 使用 Caffeine W-TinyLFU 淘汰策略,上限 10 万条,
- * 超限自动淘汰低频条目,无需手动维护
+ * key 前缀区分用户和管理员,避免 t_user 和 t_admin 的 ID 碰撞。
+ * 使用 Caffeine W-TinyLFU 淘汰策略,上限 10 万条
  */
 @Slf4j
 @Component
 public class TokenVersionCache {
 
-    /** 缓存上限(超过后 Caffeine 自动按 W-TinyLFU 淘汰低频条目) */
     private static final int MAX_SIZE = 100_000;
+    private static final String PREFIX_USER = "u:";
+    private static final String PREFIX_ADMIN = "a:";
 
-    private final Cache<Long, Long> cache = Caffeine.newBuilder()
+    private final Cache<String, Long> cache = Caffeine.newBuilder()
             .maximumSize(MAX_SIZE)
             .recordStats()
             .build();
 
+    private static String key(boolean isAdmin, Long id) {
+        return (isAdmin ? PREFIX_ADMIN : PREFIX_USER) + id;
+    }
+
     /**
-     * 获取用户当前 Token 版本号
-     * @return 缓存的版本号,未命中返回 null(调用方需回退 DB 查询)
+     * 获取当前 Token 版本号
+     * @param isAdmin true=管理员(查 t_admin),false=用户(查 t_user)
+     * @return 缓存的版本号,未命中返回 null
      */
-    public Long getVersion(Long userId) {
-        return cache.getIfPresent(userId);
+    public Long getVersion(Long id, boolean isAdmin) {
+        return cache.getIfPresent(key(isAdmin, id));
     }
 
     /**
-     * 更新用户 Token 版本号(登录时调用)
+     * 更新 Token 版本号(登录时调用)
      */
-    public void putVersion(Long userId, Long version) {
-        if (userId != null && version != null) {
-            cache.put(userId, version);
+    public void putVersion(Long id, boolean isAdmin, Long version) {
+        if (id != null && version != null) {
+            cache.put(key(isAdmin, id), version);
         }
     }
 
-    /**
-     * 每小时输出缓存命中率和大小,便于监控
-     */
     @Scheduled(fixedRate = 3600000)
     public void reportStats() {
         CacheStats stats = cache.stats();
@@ -59,9 +62,6 @@ public class TokenVersionCache {
         }
     }
 
-    /**
-     * 清空所有缓存(测试用)
-     */
     public void clear() {
         cache.invalidateAll();
     }

+ 215 - 0
zhijiayun-user/src/main/java/com/xuekairui/user/service/AdminAuthService.java

@@ -0,0 +1,215 @@
+package com.xuekairui.user.service;
+
+import com.xuekairui.common.BusinessException;
+import com.xuekairui.common.ErrorCode;
+import com.xuekairui.user.dto.LoginResponse;
+import com.xuekairui.user.entity.Admin;
+import com.xuekairui.user.mapper.AdminMapper;
+import com.xuekairui.user.security.TokenVersionCache;
+import com.xuekairui.user.util.JwtUtil;
+import com.xuekairui.user.util.PasswordUtil;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
+
+import java.time.LocalDateTime;
+
+/**
+ * 管理员认证服务(操作 t_admin 表,与普通用户 AuthService 隔离)
+ */
+@Slf4j
+@Service
+@RequiredArgsConstructor
+public class AdminAuthService {
+
+    private final AdminMapper adminMapper;
+    private final JwtUtil jwtUtil;
+    private final PasswordUtil passwordUtil;
+    private final TokenVersionCache tokenVersionCache;
+
+    /**
+     * 管理员密码登录(按用户名或手机号)
+     */
+    @Transactional
+    public LoginResponse passwordLogin(String account, String password, String ip) {
+        Admin admin = adminMapper.selectByUsername(account);
+        if (admin == null) {
+            admin = adminMapper.selectByPhone(account);
+        }
+        if (admin == null) {
+            throw new BusinessException(ErrorCode.USER_NOT_FOUND);
+        }
+        if (admin.getPassword() == null || admin.getPassword().isEmpty()) {
+            throw new BusinessException(ErrorCode.PASSWORD_NOT_SET);
+        }
+        if (!passwordUtil.matches(password, admin.getPassword())) {
+            throw new BusinessException(ErrorCode.PASSWORD_INCORRECT);
+        }
+        if (admin.getStatus() != null && admin.getStatus() == 0) {
+            throw new BusinessException(ErrorCode.USER_DISABLED);
+        }
+
+        return buildLoginResponse(admin, ip);
+    }
+
+    /**
+     * 构建管理员登录响应(递增 tokenVersion,签发 Token)
+     */
+    private LoginResponse buildLoginResponse(Admin admin, String ip) {
+        String roleCode = admin.getRole() != null ? admin.getRole() : "ADMIN";
+
+        // 管理员允许多设备:最多5台
+        long oldVersion = admin.getTokenVersion() != null ? admin.getTokenVersion() : 0L;
+        long newVersion = oldVersion + 1;
+        admin.setTokenVersion(newVersion);
+
+        String accessToken = jwtUtil.generateAccessToken(admin.getId(), roleCode, newVersion);
+        String refreshToken = jwtUtil.generateRefreshToken(admin.getId());
+
+        admin.setRefreshTokenHash(JwtUtil.hashToken(refreshToken));
+        admin.setRefreshTokenStatus("ACTIVE");
+        admin.setLastLoginTime(LocalDateTime.now());
+        admin.setLastLoginIp(ip);
+        adminMapper.updateById(admin);
+
+        tokenVersionCache.putVersion(admin.getId(), true, newVersion);
+
+        boolean hadPreviousSession = oldVersion >= 5;
+
+        return LoginResponse.builder()
+                .userId(admin.getId())
+                .accessToken(accessToken)
+                .refreshToken(refreshToken)
+                .expiresIn(jwtUtil.getExpirationInSeconds(roleCode))
+                .needBindPhone(false)
+                .newUser(false)
+                .role(roleCode)
+                .kickedPreviousSession(hadPreviousSession)
+                .build();
+    }
+
+    /**
+     * 管理员心跳续期
+     */
+    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);
+        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);
+        }
+
+        // 旋转
+        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");
+        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) {
+        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));
+        adminMapper.updateById(admin);
+    }
+
+    /**
+     * 获取管理员信息
+     */
+    public Admin getById(Long adminId) {
+        return adminMapper.selectById(adminId);
+    }
+
+    /**
+     * 按用户名查询
+     */
+    public Admin getByUsername(String username) {
+        return adminMapper.selectByUsername(username);
+    }
+}

+ 96 - 22
zhijiayun-user/src/main/java/com/xuekairui/user/service/AdminUserService.java

@@ -6,9 +6,12 @@ import com.xuekairui.common.BusinessException;
 import com.xuekairui.common.ErrorCode;
 import com.xuekairui.user.dto.AdminUserResponse;
 import com.xuekairui.user.dto.UpdateRoleRequest;
+import com.xuekairui.user.entity.Admin;
 import com.xuekairui.user.entity.User;
-import com.xuekairui.user.enums.UserRole;
+import com.xuekairui.user.mapper.AdminMapper;
 import com.xuekairui.user.mapper.UserMapper;
+import com.xuekairui.user.security.TokenVersionCache;
+import com.xuekairui.user.util.PasswordUtil;
 import lombok.RequiredArgsConstructor;
 import lombok.extern.slf4j.Slf4j;
 import org.springframework.stereotype.Service;
@@ -25,10 +28,12 @@ import java.util.Objects;
 public class AdminUserService {
 
     private final UserMapper userMapper;
+    private final AdminMapper adminMapper;
+    private final TokenVersionCache tokenVersionCache;
+    private final PasswordUtil passwordUtil;
 
     /**
-     * 分页查询用户列表(支持按ID精确/手机号模糊/昵称模糊/角色精确查询)
-     * 无删除接口,用户数据不可删除。
+     * 分页查询前端用户列表(仅 USER 角色,不含管理员)
      */
     public Page<AdminUserResponse> listUsers(Integer pageNum, Integer pageSize,
                                               Long userId, String phone, String nickname, String role) {
@@ -43,21 +48,43 @@ public class AdminUserService {
         if (nickname != null && !nickname.isBlank()) {
             wrapper.like(User::getNickname, nickname);
         }
-        if (role != null && !role.isBlank()) {
-            wrapper.eq(User::getRole, UserRole.fromCode(role));
-        }
+        // 前端用户列表仅查 USER,管理员走 listAdmins
         wrapper.orderByDesc(User::getCreateTime);
 
         Page<User> userPage = userMapper.selectPage(page, wrapper);
         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; })
+                .toList());
+        return resultPage;
+    }
+
+    /**
+     * 分页查询管理员列表(查 t_admin 表)
+     */
+    public Page<AdminUserResponse> listAdmins(Integer pageNum, Integer pageSize) {
+        Page<Admin> page = new Page<>(pageNum, pageSize);
+        LambdaQueryWrapper<Admin> wrapper = new LambdaQueryWrapper<>();
+        wrapper.orderByDesc(Admin::getCreateTime);
+        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());
         return resultPage;
     }
 
     /**
      * 修改用户角色(仅超级管理员可调用)
+     * 升级为 ADMIN/SUPER_ADMIN → 插入 t_admin;降级为 USER → 删除 t_admin 记录
      */
     @Transactional
     public AdminUserResponse updateUserRole(Long userId, UpdateRoleRequest request, Long operatorId) {
@@ -66,30 +93,60 @@ public class AdminUserService {
             throw new BusinessException(ErrorCode.USER_NOT_FOUND);
         }
 
-        UserRole targetRole = UserRole.fromCode(request.getRole());
-        UserRole currentRole = user.getRole() != null ? user.getRole() : UserRole.USER;
+        String targetRole = request.getRole();
+        Admin existingAdmin = adminMapper.selectById(userId);
 
         // 禁止取消最后一个超级管理员
-        if (currentRole == UserRole.SUPER_ADMIN && targetRole != UserRole.SUPER_ADMIN) {
-            long otherSuperAdminCount = userMapper.selectCount(
-                    new LambdaQueryWrapper<User>()
-                            .eq(User::getRole, UserRole.SUPER_ADMIN)
-                            .ne(User::getId, userId));
-            if (otherSuperAdminCount == 0) {
+        if (existingAdmin != null && "SUPER_ADMIN".equals(existingAdmin.getRole())
+                && !"SUPER_ADMIN".equals(targetRole)) {
+            long otherCount = adminMapper.selectCount(
+                    new LambdaQueryWrapper<Admin>()
+                            .eq(Admin::getRole, "SUPER_ADMIN")
+                            .ne(Admin::getId, userId));
+            if (otherCount == 0) {
                 throw new BusinessException(ErrorCode.BUSINESS_ERROR, "系统中至少保留一个超级管理员");
             }
         }
 
-        // 防止操作人取消自己的超级管理员权限后系统无超级管理员
         if (Objects.equals(operatorId, userId)
-                && currentRole == UserRole.SUPER_ADMIN
-                && targetRole != UserRole.SUPER_ADMIN) {
+                && existingAdmin != null && "SUPER_ADMIN".equals(existingAdmin.getRole())
+                && !"SUPER_ADMIN".equals(targetRole)) {
             throw new BusinessException(ErrorCode.BUSINESS_ERROR, "不能取消自己的超级管理员权限");
         }
 
-        user.setRole(targetRole);
-        userMapper.updateById(user);
-        log.info("操作人{}修改用户{}角色为{}", operatorId, userId, targetRole.getCode());
+        if ("ADMIN".equals(targetRole) || "SUPER_ADMIN".equals(targetRole)) {
+            // 升级:插入或更新 t_admin
+            if (existingAdmin != null) {
+                existingAdmin.setRole(targetRole);
+                adminMapper.updateById(existingAdmin);
+            } else {
+                // 如果没有密码,用手机号后6位作为初始密码(BCrypt加密)
+                String pwd = user.getPassword();
+                if (pwd == null || pwd.isEmpty()) {
+                    String phone = user.getPhone();
+                    String rawPwd = phone != null && phone.length() >= 6
+                            ? phone.substring(phone.length() - 6) : "123456";
+                    pwd = passwordUtil.encode(rawPwd);
+                }
+                Admin admin = Admin.builder()
+                        .id(userId)
+                        .username(user.getPhone() != null ? user.getPhone() : "admin_" + userId)
+                        .password(pwd)
+                        .phone(user.getPhone())
+                        .role(targetRole)
+                        .status(1)
+                        .build();
+                adminMapper.insert(admin);
+            }
+        } else {
+            // 降级为 USER:删除 t_admin 记录
+            if (existingAdmin != null) {
+                adminMapper.deleteById(userId);
+                tokenVersionCache.clear();
+            }
+        }
+
+        log.info("操作人{}修改用户{}角色为{}", operatorId, userId, targetRole);
         return toResponse(user);
     }
 
@@ -103,19 +160,36 @@ public class AdminUserService {
         }
         long newVersion = (user.getTokenVersion() != null ? user.getTokenVersion() : 0L) + 1;
         user.setTokenVersion(newVersion);
-        // 同时清空 Refresh Token,防止旧 Refresh Token 还能刷新
         user.setRefreshTokenHash(null);
         user.setRefreshTokenStatus(null);
         userMapper.updateById(user);
+        tokenVersionCache.putVersion(userId, false, newVersion);
         log.info("管理员{}强制踢出用户{},新tokenVersion={}", operatorId, userId, newVersion);
     }
 
+    /**
+     * 强制踢出管理员
+     */
+    public void kickAdmin(Long adminId, Long operatorId) {
+        Admin admin = adminMapper.selectById(adminId);
+        if (admin == null) {
+            throw new BusinessException(ErrorCode.USER_NOT_FOUND);
+        }
+        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);
+        log.info("管理员{}强制踢出管理员{},新tokenVersion={}", operatorId, adminId, newVersion);
+    }
+
     private AdminUserResponse toResponse(User user) {
         return AdminUserResponse.builder()
                 .id(user.getId())
                 .phone(user.getPhone())
                 .nickname(user.getNickname())
-                .role(user.getRole() != null ? user.getRole().getCode() : UserRole.USER.getCode())
+                .role("USER")
                 .status(user.getStatus())
                 .createTime(user.getCreateTime())
                 .build();

+ 8 - 5
zhijiayun-user/src/main/java/com/xuekairui/user/service/AuthService.java

@@ -38,6 +38,7 @@ public class AuthService {
     private final PasswordUtil passwordUtil;
     private final OperationAuditLogService auditLogService;
     private final MembershipService membershipService;
+    private final AdminAuthService adminAuthService;
     private final com.xuekairui.user.security.TokenVersionCache tokenVersionCache;
 
     /**
@@ -230,13 +231,13 @@ public class AuthService {
     public LoginResponse heartbeat(Long userId, String role) {
         String roleCode = role != null ? role : "USER";
         // 从缓存获取 tokenVersion(过滤器大概率已缓存),缓存 miss 时回退 DB
-        Long currentVersion = tokenVersionCache.getVersion(userId);
+        Long currentVersion = tokenVersionCache.getVersion(userId, false);
         if (currentVersion == null) {
             User user = userMapper.selectById(userId);
             currentVersion = user != null && user.getTokenVersion() != null
                     ? user.getTokenVersion() : 0L;
             if (user != null) {
-                tokenVersionCache.putVersion(userId, currentVersion);
+                tokenVersionCache.putVersion(userId, false, currentVersion);
             }
         }
 
@@ -314,7 +315,7 @@ public class AuthService {
             user.setRefreshTokenHash(null);
             user.setRefreshTokenStatus(null);
             userMapper.updateById(user);
-            tokenVersionCache.putVersion(userId, newVersion);
+            tokenVersionCache.putVersion(userId, false, newVersion);
             throw new BusinessException(ErrorCode.TOKEN_KICKED);
         }
 
@@ -382,7 +383,7 @@ public class AuthService {
         user.setRefreshTokenHash(JwtUtil.hashToken(refreshToken));
         user.setRefreshTokenStatus("ACTIVE");
         userMapper.updateById(user);
-        tokenVersionCache.putVersion(user.getId(), newVersion);
+        tokenVersionCache.putVersion(user.getId(), false, newVersion);
 
         return LoginResponse.builder()
                 .userId(user.getId())
@@ -468,7 +469,9 @@ public class AuthService {
                     new LambdaQueryWrapper<User>().eq(User::getNickname, request.getUsername()));
         }
         if (user == null) {
-            throw new BusinessException(ErrorCode.USER_NOT_FOUND);
+            return adminAuthService.passwordLogin(
+                    hasPhone ? request.getPhone() : request.getUsername(),
+                    request.getPassword(), ip);
         }
 
         // 2. 检查是否设置了密码

Неке датотеке нису приказане због велике количине промена