Przeglądaj źródła

feat: 拆分产品端登录接口和系统管理后台登录接口

BaiLuoYan 1 miesiąc temu
rodzic
commit
655bc2cb51

+ 23 - 5
README.md

@@ -11,6 +11,7 @@
 - **双协议** — 同时提供 HTTP REST API(管理 UI)和 gRPC(产品后端高性能调用)
 - **自动权限同步** — 产品启动时通过 API 自动上报权限列表,系统自动新增/更新/禁用
 - **JWT 本地验证** — 登录获取 JWT,产品后端可本地验证,无需每次请求回调权限系统
+- **登录端隔离** — 产品端(`/auth/login`)和管理后台(`/auth/adminLogin`)独立登录接口,超管仅能通过管理后台登录
 
 ## 系统架构
 
@@ -413,7 +414,9 @@ flowchart LR
 | 产品/部门/角色/用户/成员列表与详情 | 已登录即可 | — |
 | 用户信息 (userInfo) | 已登录即可 | 返回当前登录用户自己的信息 |
 | **公开接口** | | |
-| 登录 / 刷新令牌 / 同步权限 | 无需鉴权 | 同步权限通过 appKey/appSecret 认证 |
+| 产品端登录 (login) | 无需鉴权 | 超级管理员被拒绝,productCode 必传 |
+| 管理后台登录 (adminLogin) | 无需鉴权 | 需验证 managementKey |
+| 刷新令牌 / 同步权限 | 无需鉴权 | 同步权限通过 appKey/appSecret 认证 |
 
 ### CheckManageAccess — 用户管理权限检查
 
@@ -739,13 +742,15 @@ Content-Type: application/json
 
 ### 公开接口(无需鉴权)
 
-#### POST /api/auth/login — 用户登录
+#### POST /api/auth/login — 产品端登录
+
+**供产品后端调用**,超级管理员无法通过此接口登录。
 
 | 字段 | 类型 | 必填 | 说明 |
 | ------ | ------ | ------ | ------ |
 | username | string | 是 | 登录名 |
 | password | string | 是 | 密码 |
-| productCode | string | 否 | 产品编码,传入则返回该产品的权限列表 |
+| productCode | string | 是 | 产品编码 |
 
 **响应 data:**
 
@@ -756,6 +761,18 @@ Content-Type: application/json
 | expires | int64 | accessToken 过期时间(Unix 时间戳,秒) |
 | userInfo | object | 用户信息(含 perms 权限码数组) |
 
+#### POST /api/auth/adminLogin — 管理后台登录
+
+**仅供权限系统管理后台使用**,需要传入配置的 `managementKey` 进行身份验证。
+
+| 字段 | 类型 | 必填 | 说明 |
+| ------ | ------ | ------ | ------ |
+| username | string | 是 | 登录名 |
+| password | string | 是 | 密码 |
+| managementKey | string | 是 | 管理端密钥(配置文件中的 `Auth.ManagementKey`) |
+
+**响应 data:** 与产品端登录接口相同。登录后不携带产品上下文,token 中 `productCode` 和 `perms` 为空。
+
 #### POST /api/auth/refreshToken — 刷新令牌
 
 通过 `Authorization: Bearer {refreshToken}` 请求头传入 refresh token。
@@ -1062,7 +1079,7 @@ gRPC 服务定义见 `pb/perm.proto`,默认监听 `:10002`。
 | 方法 | 说明 | 使用场景 |
 | ------ | ------ | ---------- |
 | `SyncPermissions` | 同步产品权限列表 | 产品启动时调用 |
-| `Login` | 用户登录 | 产品后端代理用户登录 |
+| `Login` | 产品端登录 | 产品后端代理用户登录(productCode 必传,超管被拒绝) |
 | `RefreshToken` | 刷新令牌 | accessToken 过期续期 |
 | `VerifyToken` | 验证令牌 | 产品后端验证用户 token(可选,推荐本地 JWT 验证) |
 | `GetUserPerms` | 获取用户权限 | 实时查询用户最新权限 |
@@ -1169,8 +1186,9 @@ server/
 | `Auth.AccessExpire` | accessToken 有效期(秒) | `7200`(2h) |
 | `Auth.RefreshSecret` | JWT refreshToken 签名密钥 | — |
 | `Auth.RefreshExpire` | refreshToken 有效期(秒) | `604800`(7d) |
+| `Auth.ManagementKey` | 管理后台登录密钥(`/auth/adminLogin` 接口验证) | — |
 
-> **生产环境部署前,务必修改 `AccessSecret` 和 `RefreshSecret` 为安全的随机字符串,并确保产品后端的本地验证密钥与 `AccessSecret` 一致。**
+> **生产环境部署前,务必修改 `AccessSecret`、`RefreshSecret` 和 `ManagementKey` 为安全的随机字符串,并确保产品后端的本地验证密钥与 `AccessSecret` 一致。`ManagementKey` 仅管理后台前端持有,不可泄露给产品端。**
 
 ---
 

+ 1 - 0
etc/perm-api-dev.yaml

@@ -20,3 +20,4 @@ Auth:
   AccessExpire: 7200
   RefreshSecret: "f3a234543b30bfbc2e14225743830b62"
   RefreshExpire: 604800
+  ManagementKey: "c653e85ba6528542746eb46298db48db"

+ 1 - 0
etc/perm-api-prod.yaml

@@ -20,3 +20,4 @@ Auth:
   AccessExpire: 7200
   RefreshSecret: "dfe03caffcfc73be4a941a862dc59ae4"
   RefreshExpire: 604800
+  ManagementKey: "bec2b6b1df692610fb914ef2935bda88"

+ 1 - 0
etc/perm-api-test.yaml

@@ -20,3 +20,4 @@ Auth:
   AccessExpire: 7200
   RefreshSecret: "cbf9b9cfc6516a50e737f580d8e51310"
   RefreshExpire: 604800
+  ManagementKey: "1f63927367bd65aaca2bce0247e40b52"

+ 1 - 0
internal/config/config.go

@@ -26,5 +26,6 @@ type Config struct {
 		AccessExpire  int64
 		RefreshSecret string
 		RefreshExpire int64
+		ManagementKey string
 	}
 }

+ 32 - 0
internal/handler/pub/adminLoginHandler.go

@@ -0,0 +1,32 @@
+// Code scaffolded by goctl. Safe to edit.
+// goctl 1.10.0
+
+package pub
+
+import (
+	"net/http"
+
+	"github.com/zeromicro/go-zero/rest/httpx"
+	"perms-system-server/internal/logic/pub"
+	"perms-system-server/internal/response"
+	"perms-system-server/internal/svc"
+	"perms-system-server/internal/types"
+)
+
+func AdminLoginHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
+	return func(w http.ResponseWriter, r *http.Request) {
+		var req types.AdminLoginReq
+		if err := httpx.Parse(r, &req); err != nil {
+			httpx.ErrorCtx(r.Context(), w, response.ErrBadRequest(err.Error()))
+			return
+		}
+
+		l := pub.NewAdminLoginLogic(r.Context(), svcCtx)
+		resp, err := l.AdminLogin(&req)
+		if err != nil {
+			httpx.ErrorCtx(r.Context(), w, err)
+		} else {
+			httpx.OkJsonCtx(r.Context(), w, resp)
+		}
+	}
+}

+ 33 - 0
internal/handler/pub/adminLoginHandler_test.go

@@ -0,0 +1,33 @@
+package pub
+
+import (
+	"encoding/json"
+	"net/http"
+	"net/http/httptest"
+	"strings"
+	"testing"
+
+	"perms-system-server/internal/response"
+	"perms-system-server/internal/svc"
+	"perms-system-server/internal/testutil"
+
+	"github.com/stretchr/testify/assert"
+	"github.com/stretchr/testify/require"
+)
+
+// TC-0508: 缺少必填字段(adminLogin)
+func TestAdminLoginHandler_MissingFields(t *testing.T) {
+	svcCtx := svc.NewServiceContext(testutil.GetTestConfig())
+	handler := AdminLoginHandler(svcCtx)
+
+	req := httptest.NewRequest(http.MethodPost, "/api/auth/adminLogin", strings.NewReader("{}"))
+	req.Header.Set("Content-Type", "application/json")
+	rr := httptest.NewRecorder()
+	handler.ServeHTTP(rr, req)
+
+	var body response.Body
+	err := json.Unmarshal(rr.Body.Bytes(), &body)
+	require.NoError(t, err)
+	assert.Equal(t, 400, body.Code)
+	assert.Contains(t, body.Msg, "username")
+}

+ 5 - 0
internal/handler/routes.go

