Переглянути джерело

feat: 新增 GetUserPerms 接口用于获取为用户单独设置的允许或拒绝的权限

BaiLuoYan 4 днів тому
батько
коміт
bee02894a9

+ 5 - 0
internal/handler/routes.go

@@ -330,6 +330,11 @@ func RegisterHandlers(server *rest.Server, serverCtx *svc.ServiceContext) {
 					Path:    "/updateStatus",
 					Handler: user.UpdateUserStatusHandler(serverCtx),
 				},
+				{
+					Method:  http.MethodPost,
+					Path:    "/userPerms",
+					Handler: user.GetUserPermsHandler(serverCtx),
+				},
 			}...,
 		),
 		rest.WithPrefix("/api/user"),

+ 32 - 0
internal/handler/user/getUserPermsHandler.go

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

+ 67 - 0
internal/logic/user/getUserPermsLogic.go

@@ -0,0 +1,67 @@
+// Code scaffolded by goctl. Safe to edit.
+// goctl 1.10.1
+
+package user
+
+import (
+	"context"
+
+	authHelper "perms-system-server/internal/logic/auth"
+	"perms-system-server/internal/middleware"
+	"perms-system-server/internal/response"
+	"perms-system-server/internal/svc"
+	"perms-system-server/internal/types"
+
+	"github.com/zeromicro/go-zero/core/logx"
+)
+
+type GetUserPermsLogic struct {
+	logx.Logger
+	ctx    context.Context
+	svcCtx *svc.ServiceContext
+}
+
+func NewGetUserPermsLogic(ctx context.Context, svcCtx *svc.ServiceContext) *GetUserPermsLogic {
+	return &GetUserPermsLogic{
+		Logger: logx.WithContext(ctx),
+		ctx:    ctx,
+		svcCtx: svcCtx,
+	}
+}
+
+// GetUserPerms 查询用户在当前产品下的个性化权限覆盖项(仅 sys_user_perm 的 ALLOW/DENY 行,不含角色继承权限)。
+// 访问控制:超管不限;本人可查自己;其余调用者须是产品 ADMIN,且权限等级须高于目标用户。
+// 审计 L-R13-1:RequireProductAdminFor 在任何实体读取前调用,防止普通 MEMBER 通过响应差异枚举 userId 存在性。
+func (l *GetUserPermsLogic) GetUserPerms(req *types.GetUserPermsReq) (resp *types.GetUserPermsResp, err error) {
+	caller := middleware.GetUserDetails(l.ctx)
+	if caller == nil {
+		return nil, response.ErrUnauthorized("未登录")
+	}
+
+	productCode := middleware.GetProductCode(l.ctx)
+
+	isSelf := caller.UserId == req.UserId
+	if !caller.IsSuperAdmin && !isSelf {
+		// 审计 L-R13-1:先鉴权再读实体,避免枚举 userId。
+		if err := authHelper.RequireProductAdminFor(l.ctx, productCode); err != nil {
+			return nil, err
+		}
+		if err := authHelper.CheckManageAccess(l.ctx, l.svcCtx, req.UserId, productCode); err != nil {
+			return nil, err
+		}
+	}
+
+	rows, err := l.svcCtx.SysUserPermModel.FindByUserIdForProduct(l.ctx, req.UserId, productCode)
+	if err != nil {
+		return nil, err
+	}
+
+	perms := make([]types.UserPermItem, 0, len(rows))
+	for _, r := range rows {
+		perms = append(perms, types.UserPermItem{
+			PermId: r.PermId,
+			Effect: r.Effect,
+		})
+	}
+	return &types.GetUserPermsResp{Perms: perms}, nil
+}

+ 233 - 0
internal/logic/user/getUserPermsLogic_test.go

@@ -0,0 +1,233 @@
+package user
+
+import (
+	"errors"
+	"testing"
+	"time"
+
+	"github.com/stretchr/testify/assert"
+	"github.com/stretchr/testify/require"
+
+	"perms-system-server/internal/consts"
+	"perms-system-server/internal/loaders"
+	memberModel "perms-system-server/internal/model/productmember"
+	"perms-system-server/internal/response"
+	"perms-system-server/internal/svc"
+	"perms-system-server/internal/testutil"
+	"perms-system-server/internal/testutil/ctxhelper"
+	"perms-system-server/internal/types"
+)
+
+// insertTestMemberWithType 创建指定类型的产品成员,供 getUserPerms 测试使用。
+func insertTestMemberWithType(t *testing.T, svcCtx *svc.ServiceContext, productCode string, userId int64, memberType string) int64 {
+	t.Helper()
+	now := time.Now().Unix()
+	res, err := svcCtx.SysProductMemberModel.Insert(ctxhelper.SuperAdminCtx(), &memberModel.SysProductMember{
+		ProductCode: productCode,
+		UserId:      userId,
+		MemberType:  memberType,
+		Status:      consts.StatusEnabled,
+		CreateTime:  now,
+		UpdateTime:  now,
+	})
+	require.NoError(t, err)
+	id, _ := res.LastInsertId()
+	return id
+}
+
+// insertUserPerm 直接向 sys_user_perm 插入一条覆盖记录,绕过 SetUserPerms 的业务校验。
+func insertUserPerm(t *testing.T, svcCtx *svc.ServiceContext, userId, permId int64, effect string) {
+	t.Helper()
+	conn := testutil.GetTestSqlConn()
+	now := time.Now().Unix()
+	_, err := conn.ExecCtx(ctxhelper.SuperAdminCtx(),
+		"INSERT INTO `sys_user_perm` (`userId`,`permId`,`effect`,`createTime`,`updateTime`) VALUES (?,?,?,?,?)",
+		userId, permId, effect, now, now)
+	require.NoError(t, err)
+}
+
+// TC-1257: 超管查任意用户权限覆盖
+func TestGetUserPerms_SuperAdmin(t *testing.T) {
+	ctx := ctxhelper.SuperAdminCtx()
+	svcCtx := svc.NewServiceContext(testutil.GetTestConfig())
+	conn := testutil.GetTestSqlConn()
+
+	username := testutil.UniqueId()
+	userId := insertTestUser(t, ctx, username, testutil.HashPassword("pass"))
+	mId := insertTestMember(t, svcCtx, "test_product", userId)
+	permId := insertTestPerm(t, svcCtx, "test_product")
+
+	insertUserPerm(t, svcCtx, userId, permId, consts.PermEffectAllow)
+
+	t.Cleanup(func() {
+		testutil.CleanTableByField(ctx, conn, "`sys_user_perm`", "userId", userId)
+		testutil.CleanTable(ctx, conn, "`sys_product_member`", mId)
+		testutil.CleanTable(ctx, conn, "`sys_user`", userId)
+		testutil.CleanTable(ctx, conn, "`sys_perm`", permId)
+	})
+
+	resp, err := NewGetUserPermsLogic(ctx, svcCtx).GetUserPerms(&types.GetUserPermsReq{UserId: userId})
+	require.NoError(t, err)
+	require.NotNil(t, resp)
+	require.Len(t, resp.Perms, 1)
+	assert.Equal(t, permId, resp.Perms[0].PermId)
+	assert.Equal(t, consts.PermEffectAllow, resp.Perms[0].Effect)
+}
+
+// TC-1258: 用户查询自己的权限覆盖(包含 ALLOW 和 DENY)
+func TestGetUserPerms_SelfQuery(t *testing.T) {
+	bootstrapCtx := ctxhelper.SuperAdminCtx()
+	svcCtx := svc.NewServiceContext(testutil.GetTestConfig())
+	conn := testutil.GetTestSqlConn()
+
+	username := testutil.UniqueId()
+	userId := insertTestUser(t, bootstrapCtx, username, testutil.HashPassword("pass"))
+	mId := insertTestMember(t, svcCtx, "test_product", userId)
+	permId1 := insertTestPerm(t, svcCtx, "test_product")
+	permId2 := insertTestPerm(t, svcCtx, "test_product")
+
+	insertUserPerm(t, svcCtx, userId, permId1, consts.PermEffectAllow)
+	insertUserPerm(t, svcCtx, userId, permId2, consts.PermEffectDeny)
+
+	t.Cleanup(func() {
+		testutil.CleanTableByField(bootstrapCtx, conn, "`sys_user_perm`", "userId", userId)
+		testutil.CleanTable(bootstrapCtx, conn, "`sys_product_member`", mId)
+		testutil.CleanTable(bootstrapCtx, conn, "`sys_user`", userId)
+		testutil.CleanTable(bootstrapCtx, conn, "`sys_perm`", permId1, permId2)
+	})
+
+	// caller == target(isSelf 分支,跳过 RequireProductAdminFor + CheckManageAccess)
+	selfCtx := ctxhelper.CustomCtx(&loaders.UserDetails{
+		UserId:      userId,
+		Username:    username,
+		MemberType:  consts.MemberTypeMember,
+		ProductCode: "test_product",
+		Status:      consts.StatusEnabled,
+	})
+	resp, err := NewGetUserPermsLogic(selfCtx, svcCtx).GetUserPerms(&types.GetUserPermsReq{UserId: userId})
+	require.NoError(t, err)
+	require.NotNil(t, resp)
+	assert.Len(t, resp.Perms, 2, "应返回 ALLOW 和 DENY 两条记录")
+}
+
+// TC-1259: 产品 ADMIN 查同产品 MEMBER 的权限覆盖
+func TestGetUserPerms_AdminQueriesMember(t *testing.T) {
+	bootstrapCtx := ctxhelper.SuperAdminCtx()
+	svcCtx := svc.NewServiceContext(testutil.GetTestConfig())
+	conn := testutil.GetTestSqlConn()
+
+	username := testutil.UniqueId()
+	userId := insertTestUser(t, bootstrapCtx, username, testutil.HashPassword("pass"))
+	mId := insertTestMember(t, svcCtx, "test_product", userId) // MEMBER 类型
+	permId := insertTestPerm(t, svcCtx, "test_product")
+
+	insertUserPerm(t, svcCtx, userId, permId, consts.PermEffectAllow)
+
+	t.Cleanup(func() {
+		testutil.CleanTableByField(bootstrapCtx, conn, "`sys_user_perm`", "userId", userId)
+		testutil.CleanTable(bootstrapCtx, conn, "`sys_product_member`", mId)
+		testutil.CleanTable(bootstrapCtx, conn, "`sys_user`", userId)
+		testutil.CleanTable(bootstrapCtx, conn, "`sys_perm`", permId)
+	})
+
+	adminCtx := ctxhelper.AdminCtx("test_product")
+	resp, err := NewGetUserPermsLogic(adminCtx, svcCtx).GetUserPerms(&types.GetUserPermsReq{UserId: userId})
+	require.NoError(t, err)
+	require.NotNil(t, resp)
+	assert.Len(t, resp.Perms, 1)
+	assert.Equal(t, permId, resp.Perms[0].PermId)
+}
+
+// TC-1260: 产品 ADMIN 查同级 ADMIN 被拒绝(permsLevel 等级相同时 CheckManageAccess 拒绝)
+func TestGetUserPerms_AdminQueriesSameLevelAdmin_Forbidden(t *testing.T) {
+	bootstrapCtx := ctxhelper.SuperAdminCtx()
+	svcCtx := svc.NewServiceContext(testutil.GetTestConfig())
+	conn := testutil.GetTestSqlConn()
+
+	// 目标:ADMIN 类型成员
+	targetUsername := testutil.UniqueId()
+	targetUserId := insertTestUser(t, bootstrapCtx, targetUsername, testutil.HashPassword("pass"))
+	mId := insertTestMemberWithType(t, svcCtx, "test_product", targetUserId, consts.MemberTypeAdmin)
+
+	t.Cleanup(func() {
+		testutil.CleanTable(bootstrapCtx, conn, "`sys_product_member`", mId)
+		testutil.CleanTable(bootstrapCtx, conn, "`sys_user`", targetUserId)
+	})
+
+	// AdminCtx UserId=2,无任何角色 → callerNoRole=true → CheckManageAccess 拒绝(等级不低于目标)
+	adminCtx := ctxhelper.AdminCtx("test_product")
+	_, err := NewGetUserPermsLogic(adminCtx, svcCtx).GetUserPerms(&types.GetUserPermsReq{UserId: targetUserId})
+	require.Error(t, err)
+	var ce *response.CodeError
+	require.True(t, errors.As(err, &ce))
+	assert.Equal(t, 403, ce.Code())
+}
+
+// TC-1261: 普通 MEMBER 查他人被拒绝(RequireProductAdminFor 前置拦截)
+func TestGetUserPerms_MemberQueriesOther_Forbidden(t *testing.T) {
+	bootstrapCtx := ctxhelper.SuperAdminCtx()
+	svcCtx := svc.NewServiceContext(testutil.GetTestConfig())
+	conn := testutil.GetTestSqlConn()
+
+	otherUsername := testutil.UniqueId()
+	otherUserId := insertTestUser(t, bootstrapCtx, otherUsername, testutil.HashPassword("pass"))
+	mId := insertTestMember(t, svcCtx, "test_product", otherUserId)
+
+	t.Cleanup(func() {
+		testutil.CleanTable(bootstrapCtx, conn, "`sys_product_member`", mId)
+		testutil.CleanTable(bootstrapCtx, conn, "`sys_user`", otherUserId)
+	})
+
+	memberCtx := ctxhelper.MemberCtx("test_product")
+	_, err := NewGetUserPermsLogic(memberCtx, svcCtx).GetUserPerms(&types.GetUserPermsReq{UserId: otherUserId})
+	require.Error(t, err)
+	var ce *response.CodeError
+	require.True(t, errors.As(err, &ce))
+	assert.Equal(t, 403, ce.Code())
+	assert.Contains(t, ce.Error(), "仅超级管理员或该产品的管理员可执行此操作")
+}
+
+// TC-1262: 非 ADMIN 查不存在的 userId 必须 403(RequireProductAdminFor 先于实体读取,消除枚举 oracle)
+func TestGetUserPerms_NonAdmin_NonExistentUser_Returns403(t *testing.T) {
+	svcCtx := svc.NewServiceContext(testutil.GetTestConfig())
+
+	memberCtx := ctxhelper.MemberCtx("test_product")
+	_, err := NewGetUserPermsLogic(memberCtx, svcCtx).GetUserPerms(&types.GetUserPermsReq{UserId: 999999999})
+	require.Error(t, err)
+	var ce *response.CodeError
+	require.True(t, errors.As(err, &ce))
+	assert.Equal(t, 403, ce.Code(), "必须是 403 而非 404,防止枚举 userId 存在性")
+}
+
+// TC-1263: ADMIN 查不是当前产品成员的用户被拒绝
+func TestGetUserPerms_AdminQueriesNonMember_Forbidden(t *testing.T) {
+	bootstrapCtx := ctxhelper.SuperAdminCtx()
+	svcCtx := svc.NewServiceContext(testutil.GetTestConfig())
+	conn := testutil.GetTestSqlConn()
+
+	// 创建用户,但不加入 test_product
+	username := testutil.UniqueId()
+	userId := insertTestUser(t, bootstrapCtx, username, testutil.HashPassword("pass"))
+
+	t.Cleanup(func() {
+		testutil.CleanTable(bootstrapCtx, conn, "`sys_user`", userId)
+	})
+
+	adminCtx := ctxhelper.AdminCtx("test_product")
+	_, err := NewGetUserPermsLogic(adminCtx, svcCtx).GetUserPerms(&types.GetUserPermsReq{UserId: userId})
+	require.Error(t, err)
+	var ce *response.CodeError
+	require.True(t, errors.As(err, &ce))
+	assert.Equal(t, 403, ce.Code())
+}
+
+// TC-1264: nil UserDetails(模拟无 JWT)返回 401
+func TestGetUserPerms_NoAuth_Returns401(t *testing.T) {
+	svcCtx := svc.NewServiceContext(testutil.GetTestConfig())
+
+	_, err := NewGetUserPermsLogic(ctxhelper.CustomCtx(nil), svcCtx).GetUserPerms(&types.GetUserPermsReq{UserId: 1})
+	require.Error(t, err)
+	var ce *response.CodeError
+	require.True(t, errors.As(err, &ce))
+	assert.Equal(t, 401, ce.Code())
+}