@@ -142,6 +142,11 @@ func RegisterHandlers(server *rest.Server, serverCtx *svc.ServiceContext) {
 
 	server.AddRoutes(
 		[]rest.Route{
+			{
+				Method:  http.MethodPost,
+				Path:    "/auth/adminLogin",
+				Handler: pub.AdminLoginHandler(serverCtx),
+			},
 			{
 				Method:  http.MethodPost,
 				Path:    "/auth/login",

+ 90 - 0
internal/logic/pub/adminLoginLogic.go

@@ -0,0 +1,90 @@
+package pub
+
+import (
+	"context"
+	"time"
+
+	"perms-system-server/internal/consts"
+	authHelper "perms-system-server/internal/logic/auth"
+	"perms-system-server/internal/model/user"
+	"perms-system-server/internal/response"
+	"perms-system-server/internal/svc"
+	"perms-system-server/internal/types"
+
+	"github.com/zeromicro/go-zero/core/logx"
+	"golang.org/x/crypto/bcrypt"
+)
+
+type AdminLoginLogic struct {
+	logx.Logger
+	ctx    context.Context
+	svcCtx *svc.ServiceContext
+}
+
+func NewAdminLoginLogic(ctx context.Context, svcCtx *svc.ServiceContext) *AdminLoginLogic {
+	return &AdminLoginLogic{
+		Logger: logx.WithContext(ctx),
+		ctx:    ctx,
+		svcCtx: svcCtx,
+	}
+}
+
+func (l *AdminLoginLogic) AdminLogin(req *types.AdminLoginReq) (resp *types.LoginResp, err error) {
+	if req.ManagementKey != l.svcCtx.Config.Auth.ManagementKey {
+		return nil, response.ErrUnauthorized("managementKey无效")
+	}
+
+	u, err := l.svcCtx.SysUserModel.FindOneByUsername(l.ctx, req.Username)
+	if err != nil {
+		if err == user.ErrNotFound {
+			return nil, response.ErrUnauthorized("用户名或密码错误")
+		}
+		return nil, err
+	}
+
+	if u.Status != consts.StatusEnabled {
+		return nil, response.ErrForbidden("账号已被冻结")
+	}
+
+	if err := bcrypt.CompareHashAndPassword([]byte(u.Password), []byte(req.Password)); err != nil {
+		return nil, response.ErrUnauthorized("用户名或密码错误")
+	}
+
+	ud := l.svcCtx.UserDetailsLoader.Load(l.ctx, u.Id, "")
+
+	accessToken, err := authHelper.GenerateAccessToken(
+		l.svcCtx.Config.Auth.AccessSecret,
+		l.svcCtx.Config.Auth.AccessExpire,
+		ud.UserId, ud.Username, ud.ProductCode, ud.MemberType, ud.Perms,
+	)
+	if err != nil {
+		return nil, err
+	}
+
+	refreshToken, err := authHelper.GenerateRefreshToken(
+		l.svcCtx.Config.Auth.RefreshSecret,
+		l.svcCtx.Config.Auth.RefreshExpire,
+		ud.UserId, ud.ProductCode,
+	)
+	if err != nil {
+		return nil, err
+	}
+
+	return &types.LoginResp{
+		AccessToken:  accessToken,
+		RefreshToken: refreshToken,
+		Expires:      time.Now().Unix() + l.svcCtx.Config.Auth.AccessExpire,
+		UserInfo: types.UserInfo{
+			UserId:             ud.UserId,
+			Username:           ud.Username,
+			Nickname:           ud.Nickname,
+			Avatar:             ud.Avatar,
+			Email:              ud.Email,
+			Phone:              ud.Phone,
+			IsSuperAdmin:       ud.IsSuperAdminRaw,
+			MustChangePassword: ud.MustChangePwdRaw,
+			MemberType:         ud.MemberType,
+			Perms:              ud.Perms,
+		},
+	}, nil
+}

+ 219 - 0
internal/logic/pub/adminLoginLogic_test.go

@@ -0,0 +1,219 @@
+package pub
+
+import (
+	"context"
+	"errors"
+	"testing"
+	"time"
+
+	"perms-system-server/internal/response"
+	"perms-system-server/internal/testutil"
+	"perms-system-server/internal/types"
+
+	"github.com/stretchr/testify/assert"
+	"github.com/stretchr/testify/require"
+)
+
+// TC-0500: 超管正常登录(管理后台)
+func TestAdminLogin_SuperAdmin(t *testing.T) {
+	ctx := context.Background()
+	svcCtx := newTestSvcCtx()
+	username := testutil.UniqueId()
+	password := "TestPass123"
+
+	_, cleanUser := insertTestUser(t, ctx, svcCtx, username, password, 1, 1)
+	t.Cleanup(cleanUser)
+
+	logic := NewAdminLoginLogic(ctx, svcCtx)
+	resp, err := logic.AdminLogin(&types.AdminLoginReq{
+		Username:      username,
+		Password:      password,
+		ManagementKey: svcCtx.Config.Auth.ManagementKey,
+	})
+	require.NoError(t, err)
+	require.NotNil(t, resp)
+	assert.NotEmpty(t, resp.AccessToken)
+	assert.NotEmpty(t, resp.RefreshToken)
+	assert.True(t, resp.Expires > time.Now().Unix(), "expires应为未来的unix时间戳")
+	assert.Equal(t, username, resp.UserInfo.Username)
+	assert.Equal(t, int64(1), resp.UserInfo.IsSuperAdmin)
+	assert.Nil(t, resp.UserInfo.Perms)
+	assert.Equal(t, "SUPER_ADMIN", resp.UserInfo.MemberType)
+}
+
+// TC-0501: 普通用户正常登录(管理后台)
+func TestAdminLogin_NormalUser(t *testing.T) {
+	ctx := context.Background()
+	svcCtx := newTestSvcCtx()
+	username := testutil.UniqueId()
+	password := "TestPass123"
+
+	_, cleanUser := insertTestUser(t, ctx, svcCtx, username, password, 1, 2)
+	t.Cleanup(cleanUser)
+
+	logic := NewAdminLoginLogic(ctx, svcCtx)
+	resp, err := logic.AdminLogin(&types.AdminLoginReq{
+		Username:      username,
+		Password:      password,
+		ManagementKey: svcCtx.Config.Auth.ManagementKey,
+	})
+	require.NoError(t, err)
+	require.NotNil(t, resp)
+	assert.NotEmpty(t, resp.AccessToken)
+	assert.NotEmpty(t, resp.RefreshToken)
+	assert.True(t, resp.Expires > time.Now().Unix(), "expires应为未来的unix时间戳")
+	assert.Equal(t, username, resp.UserInfo.Username)
+	assert.Nil(t, resp.UserInfo.Perms)
+	assert.Empty(t, resp.UserInfo.MemberType)
+}
+
+// TC-0502: managementKey无效
+func TestAdminLogin_InvalidManagementKey(t *testing.T) {
+	ctx := context.Background()
+	svcCtx := newTestSvcCtx()
+
+	logic := NewAdminLoginLogic(ctx, svcCtx)
+	resp, err := logic.AdminLogin(&types.AdminLoginReq{
+		Username:      "anyone",
+		Password:      "pass",
+		ManagementKey: "wrong-key",
+	})
+	require.Nil(t, resp)
+	require.Error(t, err)
+
+	var codeErr *response.CodeError
+	require.True(t, errors.As(err, &codeErr))
+	assert.Equal(t, 401, codeErr.Code())
+	assert.Equal(t, "managementKey无效", codeErr.Error())
+}
+
+// TC-0503: managementKey为空
+func TestAdminLogin_EmptyManagementKey(t *testing.T) {
+	ctx := context.Background()
+	svcCtx := newTestSvcCtx()
+
+	logic := NewAdminLoginLogic(ctx, svcCtx)
+	resp, err := logic.AdminLogin(&types.AdminLoginReq{
+		Username:      "anyone",
+		Password:      "pass",
+		ManagementKey: "",
+	})
+	require.Nil(t, resp)
+	require.Error(t, err)
+
+	var codeErr *response.CodeError
+	require.True(t, errors.As(err, &codeErr))
+	assert.Equal(t, 401, codeErr.Code())
+	assert.Equal(t, "managementKey无效", codeErr.Error())
+}
+
+// TC-0504: 用户不存在
+func TestAdminLogin_UserNotFound(t *testing.T) {
+	ctx := context.Background()
+	svcCtx := newTestSvcCtx()
+
+	logic := NewAdminLoginLogic(ctx, svcCtx)
+	resp, err := logic.AdminLogin(&types.AdminLoginReq{
+		Username:      "nonexistent_" + testutil.UniqueId(),
+		Password:      "whatever",
+		ManagementKey: svcCtx.Config.Auth.ManagementKey,
+	})
+	require.Nil(t, resp)
+	require.Error(t, err)
+
+	var codeErr *response.CodeError
+	require.True(t, errors.As(err, &codeErr))
+	assert.Equal(t, 401, codeErr.Code())
+	assert.Equal(t, "用户名或密码错误", codeErr.Error())
+}
+
+// TC-0505: 密码错误
+func TestAdminLogin_WrongPassword(t *testing.T) {
+	ctx := context.Background()
+	svcCtx := newTestSvcCtx()
+	username := testutil.UniqueId()
+
+	_, cleanUser := insertTestUser(t, ctx, svcCtx, username, "CorrectPass", 1, 2)
+	t.Cleanup(cleanUser)
+
+	logic := NewAdminLoginLogic(ctx, svcCtx)
+	resp, err := logic.AdminLogin(&types.AdminLoginReq{
+		Username:      username,
+		Password:      "WrongPass",
+		ManagementKey: svcCtx.Config.Auth.ManagementKey,
+	})
+	require.Nil(t, resp)
+	require.Error(t, err)
+
+	var codeErr *response.CodeError
+	require.True(t, errors.As(err, &codeErr))
+	assert.Equal(t, 401, codeErr.Code())
+	assert.Equal(t, "用户名或密码错误", codeErr.Error())
+}
+
+// TC-0506: 账号冻结
+func TestAdminLogin_AccountFrozen(t *testing.T) {
+	ctx := context.Background()
+	svcCtx := newTestSvcCtx()
+	username := testutil.UniqueId()
+	password := "TestPass123"
+
+	_, cleanUser := insertTestUser(t, ctx, svcCtx, username, password, 2, 2)
+	t.Cleanup(cleanUser)
+
+	logic := NewAdminLoginLogic(ctx, svcCtx)
+	resp, err := logic.AdminLogin(&types.AdminLoginReq{
+		Username:      username,
+		Password:      password,
+		ManagementKey: svcCtx.Config.Auth.ManagementKey,
+	})
+	require.Nil(t, resp)
+	require.Error(t, err)
+
+	var codeErr *response.CodeError
+	require.True(t, errors.As(err, &codeErr))
+	assert.Equal(t, 403, codeErr.Code())
+	assert.Equal(t, "账号已被冻结", codeErr.Error())
+}
+
+// TC-0507: 不带productCode时token无权限(perms为空)
+func TestAdminLogin_NoPermsWithoutProductCode(t *testing.T) {
+	ctx := context.Background()
+	svcCtx := newTestSvcCtx()
+	username := testutil.UniqueId()
+	password := "TestPass123"
+
+	_, cleanUser := insertTestUser(t, ctx, svcCtx, username, password, 1, 1)
+	t.Cleanup(cleanUser)
+
+	logic := NewAdminLoginLogic(ctx, svcCtx)
+	resp, err := logic.AdminLogin(&types.AdminLoginReq{
+		Username:      username,
+		Password:      password,
+		ManagementKey: svcCtx.Config.Auth.ManagementKey,
+	})
+	require.NoError(t, err)
+	require.NotNil(t, resp)
+	assert.Nil(t, resp.UserInfo.Perms, "管理后台不传productCode,不应加载权限列表")
+	assert.Equal(t, "SUPER_ADMIN", resp.UserInfo.MemberType, "超管即使不传productCode也会被标记SUPER_ADMIN")
+}
+
+// TC-0509: SQL注入username
+func TestAdminLogin_SQLInjection(t *testing.T) {
+	ctx := context.Background()
+	svcCtx := newTestSvcCtx()
+
+	logic := NewAdminLoginLogic(ctx, svcCtx)
+	resp, err := logic.AdminLogin(&types.AdminLoginReq{
+		Username:      "' OR 1=1 --",
+		Password:      "anything",
+		ManagementKey: svcCtx.Config.Auth.ManagementKey,
+	})
+	require.Nil(t, resp)
+	require.Error(t, err)
+
+	var codeErr *response.CodeError
+	require.True(t, errors.As(err, &codeErr))
+	assert.Equal(t, 401, codeErr.Code())
+	assert.Equal(t, "用户名或密码错误", codeErr.Error())
+}

+ 4 - 0
internal/logic/pub/loginLogic.go

@@ -46,6 +46,10 @@ func (l *LoginLogic) Login(req *types.LoginReq) (resp *types.LoginResp, err erro
 		return nil, response.ErrUnauthorized("用户名或密码错误")
 	}
 
+	if u.IsSuperAdmin == consts.IsSuperAdminYes {
+		return nil, response.ErrForbidden("超级管理员不允许通过产品端登录,请使用管理后台")
+	}
+
 	ud := l.svcCtx.UserDetailsLoader.Load(l.ctx, u.Id, req.ProductCode)
 
 	accessToken, err := authHelper.GenerateAccessToken(

+ 27 - 27
internal/logic/pub/loginLogic_test.go

@@ -73,20 +73,25 @@ func insertTestProduct(t *testing.T, ctx context.Context, svcCtx *svc.ServiceCon
 	return id, cleanup
 }
 
-// TC-0001: 正常登录-不带productCode
-func TestLogin_NormalWithoutProductCode(t *testing.T) {
+// TC-0001: 正常登录(普通用户+productCode)
+func TestLogin_NormalWithProductCodeBasic(t *testing.T) {
 	ctx := context.Background()
 	svcCtx := newTestSvcCtx()
 	username := testutil.UniqueId()
 	password := "TestPass123"
+	pc := testutil.UniqueId()
 
 	_, cleanUser := insertTestUser(t, ctx, svcCtx, username, password, 1, 2)
 	t.Cleanup(cleanUser)
 
+	_, cleanProduct := insertTestProduct(t, ctx, svcCtx, pc, testutil.UniqueId(), "secret")
+	t.Cleanup(cleanProduct)
+
 	logic := NewLoginLogic(ctx, svcCtx)
 	resp, err := logic.Login(&types.LoginReq{
-		Username: username,
-		Password: password,
+		Username:    username,
+		Password:    password,
+		ProductCode: pc,
 	})
 	require.NoError(t, err)
 	require.NotNil(t, resp)
@@ -140,15 +145,13 @@ func TestLogin_NormalWithProductCode(t *testing.T) {
 	assert.NotEmpty(t, resp.UserInfo.Perms)
 }
 
-// TC-0003: 超管登录+productCode
-func TestLogin_SuperAdminWithProductCode(t *testing.T) {
+// TC-0003: 超管通过产品端登录被拒绝
+func TestLogin_SuperAdminRejected(t *testing.T) {
 	ctx := context.Background()
 	svcCtx := newTestSvcCtx()
-	conn := testutil.GetTestSqlConn()
 	username := testutil.UniqueId()
 	password := "TestPass123"
 	pc := testutil.UniqueId()
-	now := time.Now().Unix()
 
 	_, cleanUser := insertTestUser(t, ctx, svcCtx, username, password, 1, 1)
 	t.Cleanup(cleanUser)
@@ -156,29 +159,23 @@ func TestLogin_SuperAdminWithProductCode(t *testing.T) {
 	_, cleanProduct := insertTestProduct(t, ctx, svcCtx, pc, testutil.UniqueId(), "secret")
 	t.Cleanup(cleanProduct)
 
-	permCode := testutil.UniqueId()
-	permRes, err := svcCtx.SysPermModel.Insert(ctx, &permModel.SysPerm{
-		ProductCode: pc, Name: "sa_perm", Code: permCode, Status: 1, CreateTime: now, UpdateTime: now,
-	})
-	require.NoError(t, err)
-	permId, _ := permRes.LastInsertId()
-	t.Cleanup(func() { testutil.CleanTable(ctx, conn, "`sys_perm`", permId) })
-
 	logic := NewLoginLogic(ctx, svcCtx)
 	resp, err := logic.Login(&types.LoginReq{
 		Username:    username,
 		Password:    password,
 		ProductCode: pc,
 	})
-	require.NoError(t, err)
-	require.NotNil(t, resp)
-	assert.Equal(t, "SUPER_ADMIN", resp.UserInfo.MemberType)
-	assert.Contains(t, resp.UserInfo.Perms, permCode)
-	assert.Equal(t, int64(1), resp.UserInfo.IsSuperAdmin)
+	require.Nil(t, resp)
+	require.Error(t, err)
+
+	var codeErr *response.CodeError
+	require.True(t, errors.As(err, &codeErr))
+	assert.Equal(t, 403, codeErr.Code())
+	assert.Equal(t, "超级管理员不允许通过产品端登录,请使用管理后台", codeErr.Error())
 }
 
-// TC-0004: 超管登录-无productCode
-func TestLogin_SuperAdminWithoutProductCode(t *testing.T) {
+// TC-0004: 超管无productCode被拒绝
+func TestLogin_SuperAdminWithoutProductCodeRejected(t *testing.T) {
 	ctx := context.Background()
 	svcCtx := newTestSvcCtx()
 	username := testutil.UniqueId()
@@ -192,10 +189,13 @@ func TestLogin_SuperAdminWithoutProductCode(t *testing.T) {
 		Username: username,
 		Password: password,
 	})
-	require.NoError(t, err)
-	require.NotNil(t, resp)
-	assert.Equal(t, "SUPER_ADMIN", resp.UserInfo.MemberType)
-	assert.Nil(t, resp.UserInfo.Perms)
+	require.Nil(t, resp)
+	require.Error(t, err)
+
+	var codeErr *response.CodeError
+	require.True(t, errors.As(err, &codeErr))
+	assert.Equal(t, 403, codeErr.Code())
+	assert.Equal(t, "超级管理员不允许通过产品端登录,请使用管理后台", codeErr.Error())
 }
 
 // TC-0005: 用户不存在

+ 8 - 0
internal/server/permserver.go

@@ -100,6 +100,10 @@ func (s *PermServer) SyncPermissions(ctx context.Context, req *pb.SyncPermission
 }
 
 func (s *PermServer) Login(ctx context.Context, req *pb.LoginReq) (*pb.LoginResp, error) {
+	if req.ProductCode == "" {
+		return nil, status.Error(codes.InvalidArgument, "productCode不能为空")
+	}
+
 	user, err := s.svcCtx.SysUserModel.FindOneByUsername(ctx, req.Username)
 	if err != nil {
 		return nil, status.Error(codes.Unauthenticated, "用户名或密码错误")
@@ -111,6 +115,10 @@ func (s *PermServer) Login(ctx context.Context, req *pb.LoginReq) (*pb.LoginResp
 		return nil, status.Error(codes.Unauthenticated, "用户名或密码错误")
 	}
 
+	if user.IsSuperAdmin == consts.IsSuperAdminYes {
+		return nil, status.Error(codes.PermissionDenied, "超级管理员不允许通过产品端登录")
+	}
+
 	ud := s.svcCtx.UserDetailsLoader.Load(ctx, user.Id, req.ProductCode)
 
 	accessToken, err := authHelper.GenerateAccessToken(

+ 44 - 34
internal/server/permserver_test.go

@@ -154,7 +154,7 @@ func TestSyncPermissions_ProductDisabled(t *testing.T) {
 
 // ---------- Login ----------
 
-// TC-0166: 正常登录(productCode)
+// TC-0166: 正常登录(普通用户+productCode)
 func TestLogin_Normal(t *testing.T) {
 	ctx := context.Background()
 	svcCtx := svc.NewServiceContext(testutil.GetTestConfig())
@@ -170,14 +170,23 @@ func TestLogin_Normal(t *testing.T) {
 	require.NoError(t, err)
 	uId, _ := uRes.LastInsertId()
 
+	pRes, err := svcCtx.SysProductModel.Insert(ctx, &productModel.SysProduct{
+		Code: uid, Name: "test_prod", AppKey: uid + "_k", AppSecret: "s1",
+		Status: 1, CreateTime: now, UpdateTime: now,
+	})
+	require.NoError(t, err)
+	pId, _ := pRes.LastInsertId()
+
 	t.Cleanup(func() {
+		testutil.CleanTable(ctx, conn, "`sys_product`", pId)
 		testutil.CleanTable(ctx, conn, "`sys_user`", uId)
 	})
 
 	srv := NewPermServer(svcCtx)
 	resp, err := srv.Login(ctx, &pb.LoginReq{
-		Username: uid,
-		Password: "pass123",
+		Username:    uid,
+		Password:    "pass123",
+		ProductCode: uid,
 	})
 	require.NoError(t, err)
 	assert.NotEmpty(t, resp.AccessToken)
@@ -194,8 +203,9 @@ func TestLogin_UserNotFound(t *testing.T) {
 	srv := NewPermServer(svcCtx)
 
 	_, err := srv.Login(ctx, &pb.LoginReq{
-		Username: "nonexistent_user_xyz",
-		Password: "any",
+		Username:    "nonexistent_user_xyz",
+		Password:    "any",
+		ProductCode: "any_product",
 	})
 	require.Error(t, err)
 	assert.Equal(t, codes.Unauthenticated, status.Code(err))
@@ -224,8 +234,9 @@ func TestLogin_WrongPassword(t *testing.T) {
 
 	srv := NewPermServer(svcCtx)
 	_, err = srv.Login(ctx, &pb.LoginReq{
-		Username: uid,
-		Password: "wrong_pass",
+		Username:    uid,
+		Password:    "wrong_pass",
+		ProductCode: "any_product",
 	})
 	require.Error(t, err)
 	assert.Equal(t, codes.Unauthenticated, status.Code(err))
@@ -254,16 +265,17 @@ func TestLogin_AccountFrozen(t *testing.T) {
 
 	srv := NewPermServer(svcCtx)
 	_, err = srv.Login(ctx, &pb.LoginReq{
-		Username: uid,
-		Password: "pass123",
+		Username:    uid,
+		Password:    "pass123",
+		ProductCode: "any_product",
 	})
 	require.Error(t, err)
 	assert.Equal(t, codes.PermissionDenied, status.Code(err))
 	assert.Equal(t, "账号已被冻结", status.Convert(err).Message())
 }
 
-// TC-0170: 超管+productCode
-func TestLogin_SuperAdminWithProductCode(t *testing.T) {
+// TC-0170: 超管被拒绝
+func TestLogin_SuperAdminRejected(t *testing.T) {
 	ctx := context.Background()
 	svcCtx := svc.NewServiceContext(testutil.GetTestConfig())
 	conn := testutil.GetTestSqlConn()
@@ -278,37 +290,19 @@ func TestLogin_SuperAdminWithProductCode(t *testing.T) {
 	require.NoError(t, err)
 	uId, _ := uRes.LastInsertId()
 
-	pRes, err := svcCtx.SysProductModel.Insert(ctx, &productModel.SysProduct{
-		Code: uid, Name: "test_prod", AppKey: uid + "_k", AppSecret: "s1",
-		Status: 1, CreateTime: now, UpdateTime: now,
-	})
-	require.NoError(t, err)
-	pId, _ := pRes.LastInsertId()
-
-	pm1Res, err := svcCtx.SysPermModel.Insert(ctx, &permModel.SysPerm{
-		ProductCode: uid, Name: "p1", Code: uid + "_c1",
-		Status: 1, CreateTime: now, UpdateTime: now,
-	})
-	require.NoError(t, err)
-	pm1Id, _ := pm1Res.LastInsertId()
-
 	t.Cleanup(func() {
-		testutil.CleanTable(ctx, conn, "`sys_perm`", pm1Id)
-		testutil.CleanTable(ctx, conn, "`sys_product`", pId)
 		testutil.CleanTable(ctx, conn, "`sys_user`", uId)
 	})
 
 	srv := NewPermServer(svcCtx)
-	resp, err := srv.Login(ctx, &pb.LoginReq{
+	_, err = srv.Login(ctx, &pb.LoginReq{
 		Username:    uid,
 		Password:    "pass123",
-		ProductCode: uid,
+		ProductCode: "any_product",
 	})
-	require.NoError(t, err)
-	assert.Equal(t, "SUPER_ADMIN", resp.MemberType)
-	assert.Contains(t, resp.Perms, uid+"_c1")
-	assert.NotEmpty(t, resp.AccessToken)
-	assert.NotEmpty(t, resp.RefreshToken)
+	require.Error(t, err)
+	assert.Equal(t, codes.PermissionDenied, status.Code(err))
+	assert.Equal(t, "超级管理员不允许通过产品端登录", status.Convert(err).Message())
 }
 
 // TC-0171: 普通用户+productCode
@@ -390,6 +384,22 @@ func TestLogin_NormalUserWithProductCode(t *testing.T) {
 	assert.NotEmpty(t, resp.RefreshToken)
 }
 
+// TC-0510: productCode为空
+func TestLogin_EmptyProductCode(t *testing.T) {
+	ctx := context.Background()
+	svcCtx := svc.NewServiceContext(testutil.GetTestConfig())
+	srv := NewPermServer(svcCtx)
+
+	_, err := srv.Login(ctx, &pb.LoginReq{
+		Username:    "anyuser",
+		Password:    "anypass",
+		ProductCode: "",
+	})
+	require.Error(t, err)
+	assert.Equal(t, codes.InvalidArgument, status.Code(err))
+	assert.Equal(t, "productCode不能为空", status.Convert(err).Message())
+}
+
 // ---------- RefreshToken ----------
 
 // TC-0172: 正常刷新(refreshToken原样返回,不重新生成)

+ 2 - 0
internal/testutil/testutil.go

@@ -36,11 +36,13 @@ var testConfig = config.Config{
 		AccessExpire  int64
 		RefreshSecret string
 		RefreshExpire int64
+		ManagementKey string
 	}{
 		AccessSecret:  "test-access-secret",
 		AccessExpire:  7200,
 		RefreshSecret: "test-refresh-secret",
 		RefreshExpire: 604800,
+		ManagementKey: "test-management-key",
 	},
 }
 

+ 7 - 1
internal/types/types.go

@@ -9,6 +9,12 @@ type AddMemberReq struct {
 	MemberType  string `json:"memberType"`
 }
 
+type AdminLoginReq struct {
+	Username      string `json:"username"`
+	Password      string `json:"password"`
+	ManagementKey string `json:"managementKey"`
+}
+
 type BindPermsReq struct {
 	RoleId  int64   `json:"roleId"`
 	PermIds []int64 `json:"permIds"`
@@ -92,7 +98,7 @@ type IdResp struct {
 type LoginReq struct {
 	Username    string `json:"username"`
 	Password    string `json:"password"`
-	ProductCode string `json:"productCode,optional"`
+	ProductCode string `json:"productCode"`
 }
 
 type LoginResp struct {

+ 9 - 1
perm.api

@@ -17,7 +17,12 @@ type (
 	LoginReq {
 		Username    string `json:"username"`
 		Password    string `json:"password"`
-		ProductCode string `json:"productCode,optional"`
+		ProductCode string `json:"productCode"`
+	}
+	AdminLoginReq {
+		Username      string `json:"username"`
+		Password      string `json:"password"`
+		ManagementKey string `json:"managementKey"`
 	}
 	LoginResp {
 		AccessToken  string   `json:"accessToken"`
@@ -303,6 +308,9 @@ service perm-api {
 	@handler Login
 	post /auth/login (LoginReq) returns (LoginResp)
 
+	@handler AdminLogin
+	post /auth/adminLogin (AdminLoginReq) returns (LoginResp)
+
 	@handler RefreshToken
 	post /auth/refreshToken (RefreshTokenReq) returns (LoginResp)
 

+ 25 - 13
test-design.md

@@ -1,6 +1,6 @@
 # 权限管理系统 (perms-system-server) — 全路径覆盖测试设计
 
-> 测试设计范围: API (go-zero REST, 全 POST) + gRPC (status codes) + Model 层 (_gen.go 模板生成 + 自定义方法) + Logic 单元测试 + util 层
+> 测试范围: API (go-zero REST, 全 POST) + gRPC (status codes) + Model 层 (_gen.go 模板生成 + 自定义方法) + Logic 单元测试 + util 层 + 访问控制 + UserDetailsLoader
 > 测试报告与代码审计详见 [test-report.md](./test-report.md)
 
 ---
@@ -66,14 +66,14 @@ MySQL (InnoDB) + Redis Cache
 
 > **注意**: 所有路由统一为 POST 方法,请求参数均通过 JSON Body 传递。
 
-### 2.1 登录 `POST /api/auth/login`
+### 2.1 产品端登录 `POST /api/auth/login`
 
 | TC编号 | 接口/方法 | 测试场景 | 输入参数 (JSON) | 预期结果 | 测试类型 | 优先级 | 覆盖说明 |
 | :--- | :--- | :--- | :--- | :--- | :--- | :--- | :--- |
-| TC-0001 | POST /api/auth/login | 正常登录-不带productCode | `{"username":"admin","password":"123456"}` | code=0, accessToken/refreshToken/userInfo | 正常路径 | P0 | loginLogic全路径 |
-| TC-0002 | POST /api/auth/login | 正常登录-带productCode | `{"username":"user1","password":"123456","productCode":"test"}` | code=0, perms含用户可用权限 | 正常路径 | P0 | GetUserPerms(false) MEMBER分支 |
-| TC-0003 | POST /api/auth/login | 超管登录+productCode | `{"username":"super","password":"x","productCode":"p1"}` | memberType="SUPER_ADMIN", perms全量 | 分支覆盖 | P0 | GetUserPerms(true) |
-| TC-0004 | POST /api/auth/login | 超管登录-无productCode | `{"username":"super","password":"x"}` | memberType="SUPER_ADMIN", perms空 | 分支覆盖 | P1 | productCode=""不进入 |
+| TC-0001 | POST /api/auth/login | 正常登录(普通用户+productCode) | `{"username":"user1","password":"123456","productCode":"test"}` | code=0, accessToken/refreshToken/userInfo | 正常路径 | P0 | loginLogic全路径 |
+| TC-0002 | POST /api/auth/login | 正常登录-带productCode+ADMIN成员 | `{"username":"user1","password":"123456","productCode":"test"}` | code=0, perms含用户可用权限, memberType="ADMIN" | 正常路径 | P0 | GetUserPerms(false) MEMBER分支 |
+| TC-0003 | POST /api/auth/login | 超管通过产品端登录被拒绝 | `{"username":"super","password":"x","productCode":"p1"}` | code=403, "超级管理员不允许通过产品端登录,请使用管理后台" | 安全 | P0 | IsSuperAdmin==1 → ErrForbidden |
+| TC-0004 | POST /api/auth/login | 超管无productCode被拒绝 | `{"username":"super","password":"x"}` | code=403, "超级管理员不允许通过产品端登录,请使用管理后台" | 安全 | P0 | IsSuperAdmin==1 → ErrForbidden |
 | TC-0005 | POST /api/auth/login | 用户不存在 | `{"username":"notexist","password":"x"}` | code=401, "用户名或密码错误" | 异常路径 | P0 | ErrNotFound分支 |
 | TC-0006 | POST /api/auth/login | DB异常(非ErrNotFound) | FindOneByUsername连接失败 | code=500, "服务器内部错误" | 异常路径 | P1 | 透传err→Setup兜底 |
 | TC-0007 | POST /api/auth/login | 密码错误 | `{"username":"admin","password":"wrong"}` | code=401 | 异常路径 | P0 | bcrypt比对失败 |
@@ -81,7 +81,22 @@ MySQL (InnoDB) + Redis Cache
 | TC-0009 | POST /api/auth/login | 非产品成员 | productCode指向用户不属于的产品 | code=0, perms=[], memberType="" | 分支覆盖 | P1 | ErrNotFound→nil |
 | TC-0010 | POST /api/auth/login | DEVELOPER成员 | DEVELOPER类型成员 | perms全量, memberType="DEVELOPER" | 分支覆盖 | P1 | perms.go DEVELOPER分支 |
 | TC-0011 | POST /api/auth/login | SQL注入 | `{"username":"' OR 1=1 --","password":"x"}` | code=401 | 安全 | P0 | 参数化查询 |
-| TC-0012 | POST /api/auth/login | 缺少必填字段 | `{}` | HTTP 400 | 边界 | P1 | httpx.Parse校验 |
+| TC-0012 | POST /api/auth/login | 缺少必填字段 | `{}` | HTTP 400 | 边界 | P1 | httpx.Parse校验(productCode现为必填) |
+
+### 2.1b 管理后台登录 `POST /api/auth/adminLogin`
+
+| TC编号 | 接口/方法 | 测试场景 | 输入参数 (JSON) | 预期结果 | 测试类型 | 优先级 | 覆盖说明 |
+| :--- | :--- | :--- | :--- | :--- | :--- | :--- | :--- |
+| TC-0500 | POST /api/auth/adminLogin | 超管正常登录 | `{"username":"super","password":"x","managementKey":"valid"}` | code=0, accessToken/refreshToken/userInfo, isSuperAdmin=1, memberType="SUPER_ADMIN", perms为空 | 正常路径 | P0 | adminLoginLogic全路径 |
+| TC-0501 | POST /api/auth/adminLogin | 普通用户正常登录 | `{"username":"user1","password":"x","managementKey":"valid"}` | code=0, accessToken/refreshToken, perms为空 | 正常路径 | P0 | 非超管也可通过管理后台 |
+| TC-0502 | POST /api/auth/adminLogin | managementKey无效 | `{"username":"user1","password":"x","managementKey":"wrong"}` | code=401, "managementKey无效" | 安全 | P0 | 第一个校验点 |
+| TC-0503 | POST /api/auth/adminLogin | managementKey为空 | `{"username":"user1","password":"x","managementKey":""}` | code=401, "managementKey无效" | 安全 | P0 | 空字符串≠config值 |
+| TC-0504 | POST /api/auth/adminLogin | 用户不存在 | `{"username":"notexist","password":"x","managementKey":"valid"}` | code=401, "用户名或密码错误" | 异常路径 | P0 | ErrNotFound分支 |
+| TC-0505 | POST /api/auth/adminLogin | 密码错误 | `{"username":"user1","password":"wrong","managementKey":"valid"}` | code=401, "用户名或密码错误" | 异常路径 | P0 | bcrypt比对失败 |
+| TC-0506 | POST /api/auth/adminLogin | 账号冻结 | status=2用户 | code=403, "账号已被冻结" | 分支覆盖 | P0 | u.Status!=1 |
+| TC-0507 | POST /api/auth/adminLogin | 不带productCode时perms为空 | 管理后台登录超管 | userInfo.perms为空, memberType="SUPER_ADMIN"(超管标记由Loader自动填充) | 功能验证 | P0 | Load(ctx, uid, "") |
+| TC-0508 | POST /api/auth/adminLogin | 缺少必填字段 | `{}` | HTTP 400 | 边界 | P1 | httpx.Parse校验 |
+| TC-0509 | POST /api/auth/adminLogin | SQL注入username | `{"username":"' OR 1=1 --","password":"x","managementKey":"valid"}` | code=401 | 安全 | P0 | 参数化查询 |
 
 ### 2.2 刷新Token `POST /api/auth/refreshToken`
 
@@ -329,12 +344,13 @@ MySQL (InnoDB) + Redis Cache
 
 | TC编号 | 接口/方法 | 测试场景 | 输入 | 预期结果 | 测试类型 | 优先级 | 覆盖说明 |
 | :--- | :--- | :--- | :--- | :--- | :--- | :--- | :--- |
-| TC-0166 | Login | 正常登录(无productCode) | valid credentials | token对+userInfo | 正常路径 | P0 | permserver.go Login |
+| TC-0166 | Login | 正常登录(普通用户+productCode) | valid credentials + productCode | token对+userInfo | 正常路径 | P0 | permserver.go Login |
 | TC-0167 | Login | 用户不存在 | wrong username | codes.Unauthenticated | 异常路径 | P0 | status.Error |
 | TC-0168 | Login | 密码错误 | wrong password | codes.Unauthenticated | 异常路径 | P0 | status.Error |
 | TC-0169 | Login | 账号冻结 | frozen user | codes.PermissionDenied | 分支覆盖 | P0 | status.Error |
-| TC-0170 | Login | 超管+productCode | isSuperAdmin=1+productCode | memberType="SUPER_ADMIN", perms全量 | 分支覆盖 | P0 | isSuperAdmin && productCode!="" |
+| TC-0170 | Login | 超管被拒绝 | isSuperAdmin=1+productCode | codes.PermissionDenied, "超级管理员不允许通过产品端登录" | 安全 | P0 | IsSuperAdmin==1 → 拒绝 |
 | TC-0171 | Login | 普通用户+productCode | 普通MEMBER+productCode | perms含角色权限, memberType="MEMBER" | 分支覆盖 | P0 | !isSuperAdmin && productCode!="" |
+| TC-0510 | Login | productCode为空 | productCode="" | codes.InvalidArgument, "productCode不能为空" | 输入校验 | P0 | 第一个校验点 |
 | TC-0172 | RefreshToken | 正常刷新 | valid token | 新token对 | 正常路径 | P0 | RefreshToken |
 | TC-0173 | RefreshToken | token无效 | invalid token | codes.Unauthenticated | 异常路径 | P0 | status.Error |
 | TC-0174 | RefreshToken | 账号冻结 | frozen | codes.PermissionDenied | 分支覆盖 | P0 | status.Error |
@@ -446,8 +462,6 @@ MySQL (InnoDB) + Redis Cache
 ### 6.4 auth/perms.go — GetUserPerms
 
 > 需 mock: SysPermModel, SysProductMemberModel, SysUserRoleModel, SysRoleModel, SysRolePermModel, SysUserPermModel, SysDeptModel
->
-> **v7 变更**: GetUserPerms 新增 `deptId` 参数,MEMBER 类型增加 DEV 部门全权限分支。
 
 | TC编号 | 测试场景 | Mock设置 | 预期结果 | 类型 | 优先级 | 覆盖说明 |
 | :--- | :--- | :--- | :--- | :--- | :--- | :--- |
@@ -568,8 +582,6 @@ MySQL (InnoDB) + Redis Cache
 
 ### 7.5 唯一索引查询方法 (按 Model 差异)
 
-> **v7 新增**: 每个 FindOneBy{UniqueField} 均新增对应 FindOneBy{UniqueField}WithTx 方法,通过 session 直查(无缓存)。
-
 | TC编号 | Model | 方法 | 测试场景 | 预期结果 | 优先级 | 覆盖说明 |
 | :--- | :--- | :--- | :--- | :--- | :--- | :--- |
 | TC-0311 | SysUser | FindOneByUsername | 正常查询 | 返回用户, 缓存写入 (索引缓存→主键缓存双层) | P0 | QueryRowIndexCtx |

+ 145 - 227
test-report.md

@@ -1,6 +1,6 @@
 # 权限管理系统 (perms-system-server) — 测试报告
 
-> 报告日期: 2026-04-16(第七轮,Expires 字段重命名 + 含义变更验证)
+> 报告日期: 2026-04-16
 > 测试范围: API (go-zero REST, 全 POST) + gRPC (status codes) + Model 层 (_gen.go 模板生成 + 自定义方法) + Logic 单元测试 + util 层 + 访问控制 + UserDetailsLoader
 > 测试用例设计详见 [test-design.md](./test-design.md)
 
@@ -10,63 +10,56 @@
 
 | 指标 | 数值 |
 | :--- | :--- |
-| 测试用例总数 (test-design.md) | 499 |
-| 已覆盖 TC 数 | 498 |
+| 测试用例总数 (test-design.md) | 510 |
+| 已覆盖 TC 数 | 509 |
 | 未实现 TC 数 | 1 (TC-0189, 不可达防御分支, t.Skip) |
-| 测试函数总数 | 662 |
-| 测试子用例总数 (含 table-driven) | 744 |
+| 测试函数总数 | 673 |
+| 测试子用例总数 (含 table-driven) | 755 |
 | 测试包数量 | 23 |
-| ✅ 通过 | **743 / 744** |
+| ✅ 通过 | **754 / 755** |
 | ❌ 失败 | **0** |
 | ⏭️ 跳过 | **1** (TC-0189 — 防御性不可达分支) |
 
-> **第七轮变更说明**:
->
-> - **Expires 字段变更** ✅:`ExpiresIn`(过期秒数) → `Expires`(过期 unix 时间戳),REST/gRPC 同步更新
-> - 更新了 TC-0001 (loginLogic)、TC-0013 (refreshTokenLogic)、TC-0165/TC-0172 (gRPC) 的断言逻辑
-> - 至此 BUG-001 ~ BUG-004 及 WARN-001 全部修复,WARN-002 / WARN-003 为设计取舍不修改
-> - **全部 743 个可执行测试子用例通过,0 失败**
-
 ### 1.1 各包测试耗时
 
 | 测试包 | 状态 | 耗时 |
 | :--- | :--- | :--- |
-| handler/pub | ✅ ok | 2.475s |
-| loaders | ✅ ok | 3.104s |
-| logic/auth | ✅ ok | 8.033s |
-| logic/dept | ✅ ok | 4.314s |
-| logic/member | ✅ ok | 5.162s |
-| logic/perm | ✅ ok | 5.819s |
-| logic/product | ✅ ok | 6.947s |
-| logic/pub | ✅ ok | 6.801s |
-| logic/role | ✅ ok | 5.764s |
-| logic/user | ✅ ok | 10.236s |
-| middleware | ✅ ok | 8.668s |
-| model/dept | ✅ ok | 7.094s |
-| model/perm | ✅ ok | 7.932s |
-| model/product | ✅ ok | 10.637s |
-| model/productmember | ✅ ok | 12.484s |
-| model/role | ✅ ok | 12.569s |
-| model/roleperm | ✅ ok | 11.723s |
-| model/user | ✅ ok | 11.735s |
-| model/userperm | ✅ ok | 11.803s |
-| model/userrole | ✅ ok | 11.483s |
-| response | ✅ ok | 11.454s |
-| server | ✅ ok | 11.790s |
-| util | ✅ ok | 11.745s |
+| handler/pub | ✅ ok | 0.680s |
+| loaders | ✅ ok | 1.439s |
+| logic/auth | ✅ ok | 6.427s |
+| logic/dept | ✅ ok | 2.200s |
+| logic/member | ✅ ok | 2.819s |
+| logic/perm | ✅ ok | 3.275s |
+| logic/product | ✅ ok | 4.218s |
+| logic/pub | ✅ ok | 4.921s |
+| logic/role | ✅ ok | 5.047s |
+| logic/user | ✅ ok | 6.112s |
+| middleware | ✅ ok | 5.080s |
+| model/dept | ✅ ok | 5.525s |
+| model/perm | ✅ ok | 6.499s |
+| model/product | ✅ ok | 6.499s |
+| model/productmember | ✅ ok | 7.140s |
+| model/role | ✅ ok | 7.769s |
+| model/roleperm | ✅ ok | 7.882s |
+| model/user | ✅ ok | 7.827s |
+| model/userperm | ✅ ok | 7.935s |
+| model/userrole | ✅ ok | 7.545s |
+| response | ✅ ok | 7.080s |
+| server | ✅ ok | 6.574s |
+| util | ✅ ok | 5.796s |
 
 ---
 
 ## 二、TC 测试结果明细
 
-### 2.1 REST API (TC-0001 ~ TC-0160)
+### 2.1 REST API — 产品端登录 (TC-0001 ~ TC-0012)
 
 | TC编号 | 测试场景 | 测试结果 |
 | :--- | :--- | :--- |
-| TC-0001 | 正常登录-不带productCode | ✅ pass |
-| TC-0002 | 正常登录-带productCode | ✅ pass |
-| TC-0003 | 超管登录+productCode | ✅ pass |
-| TC-0004 | 超管登录-无productCode | ✅ pass |
+| TC-0001 | 正常登录(普通用户+productCode) | ✅ pass |
+| TC-0002 | 正常登录-带productCode+ADMIN成员 | ✅ pass |
+| TC-0003 | 超管通过产品端登录被拒绝(403) | ✅ pass |
+| TC-0004 | 超管无productCode被拒绝(403) | ✅ pass |
 | TC-0005 | 用户不存在 | ✅ pass |
 | TC-0006 | DB异常(非ErrNotFound) | ✅ pass |
 | TC-0007 | 密码错误 | ✅ pass |
@@ -74,13 +67,38 @@
 | TC-0009 | 非产品成员 | ✅ pass |
 | TC-0010 | DEVELOPER成员 | ✅ pass |
 | TC-0011 | SQL注入 | ✅ pass |
-| TC-0012 | 缺少必填字段 | ✅ pass |
+| TC-0012 | 缺少必填字段(productCode现为必填) | ✅ pass |
+
+### 2.2 REST API — 管理后台登录 (TC-0500 ~ TC-0509)
+
+| TC编号 | 测试场景 | 测试结果 |
+| :--- | :--- | :--- |
+| TC-0500 | 超管正常登录(管理后台) | ✅ pass |
+| TC-0501 | 普通用户正常登录(管理后台) | ✅ pass |
+| TC-0502 | managementKey无效 | ✅ pass |
+| TC-0503 | managementKey为空 | ✅ pass |
+| TC-0504 | 用户不存在 | ✅ pass |
+| TC-0505 | 密码错误 | ✅ pass |
+| TC-0506 | 账号冻结 | ✅ pass |
+| TC-0507 | 不带productCode时perms为空 | ✅ pass |
+| TC-0508 | 缺少必填字段(handler校验) | ✅ pass |
+| TC-0509 | SQL注入username | ✅ pass |
+
+### 2.3 REST API — 刷新Token (TC-0013 ~ TC-0018)
+
+| TC编号 | 测试场景 | 测试结果 |
+| :--- | :--- | :--- |
 | TC-0013 | 正常刷新 | ✅ pass |
 | TC-0014 | 不带productCode(回退) | ✅ pass |
 | TC-0015 | token无效 | ✅ pass |
 | TC-0016 | 用户已删除 | ✅ pass |
 | TC-0017 | 账号冻结 | ✅ pass |
 | TC-0018 | 超管+productCode | ✅ pass |
+
+### 2.4 REST API — 同步权限 (TC-0019 ~ TC-0029)
+
+| TC编号 | 测试场景 | 测试结果 |
+| :--- | :--- | :--- |
 | TC-0019 | 全部新增 | ✅ pass |
 | TC-0020 | 更新已有(名称变更) | ✅ pass |
 | TC-0021 | 无变化 | ✅ pass |
@@ -92,6 +110,11 @@
 | TC-0027 | appSecret错误 | ✅ pass |
 | TC-0028 | 产品已禁用 | ✅ pass |
 | TC-0029 | 大批量(1000条) | ✅ pass |
+
+### 2.5 REST API — 用户信息 / 修改密码 (TC-0030 ~ TC-0044)
+
+| TC编号 | 测试场景 | 测试结果 |
+| :--- | :--- | :--- |
 | TC-0030 | 正常获取-含productCode | ✅ pass |
 | TC-0031 | 不含productCode | ✅ pass |
 | TC-0032 | 未登录 | ✅ pass |
@@ -107,6 +130,11 @@
 | TC-0042 | 新密码恰好72字符 | ✅ pass |
 | TC-0043 | 新旧密码相同 | ✅ pass |
 | TC-0044 | 用户不存在 | ✅ pass |
+
+### 2.6 REST API — 产品管理 (TC-0045 ~ TC-0059)
+
+| TC编号 | 测试场景 | 测试结果 |
+| :--- | :--- | :--- |
 | TC-0045 | 正常创建 | ✅ pass |
 | TC-0046 | 事务回滚-用户创建失败 | ✅ pass |
 | TC-0047 | 事务回滚-成员创建失败 | ✅ pass |
@@ -122,6 +150,11 @@
 | TC-0057 | page负值 | ✅ pass |
 | TC-0058 | 正常查询 | ✅ pass |
 | TC-0059 | 不存在 | ✅ pass |
+
+### 2.7 REST API — 部门管理 (TC-0060 ~ TC-0079)
+
+| TC编号 | 测试场景 | 测试结果 |
+| :--- | :--- | :--- |
 | TC-0060 | 创建顶级部门 | ✅ pass |
 | TC-0061 | 创建子部门 | ✅ pass |
 | TC-0062 | 父部门不存在 | ✅ pass |
@@ -142,10 +175,20 @@
 | TC-0077 | 正常获取 | ✅ pass |
 | TC-0078 | 空数据 | ✅ pass |
 | TC-0079 | 孤儿节点 | ✅ pass |
+
+### 2.8 REST API — 权限列表 (TC-0080 ~ TC-0083)
+
+| TC编号 | 测试场景 | 测试结果 |
+| :--- | :--- | :--- |
 | TC-0080 | 正常查询 | ✅ pass |
 | TC-0081 | 默认分页 | ✅ pass |
 | TC-0082 | pageSize超过上限 | ✅ pass |
 | TC-0083 | 不存在的productCode | ✅ pass |
+
+### 2.9 REST API — 角色管理 (TC-0084 ~ TC-0100)
+
+| TC编号 | 测试场景 | 测试结果 |
+| :--- | :--- | :--- |
 | TC-0084 | 正常创建 | ✅ pass |
 | TC-0085 | 重复角色名 | ✅ pass |
 | TC-0086 | 并发同名创建 | ✅ pass |
@@ -163,6 +206,11 @@
 | TC-0098 | 清空权限 | ✅ pass |
 | TC-0099 | 重复permId | ✅ pass |
 | TC-0100 | 事务回滚 | ✅ pass |
+
+### 2.10 REST API — 用户管理 (TC-0101 ~ TC-0145)
+
+| TC编号 | 测试场景 | 测试结果 |
+| :--- | :--- | :--- |
 | TC-0101 | 正常创建 | ✅ pass |
 | TC-0102 | 用户名已存在(预检) | ✅ pass |
 | TC-0103 | 带完整可选字段 | ✅ pass |
@@ -208,6 +256,11 @@
 | TC-0143 | 非法status(0) | ✅ pass |
 | TC-0144 | 冻结自己 | ✅ pass |
 | TC-0145 | 冻结超管 | ✅ pass |
+
+### 2.11 REST API — 成员管理 (TC-0146 ~ TC-0160)
+
+| TC编号 | 测试场景 | 测试结果 |
+| :--- | :--- | :--- |
 | TC-0146 | 正常添加 | ✅ pass |
 | TC-0147 | 产品不存在 | ✅ pass |
 | TC-0148 | 用户不存在 | ✅ pass |
@@ -224,7 +277,7 @@
 | TC-0159 | 成员不存在 | ✅ pass |
 | TC-0160 | 事务回滚 | ✅ pass |
 
-### 2.2 gRPC 接口 (TC-0161 ~ TC-0183)
+### 2.12 gRPC 接口 (TC-0161 ~ TC-0183, TC-0510)
 
 | TC编号 | 测试场景 | 测试结果 |
 | :--- | :--- | :--- |
@@ -233,12 +286,13 @@
 | TC-0163 | appSecret错误 | ✅ pass |
 | TC-0164 | 产品已禁用 | ✅ pass |
 | TC-0165 | 验证disabled计数 | ✅ pass |
-| TC-0166 | 正常登录(productCode) | ✅ pass |
+| TC-0166 | 正常登录(普通用户+productCode) | ✅ pass |
 | TC-0167 | 用户不存在 | ✅ pass |
 | TC-0168 | 密码错误 | ✅ pass |
 | TC-0169 | 账号冻结 | ✅ pass |
-| TC-0170 | 超管+productCode | ✅ pass |
+| TC-0170 | 超管被拒绝(PermissionDenied) | ✅ pass |
 | TC-0171 | 普通用户+productCode | ✅ pass |
+| TC-0510 | productCode为空(InvalidArgument) | ✅ pass |
 | TC-0172 | 正常刷新 | ✅ pass |
 | TC-0173 | token无效 | ✅ pass |
 | TC-0174 | 账号冻结 | ✅ pass |
@@ -252,7 +306,7 @@
 | TC-0182 | 超管 | ✅ pass |
 | TC-0183 | MEMBER-DENY覆盖 | ✅ pass |
 
-### 2.3 中间件 / 统一响应 / util (TC-0184 ~ TC-0266, TC-0434, TC-0478 ~ TC-0480)
+### 2.13 中间件 / 统一响应 (TC-0184 ~ TC-0193, TC-0434, TC-0478 ~ TC-0480)
 
 | TC编号 | 测试场景 | 测试结果 |
 | :--- | :--- | :--- |
@@ -261,7 +315,7 @@
 | TC-0186 | 无Bearer前缀 | ✅ pass |
 | TC-0187 | token签名错误 | ✅ pass |
 | TC-0188 | token过期 | ✅ pass |
-| TC-0189 | claims类型断言失败 | ⏭️ skip(!ok 防御性分支不可达,jwt.ParseWithClaims 始终返回 *Claims) |
+| TC-0189 | claims类型断言失败 | ⏭️ skip(防御性分支不可达) |
 | TC-0434 | refresh token被拒绝 | ✅ pass |
 | TC-0478 | 冻结用户被403 | ✅ pass |
 | TC-0479 | 用户不存在(Status=0) | ✅ pass |
@@ -270,6 +324,11 @@
 | TC-0191 | 内部错误 | ✅ pass |
 | TC-0192 | 成功(有data) | ✅ pass |
 | TC-0193 | 成功(无data) | ✅ pass |
+
+### 2.14 util 层 (TC-0194 ~ TC-0216)
+
+| TC编号 | 测试场景 | 测试结果 |
+| :--- | :--- | :--- |
 | TC-0194 | 正常值 | ✅ pass |
 | TC-0195 | page<=0 | ✅ pass |
 | TC-0196 | page=-1 | ✅ pass |
@@ -293,6 +352,11 @@
 | TC-0214 | 超长16位 | ✅ pass |
 | TC-0215 | 包含字母 | ✅ pass |
 | TC-0216 | 空字符串 | ✅ pass |
+
+### 2.15 Logic 层单元测试 — JWT / 权限计算 / Helper (TC-0217 ~ TC-0266)
+
+| TC编号 | 测试场景 | 测试结果 |
+| :--- | :--- | :--- |
 | TC-0217 | 正常生成 | ✅ pass |
 | TC-0218 | 解析token验证claims | ✅ pass |
 | TC-0219 | 空secret | ✅ pass |
@@ -344,12 +408,10 @@
 | TC-0265 | IsSuperAdmin-否 | ✅ pass |
 | TC-0266 | IsSuperAdmin-空 | ✅ pass |
 
-### 2.4 Model 层 _gen.go 通用方法 (TC-0267 ~ TC-0356)
+### 2.16 Model 层 _gen.go 通用 CRUD / 批量方法 (TC-0267 ~ TC-0310)
 
 > 以下每个 TC 为通用模式,适用于全部 9 个 Model,实际测试函数数 = TC × 模型数。
 
-**CRUD / 事务 / 批量方法 (TC-0267 ~ TC-0310):**
-
 | TC编号 | 测试场景 | 测试结果 |
 | :--- | :--- | :--- |
 | TC-0267 | 正常插入 | ✅ pass |
@@ -397,7 +459,7 @@
 | TC-0309 | 空ids | ✅ pass |
 | TC-0310 | 正常多条 | ✅ pass |
 
-**唯一索引方法明细 (TC-0311 ~ TC-0346):**
+### 2.17 Model 层 _gen.go 唯一索引方法 (TC-0311 ~ TC-0346)
 
 | TC编号 | Model | 方法 | 测试场景 | 测试结果 |
 | :--- | :--- | :--- | :--- | :--- |
@@ -438,22 +500,22 @@
 | TC-0345 | SysProductMember | FindOneByProductCodeUserIdWithTx | 事务内正常查询 | ✅ pass |
 | TC-0346 | SysProductMember | FindOneByProductCodeUserIdWithTx | 事务内不存在 | ✅ pass |
 
-**内部辅助方法 (TC-0347 ~ TC-0356):**
-
-| TC编号 | 测试场景 | 测试结果 | 未实现原因 |
-| :--- | :--- | :--- | :--- |
-| TC-0347 | 空ids | ✅ pass | role 同包测试可访问私有函数 |
-| TC-0348 | 正常ids | ✅ pass | role 同包测试 |
-| TC-0349 | 部分不存在 | ✅ pass | role 同包测试 |
-| TC-0350 | DB异常 | ✅ pass | 坏连接模拟 |
-| TC-0351 | 正常 | ✅ pass | role 同包测试 |
-| TC-0352 | 正常 | ✅ pass | role 同包测试 |
-| TC-0353 | 正常 | ✅ pass | role 同包测试 |
-| TC-0354 | cachePrefix为空 | ✅ pass | role 同包测试 |
-| TC-0355 | cachePrefix非空 | ✅ pass | role 同包测试 |
-| TC-0356 | 多唯一索引前缀(SysProduct) | ✅ pass | product 同包测试 |
+### 2.18 Model 层 _gen.go 内部辅助方法 (TC-0347 ~ TC-0356)
 
-### 2.5 Model 层自定义方法 (TC-0357 ~ TC-0433)
+| TC编号 | 测试场景 | 测试结果 |
+| :--- | :--- | :--- |
+| TC-0347 | 空ids | ✅ pass |
+| TC-0348 | 正常ids | ✅ pass |
+| TC-0349 | 部分不存在 | ✅ pass |
+| TC-0350 | DB异常 | ✅ pass |
+| TC-0351 | 正常 | ✅ pass |
+| TC-0352 | 正常 | ✅ pass |
+| TC-0353 | 正常 | ✅ pass |
+| TC-0354 | cachePrefix为空 | ✅ pass |
+| TC-0355 | cachePrefix非空 | ✅ pass |
+| TC-0356 | 多唯一索引前缀(SysProduct) | ✅ pass |
+
+### 2.19 Model 层自定义方法 (TC-0357 ~ TC-0433)
 
 | TC编号 | 测试场景 | 测试结果 |
 | :--- | :--- | :--- |
@@ -535,7 +597,7 @@
 | TC-0432 | 部分不是成员 | ✅ pass |
 | TC-0433 | map key正确 | ✅ pass |
 
-### 2.6 访问控制 access.go (TC-0435 ~ TC-0457) 🆕
+### 2.20 访问控制 access.go (TC-0435 ~ TC-0457)
 
 | TC编号 | 测试场景 | 测试结果 |
 | :--- | :--- | :--- |
@@ -563,7 +625,7 @@
 | TC-0456 | CheckManageAccess-未登录 | ✅ pass |
 | TC-0457 | memberTypePriority-全类型验证 | ✅ pass |
 
-### 2.7 UserDetailsLoader (TC-0458 ~ TC-0477) 🆕
+### 2.21 UserDetailsLoader (TC-0458 ~ TC-0477)
 
 | TC编号 | 测试场景 | 测试结果 |
 | :--- | :--- | :--- |
@@ -572,8 +634,8 @@
 | TC-0460 | Load-用户不存在 | ✅ pass |
 | TC-0461 | Load-productCode为空 | ✅ pass |
 | TC-0462 | Del删除指定缓存 | ✅ pass |
-| TC-0463 | Clean清除用户所有产品缓存 | ✅ pass(BUG-004 已修复) |
-| TC-0464 | CleanByProduct清除产品所有用户 | ✅ pass(BUG-004 已修复) |
+| TC-0463 | Clean清除用户所有产品缓存 | ✅ pass |
+| TC-0464 | CleanByProduct清除产品所有用户 | ✅ pass |
 | TC-0465 | BatchDel批量删除 | ✅ pass |
 | TC-0466 | BatchDel空数组 | ✅ pass |
 | TC-0467 | loadPerms-超管全量权限 | ✅ pass |
@@ -588,7 +650,7 @@
 | TC-0476 | loadMembership-超管自动SUPER_ADMIN | ✅ pass |
 | TC-0477 | loadMembership-非成员MemberType为空 | ✅ pass |
 
-### 2.8 Logic 层访问控制负面测试 (TC-0481 ~ TC-0491) 🆕
+### 2.22 Logic 层访问控制负面测试 (TC-0481 ~ TC-0491)
 
 | TC编号 | 测试场景 | 测试结果 |
 | :--- | :--- | :--- |
@@ -604,12 +666,12 @@
 | TC-0490 | bindRolePerms-非产品管理员拒绝 | ✅ pass |
 | TC-0491 | updateUser-非本人非超管拒绝 | ✅ pass |
 
-### 2.9 Model 层新增方法 (TC-0492 ~ TC-0499) 🆕
+### 2.23 Model 层新增方法 (TC-0492 ~ TC-0499)
 
 | TC编号 | 测试场景 | 测试结果 |
 | :--- | :--- | :--- |
-| TC-0492 | FindMinPermsLevelByUserIdAndProductCode-正常 | ✅ pass(BUG-003 已修复) |
-| TC-0493 | FindMinPermsLevelByUserIdAndProductCode-无角色 | ✅ pass(BUG-003 已修复) |
+| TC-0492 | FindMinPermsLevelByUserIdAndProductCode-正常 | ✅ pass |
+| TC-0493 | FindMinPermsLevelByUserIdAndProductCode-无角色 | ✅ pass |
 | TC-0494 | FindAllCodesByProductCode-正常 | ✅ pass |
 | TC-0495 | FindAllCodesByProductCode-无权限 | ✅ pass |
 | TC-0496 | FindIdsByDeptId-正常 | ✅ pass |
@@ -619,160 +681,16 @@
 
 ---
 
-## 三、源码审计
-
-### 3.1 历史修复确认 (v1→v7)
-
-| # | 原始隐患 | 修复轮次 | 最终状态 |
-| :--- | :--- | :--- | :--- |
-| 1 | BindRoles/BindPerms/SetPerms 非事务 | v2 | ✅ TransactCtx + \*WithTx |
-| 2 | DeleteRole 无级联删除 | v2 | ✅ 事务内三步级联 |
-| 3 | RemoveMember 无级联清理 | v2→v3 | ✅ v2增加ForProduct; v3升级事务 |
-| 4 | UpdateUserStatus 无校验 | v2 | ✅ status∈{1,2}+自我保护+超管保护 |
-| 5 | SyncPerms N+1 查询 | v2 | ✅ FindMapByProductCode+批量 |
-| 6 | CreateProduct 非事务 | v2 | ✅ TransactCtx |
-| 7 | GetUserPerms 冗余查询 | v2 | ✅ isSuperAdmin参数 |
-| 8 | 错误信息泄露 | v2 | ✅ 统一"服务器内部错误"+logx |
-| 9 | VerifyToken 断言无保护 | v2 | ✅ ok检查 |
-| 10 | addMember 不校验产品/用户存在性 | v3 | ✅ FindOneByCode+FindOne预检 |
-| 11 | bindRoles/bindPerms/setPerms 不校验目标存在性 | v3 | ✅ FindOne预检 |
-| 12 | changePassword 新旧相同/截断 | v3 | ✅ len>72+相同密码校验 |
-| 13 | CreateDept 非事务 | v3 | ✅ TransactCtx |
-| 14 | gRPC errors.New 非规范 | v3 | ✅ status.Error(codes.Xxx) |
-| 15 | SyncPerms 不返回 disabled | v3 | ✅ DisableNotInCodes返回int64 |
-| 16 | RemoveMember 三步非事务 | v3 | ✅ TransactCtx+\*ForProductTx |
-| 17 | 全部路由改POST | v3 | ✅ perm.api统一POST |
-| 18 | updateUser 无法清空字段 | v4 | ✅ \*string 指针类型 |
-| 19 | pageSize 无上限 | v4 | ✅ NormalizePage cap 100 |
-| 20 | createUser TOCTOU 错误不友好 | v4 | ✅ 捕获 Duplicate entry→ErrConflict |
-| 21 | email/phone 无格式校验 | v4 | ✅ util.IsValidEmail/IsValidPhone |
-| 22 | changePassword 新密码允许空字符串 | v4 | ✅ len<6 最小长度校验 |
-| 23 | createRole 重复角色名返回500 | v5 | ✅ 捕获 Duplicate entry→ErrConflict |
-| 24 | memberList N+1 查用户 | v5 | ✅ FindByIds批量查询+map |
-| 25 | userList N+1 查成员类型 | v5 | ✅ FindMapByProductCodeUserIds批量 |
-| 26 | updateUser DeptId无法清零 | v5 | ✅ \*int64 指针类型 |
-| 27 | createDeptLogic 事务内 FindOne 隔离 bug | v6 | ✅ FindOne→FindOneWithTx(session) |
-| 28 | Model \_gen.go 新增 FindOneWithTx / FindOneBy...WithTx | v6 | ✅ 16 个新事务查询方法 |
-| 29 | DeptType 字段新增 | v6 | ✅ 创建/更新/树逻辑+consts |
-| 30 | GetUserPerms DEV 部门全权限分支 | v6 | ✅ deptId参数+DeptTypeDev检查 |
-| 31 | BUG-001: Handler 参数校验返回 500 | v7 | ✅ ErrBadRequest |
-| 32 | BUG-002: JWT token 类型无区分 | v7 | ✅ TokenType 字段 |
-| 33 | FindByPathPrefix LIKE 注入 | v7 | ✅ NewReplacer 转义 |
-| 34 | BUG-003: FindMinPermsLevel sql.NullInt64 不兼容 | v8 | ✅ IFNULL+int64 |
-| 35 | BUG-004: cleanByPattern Lua SCAN cursor 类型不匹配 | v9 | ✅ tonumber(nextCursor) |
-| 36 | refreshToken 无限续期安全隐患 | v10 | ✅ 不再重新生成 refreshToken,原样返回 |
-| 37 | refreshToken 从 body 改为 header 获取 | v10 | ✅ `header:"Authorization"` |
-| 38 | ExpiresIn → Expires 字段重命名+含义变更 | v11 | ✅ 秒数→unix时间戳 |
-
-### 3.2 v8 新增模块审计(access 控制 + UserDetailsLoader)
-
-#### 新增文件
-
-| 文件 | 说明 | 审计结论 |
-| :--- | :--- | :--- |
-| `internal/loaders/userDetailsLoader.go` | 用户详情加载器 + Redis 缓存 | 见下方详细分析 |
-| `internal/logic/auth/access.go` | 集中访问控制函数 | 逻辑正确 |
-
-#### access.go 审计结论
-
-| 函数 | 审计结论 |
-| :--- | :--- |
-| `RequireSuperAdmin` | ✅ 正确:nil 检查 → IsSuperAdmin 检查 |
-| `RequireProductAdmin` | ✅ 正确:nil → SuperAdmin → Admin → 拒绝 |
-| `CheckMemberTypeAssignment` | ✅ 正确:SuperAdmin 豁免,优先级比较用 `>=` 阻止同级分配 |
-| `CheckManageAccess` | ✅ 正确:SuperAdmin/Self 豁免 → 部门层级 → 权限级别 |
-| `checkDeptHierarchy` | ✅ 正确:ADMIN 豁免,DeptId=0 检查,HasPrefix 子部门判断 |
-| `checkPermLevel` | ✅ 正确:memberType 优先级 → permsLevel 比较 |
-| `memberTypePriority` | ✅ 正确:SA=0, A=1, D=2, M=3, unknown=MaxInt32 |
-
-#### UserDetailsLoader 审计发现
-
-| # | 发现 | 风险等级 | 说明 |
-| :--- | :--- | :--- | :--- |
-| ~~BUG-003~~ | ~~`FindMinPermsLevelByUserIdAndProductCode` 使用 `sql.NullInt64` 与 go-zero 不兼容~~ | ~~🔴 高~~ | ✅ **已修复 (v9)**:改为 `IFNULL(MIN(...), -1)` + `int64`,level < 0 时返回 `ErrNotFound`。TC-0492、TC-0493 恢复通过。 |
-| ~~WARN-001~~ | ~~`cleanByPattern` 使用 Redis `KEYS` 命令~~ | ~~⚠️ 中~~ | ✅ **已修改 (v9)**:改用 Lua 脚本 + `SCAN` 替代 `KEYS`。但引入 BUG-004(见下)。 |
-| ~~BUG-004~~ | ~~`cleanByPattern` Lua SCAN cursor 类型断言失败~~ | ~~🟡 中~~ | ✅ **已修复 (v10)**:Lua 脚本中 `return nextCursor` 改为 `return tonumber(nextCursor)`,Go 侧 `val.(int64)` 断言恢复正常。TC-0463、TC-0464 通过。 |
-| WARN-002 | `Load` 对不存在用户返回 Status=0 | ℹ️ 低 | 当 `loadUser` 失败(用户不存在/DB 错误),UserDetails.Status 为零值 0,中间件检查 `Status != StatusEnabled(1)` 会返回 403 "账号已被冻结"。错误消息不够精确(应为 "用户不存在"),但安全性正确(不存在用户被拒绝)。 |
-| WARN-003 | `loadPerms` 错误被静默吞掉 | ℹ️ 低 | `FindAllCodesByProductCode` 失败时仅 logx.Errorf,Perms 为 nil。虽有日志但调用方无法感知失败。当前设计为 "尽力加载",不影响安全性(权限为空 = 无权限)。 |
-
-#### 中间件变更审计
-
-| 变更 | 审计结论 |
-| :--- | :--- |
-| 新增 `loader.Load()` 调用 | ✅ 正确:每次请求加载最新用户详情 |
-| 冻结账号 403 拦截 | ✅ 正确:`ud.Status != consts.StatusEnabled` |
-| UserDetails 注入 context | ✅ 正确:替代旧的多个 context key |
-| 公共 helper 函数 | ✅ 正确:`GetUserDetails`/`GetUserId`/`GetUsername`/`GetProductCode`/`GetMemberType`/`IsSuperAdmin` |
-
-#### Logic 层 access 控制接入审计
-
-| Logic | access 检查 | 缓存失效 | 审计结论 |
-| :--- | :--- | :--- | :--- |
-| createDept | RequireSuperAdmin | — | ✅ |
-| updateDept | RequireSuperAdmin | Clean(dept 下所有用户) | ✅ |
-| deleteDept | RequireSuperAdmin | — | ✅ |
-| createProduct | RequireSuperAdmin | — | ✅ |
-| updateProduct | RequireSuperAdmin | CleanByProduct | ✅ |
-| createUser | RequireProductAdmin | — | ✅ |
-| updateUser | Self 或 SuperAdmin | Clean(userId) | ✅ |
-| updateUserStatus | CheckManageAccess | Clean(userId) | ✅ |
-| bindRoles | CheckManageAccess | Del(userId, productCode) | ✅ |
-| setUserPerms | CheckManageAccess | Del(userId, productCode) | ✅ |
-| createRole | RequireProductAdmin | — | ✅ |
-| updateRole | RequireProductAdmin | BatchDel(affectedUsers) | ✅ |
-| deleteRole | RequireProductAdmin | BatchDel(affectedUsers) | ✅ |
-| bindRolePerms | RequireProductAdmin | BatchDel(affectedUsers) | ✅ |
-| addMember | CheckManageAccess + CheckMemberTypeAssignment | Del(userId, productCode) | ✅ |
-| updateMember | CheckManageAccess + CheckMemberTypeAssignment | Del(userId, productCode) | ✅ |
-| removeMember | CheckManageAccess | Del(userId, productCode) | ✅ |
-| login | — (公开) | Load (查询) | ✅ |
-| refreshToken | — (公开) | Load (查询) | ✅ |
-| syncPerms | appKey/appSecret 校验 | CleanByProduct | ✅ |
-| userInfo | — (中间件已鉴权) | — | ✅ |
-
-### 3.3 ~~BUG-003~~ ✅ 已修复
-
-- **关联 TC**: TC-0492、TC-0493
-- **位置**: `internal/model/role/sysRoleModel.go` → `FindMinPermsLevelByUserIdAndProductCode`
-- **修复方案**: DEV 改为 `IFNULL(MIN(r.permsLevel), -1)` + 普通 `int64` 扫描,`level < 0` 时返回 `ErrNotFound`
-- **验证结果**: TC-0492、TC-0493 全部通过 ✅
-
-### 3.4 ~~BUG-004~~ ✅ 已修复
-
-- **关联 TC**: TC-0463、TC-0464
-- **位置**: `internal/loaders/userDetailsLoader.go` → `cleanByPattern`
-- **修复方案**: DEV 在 Lua 脚本中将 `return nextCursor` 改为 `return tonumber(nextCursor)`,使返回值为数字类型,Go 侧 `val.(int64)` 断言恢复正常
-- **验证结果**: TC-0463、TC-0464 全部通过 ✅
-
----
-
-## 四、代码质量总评
-
-经过三轮审计,本次新增的 access 控制和 UserDetailsLoader 实现质量良好:
-
-- **访问控制架构**: 集中式 access.go 管控,4 个关键函数覆盖超管/产品管理员/成员类型/部门层级/权限级别等维度
-- **缓存策略**: UserDetailsLoader 采用 Redis 缓存 + TTL(300s) + 主动失效,所有写操作均有对应的 Del/Clean/BatchDel
-- **安全纵深**: JWT 中间件 → UserDetailsLoader 实时加载 → 冻结账号拦截 → Logic 层 access 检查,四层防护
-- **部门层级管控**: HasPrefix 子部门判断 + ADMIN 豁免 + DeptId=0 边界处理
-- **权限级别比较**: memberType 优先级 + permsLevel 数值双重比较,确保低级别用户无法管理高级别用户
-- ~~**BUG-003**~~: ✅ 已修复,`FindMinPermsLevelByUserIdAndProductCode` 改为 `IFNULL` + `int64`
-- ~~**WARN-001**~~: ✅ 已改用 Lua + SCAN 替代 KEYS
-- ~~**BUG-004**~~: ✅ 已修复,Lua 脚本 `return tonumber(nextCursor)`
-- **WARN-002 / WARN-003**: DEV 确认为设计取舍,安全性不受影响,不修改
-
-### 测试覆盖统计
+## 三、测试覆盖统计
 
 | 指标 | 数值 |
 | :--- | :--- |
-| TC 总数 | 499 |
-| 已实现 | 498 (99.8%) |
-| 跳过 | 1 (TC-0189,`!ok` 防御性不可达分支) |
-| 测试函数 | 662 |
-| 测试子用例 | 744 |
-| ✅ 通过 | **743** |
+| TC 总数 | 510 |
+| 已实现 | 509 (99.8%) |
+| 跳过 | 1 (TC-0189,防御性不可达分支) |
+| 测试函数 | 673 |
+| 测试子用例 | 755 |
+| ✅ 通过 | **754** |
 | ❌ 失败 | **0** |
-| ⏭️ 跳过 | **1** (TC-0189 — t.Skip) |
-| 通过率 | **100%** (743/743) |
-| BUG-001 ~ BUG-004 | ✅ 全部已修复 |
-| WARN-001 | ✅ 已修复(KEYS → Lua SCAN) |
-| WARN-002 / WARN-003 | 设计取舍,不修改 |
+| ⏭️ 跳过 | **1** (TC-0189) |
+| 通过率 | **100%** (754/754,排除不可达分支) |