+ 12 - 0
internal/model/userperm/sysUserPermModel.go

@@ -17,6 +17,7 @@ type (
 	SysUserPermModel interface {
 		sysUserPermModel
 		FindPermIdsByUserIdAndEffectForProduct(ctx context.Context, userId int64, effect string, productCode string) ([]int64, error)
+		FindByUserIdForProduct(ctx context.Context, userId int64, productCode string) ([]SysUserPerm, error)
 		DeleteByUserIdForProductTx(ctx context.Context, session sqlx.Session, userId int64, productCode string) error
 	}
 
@@ -42,6 +43,17 @@ func (m *customSysUserPermModel) FindPermIdsByUserIdAndEffectForProduct(ctx cont
 	return ids, nil
 }
 
+func (m *customSysUserPermModel) FindByUserIdForProduct(ctx context.Context, userId int64, productCode string) ([]SysUserPerm, error) {
+	var list []SysUserPerm
+	query := fmt.Sprintf(
+		"SELECT up.`id`, up.`userId`, up.`permId`, up.`effect`, up.`createTime`, up.`updateTime` FROM %s up INNER JOIN `sys_perm` p ON up.`permId` = p.`id` WHERE up.`userId` = ? AND p.`productCode` = ? AND p.`status` = ?",
+		m.table)
+	if err := m.QueryRowsNoCacheCtx(ctx, &list, query, userId, productCode, consts.StatusEnabled); err != nil {
+		return nil, err
+	}
+	return list, nil
+}
+
 func (m *customSysUserPermModel) DeleteByUserIdForProductTx(ctx context.Context, session sqlx.Session, userId int64, productCode string) error {
 	// 审计 L-R11-2:SELECT 只取 cache key 所需三列,不回灌全行。
 	var list []struct {

+ 15 - 0
internal/testutil/mocks/mock_userperm_model.go

@@ -229,6 +229,21 @@ func (mr *MockSysUserPermModelMockRecorder) FindOneWithTx(ctx, session, id any)
 	return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "FindOneWithTx", reflect.TypeOf((*MockSysUserPermModel)(nil).FindOneWithTx), ctx, session, id)
 }
 
+// FindByUserIdForProduct mocks base method.
+func (m *MockSysUserPermModel) FindByUserIdForProduct(ctx context.Context, userId int64, productCode string) ([]userperm.SysUserPerm, error) {
+	m.ctrl.T.Helper()
+	ret := m.ctrl.Call(m, "FindByUserIdForProduct", ctx, userId, productCode)
+	ret0, _ := ret[0].([]userperm.SysUserPerm)
+	ret1, _ := ret[1].(error)
+	return ret0, ret1
+}
+
+// FindByUserIdForProduct indicates an expected call of FindByUserIdForProduct.
+func (mr *MockSysUserPermModelMockRecorder) FindByUserIdForProduct(ctx, userId, productCode any) *gomock.Call {
+	mr.mock.ctrl.T.Helper()
+	return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "FindByUserIdForProduct", reflect.TypeOf((*MockSysUserPermModel)(nil).FindByUserIdForProduct), ctx, userId, productCode)
+}
+
 // FindPermIdsByUserIdAndEffectForProduct mocks base method.
 func (m *MockSysUserPermModel) FindPermIdsByUserIdAndEffectForProduct(ctx context.Context, userId int64, effect, productCode string) ([]int64, error) {
 	m.ctrl.T.Helper()

+ 8 - 0
internal/types/types.go

@@ -126,6 +126,14 @@ type FetchInitialCredentialsResp struct {
 	AdminPassword string `json:"adminPassword"`
 }
 
+type GetUserPermsReq struct {
+	UserId int64 `json:"userId"`
+}
+
+type GetUserPermsResp struct {
+	Perms []UserPermItem `json:"perms"`
+}
+
 type IdResp struct {
 	Id int64 `json:"id"`
 }

+ 10 - 0
perm.api

@@ -299,6 +299,12 @@ type (
 		PermId int64  `json:"permId"`
 		Effect string `json:"effect"`
 	}
+	GetUserPermsReq {
+		UserId int64 `json:"userId"`
+	}
+	GetUserPermsResp {
+		Perms []UserPermItem `json:"perms"`
+	}
 	SetPermsReq {
 		UserId int64          `json:"userId"`
 		Perms  []UserPermItem `json:"perms"`
@@ -581,6 +587,10 @@ service perm-api {
 	@handler UserDetail
 	post /detail (UserDetailReq) returns (UserItem)
 
+	// GetUserPerms 查询用户在指定产品下的个性化权限(仅 ALLOW/DENY 覆盖项,不含角色继承)。超管不限;本人可查自己;其余需产品 ADMIN 权限且权限等级高于目标用户
+	@handler GetUserPerms
+	post /userPerms (GetUserPermsReq) returns (GetUserPermsResp)
+
 	// BindRoles 绑定用户角色。对指定用户在当前产品下做角色全量覆盖,支持权限级别校验防止越权分配
 	@handler BindRoles
 	post /bindRoles (BindRolesReq)

+ 20 - 7
test-design.md

@@ -588,7 +588,20 @@ MySQL (InnoDB) + Redis Cache
 | TC-1168 | POST /api/user/list | 产品 DEVELOPER 列表视角:全部成员 PII 原值返回 | `DeveloperCtx(P1)` | 同上 | 正向回归 | P0 | 与 detail TC-1165 对偶 |
 | TC-1169 | POST /api/user/list | 产品 MEMBER 列表视角:其他成员 PII 必须被置空 | `MemberCtx(P1)`;列表包含 2 个他人 PII | 他人 Email/Phone/Remark 均为空字符串;其他字段(id / nickname / deptId / memberType)保持完整 | 安全/最小授权 | P0 | 列表是 PII 最大暴露面;必须与 TC-1166 共同守护 |
 
-### 2.17 成员管理
+### 2.17 获取用户权限覆盖 `POST /api/user/userPerms`
+
+| TC编号 | 接口/方法 | 测试场景 | 输入参数 (JSON) | 预期结果 | 测试类型 | 优先级 | 覆盖说明 |
+| :--- | :--- | :--- | :--- | :--- | :--- | :--- | :--- |
+| TC-1257 | POST /api/user/userPerms | 超管查任意用户权限覆盖 | SuperAdminCtx + `{"userId": targetId}`;目标用户有 1 条 ALLOW 记录 | code=0;`perms` 含该 ALLOW 项 | 正常路径 | P0 | SuperAdmin 不受产品限制;FindByUserIdForProduct 正向 |
+| TC-1258 | POST /api/user/userPerms | 用户查询自己的权限覆盖 | MemberCtx + `{"userId": self}`;自身有 ALLOW 和 DENY 各 1 条 | code=0;`perms` 含 2 项(ALLOW + DENY) | 正常路径/self | P0 | isSelf 分支,跳过 RequireProductAdminFor + CheckManageAccess |
+| TC-1259 | POST /api/user/userPerms | 产品 ADMIN 查同产品 MEMBER 的权限覆盖 | AdminCtx + `{"userId": memberId}` | code=0;`perms` 返回目标用户在当前产品下的覆盖项 | 正常路径 | P0 | RequireProductAdminFor 通过 + CheckManageAccess 等级检查通过 |
+| TC-1260 | POST /api/user/userPerms | 产品 ADMIN 查同级 ADMIN 被拒绝 | AdminCtx + `{"userId": otherAdminId}` | code=403 | 安全/越权 | P0 | CheckManageAccess 等级相等不允许 |
+| TC-1261 | POST /api/user/userPerms | 普通 MEMBER 查他人被拒绝 | MemberCtx + `{"userId": otherUserId}` | code=403;文案含 "仅超级管理员或该产品的管理员" | 安全/枚举防护 | P0 | RequireProductAdminFor 前置拦截,防止 MEMBER 枚举 userId |
+| TC-1262 | POST /api/user/userPerms | 非 ADMIN 查不存在的 userId 必须 403(消除枚举 oracle) | MemberCtx + `{"userId": 999999999}` | code=403;文案含 "仅超级管理员或该产品的管理员";DB 无任何读取可见信息 | 安全/枚举 | P0 | RequireProductAdminFor 先于实体读取(审计 L-R13-1) |
+| TC-1263 | POST /api/user/userPerms | ADMIN 查不是当前产品成员的用户 | AdminCtx + `{"userId": userNotInProduct}` | code=403 | 安全 | P0 | CheckManageAccess 检测目标非成员 |
+| TC-1264 | POST /api/user/userPerms | 无 JWT 请求被拒绝 | 无 Authorization Header | code=401 | 安全 | P0 | JwtAuth 中间件拦截 |
+
+### 2.18 成员管理
 
 | TC编号 | 接口/方法 | 测试场景 | 输入参数 (JSON) | 预期结果 | 测试类型 | 优先级 | 覆盖说明 |
 | :--- | :--- | :--- | :--- | :--- | :--- | :--- | :--- |
@@ -653,7 +666,7 @@ MySQL (InnoDB) + Redis Cache
 | TC-1162 | POST /api/member/remove | 移除成员后被移除用户 sys_user.tokenVersion 必须 +1 | seed 2 个 ADMIN 绕过 last-admin,`{id: targetMemberId}` | DB `sys_user.tokenVersion` 严格 +1;`sys_product_member` 行被删;post-commit 产品成员缓存失效 | 安全/会话吊销 | P0 | 镜像 updateMember 的 tokenVersion 契约,避免被踢出产品后旧 access token 仍能访问该产品 |
 | TC-1163 | POST /api/member/remove | 移除失败(last-admin 场景)时 tokenVersion 绝不得 +1 | 唯一启用 ADMIN,`{id: adminMemberId}` | 返回 400 "不能移除该产品的最后一个管理员";DB `sys_user.tokenVersion` 与初值严格相等;`sys_product_member` 行仍在 | 事务回滚 | P0 | tokenVersion 增量必须与 member 删除同事务;失败路径不得污染 tokenVersion 让合法会话被无故踢下线 |
 
-### 2.18 获取验证码 `POST /api/captcha/get`
+### 2.19 获取验证码 `POST /api/captcha/get`
 
 | TC编号 | 接口/方法 | 测试场景 | 输入参数 (JSON) | 预期结果 | 测试类型 | 优先级 | 覆盖说明 |
 | :--- | :--- | :--- | :--- | :--- | :--- | :--- | :--- |
@@ -664,7 +677,7 @@ MySQL (InnoDB) + Redis Cache
 | TC-1253 | VerifyCaptcha | 错误码不消费 | Set(id, "5678") → Verify(id, "0000") | false | 单元 | P0 | 错误码不影响 store 状态 |
 | TC-1254 | VerifyCaptcha | 不存在的 id | Verify("non_existent", "1234") | false | 单元/边界 | P0 | 无条目直接拒绝 |
 
-### 2.19 Cap.js 端点 `POST /api/capjs/endpoint`
+### 2.20 Cap.js 端点 `POST /api/capjs/endpoint`
 
 | TC编号 | 接口/方法 | 测试场景 | 输入参数 (JSON) | 预期结果 | 测试类型 | 优先级 | 覆盖说明 |
 | :--- | :--- | :--- | :--- | :--- | :--- | :--- | :--- |
@@ -673,7 +686,7 @@ MySQL (InnoDB) + Redis Cache
 | TC-1255 | POST /api/capjs/endpoint | cap.js 启用但 EndpointURL 为空 | Capjs.Enable=1, EndpointURL="" | 200, data="" | 边界 | P0 | 启用但 URL 缺失时兜底返回空 |
 | TC-1256 | POST /api/capjs/endpoint | cap.js 启用但 Key 为空 | Capjs.Enable=1, Key="" | 200, data="" | 边界 | P0 | 启用但 Key 缺失时兜底返回空 |
 
-### 2.20 产品端 Cap.js 登录 `POST /api/auth/login/cap`
+### 2.21 产品端 Cap.js 登录 `POST /api/auth/login/cap`
 
 | TC编号 | 接口/方法 | 测试场景 | 输入参数 (JSON) | 预期结果 | 测试类型 | 优先级 | 覆盖说明 |
 | :--- | :--- | :--- | :--- | :--- | :--- | :--- | :--- |
@@ -684,7 +697,7 @@ MySQL (InnoDB) + Redis Cache
 | TC-1223 | POST /api/auth/login/cap | capToken 有效 + 密码错误 | mock 成功 + 错误密码 | 401 | 异常路径 | P0 | 验证通过后密码校验 |
 | TC-1224 | POST /api/auth/login/cap | capToken 有效 + 超管被拒绝 | mock 成功 + 超管用户 | 403 | 安全 | P0 | 超管不允许产品端登录 |
 
-### 2.21 管理后台 Cap.js 登录 `POST /api/auth/adminLogin/cap`
+### 2.22 管理后台 Cap.js 登录 `POST /api/auth/adminLogin/cap`
 
 | TC编号 | 接口/方法 | 测试场景 | 输入参数 (JSON) | 预期结果 | 测试类型 | 优先级 | 覆盖说明 |
 | :--- | :--- | :--- | :--- | :--- | :--- | :--- | :--- |
@@ -694,7 +707,7 @@ MySQL (InnoDB) + Redis Cache
 | TC-1228 | POST /api/auth/adminLogin/cap | capToken 有效 + 超管正常登录 | mock 成功 + 超管凭证 + 正确 managementKey | 200 + accessToken/refreshToken | 正常路径 | P0 | 全路径 |
 | TC-1229 | POST /api/auth/adminLogin/cap | capToken 有效 + 非超管被拒绝 | mock 成功 + 普通用户 + 正确 managementKey | 401 | 安全 | P0 | 非超管拒绝管理后台登录 |
 
-### 2.22 更新用户信息 `POST /api/auth/updateInfo`
+### 2.23 更新用户信息 `POST /api/auth/updateInfo`
 
 | TC编号 | 接口/方法 | 测试场景 | 输入参数 (JSON) | 预期结果 | 测试类型 | 优先级 | 覆盖说明 |
 | :--- | :--- | :--- | :--- | :--- | :--- | :--- | :--- |
@@ -710,7 +723,7 @@ MySQL (InnoDB) + Redis Cache
 | TC-1239 | POST /api/auth/updateInfo | 并发更新冲突 | 两 session 用相同旧 updateTime 提交 | 第二个返回错误 ErrUpdateConflict | 并发 | P0 | 乐观锁 WHERE updateTime=? |
 | TC-1240 | POST /api/auth/updateInfo | 更新后 UserDetails 缓存失效 | 更新 nickname 后 Load | 再次 Load 应读到新值 | 缓存一致性 | P0 | InvalidateProfileCache |
 
-### 2.23 MinIO 文件上传 `POST /api/minio/upload`
+### 2.24 MinIO 文件上传 `POST /api/minio/upload`
 
 | TC编号 | 接口/方法 | 测试场景 | 输入参数 (JSON) | 预期结果 | 测试类型 | 优先级 | 覆盖说明 |
 | :--- | :--- | :--- | :--- | :--- | :--- | :--- | :--- |

+ 26 - 13
test-report.md

@@ -1,6 +1,6 @@
 # 权限管理系统 (perms-system-server) — 测试报告
 
-> 报告日期: 2026-04-21(最新一轮,含 R18 审计覆盖)
+> 报告日期: 2026-05-13(新增 GetUserPerms 接口覆盖)
 > 测试范围: REST API (go-zero) + gRPC + Model 层 (自定义方法 + _gen.go 模板生成) + Logic 单元测试 + util 层 + 访问控制 + UserDetailsLoader + 中间件
 > 测试用例设计详见 [test-design.md](./test-design.md)
 > 执行命令: `go test -count=1 -timeout 600s ./...`
@@ -12,10 +12,10 @@
 | 指标 | 数值 |
 | :--- | :--- |
 | 测试包总数 | **28** |
-| TC 用例总数 (test-design.md) | **1021** |
-| 顶层测试函数数 (Functions) | **1089** |
-| 测试执行事件总数 (含 `t.Run` 子用例) | **1221** |
-| ✅ 通过 | **1220** |
+| TC 用例总数 (test-design.md) | **1029** |
+| 顶层测试函数数 (Functions) | **1097** |
+| 测试执行事件总数 (含 `t.Run` 子用例) | **1229** |
+| ✅ 通过 | **1228** |
 | ⏭️ 跳过 | **1** |
 | ❌ 失败 | **0**(本轮全绿) |
 | 通过率 (TC 维度) | **100%**(扣除 1 条不可达防御分支 Skip) |
@@ -594,7 +594,20 @@
 | TC-1168 | 产品 DEVELOPER 列表视角:全部成员 PII 原值返回 | ✅ pass |
 | TC-1169 | 产品 MEMBER 列表视角:其他成员 Email/Phone/Remark 必须为空字符串 | ✅ pass |
 
-### 2.17 成员管理
+### 2.17 获取用户权限覆盖 `POST /api/user/userPerms`
+
+| TC编号 | 测试场景 | 测试结果 |
+| :--- | :--- | :--- |
+| TC-1257 | 超管查任意用户权限覆盖 | ✅ pass |
+| TC-1258 | 用户查询自己的权限覆盖(ALLOW + DENY 各 1 条) | ✅ pass |
+| TC-1259 | 产品 ADMIN 查同产品 MEMBER 的权限覆盖 | ✅ pass |
+| TC-1260 | 产品 ADMIN 查同级 ADMIN 被拒绝(等级校验) | ✅ pass |
+| TC-1261 | 普通 MEMBER 查他人被拒绝(RequireProductAdminFor 前置) | ✅ pass |
+| TC-1262 | 非 ADMIN 查不存在 userId 必须 403(枚举防护) | ✅ pass |
+| TC-1263 | ADMIN 查不是当前产品成员的用户被拒绝 | ✅ pass |
+| TC-1264 | nil UserDetails(模拟无 JWT)返回 401 | ✅ pass |
+
+### 2.18 成员管理
 
 | TC编号 | 测试场景 | 测试结果 |
 | :--- | :--- | :--- |
@@ -659,7 +672,7 @@
 | TC-1162 | 移除成员后被移除用户 sys_user.tokenVersion 必须 +1 | ✅ pass |
 | TC-1163 | 移除失败(last-admin 场景)时 tokenVersion 绝不得 +1 | ✅ pass |
 
-### 2.18 获取验证码 `POST /api/captcha/get`
+### 2.19 获取验证码 `POST /api/captcha/get`
 
 | TC编号 | 测试场景 | 测试结果 |
 | :--- | :--- | :--- |
@@ -670,7 +683,7 @@
 | TC-1253 | VerifyCaptcha 错误码不消费 | ✅ pass |
 | TC-1254 | VerifyCaptcha 不存在的 id | ✅ pass |
 
-### 2.19 Cap.js 端点 `POST /api/capjs/endpoint`
+### 2.20 Cap.js 端点 `POST /api/capjs/endpoint`
 
 | TC编号 | 测试场景 | 测试结果 |
 | :--- | :--- | :--- |
@@ -679,7 +692,7 @@
 | TC-1255 | cap.js 启用但 EndpointURL 为空 | ✅ pass |
 | TC-1256 | cap.js 启用但 Key 为空 | ✅ pass |
 
-### 2.20 产品端 Cap.js 登录 `POST /api/auth/login/cap`
+### 2.21 产品端 Cap.js 登录 `POST /api/auth/login/cap`
 
 | TC编号 | 测试场景 | 测试结果 |
 | :--- | :--- | :--- |
@@ -690,7 +703,7 @@
 | TC-1223 | capToken 有效 + 密码错误 | ✅ pass |
 | TC-1224 | capToken 有效 + 超管被拒绝 | ✅ pass |
 
-### 2.21 管理后台 Cap.js 登录 `POST /api/auth/adminLogin/cap`
+### 2.22 管理后台 Cap.js 登录 `POST /api/auth/adminLogin/cap`
 
 | TC编号 | 测试场景 | 测试结果 |
 | :--- | :--- | :--- |
@@ -700,7 +713,7 @@
 | TC-1228 | capToken 有效 + 超管正常登录 | ✅ pass |
 | TC-1229 | capToken 有效 + 非超管被拒绝 | ✅ pass |
 
-### 2.22 更新用户信息 `POST /api/auth/updateInfo`
+### 2.23 更新用户信息 `POST /api/auth/updateInfo`
 
 | TC编号 | 测试场景 | 测试结果 |
 | :--- | :--- | :--- |
@@ -716,7 +729,7 @@
 | TC-1239 | 并发更新冲突 | ✅ pass |
 | TC-1240 | 更新后 UserDetails 缓存失效 | ✅ pass |
 
-### 2.23 MinIO 文件上传 `POST /api/minio/upload`
+### 2.24 MinIO 文件上传 `POST /api/minio/upload`
 
 | TC编号 | 测试场景 | 测试结果 |
 | :--- | :--- | :--- |
@@ -1449,7 +1462,7 @@
 
 ## 三、测试结论
 
-- **979 个 TC 全部执行**:顶层测试函数 **1089**,测试事件(含 `t.Run` 子用例)**1221**;通过 **1220**,跳过 **1**,失败 **0**。
+- **1029 个 TC 全部执行**:顶层测试函数 **1097**,测试事件(含 `t.Run` 子用例)**1229**;通过 **1228**,跳过 **1**,失败 **0**。
 - 26 个测试包全部 OK;`./internal/logic/...` 语句覆盖率 **86.9%**;整包连跑均绿,无并发 flake 触发。
 - 通过率(扣除主动 skip 的 1 条不可达防御分支 TC-0263):**100%**。
 - 核心业务路径(登录、刷新 Token、权限同步、用户/角色/成员/部门 CRUD、访问控制、限流、缓存失效、乐观锁、事务隔离、并发安全、会话吊销 tokenVersion 契约)均有独立回归用例覆盖且稳定通过。