Forráskód Böngészése

feat: 接口逻辑优化

BaiLuoYan 6 órája
szülő
commit
f0347d793c

+ 5 - 5
README.md

@@ -1181,7 +1181,7 @@ Content-Type: application/json
 
 | 字段 | 类型 | 必填 | 说明 |
 | ------ | ------ | ------ | ------ |
-| productCode | string | 是 | 产品编码 |
+| productCode | string | 超管必填 | 产品编码;超级管理员传入时按产品过滤,不传时返回全量跨产品数据;非超管从 JWT 上下文自动取,传入值被忽略 |
 | page | int64 | 否 | 页码 |
 | pageSize | int64 | 否 | 每页条数 |
 
@@ -1203,7 +1203,7 @@ Content-Type: application/json
 
 | 字段 | 类型 | 必填 | 说明 |
 | ------ | ------ | ------ | ------ |
-| productCode | string | 是 | 所属产品编码 |
+| productCode | string | 超管必填 | 所属产品编码;超级管理员必须显式传入;非超管从 JWT 上下文自动取,传入值被忽略 |
 | name | string | 是 | 角色名(产品内唯一) |
 | remark | string | 否 | 备注 |
 | permsLevel | int64 | 是 | 权限等级(数值越小权限越高,用于管理权限判定) |
@@ -1259,7 +1259,7 @@ Content-Type: application/json
 
 | 字段 | 类型 | 必填 | 说明 |
 | ------ | ------ | ------ | ------ |
-| productCode | string | 是 | 产品编码 |
+| productCode | string | 超管必填 | 产品编码;超级管理员传入时按产品过滤,不传时返回全量跨产品数据;非超管从 JWT 上下文自动取,传入值被忽略 |
 | page | int64 | 否 | 页码 |
 | pageSize | int64 | 否 | 每页条数 |
 
@@ -1535,7 +1535,7 @@ Content-Type: application/json
 
 | 字段 | 类型 | 必填 | 说明 |
 | ------ | ------ | ------ | ------ |
-| productCode | string | 是 | 产品编码 |
+| productCode | string | 超管必填 | 产品编码;超级管理员必须显式传入;非超管从 JWT 上下文自动取,传入值被忽略 |
 | userId | int64 | 是 | 用户 ID |
 | memberType | string | 是 | `ADMIN`(产品管理员)/ `DEVELOPER`(开发者,全权限)/ `MEMBER`(普通成员,按角色授权) |
 
@@ -1599,7 +1599,7 @@ Content-Type: application/json
 
 | 字段 | 类型 | 必填 | 说明 |
 | ------ | ------ | ------ | ------ |
-| productCode | string | 是 | 产品编码 |
+| productCode | string | 超管必填 | 产品编码;超级管理员传入时按产品过滤,不传时返回全量跨产品数据;非超管从 JWT 上下文自动取,传入值被忽略 |
 | page | int64 | 否 | 页码 |
 | pageSize | int64 | 否 | 每页条数 |
 

+ 24 - 11
internal/logic/member/addMemberLogic.go

@@ -7,6 +7,7 @@ import (
 	"perms-system-server/internal/consts"
 	"perms-system-server/internal/loaders"
 	authHelper "perms-system-server/internal/logic/auth"
+	"perms-system-server/internal/middleware"
 	"perms-system-server/internal/model/productmember"
 	"perms-system-server/internal/response"
 	"perms-system-server/internal/svc"
@@ -32,13 +33,25 @@ func NewAddMemberLogic(ctx context.Context, svcCtx *svc.ServiceContext) *AddMemb
 
 // AddMember 添加产品成员。将已有用户加入指定产品并设置成员类型(ADMIN/DEVELOPER/MEMBER),需产品 ADMIN 或超管权限。产品必须已启用。
 func (l *AddMemberLogic) AddMember(req *types.AddMemberReq) (resp *types.IdResp, err error) {
-	// 审计 L-R13-1:把"无权调此接口"的 403 提到所有实体读取之前。原先顺序(读产品 → 读用户 →
-	// memberType 字面校验 → RequireProductAdminFor)会让任何持有有效 JWT 的用户(即便只是
-	// MEMBER)通过响应码差分枚举"产品 code 是否在线 / 是否被禁用"以及"userId 是否存在 / 是否
-	// 已冻结"两条事实——比 R10-10 封住的 GetUserPerms 枚举面更宽。req.ProductCode 是入参,
-	// RequireProductAdminFor 已经在此处承担了"仅允许该产品 ADMIN/超管"的鉴权,提前不会改变
-	// 业务语义,仅把枚举面收敛到"已经拥有该产品 ADMIN 身份"的调用方内部。
-	if err := authHelper.RequireProductAdminFor(l.ctx, req.ProductCode); err != nil {
+	caller := middleware.GetUserDetails(l.ctx)
+	if caller == nil {
+		return nil, response.ErrUnauthorized("未登录")
+	}
+
+	var productCode string
+	if caller.IsSuperAdmin {
+		if req.ProductCode == "" {
+			return nil, response.ErrBadRequest("必须指定产品编码")
+		}
+		productCode = req.ProductCode
+	} else {
+		productCode = middleware.GetProductCode(l.ctx)
+		if productCode == "" {
+			return nil, response.ErrForbidden("缺少产品上下文")
+		}
+	}
+
+	if err := authHelper.RequireProductAdminFor(l.ctx, productCode); err != nil {
 		return nil, err
 	}
 	// 字面校验在 DB 读之前一并做掉——对非法 memberType 的请求直接 400,无需耗费 DB/缓存。
@@ -51,7 +64,7 @@ func (l *AddMemberLogic) AddMember(req *types.AddMemberReq) (resp *types.IdResp,
 		return nil, err
 	}
 
-	product, err := l.svcCtx.SysProductModel.FindOneByCode(l.ctx, req.ProductCode)
+	product, err := l.svcCtx.SysProductModel.FindOneByCode(l.ctx, productCode)
 	if err != nil {
 		return nil, response.ErrNotFound("产品不存在")
 	}
@@ -84,14 +97,14 @@ func (l *AddMemberLogic) AddMember(req *types.AddMemberReq) (resp *types.IdResp,
 		return nil, err
 	}
 
-	_, findErr := l.svcCtx.SysProductMemberModel.FindOneByProductCodeUserId(l.ctx, req.ProductCode, req.UserId)
+	_, findErr := l.svcCtx.SysProductMemberModel.FindOneByProductCodeUserId(l.ctx, productCode, req.UserId)
 	if findErr == nil {
 		return nil, response.ErrConflict("该用户已是该产品成员")
 	}
 
 	now := time.Now().Unix()
 	result, err := l.svcCtx.SysProductMemberModel.Insert(l.ctx, &productmember.SysProductMember{
-		ProductCode: req.ProductCode,
+		ProductCode: productCode,
 		UserId:      req.UserId,
 		MemberType:  req.MemberType,
 		Status:      consts.StatusEnabled,
@@ -109,7 +122,7 @@ func (l *AddMemberLogic) AddMember(req *types.AddMemberReq) (resp *types.IdResp,
 	// 防止 HTTP 层取消把 UD 旧状态悬挂到 TTL 结束。
 	cleanCtx, cancel := loaders.DetachCacheCleanCtx(l.ctx)
 	defer cancel()
-	l.svcCtx.UserDetailsLoader.Del(cleanCtx, req.UserId, req.ProductCode)
+	l.svcCtx.UserDetailsLoader.Del(cleanCtx, req.UserId, productCode)
 
 	id, _ := result.LastInsertId()
 	return &types.IdResp{Id: id}, nil

+ 65 - 7
internal/logic/member/addMemberLogic_test.go

@@ -356,14 +356,14 @@ func TestAddMember_DisabledProductRejected(t *testing.T) {
 	assert.Contains(t, ce.Error(), "禁用")
 }
 
-// TC-1107: 非 ADMIN caller + 不存在的 productCode —— 必须 403(不是 404),
-// L-R13-1:`RequireProductAdminFor` 先行于 `SysProductModel.FindOneByCode`,消除通过
-// "产品不存在 vs 权限不足"的响应码差分枚举产品 code 的 oracle。
+// TC-1107: 非超管 req.ProductCode 被忽略,枚举攻击路径从根本上被堵死。
+// L-R13-1 改造后:非超管的 productCode 统一从 JWT 获取,req.ProductCode 无论传什么都被忽略,
+// 因此无法通过 404/403 差分枚举产品 code。此测试验证传入不存在的 productCode 时被忽略,
+// 实际使用 JWT 的 productCode(some_other_product),因该产品不存在而返回 404。
 func TestAddMember_L_R13_1_ProductEnumerationBlocked(t *testing.T) {
 	svcCtx := svc.NewServiceContext(testutil.GetTestConfig())
 	conn := testutil.GetTestSqlConn()
 
-	// caller 是另一个产品的 ADMIN,对目标产品没有权限。
 	callerCtx := ctxhelper.AdminCtx("some_other_product")
 
 	_, err := NewAddMemberLogic(callerCtx, svcCtx).AddMember(&types.AddMemberReq{
@@ -374,10 +374,9 @@ func TestAddMember_L_R13_1_ProductEnumerationBlocked(t *testing.T) {
 	require.Error(t, err)
 	var ce *response.CodeError
 	require.True(t, errors.As(err, &ce))
-	assert.Equal(t, 403, ce.Code(),
-		"L-R13-1:productCode 不存在的差异必须被权限闸吞掉,不得 404 泄漏产品存在性")
+	assert.Equal(t, 404, ce.Code(),
+		"L-R13-1:非超管 req.ProductCode 被忽略,使用 JWT 的 productCode 查询产品,不存在则 404")
 
-	// 并发安全:DB 未因这条请求留下任何 sys_product_member 行。
 	var count int64
 	_ = conn.QueryRowCtx(callerCtx, &count,
 		"SELECT COUNT(*) FROM `sys_product_member` WHERE `userId` = ?", int64(999999999))
@@ -431,3 +430,62 @@ func TestAddMember_L_R13_1_SuperAdminStillGets400ForInvalidType(t *testing.T) {
 		"超管权限通过后必须继续走字面 400 检查,不得因 L-R13-1 改动被吞掉")
 	assert.Equal(t, "无效的成员类型", ce.Error())
 }
+
+// TC-1314: 非超管不传productCode时从JWT获取并正常添加
+func TestAddMember_NonSuperAdminUsesJWTProductCode(t *testing.T) {
+	svcCtx := svc.NewServiceContext(testutil.GetTestConfig())
+	conn := testutil.GetTestSqlConn()
+	now := time.Now().Unix()
+	pc := testutil.UniqueId()
+
+	pRes, err := svcCtx.SysProductModel.Insert(ctxhelper.SuperAdminCtx(), &productModel.SysProduct{
+		Code: pc, Name: "test_prod", AppKey: pc, AppSecret: "s1",
+		Status: 1, CreateTime: now, UpdateTime: now,
+	})
+	require.NoError(t, err)
+	pId, _ := pRes.LastInsertId()
+
+	uRes, err := svcCtx.SysUserModel.Insert(ctxhelper.SuperAdminCtx(), &userModel.SysUser{
+		Username: testutil.UniqueId(), Password: testutil.HashPassword("pass123"), Nickname: "nick",
+		Avatar: sql.NullString{}, DeptId: 1, IsSuperAdmin: 2, MustChangePassword: 2,
+		Status: 1, CreateTime: now, UpdateTime: now,
+	})
+	require.NoError(t, err)
+	uId, _ := uRes.LastInsertId()
+
+	t.Cleanup(func() {
+		testutil.CleanTableByField(ctxhelper.SuperAdminCtx(), conn, "`sys_product_member`", "productCode", pc)
+		testutil.CleanTable(ctxhelper.SuperAdminCtx(), conn, "`sys_user`", uId)
+		testutil.CleanTable(ctxhelper.SuperAdminCtx(), conn, "`sys_product`", pId)
+	})
+
+	ctx := ctxhelper.AdminCtx(pc)
+	logic := NewAddMemberLogic(ctx, svcCtx)
+	resp, err := logic.AddMember(&types.AddMemberReq{
+		UserId:     uId,
+		MemberType: "MEMBER",
+	})
+	require.NoError(t, err)
+	assert.Greater(t, resp.Id, int64(0))
+
+	member, err := svcCtx.SysProductMemberModel.FindOne(ctxhelper.SuperAdminCtx(), resp.Id)
+	require.NoError(t, err)
+	assert.Equal(t, pc, member.ProductCode)
+}
+
+// TC-1315: 超管不传productCode时返回400
+func TestAddMember_SuperAdminNoProductCodeReturns400(t *testing.T) {
+	ctx := ctxhelper.SuperAdminCtx()
+	svcCtx := svc.NewServiceContext(testutil.GetTestConfig())
+
+	logic := NewAddMemberLogic(ctx, svcCtx)
+	_, err := logic.AddMember(&types.AddMemberReq{
+		UserId:     1,
+		MemberType: "MEMBER",
+	})
+	require.Error(t, err)
+	ce, ok := err.(*response.CodeError)
+	require.True(t, ok)
+	assert.Equal(t, 400, ce.Code())
+	assert.Equal(t, "必须指定产品编码", ce.Error())
+}

+ 17 - 3
internal/logic/member/memberListLogic.go

@@ -4,6 +4,7 @@ import (
 	"context"
 
 	"perms-system-server/internal/middleware"
+	"perms-system-server/internal/model/productmember"
 	"perms-system-server/internal/response"
 	"perms-system-server/internal/svc"
 	"perms-system-server/internal/types"
@@ -42,11 +43,24 @@ func (l *MemberListLogic) MemberList(req *types.MemberListReq) (resp *types.Page
 	if caller == nil {
 		return nil, response.ErrUnauthorized("未登录")
 	}
-	if !caller.IsSuperAdmin && caller.ProductCode != req.ProductCode {
-		return nil, response.ErrForbidden("无权访问该产品的数据")
+
+	var productCode string
+	if caller.IsSuperAdmin {
+		productCode = req.ProductCode
+	} else {
+		productCode = middleware.GetProductCode(l.ctx)
+		if productCode == "" {
+			return nil, response.ErrForbidden("缺少产品上下文")
+		}
 	}
 
-	list, total, err := l.svcCtx.SysProductMemberModel.FindListByProductCode(l.ctx, req.ProductCode, page, pageSize)
+	var list []*productmember.SysProductMember
+	var total int64
+	if productCode != "" {
+		list, total, err = l.svcCtx.SysProductMemberModel.FindListByProductCode(l.ctx, productCode, page, pageSize)
+	} else {
+		list, total, err = l.svcCtx.SysProductMemberModel.FindListByPage(l.ctx, page, pageSize)
+	}
 	if err != nil {
 		return nil, err
 	}

+ 121 - 0
internal/logic/member/memberListLogic_test.go

@@ -147,3 +147,124 @@ func TestMemberList_DeletedUserEmptyInfo(t *testing.T) {
 	assert.Equal(t, "", items[0].Username)
 	assert.Equal(t, "", items[0].Nickname)
 }
+
+// TC-1305: 非超管不传productCode时从JWT获取
+func TestMemberList_NonSuperAdminUsesJWTProductCode(t *testing.T) {
+	svcCtx := svc.NewServiceContext(testutil.GetTestConfig())
+	conn := testutil.GetTestSqlConn()
+	now := time.Now().Unix()
+	pc := testutil.UniqueId()
+
+	uRes, err := svcCtx.SysUserModel.Insert(ctxhelper.SuperAdminCtx(), &userModel.SysUser{
+		Username: testutil.UniqueId(), Password: testutil.HashPassword("pass"), Nickname: "Nick",
+		Avatar: sql.NullString{}, IsSuperAdmin: 2, MustChangePassword: 2,
+		Status: 1, CreateTime: now, UpdateTime: now,
+	})
+	require.NoError(t, err)
+	uId, _ := uRes.LastInsertId()
+
+	mRes, err := svcCtx.SysProductMemberModel.Insert(ctxhelper.SuperAdminCtx(), &memberModel.SysProductMember{
+		ProductCode: pc, UserId: uId, MemberType: "MEMBER",
+		Status: 1, CreateTime: now, UpdateTime: now,
+	})
+	require.NoError(t, err)
+	mId, _ := mRes.LastInsertId()
+
+	t.Cleanup(func() {
+		testutil.CleanTable(ctxhelper.SuperAdminCtx(), conn, "`sys_product_member`", mId)
+		testutil.CleanTable(ctxhelper.SuperAdminCtx(), conn, "`sys_user`", uId)
+	})
+
+	ctx := ctxhelper.AdminCtx(pc)
+	logic := NewMemberListLogic(ctx, svcCtx)
+	resp, err := logic.MemberList(&types.MemberListReq{
+		Page:     1,
+		PageSize: 10,
+	})
+	require.NoError(t, err)
+	assert.Equal(t, int64(1), resp.Total)
+}
+
+// TC-1306: 非超管传了其他产品code时被忽略,仍返回JWT产品数据
+func TestMemberList_NonSuperAdminIgnoresReqProductCode(t *testing.T) {
+	svcCtx := svc.NewServiceContext(testutil.GetTestConfig())
+	conn := testutil.GetTestSqlConn()
+	now := time.Now().Unix()
+	pc := testutil.UniqueId()
+
+	uRes, err := svcCtx.SysUserModel.Insert(ctxhelper.SuperAdminCtx(), &userModel.SysUser{
+		Username: testutil.UniqueId(), Password: testutil.HashPassword("pass"), Nickname: "Nick",
+		Avatar: sql.NullString{}, IsSuperAdmin: 2, MustChangePassword: 2,
+		Status: 1, CreateTime: now, UpdateTime: now,
+	})
+	require.NoError(t, err)
+	uId, _ := uRes.LastInsertId()
+
+	mRes, err := svcCtx.SysProductMemberModel.Insert(ctxhelper.SuperAdminCtx(), &memberModel.SysProductMember{
+		ProductCode: pc, UserId: uId, MemberType: "MEMBER",
+		Status: 1, CreateTime: now, UpdateTime: now,
+	})
+	require.NoError(t, err)
+	mId, _ := mRes.LastInsertId()
+
+	t.Cleanup(func() {
+		testutil.CleanTable(ctxhelper.SuperAdminCtx(), conn, "`sys_product_member`", mId)
+		testutil.CleanTable(ctxhelper.SuperAdminCtx(), conn, "`sys_user`", uId)
+	})
+
+	ctx := ctxhelper.AdminCtx(pc)
+	logic := NewMemberListLogic(ctx, svcCtx)
+	resp, err := logic.MemberList(&types.MemberListReq{
+		ProductCode: "other_product_code",
+		Page:        1,
+		PageSize:    10,
+	})
+	require.NoError(t, err)
+	assert.Equal(t, int64(1), resp.Total)
+}
+
+// TC-1307: 超管不传productCode时返回全量数据
+func TestMemberList_SuperAdminNoProductCodeReturnsAll(t *testing.T) {
+	ctx := ctxhelper.SuperAdminCtx()
+	svcCtx := svc.NewServiceContext(testutil.GetTestConfig())
+	conn := testutil.GetTestSqlConn()
+	now := time.Now().Unix()
+
+	pc1 := testutil.UniqueId()
+	pc2 := testutil.UniqueId()
+
+	uRes, err := svcCtx.SysUserModel.Insert(ctx, &userModel.SysUser{
+		Username: testutil.UniqueId(), Password: testutil.HashPassword("pass"), Nickname: "Nick",
+		Avatar: sql.NullString{}, IsSuperAdmin: 2, MustChangePassword: 2,
+		Status: 1, CreateTime: now, UpdateTime: now,
+	})
+	require.NoError(t, err)
+	uId, _ := uRes.LastInsertId()
+
+	m1Res, err := svcCtx.SysProductMemberModel.Insert(ctx, &memberModel.SysProductMember{
+		ProductCode: pc1, UserId: uId, MemberType: "MEMBER",
+		Status: 1, CreateTime: now, UpdateTime: now,
+	})
+	require.NoError(t, err)
+	m1Id, _ := m1Res.LastInsertId()
+
+	m2Res, err := svcCtx.SysProductMemberModel.Insert(ctx, &memberModel.SysProductMember{
+		ProductCode: pc2, UserId: uId, MemberType: "ADMIN",
+		Status: 1, CreateTime: now, UpdateTime: now,
+	})
+	require.NoError(t, err)
+	m2Id, _ := m2Res.LastInsertId()
+
+	t.Cleanup(func() {
+		testutil.CleanTable(ctx, conn, "`sys_product_member`", m1Id, m2Id)
+		testutil.CleanTable(ctx, conn, "`sys_user`", uId)
+	})
+
+	logic := NewMemberListLogic(ctx, svcCtx)
+	resp, err := logic.MemberList(&types.MemberListReq{
+		Page:     1,
+		PageSize: 10000,
+	})
+	require.NoError(t, err)
+	assert.GreaterOrEqual(t, resp.Total, int64(2))
+}

+ 17 - 3
internal/logic/perm/permListLogic.go

@@ -4,6 +4,7 @@ import (
 	"context"
 
 	"perms-system-server/internal/middleware"
+	permModel "perms-system-server/internal/model/perm"
 	"perms-system-server/internal/response"
 	"perms-system-server/internal/svc"
 	"perms-system-server/internal/types"
@@ -34,11 +35,24 @@ func (l *PermListLogic) PermList(req *types.PermListReq) (resp *types.PageResp,
 	if caller == nil {
 		return nil, response.ErrUnauthorized("未登录")
 	}
-	if !caller.IsSuperAdmin && caller.ProductCode != req.ProductCode {
-		return nil, response.ErrForbidden("无权访问该产品的数据")
+
+	var productCode string
+	if caller.IsSuperAdmin {
+		productCode = req.ProductCode
+	} else {
+		productCode = middleware.GetProductCode(l.ctx)
+		if productCode == "" {
+			return nil, response.ErrForbidden("缺少产品上下文")
+		}
 	}
 
-	list, total, err := l.svcCtx.SysPermModel.FindListByProductCode(l.ctx, req.ProductCode, page, pageSize)
+	var list []*permModel.SysPerm
+	var total int64
+	if productCode != "" {
+		list, total, err = l.svcCtx.SysPermModel.FindListByProductCode(l.ctx, productCode, page, pageSize)
+	} else {
+		list, total, err = l.svcCtx.SysPermModel.FindListByPage(l.ctx, page, pageSize)
+	}
 	if err != nil {
 		return nil, err
 	}

+ 94 - 0
internal/logic/perm/permListLogic_test.go

@@ -117,3 +117,97 @@ func TestPermList_NonExistentProductCode(t *testing.T) {
 	require.NoError(t, err)
 	assert.Equal(t, int64(0), resp.Total)
 }
+
+// TC-1311: 非超管不传productCode时从JWT获取
+func TestPermList_NonSuperAdminUsesJWTProductCode(t *testing.T) {
+	svcCtx := svc.NewServiceContext(testutil.GetTestConfig())
+	conn := testutil.GetTestSqlConn()
+	now := time.Now().Unix()
+	pc := testutil.UniqueId()
+
+	res, err := svcCtx.SysPermModel.Insert(ctxhelper.SuperAdminCtx(), &permModel.SysPerm{
+		ProductCode: pc, Name: testutil.UniqueId(), Code: testutil.UniqueId(),
+		Status: 1, CreateTime: now, UpdateTime: now,
+	})
+	require.NoError(t, err)
+	id, _ := res.LastInsertId()
+
+	t.Cleanup(func() {
+		testutil.CleanTable(ctxhelper.SuperAdminCtx(), conn, "`sys_perm`", id)
+	})
+
+	ctx := ctxhelper.AdminCtx(pc)
+	logic := NewPermListLogic(ctx, svcCtx)
+	resp, err := logic.PermList(&types.PermListReq{
+		Page:     1,
+		PageSize: 10,
+	})
+	require.NoError(t, err)
+	assert.Equal(t, int64(1), resp.Total)
+}
+
+// TC-1312: 非超管传了其他产品code时被忽略
+func TestPermList_NonSuperAdminIgnoresReqProductCode(t *testing.T) {
+	svcCtx := svc.NewServiceContext(testutil.GetTestConfig())
+	conn := testutil.GetTestSqlConn()
+	now := time.Now().Unix()
+	pc := testutil.UniqueId()
+
+	res, err := svcCtx.SysPermModel.Insert(ctxhelper.SuperAdminCtx(), &permModel.SysPerm{
+		ProductCode: pc, Name: testutil.UniqueId(), Code: testutil.UniqueId(),
+		Status: 1, CreateTime: now, UpdateTime: now,
+	})
+	require.NoError(t, err)
+	id, _ := res.LastInsertId()
+
+	t.Cleanup(func() {
+		testutil.CleanTable(ctxhelper.SuperAdminCtx(), conn, "`sys_perm`", id)
+	})
+
+	ctx := ctxhelper.AdminCtx(pc)
+	logic := NewPermListLogic(ctx, svcCtx)
+	resp, err := logic.PermList(&types.PermListReq{
+		ProductCode: "other_product",
+		Page:        1,
+		PageSize:    10,
+	})
+	require.NoError(t, err)
+	assert.Equal(t, int64(1), resp.Total)
+}
+
+// TC-1313: 超管不传productCode时返回全量数据
+func TestPermList_SuperAdminNoProductCodeReturnsAll(t *testing.T) {
+	ctx := ctxhelper.SuperAdminCtx()
+	svcCtx := svc.NewServiceContext(testutil.GetTestConfig())
+	conn := testutil.GetTestSqlConn()
+	now := time.Now().Unix()
+
+	pc1 := testutil.UniqueId()
+	pc2 := testutil.UniqueId()
+
+	r1, err := svcCtx.SysPermModel.Insert(ctx, &permModel.SysPerm{
+		ProductCode: pc1, Name: testutil.UniqueId(), Code: testutil.UniqueId(),
+		Status: 1, CreateTime: now, UpdateTime: now,
+	})
+	require.NoError(t, err)
+	id1, _ := r1.LastInsertId()
+
+	r2, err := svcCtx.SysPermModel.Insert(ctx, &permModel.SysPerm{
+		ProductCode: pc2, Name: testutil.UniqueId(), Code: testutil.UniqueId(),
+		Status: 1, CreateTime: now, UpdateTime: now,
+	})
+	require.NoError(t, err)
+	id2, _ := r2.LastInsertId()
+
+	t.Cleanup(func() {
+		testutil.CleanTable(ctx, conn, "`sys_perm`", id1, id2)
+	})
+
+	logic := NewPermListLogic(ctx, svcCtx)
+	resp, err := logic.PermList(&types.PermListReq{
+		Page:     1,
+		PageSize: 10000,
+	})
+	require.NoError(t, err)
+	assert.GreaterOrEqual(t, resp.Total, int64(2))
+}

+ 21 - 4
internal/logic/role/createRoleLogic.go

@@ -32,11 +32,29 @@ func NewCreateRoleLogic(ctx context.Context, svcCtx *svc.ServiceContext) *Create
 
 // CreateRole 创建角色。在指定产品下新建角色并设置权限级别,需产品 ADMIN 或超管权限。产品必须存在且已启用。
 func (l *CreateRoleLogic) CreateRole(req *types.CreateRoleReq) (resp *types.IdResp, err error) {
-	if err := authHelper.RequireProductAdminFor(l.ctx, req.ProductCode); err != nil {
+	caller := middleware.GetUserDetails(l.ctx)
+	if caller == nil {
+		return nil, response.ErrUnauthorized("未登录")
+	}
+
+	var productCode string
+	if caller.IsSuperAdmin {
+		if req.ProductCode == "" {
+			return nil, response.ErrBadRequest("必须指定产品编码")
+		}
+		productCode = req.ProductCode
+	} else {
+		productCode = middleware.GetProductCode(l.ctx)
+		if productCode == "" {
+			return nil, response.ErrForbidden("缺少产品上下文")
+		}
+	}
+
+	if err := authHelper.RequireProductAdminFor(l.ctx, productCode); err != nil {
 		return nil, err
 	}
 
-	product, err := l.svcCtx.SysProductModel.FindOneByCode(l.ctx, req.ProductCode)
+	product, err := l.svcCtx.SysProductModel.FindOneByCode(l.ctx, productCode)
 	if err != nil {
 		return nil, response.ErrNotFound("产品不存在")
 	}
@@ -63,14 +81,13 @@ func (l *CreateRoleLogic) CreateRole(req *types.CreateRoleReq) (resp *types.IdRe
 	//   - SuperAdmin 不受限;
 	//   - 非超管(含 product ADMIN / DEVELOPER)创建时 permsLevel 必须 >= 2,permsLevel=1
 	//     作为"顶格语义"只允许 SuperAdmin 所生。
-	caller := middleware.GetUserDetails(l.ctx)
 	if err := authHelper.GuardCreateRolePermsLevel(l.ctx, l.svcCtx, caller, req.PermsLevel); err != nil {
 		return nil, err
 	}
 
 	now := time.Now().Unix()
 	result, err := l.svcCtx.SysRoleModel.Insert(l.ctx, &roleModel.SysRole{
-		ProductCode: req.ProductCode,
+		ProductCode: productCode,
 		Name:        req.Name,
 		Remark:      req.Remark,
 		Status:      consts.StatusEnabled,

+ 43 - 0
internal/logic/role/createRoleLogic_test.go

@@ -251,3 +251,46 @@ func TestCreateRole_H_R17_3_SuperAdminCanCreatePermsLevel1(t *testing.T) {
 	require.NoError(t, err)
 	assert.Equal(t, int64(1), role.PermsLevel)
 }
+
+// TC-1316: 非超管不传productCode时从JWT获取并正常创建
+func TestCreateRole_NonSuperAdminUsesJWTProductCode(t *testing.T) {
+	pc := testutil.UniqueId()
+	ctx := ctxhelper.AdminCtx(pc)
+	svcCtx := svc.NewServiceContext(testutil.GetTestConfig())
+	conn := testutil.GetTestSqlConn()
+	pid := mustInsertEnabledProduct(t, ctx, svcCtx, pc)
+
+	logic := NewCreateRoleLogic(ctx, svcCtx)
+	resp, err := logic.CreateRole(&types.CreateRoleReq{
+		Name:       testutil.UniqueId(),
+		PermsLevel: 5,
+	})
+	require.NoError(t, err)
+	assert.Greater(t, resp.Id, int64(0))
+
+	t.Cleanup(func() {
+		testutil.CleanTable(ctx, conn, "`sys_role`", resp.Id)
+		testutil.CleanTable(ctx, conn, "`sys_product`", pid)
+	})
+
+	role, err := svcCtx.SysRoleModel.FindOne(ctx, resp.Id)
+	require.NoError(t, err)
+	assert.Equal(t, pc, role.ProductCode)
+}
+
+// TC-1317: 超管不传productCode时返回400
+func TestCreateRole_SuperAdminNoProductCodeReturns400(t *testing.T) {
+	ctx := ctxhelper.SuperAdminCtx()
+	svcCtx := svc.NewServiceContext(testutil.GetTestConfig())
+
+	logic := NewCreateRoleLogic(ctx, svcCtx)
+	_, err := logic.CreateRole(&types.CreateRoleReq{
+		Name:       testutil.UniqueId(),
+		PermsLevel: 5,
+	})
+	require.Error(t, err)
+	var ce *response.CodeError
+	require.True(t, errors.As(err, &ce))
+	assert.Equal(t, 400, ce.Code())
+	assert.Equal(t, "必须指定产品编码", ce.Error())
+}

+ 17 - 3
internal/logic/role/roleListLogic.go

@@ -4,6 +4,7 @@ import (
 	"context"
 
 	"perms-system-server/internal/middleware"
+	roleModel "perms-system-server/internal/model/role"
 	"perms-system-server/internal/response"
 	"perms-system-server/internal/svc"
 	"perms-system-server/internal/types"
@@ -34,11 +35,24 @@ func (l *RoleListLogic) RoleList(req *types.RoleListReq) (resp *types.PageResp,
 	if caller == nil {
 		return nil, response.ErrUnauthorized("未登录")
 	}
-	if !caller.IsSuperAdmin && caller.ProductCode != req.ProductCode {
-		return nil, response.ErrForbidden("无权访问该产品的数据")
+
+	var productCode string
+	if caller.IsSuperAdmin {
+		productCode = req.ProductCode
+	} else {
+		productCode = middleware.GetProductCode(l.ctx)
+		if productCode == "" {
+			return nil, response.ErrForbidden("缺少产品上下文")
+		}
 	}
 
-	list, total, err := l.svcCtx.SysRoleModel.FindListByProductCode(l.ctx, req.ProductCode, page, pageSize)
+	var list []*roleModel.SysRole
+	var total int64
+	if productCode != "" {
+		list, total, err = l.svcCtx.SysRoleModel.FindListByProductCode(l.ctx, productCode, page, pageSize)
+	} else {
+		list, total, err = l.svcCtx.SysRoleModel.FindListByPage(l.ctx, page, pageSize)
+	}
 	if err != nil {
 		return nil, err
 	}

+ 94 - 0
internal/logic/role/roleListLogic_test.go

@@ -101,3 +101,97 @@ func TestRoleList_PageSizeExceedsLimit(t *testing.T) {
 	require.NoError(t, err)
 	assert.Equal(t, int64(1), resp.Total)
 }
+
+// TC-1308: 非超管不传productCode时从JWT获取
+func TestRoleList_NonSuperAdminUsesJWTProductCode(t *testing.T) {
+	svcCtx := svc.NewServiceContext(testutil.GetTestConfig())
+	conn := testutil.GetTestSqlConn()
+	now := time.Now().Unix()
+	pc := testutil.UniqueId()
+
+	res, err := svcCtx.SysRoleModel.Insert(ctxhelper.SuperAdminCtx(), &roleModel.SysRole{
+		ProductCode: pc, Name: testutil.UniqueId(), Status: 1, PermsLevel: 5,
+		CreateTime: now, UpdateTime: now,
+	})
+	require.NoError(t, err)
+	roleId, _ := res.LastInsertId()
+
+	t.Cleanup(func() {
+		testutil.CleanTable(ctxhelper.SuperAdminCtx(), conn, "`sys_role`", roleId)
+	})
+
+	ctx := ctxhelper.AdminCtx(pc)
+	logic := NewRoleListLogic(ctx, svcCtx)
+	resp, err := logic.RoleList(&types.RoleListReq{
+		Page:     1,
+		PageSize: 10,
+	})
+	require.NoError(t, err)
+	assert.Equal(t, int64(1), resp.Total)
+}
+
+// TC-1309: 非超管传了其他产品code时被忽略
+func TestRoleList_NonSuperAdminIgnoresReqProductCode(t *testing.T) {
+	svcCtx := svc.NewServiceContext(testutil.GetTestConfig())
+	conn := testutil.GetTestSqlConn()
+	now := time.Now().Unix()
+	pc := testutil.UniqueId()
+
+	res, err := svcCtx.SysRoleModel.Insert(ctxhelper.SuperAdminCtx(), &roleModel.SysRole{
+		ProductCode: pc, Name: testutil.UniqueId(), Status: 1, PermsLevel: 5,
+		CreateTime: now, UpdateTime: now,
+	})
+	require.NoError(t, err)
+	roleId, _ := res.LastInsertId()
+
+	t.Cleanup(func() {
+		testutil.CleanTable(ctxhelper.SuperAdminCtx(), conn, "`sys_role`", roleId)
+	})
+
+	ctx := ctxhelper.AdminCtx(pc)
+	logic := NewRoleListLogic(ctx, svcCtx)
+	resp, err := logic.RoleList(&types.RoleListReq{
+		ProductCode: "other_product",
+		Page:        1,
+		PageSize:    10,
+	})
+	require.NoError(t, err)
+	assert.Equal(t, int64(1), resp.Total)
+}
+
+// TC-1310: 超管不传productCode时返回全量数据
+func TestRoleList_SuperAdminNoProductCodeReturnsAll(t *testing.T) {
+	ctx := ctxhelper.SuperAdminCtx()
+	svcCtx := svc.NewServiceContext(testutil.GetTestConfig())
+	conn := testutil.GetTestSqlConn()
+	now := time.Now().Unix()
+
+	pc1 := testutil.UniqueId()
+	pc2 := testutil.UniqueId()
+
+	r1Res, err := svcCtx.SysRoleModel.Insert(ctx, &roleModel.SysRole{
+		ProductCode: pc1, Name: testutil.UniqueId(), Status: 1, PermsLevel: 1,
+		CreateTime: now, UpdateTime: now,
+	})
+	require.NoError(t, err)
+	r1Id, _ := r1Res.LastInsertId()
+
+	r2Res, err := svcCtx.SysRoleModel.Insert(ctx, &roleModel.SysRole{
+		ProductCode: pc2, Name: testutil.UniqueId(), Status: 1, PermsLevel: 2,
+		CreateTime: now, UpdateTime: now,
+	})
+	require.NoError(t, err)
+	r2Id, _ := r2Res.LastInsertId()
+
+	t.Cleanup(func() {
+		testutil.CleanTable(ctx, conn, "`sys_role`", r1Id, r2Id)
+	})
+
+	logic := NewRoleListLogic(ctx, svcCtx)
+	resp, err := logic.RoleList(&types.RoleListReq{
+		Page:     1,
+		PageSize: 10000,
+	})
+	require.NoError(t, err)
+	assert.GreaterOrEqual(t, resp.Total, int64(2))
+}

+ 10 - 8
internal/logic/user/userListLogic.go

@@ -37,12 +37,14 @@ func (l *UserListLogic) UserList(req *types.UserListReq) (resp *types.PageResp,
 	if caller == nil {
 		return nil, response.ErrUnauthorized("未登录")
 	}
-	if !caller.IsSuperAdmin {
-		if req.ProductCode == "" {
-			return nil, response.ErrForbidden("必须指定产品编码")
-		}
-		if caller.ProductCode != req.ProductCode {
-			return nil, response.ErrForbidden("无权访问该产品的数据")
+
+	var productCode string
+	if caller.IsSuperAdmin {
+		productCode = req.ProductCode
+	} else {
+		productCode = middleware.GetProductCode(l.ctx)
+		if productCode == "" {
+			return nil, response.ErrForbidden("缺少产品上下文")
 		}
 	}
 
@@ -50,9 +52,9 @@ func (l *UserListLogic) UserList(req *types.UserListReq) (resp *types.PageResp,
 	var total int64
 	var memberMap map[int64]string
 
-	if req.ProductCode != "" {
+	if productCode != "" {
 		var mtMap map[int64]string
-		list, mtMap, total, err = l.svcCtx.SysUserModel.FindListByProductMembers(l.ctx, req.ProductCode, page, pageSize)
+		list, mtMap, total, err = l.svcCtx.SysUserModel.FindListByProductMembers(l.ctx, productCode, page, pageSize)
 		if err != nil {
 			return nil, err
 		}

+ 47 - 21
internal/logic/user/userListLogic_test.go

@@ -1,13 +1,11 @@
 package user
 
 import (
-	"errors"
 	"testing"
 	"time"
 
 	productMemberModel "perms-system-server/internal/model/productmember"
 	userModelPkg "perms-system-server/internal/model/user"
-	"perms-system-server/internal/response"
 	"perms-system-server/internal/svc"
 	"perms-system-server/internal/testutil"
 	"perms-system-server/internal/testutil/ctxhelper"
@@ -209,21 +207,35 @@ func TestUserList_NonSuperAdminOnlySeeProductMembers(t *testing.T) {
 	}
 }
 
-// TC-0206: 非超管不带productCode时返回403
-func TestUserList_NonSuperAdminWithoutProductCode_Rejected(t *testing.T) {
-	ctx := ctxhelper.AdminCtx("test_product")
+// TC-0206: 非超管不传productCode时从JWT获取并正常返回
+func TestUserList_NonSuperAdminWithoutProductCode_UsesJWT(t *testing.T) {
 	svcCtx := svc.NewServiceContext(testutil.GetTestConfig())
+	conn := testutil.GetTestSqlConn()
+	superCtx := ctxhelper.SuperAdminCtx()
+	now := time.Now().Unix()
+	pc := testutil.UniqueId()
 
+	memberId := insertTestUser(t, superCtx, testutil.UniqueId(), testutil.HashPassword("pass"))
+	pmRes, err := svcCtx.SysProductMemberModel.Insert(superCtx, &productMemberModel.SysProductMember{
+		ProductCode: pc, UserId: memberId, MemberType: "MEMBER",
+		Status: 1, CreateTime: now, UpdateTime: now,
+	})
+	require.NoError(t, err)
+	pmId, _ := pmRes.LastInsertId()
+
+	t.Cleanup(func() {
+		testutil.CleanTable(superCtx, conn, "`sys_product_member`", pmId)
+		testutil.CleanTable(superCtx, conn, "`sys_user`", memberId)
+	})
+
+	ctx := ctxhelper.AdminCtx(pc)
 	logic := NewUserListLogic(ctx, svcCtx)
-	_, err := logic.UserList(&types.UserListReq{
+	resp, err := logic.UserList(&types.UserListReq{
 		Page:     1,
 		PageSize: 10,
 	})
-	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(), "必须指定产品编码")
+	require.NoError(t, err)
+	assert.GreaterOrEqual(t, resp.Total, int64(1))
 }
 
 // TC-1167 / TC-1168 / TC-1169 统一使用这套 PII 种子,保证 UserList 三类调用者看到的
@@ -359,20 +371,34 @@ func TestUserList_M_R16_1_ProductMember_MasksPII(t *testing.T) {
 	assert.NotEmpty(t, found.Username, "Username 不应被脱敏,保证 UI 还能渲染出成员行")
 }
 
-// TC-0207: 非超管访问其他产品数据被拒绝
-func TestUserList_NonSuperAdminWrongProductCode_Rejected(t *testing.T) {
-	ctx := ctxhelper.AdminCtx("product_a")
+// TC-0207: 非超管传了其他产品code时被忽略,仍返回JWT产品数据
+func TestUserList_NonSuperAdminIgnoresReqProductCode(t *testing.T) {
 	svcCtx := svc.NewServiceContext(testutil.GetTestConfig())
+	conn := testutil.GetTestSqlConn()
+	superCtx := ctxhelper.SuperAdminCtx()
+	now := time.Now().Unix()
+	pc := testutil.UniqueId()
 
+	memberId := insertTestUser(t, superCtx, testutil.UniqueId(), testutil.HashPassword("pass"))
+	pmRes, err := svcCtx.SysProductMemberModel.Insert(superCtx, &productMemberModel.SysProductMember{
+		ProductCode: pc, UserId: memberId, MemberType: "MEMBER",
+		Status: 1, CreateTime: now, UpdateTime: now,
+	})
+	require.NoError(t, err)
+	pmId, _ := pmRes.LastInsertId()
+
+	t.Cleanup(func() {
+		testutil.CleanTable(superCtx, conn, "`sys_product_member`", pmId)
+		testutil.CleanTable(superCtx, conn, "`sys_user`", memberId)
+	})
+
+	ctx := ctxhelper.AdminCtx(pc)
 	logic := NewUserListLogic(ctx, svcCtx)
-	_, err := logic.UserList(&types.UserListReq{
-		ProductCode: "product_b",
+	resp, err := logic.UserList(&types.UserListReq{
+		ProductCode: "other_product_code",
 		Page:        1,
 		PageSize:    10,
 	})
-	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(), "无权访问该产品的数据")
+	require.NoError(t, err)
+	assert.GreaterOrEqual(t, resp.Total, int64(1))
 }

+ 17 - 0
internal/model/perm/sysPermModel.go

@@ -18,6 +18,7 @@ type (
 	SysPermModel interface {
 		sysPermModel
 		FindListByProductCode(ctx context.Context, productCode string, page, pageSize int64) ([]*SysPerm, int64, error)
+		FindListByPage(ctx context.Context, page, pageSize int64) ([]*SysPerm, int64, error)
 		FindAllCodesByProductCode(ctx context.Context, productCode string) ([]string, error)
 		FindByIds(ctx context.Context, ids []int64) ([]*SysPerm, error)
 		// FindMapByProductCodeWithTx 在事务内查询权限快照;配合 SysProductModel.LockByCodeTx 锁住
@@ -54,6 +55,22 @@ func (m *customSysPermModel) FindListByProductCode(ctx context.Context, productC
 	return list, total, nil
 }
 
+func (m *customSysPermModel) FindListByPage(ctx context.Context, page, pageSize int64) ([]*SysPerm, int64, error) {
+	var total int64
+	countQuery := fmt.Sprintf("SELECT COUNT(*) FROM %s", m.table)
+	if err := m.QueryRowNoCacheCtx(ctx, &total, countQuery); err != nil {
+		return nil, 0, err
+	}
+
+	var list []*SysPerm
+	query := fmt.Sprintf("SELECT %s FROM %s ORDER BY id DESC LIMIT ?,?", sysPermRows, m.table)
+	if err := m.QueryRowsNoCacheCtx(ctx, &list, query, (page-1)*pageSize, pageSize); err != nil {
+		return nil, 0, err
+	}
+
+	return list, total, nil
+}
+
 func (m *customSysPermModel) FindAllCodesByProductCode(ctx context.Context, productCode string) ([]string, error) {
 	// 审计 L-1:第 7 轮 L-4 已经统一走 prepared statement 占位符,这里对齐同一口径,避免未来把
 	// StatusEnabled 从 int 改成 int8/枚举时 %d 悄悄不匹配;另一层收益是 SQL 指纹更稳定,慢查询

+ 17 - 0
internal/model/productmember/sysProductMemberModel.go

@@ -16,6 +16,7 @@ type (
 	SysProductMemberModel interface {
 		sysProductMemberModel
 		FindListByProductCode(ctx context.Context, productCode string, page, pageSize int64) ([]*SysProductMember, int64, error)
+		FindListByPage(ctx context.Context, page, pageSize int64) ([]*SysProductMember, int64, error)
 		// CountOtherActiveAdminsTx 统计"除 excludeId 这一行以外"的启用 ADMIN 数量,是
 		// "不能移除/降级最后一个 admin"这条不变式的唯一出口。之前同批引入的
 		// CountActiveAdminsTx(不带 Other)业务层零调用(调用方反向推导更绕且易错),
@@ -110,6 +111,22 @@ func (m *customSysProductMemberModel) FindByUserId(ctx context.Context, userId i
 	return list, nil
 }
 
+func (m *customSysProductMemberModel) FindListByPage(ctx context.Context, page, pageSize int64) ([]*SysProductMember, int64, error) {
+	var total int64
+	countQuery := fmt.Sprintf("SELECT COUNT(*) FROM %s", m.table)
+	if err := m.QueryRowNoCacheCtx(ctx, &total, countQuery); err != nil {
+		return nil, 0, err
+	}
+
+	var list []*SysProductMember
+	query := fmt.Sprintf("SELECT %s FROM %s ORDER BY id DESC LIMIT ?,?", sysProductMemberRows, m.table)
+	if err := m.QueryRowsNoCacheCtx(ctx, &list, query, (page-1)*pageSize, pageSize); err != nil {
+		return nil, 0, err
+	}
+
+	return list, total, nil
+}
+
 // FindActiveMemberUserIdsByProductCodeTx 见接口注释(审计 L-R15-3)。
 func (m *customSysProductMemberModel) FindActiveMemberUserIdsByProductCodeTx(ctx context.Context, session sqlx.Session, productCode string) ([]int64, error) {
 	var ids []int64

+ 17 - 0
internal/model/role/sysRoleModel.go

@@ -23,6 +23,7 @@ type (
 	SysRoleModel interface {
 		sysRoleModel
 		FindListByProductCode(ctx context.Context, productCode string, page, pageSize int64) ([]*SysRole, int64, error)
+		FindListByPage(ctx context.Context, page, pageSize int64) ([]*SysRole, int64, error)
 		FindByIds(ctx context.Context, ids []int64) ([]*SysRole, error)
 		FindMinPermsLevelByUserIdAndProductCode(ctx context.Context, userId int64, productCode string) (int64, error)
 		UpdateWithOptLock(ctx context.Context, data *SysRole, expectedUpdateTime int64) error
@@ -98,6 +99,22 @@ func (m *customSysRoleModel) FindByIds(ctx context.Context, ids []int64) ([]*Sys
 	return list, nil
 }
 
+func (m *customSysRoleModel) FindListByPage(ctx context.Context, page, pageSize int64) ([]*SysRole, int64, error) {
+	var total int64
+	countQuery := fmt.Sprintf("SELECT COUNT(*) FROM %s", m.table)
+	if err := m.QueryRowNoCacheCtx(ctx, &total, countQuery); err != nil {
+		return nil, 0, err
+	}
+
+	var list []*SysRole
+	query := fmt.Sprintf("SELECT %s FROM %s ORDER BY `permsLevel` ASC, id DESC LIMIT ?,?", sysRoleRows, m.table)
+	if err := m.QueryRowsNoCacheCtx(ctx, &list, query, (page-1)*pageSize, pageSize); err != nil {
+		return nil, 0, err
+	}
+
+	return list, total, nil
+}
+
 func (m *customSysRoleModel) UpdateWithOptLock(ctx context.Context, data *SysRole, expectedUpdateTime int64) error {
 	sysRoleIdKey := fmt.Sprintf("%s%v", cacheSysRoleIdPrefix, data.Id)
 	sysRoleProductCodeNameKey := fmt.Sprintf("%s%v:%v", cacheSysRoleProductCodeNamePrefix, data.ProductCode, data.Name)

+ 16 - 15
internal/testutil/mocks/mock_perm_model.go

@@ -200,35 +200,36 @@ func (mr *MockSysPermModelMockRecorder) FindByIds(ctx, ids any) *gomock.Call {
 	return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "FindByIds", reflect.TypeOf((*MockSysPermModel)(nil).FindByIds), ctx, ids)
 }
 
-// FindListByProductCode mocks base method.
-func (m *MockSysPermModel) FindListByProductCode(ctx context.Context, productCode string, page, pageSize int64) ([]*perm.SysPerm, int64, error) {
+// FindListByPage mocks base method.
+func (m *MockSysPermModel) FindListByPage(ctx context.Context, page, pageSize int64) ([]*perm.SysPerm, int64, error) {
 	m.ctrl.T.Helper()
-	ret := m.ctrl.Call(m, "FindListByProductCode", ctx, productCode, page, pageSize)
+	ret := m.ctrl.Call(m, "FindListByPage", ctx, page, pageSize)
 	ret0, _ := ret[0].([]*perm.SysPerm)
 	ret1, _ := ret[1].(int64)
 	ret2, _ := ret[2].(error)
 	return ret0, ret1, ret2
 }
 
-// FindListByProductCode indicates an expected call of FindListByProductCode.
-func (mr *MockSysPermModelMockRecorder) FindListByProductCode(ctx, productCode, page, pageSize any) *gomock.Call {
+// FindListByPage indicates an expected call of FindListByPage.
+func (mr *MockSysPermModelMockRecorder) FindListByPage(ctx, page, pageSize any) *gomock.Call {
 	mr.mock.ctrl.T.Helper()
-	return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "FindListByProductCode", reflect.TypeOf((*MockSysPermModel)(nil).FindListByProductCode), ctx, productCode, page, pageSize)
+	return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "FindListByPage", reflect.TypeOf((*MockSysPermModel)(nil).FindListByPage), ctx, page, pageSize)
 }
 
-// FindMapByProductCode mocks base method.
-func (m *MockSysPermModel) FindMapByProductCode(ctx context.Context, productCode string) (map[string]*perm.SysPerm, error) {
+// FindListByProductCode mocks base method.
+func (m *MockSysPermModel) FindListByProductCode(ctx context.Context, productCode string, page, pageSize int64) ([]*perm.SysPerm, int64, error) {
 	m.ctrl.T.Helper()
-	ret := m.ctrl.Call(m, "FindMapByProductCode", ctx, productCode)
-	ret0, _ := ret[0].(map[string]*perm.SysPerm)
-	ret1, _ := ret[1].(error)
-	return ret0, ret1
+	ret := m.ctrl.Call(m, "FindListByProductCode", ctx, productCode, page, pageSize)
+	ret0, _ := ret[0].([]*perm.SysPerm)
+	ret1, _ := ret[1].(int64)
+	ret2, _ := ret[2].(error)
+	return ret0, ret1, ret2
 }
 
-// FindMapByProductCode indicates an expected call of FindMapByProductCode.
-func (mr *MockSysPermModelMockRecorder) FindMapByProductCode(ctx, productCode any) *gomock.Call {
+// FindListByProductCode indicates an expected call of FindListByProductCode.
+func (mr *MockSysPermModelMockRecorder) FindListByProductCode(ctx, productCode, page, pageSize any) *gomock.Call {
 	mr.mock.ctrl.T.Helper()
-	return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "FindMapByProductCode", reflect.TypeOf((*MockSysPermModel)(nil).FindMapByProductCode), ctx, productCode)
+	return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "FindListByProductCode", reflect.TypeOf((*MockSysPermModel)(nil).FindListByProductCode), ctx, productCode, page, pageSize)
 }
 
 // FindMapByProductCodeWithTx mocks base method.

+ 31 - 15
internal/testutil/mocks/mock_productmember_model.go

@@ -185,6 +185,37 @@ func (mr *MockSysProductMemberModelMockRecorder) FindActiveMemberUserIdsByProduc
 	return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "FindActiveMemberUserIdsByProductCodeTx", reflect.TypeOf((*MockSysProductMemberModel)(nil).FindActiveMemberUserIdsByProductCodeTx), ctx, session, productCode)
 }
 
+// FindByUserId mocks base method.
+func (m *MockSysProductMemberModel) FindByUserId(ctx context.Context, userId int64) ([]*productmember.SysProductMember, error) {
+	m.ctrl.T.Helper()
+	ret := m.ctrl.Call(m, "FindByUserId", ctx, userId)
+	ret0, _ := ret[0].([]*productmember.SysProductMember)
+	ret1, _ := ret[1].(error)
+	return ret0, ret1
+}
+
+// FindByUserId indicates an expected call of FindByUserId.
+func (mr *MockSysProductMemberModelMockRecorder) FindByUserId(ctx, userId any) *gomock.Call {
+	mr.mock.ctrl.T.Helper()
+	return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "FindByUserId", reflect.TypeOf((*MockSysProductMemberModel)(nil).FindByUserId), ctx, userId)
+}
+
+// FindListByPage mocks base method.
+func (m *MockSysProductMemberModel) FindListByPage(ctx context.Context, page, pageSize int64) ([]*productmember.SysProductMember, int64, error) {
+	m.ctrl.T.Helper()
+	ret := m.ctrl.Call(m, "FindListByPage", ctx, page, pageSize)
+	ret0, _ := ret[0].([]*productmember.SysProductMember)
+	ret1, _ := ret[1].(int64)
+	ret2, _ := ret[2].(error)
+	return ret0, ret1, ret2
+}
+
+// FindListByPage indicates an expected call of FindListByPage.
+func (mr *MockSysProductMemberModelMockRecorder) FindListByPage(ctx, page, pageSize any) *gomock.Call {
+	mr.mock.ctrl.T.Helper()
+	return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "FindListByPage", reflect.TypeOf((*MockSysProductMemberModel)(nil).FindListByPage), ctx, page, pageSize)
+}
+
 // FindListByProductCode mocks base method.
 func (m *MockSysProductMemberModel) FindListByProductCode(ctx context.Context, productCode string, page, pageSize int64) ([]*productmember.SysProductMember, int64, error) {
 	m.ctrl.T.Helper()
@@ -376,18 +407,3 @@ func (mr *MockSysProductMemberModelMockRecorder) UpdateWithTx(ctx, session, data
 	mr.mock.ctrl.T.Helper()
 	return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpdateWithTx", reflect.TypeOf((*MockSysProductMemberModel)(nil).UpdateWithTx), ctx, session, data)
 }
-
-// FindByUserId mocks base method.
-func (m *MockSysProductMemberModel) FindByUserId(ctx context.Context, userId int64) ([]*productmember.SysProductMember, error) {
-	m.ctrl.T.Helper()
-	ret := m.ctrl.Call(m, "FindByUserId", ctx, userId)
-	ret0, _ := ret[0].([]*productmember.SysProductMember)
-	ret1, _ := ret[1].(error)
-	return ret0, ret1
-}
-
-// FindByUserId indicates an expected call of FindByUserId.
-func (mr *MockSysProductMemberModelMockRecorder) FindByUserId(ctx, userId any) *gomock.Call {
-	mr.mock.ctrl.T.Helper()
-	return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "FindByUserId", reflect.TypeOf((*MockSysProductMemberModel)(nil).FindByUserId), ctx, userId)
-}

+ 16 - 0
internal/testutil/mocks/mock_role_model.go

@@ -170,6 +170,22 @@ func (mr *MockSysRoleModelMockRecorder) FindByIds(ctx, ids any) *gomock.Call {
 	return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "FindByIds", reflect.TypeOf((*MockSysRoleModel)(nil).FindByIds), ctx, ids)
 }
 
+// FindListByPage mocks base method.
+func (m *MockSysRoleModel) FindListByPage(ctx context.Context, page, pageSize int64) ([]*role.SysRole, int64, error) {
+	m.ctrl.T.Helper()
+	ret := m.ctrl.Call(m, "FindListByPage", ctx, page, pageSize)
+	ret0, _ := ret[0].([]*role.SysRole)
+	ret1, _ := ret[1].(int64)
+	ret2, _ := ret[2].(error)
+	return ret0, ret1, ret2
+}
+
+// FindListByPage indicates an expected call of FindListByPage.
+func (mr *MockSysRoleModelMockRecorder) FindListByPage(ctx, page, pageSize any) *gomock.Call {
+	mr.mock.ctrl.T.Helper()
+	return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "FindListByPage", reflect.TypeOf((*MockSysRoleModel)(nil).FindListByPage), ctx, page, pageSize)
+}
+
 // FindListByProductCode mocks base method.
 func (m *MockSysRoleModel) FindListByProductCode(ctx context.Context, productCode string, page, pageSize int64) ([]*role.SysRole, int64, error) {
 	m.ctrl.T.Helper()

+ 5 - 5
internal/types/types.go

@@ -4,7 +4,7 @@
 package types
 
 type AddMemberReq struct {
-	ProductCode string `json:"productCode"`
+	ProductCode string `json:"productCode,optional"`
 	UserId      int64  `json:"userId"`
 	MemberType  string `json:"memberType"`
 }
@@ -79,7 +79,7 @@ type CreateProductResp struct {
 }
 
 type CreateRoleReq struct {
-	ProductCode string `json:"productCode"`
+	ProductCode string `json:"productCode,optional"`
 	Name        string `json:"name"`
 	Remark      string `json:"remark,optional"`
 	PermsLevel  int64  `json:"permsLevel"`
@@ -188,7 +188,7 @@ type MemberItem struct {
 }
 
 type MemberListReq struct {
-	ProductCode string `json:"productCode"`
+	ProductCode string `json:"productCode,optional"`
 	Page        int64  `json:"page,optional"`
 	PageSize    int64  `json:"pageSize,optional"`
 }
@@ -209,7 +209,7 @@ type PermItem struct {
 }
 
 type PermListReq struct {
-	ProductCode string `json:"productCode"`
+	ProductCode string `json:"productCode,optional"`
 	Page        int64  `json:"page,optional"`
 	PageSize    int64  `json:"pageSize,optional"`
 }
@@ -267,7 +267,7 @@ type RoleItem struct {
 }
 
 type RoleListReq struct {
-	ProductCode string `json:"productCode"`
+	ProductCode string `json:"productCode,optional"`
 	Page        int64  `json:"page,optional"`
 	PageSize    int64  `json:"pageSize,optional"`
 }

+ 5 - 5
perm.api

@@ -142,7 +142,7 @@ type (
 // ==================== Perm ====================
 type (
 	PermListReq {
-		ProductCode string `json:"productCode"`
+		ProductCode string `json:"productCode,optional"`
 		Page        int64  `json:"page,optional"`
 		PageSize    int64  `json:"pageSize,optional"`
 	}
@@ -175,7 +175,7 @@ type (
 // ==================== Role ====================
 type (
 	CreateRoleReq {
-		ProductCode string `json:"productCode"`
+		ProductCode string `json:"productCode,optional"`
 		Name        string `json:"name"`
 		Remark      string `json:"remark,optional"`
 		PermsLevel  int64  `json:"permsLevel"`
@@ -191,7 +191,7 @@ type (
 		Id int64 `json:"id"`
 	}
 	RoleListReq {
-		ProductCode string `json:"productCode"`
+		ProductCode string `json:"productCode,optional"`
 		Page        int64  `json:"page,optional"`
 		PageSize    int64  `json:"pageSize,optional"`
 	}
@@ -344,7 +344,7 @@ type (
 // ==================== Product Member ====================
 type (
 	AddMemberReq {
-		ProductCode string `json:"productCode"`
+		ProductCode string `json:"productCode,optional"`
 		UserId      int64  `json:"userId"`
 		MemberType  string `json:"memberType"`
 	}
@@ -360,7 +360,7 @@ type (
 		Id int64 `json:"id"`
 	}
 	MemberListReq {
-		ProductCode string `json:"productCode"`
+		ProductCode string `json:"productCode,optional"`
 		Page        int64  `json:"page,optional"`
 		PageSize    int64  `json:"pageSize,optional"`
 	}

+ 16 - 3
test-design.md

@@ -372,6 +372,9 @@ MySQL (InnoDB) + Redis Cache
 | TC-0114 | POST /api/perm/list | 默认分页 | `{"productCode":"p1"}` | page=1, pageSize=20 | 分支覆盖 | P1 | NormalizePage |
 | TC-0115 | POST /api/perm/list | pageSize超过上限 | `{"productCode":"p1","pageSize":200}` | 实际pageSize=100 | 边界 | P0 | NormalizePage cap |
 | TC-0116 | POST /api/perm/list | 不存在的productCode | `{"productCode":"notexist"}` | total=0, list=[] | 边界 | P1 | 空结果 |
+| TC-1311 | POST /api/perm/list | 非超管不传productCode时从JWT获取 | `AdminCtx(pc)` + `{}` | 从JWT取productCode,返回本产品权限数据 | 安全 | P0 | 非超管productCode统一从JWT获取 |
+| TC-1312 | POST /api/perm/list | 非超管传了其他产品code时被忽略 | `AdminCtx(pc)` + `{"productCode":"other"}` | 忽略req.ProductCode,仍返回JWT产品数据 | 安全 | P0 | 非超管req.ProductCode被忽略 |
+| TC-1313 | POST /api/perm/list | 超管不传productCode时返回全量数据 | `SuperAdminCtx` + `{}` | 返回跨产品全量权限数据 | 正常路径 | P0 | 超管不传productCode走FindListByPage |
 
 ### 2.11 角色管理
 
@@ -384,6 +387,9 @@ MySQL (InnoDB) + Redis Cache
 | TC-0121 | POST /api/role/update | 不存在 | `{"id":9999,...}` | code=404 | 异常路径 | P0 | FindOne失败 |
 | TC-0122 | POST /api/role/list | 正常查询 | `{"productCode":"p1","page":1,"pageSize":10}` | code=0 | 正常路径 | P0 | roleListLogic |
 | TC-0123 | POST /api/role/list | pageSize超过上限 | `{"productCode":"p1","pageSize":200}` | 实际pageSize=100 | 边界 | P0 | NormalizePage cap |
+| TC-1308 | POST /api/role/list | 非超管不传productCode时从JWT获取 | `AdminCtx(pc)` + `{}` | 从JWT取productCode,返回本产品角色数据 | 安全 | P0 | 非超管productCode统一从JWT获取 |
+| TC-1309 | POST /api/role/list | 非超管传了其他产品code时被忽略 | `AdminCtx(pc)` + `{"productCode":"other"}` | 忽略req.ProductCode,仍返回JWT产品数据 | 安全 | P0 | 非超管req.ProductCode被忽略 |
+| TC-1310 | POST /api/role/list | 超管不传productCode时返回全量数据 | `SuperAdminCtx` + `{}` | 返回跨产品全量角色数据 | 正常路径 | P0 | 超管不传productCode走FindListByPage |
 | TC-0124 | POST /api/role/detail | 正常查询 | `{"id":1}` | code=0, 含permIds | 正常路径 | P0 | roleDetailLogic |
 | TC-0125 | POST /api/role/detail | 不存在 | `{"id":9999}` | code=404 | 异常路径 | P0 | FindOne失败 |
 | TC-0730 | POST /api/role/* | 非超管 admin 把 roleA.PermsLevel 从 100 调到 10(数字变小 = 提升权级) | AdminCtx,PermsLevel 100→10 | 403 "非超管不能提升角色的权限级别",DB 保持 100 | 安全 | P0 | caller.IsSuperAdmin=false && newLevel<oldLevel(数字越小 = 权限越高);R12 后错误消息从"降低"修正为"提升" |
@@ -403,6 +409,8 @@ MySQL (InnoDB) + Redis Cache
 | TC-1197 | POST /api/role/create | 非超管 product ADMIN 禁止创建 `permsLevel=1` 顶格角色 | `AdminCtx(pc)` + `{productCode:pc, name:...,permsLevel:1}` | `CodeError.Code()==403`;文案含 "权限级别为 1 的顶格角色";DB 无新角色 | 安全/纵向越权 | P0 | 顶格角色只能由 SuperAdmin 创建;若放行 ADMIN,其可"建 R_super + BindRoles 给下属" 绕开 `GuardRoleLevelAssignable` 的同级拦截,形成等价横向提权链路 |
 | TC-1198 | POST /api/role/create | product ADMIN 创建 `permsLevel>=2` 次级角色放行 | `AdminCtx(pc)` + `{productCode:pc, name:..., permsLevel:2}` | 成功;DB `sys_role.permsLevel=2` | 正向回归 | P0 | 防 `GuardCreateRolePermsLevel` 过度收紧把合法业务路径也打死 |
 | TC-1199 | POST /api/role/create | SuperAdmin 不受 `permsLevel=1` 约束 | `SuperAdminCtx()` + `{..., permsLevel:1}` | 成功;DB `sys_role.permsLevel=1` | 正向回归 | P0 | SuperAdmin 是顶格角色的唯一合法来源;若回滚把超管也拦住,系统将没有任何路径能初始化 permsLevel=1 的角色 |
+| TC-1316 | POST /api/role/create | 非超管不传productCode时从JWT获取并正常创建 | `AdminCtx(pc)` + `{name:..., permsLevel:5}` 不传productCode | 从JWT取productCode,创建成功,DB落盘productCode=pc | 正常路径 | P0 | 非超管productCode统一从JWT获取 |
+| TC-1317 | POST /api/role/create | 超管不传productCode时返回400 | `SuperAdminCtx` + `{name:..., permsLevel:5}` 不传productCode | `CodeError.Code()==400`,文案含 "必须指定产品编码" | 参数校验 | P0 | 超管写操作必须显式传入productCode |
 | TC-1204 | POST /api/role/update | UpdateRole 重命名后旧 name 索引缓存必须失效 | 超管创建角色 name=A;第一次 Load 让 `sysRole:productCode:name:<pc>:A` 写入缓存;UpdateRole 把 name 改为 B;再次 `FindOneByProductCodeName(pc, "A")` | `FindOneByProductCodeName` 返回 `sqlx.ErrNotFound`(不得返回旧行数据);说明 post-commit `InvalidateRoleCache(oldName)` 已把 Redis 里的 `<pc>:A` 索引键清掉 | 缓存一致性/安全 | P0 | `UpdateWithOptLock` 内部只失效新 name 键;rename 路径的旧 name 键必须在 post-commit 由 `InvalidateRoleCache(prevName)` 显式清除,否则 Redis TTL 窗口内同名并发创建会命中幽灵快照 |
 
 ### 2.12 删除角色 `POST /api/role/delete`
@@ -558,8 +566,8 @@ MySQL (InnoDB) + Redis Cache
 | TC-0203 | POST /api/user/updateStatus | 冻结自己 | id=当前登录userId | code=400, "不能修改自己的状态" | 自我保护 | P0 | callerId==req.Id |
 | TC-0204 | POST /api/user/updateStatus | 冻结超管 | id=超管 | code=403, "不能修改超级管理员的状态" | 超管保护 | P0 | IsSuperAdmin==1 |
 | TC-0205 | POST /api/user/list | userList-非超管仅可见产品成员 | ctx=ADMIN(非超管), productCode指定 | 仅返回该产品成员, 不返回非成员 | 安全 | P0 | FindListByProductMembers数据隔离 |
-| TC-0206 | POST /api/user/list | userList-非超管未指定productCode被拒绝 | ctx=ADMIN(非超管), productCode="" | 403 "必须指定产品编码" | 安全 | P0 | 强制productCode |
-| TC-0207 | POST /api/user/list | userList-非超管使用错误productCode被拒绝 | ctx=ADMIN, productCode!=ctx.ProductCode | 403 | 安全 | P0 | productCode一致性校验 |
+| TC-0206 | POST /api/user/list | userList-非超管不传productCode时从JWT获取 | ctx=ADMIN(非超管), productCode="" | 从JWT取productCode,正常返回本产品成员数据 | 安全 | P0 | 非超管productCode统一从JWT获取 |
+| TC-0207 | POST /api/user/list | userList-非超管传了其他产品code时被忽略 | ctx=ADMIN, productCode="other" | 忽略req.ProductCode,仍返回JWT产品数据 | 安全 | P0 | 非超管req.ProductCode被忽略 |
 | TC-0208 | POST /api/user/bindRoles | bindRoles-permsLevel越权拒绝 | ctx=ADMIN(MinPermsLevel=50), role.permsLevel=1 | 403 "不能分配权限级别高于自身的角色" | 安全 | P0 | 角色权限级别越权防护 |
 | TC-0209 | POST /api/user/bindRoles | bindRoles-超管可分配任意级别角色 | ctx=SuperAdmin, role.permsLevel=1 | 绑定成功 | 正常路径 | P0 | 超管无permsLevel限制 |
 | TC-0210 | POST /api/user/setPerms | 同一权限ID冲突Effect被拒绝 | perms含[{permId:1,effect:"ALLOW"},{permId:1,effect:"DENY"}] | 400 "同一权限ID不能同时为 ALLOW 和 DENY" | 业务约束 | P0 | seen[permId]冲突检测 |
@@ -630,6 +638,9 @@ MySQL (InnoDB) + Redis Cache
 | TC-0223 | POST /api/member/list | 成员用户已删除 | userId不存在于FindByIds结果 | username/nickname为空 | 分支覆盖 | P1 | userMap无对应key |
 | TC-0224 | POST /api/member/list | pageSize超过上限 | `{"productCode":"p1","pageSize":200}` | 实际pageSize=100 | 边界 | P0 | NormalizePage cap |
 | TC-0225 | POST /api/member/list | 空成员列表 | productCode下无成员 | total=0, list=[], 不调FindByIds | 分支覆盖 | P1 | userIds空 |
+| TC-1305 | POST /api/member/list | 非超管不传productCode时从JWT获取 | `AdminCtx(pc)` + `{}` | 从JWT取productCode,返回本产品成员数据 | 安全 | P0 | 非超管productCode统一从JWT获取 |
+| TC-1306 | POST /api/member/list | 非超管传了其他产品code时被忽略 | `AdminCtx(pc)` + `{"productCode":"other"}` | 忽略req.ProductCode,仍返回JWT产品数据 | 安全 | P0 | 非超管req.ProductCode被忽略 |
+| TC-1307 | POST /api/member/list | 超管不传productCode时返回全量数据 | `SuperAdminCtx` + `{}` | 返回跨产品全量成员数据 | 正常路径 | P0 | 超管不传productCode走FindListByPage |
 | TC-0226 | POST /api/member/remove | 正常移除+级联(事务内) | `{"id":1}` (含角色/权限) | code=0, user_role+user_perm同步清理 | 正常+事务 | P0 | TransactCtx全路径 |
 | TC-0227 | POST /api/member/remove | 跨产品隔离 | 用户在多产品有角色 | 仅清理该产品的 | 深度业务 | P0 | ForProductTx子查询 |
 | TC-0228 | POST /api/member/remove | 成员不存在 | `{"id":9999}` | code=404, "成员不存在" | 异常路径 | P0 | FindOne失败 |
@@ -682,9 +693,11 @@ MySQL (InnoDB) + Redis Cache
 | TC-1135 | POST /api/member/update | 降级事务失败(last-admin 400):sys_user.tokenVersion 不变 | 唯一启用 ADMIN,`{MemberType:"MEMBER"}` | 返回 400 "最后一个管理员";DB `sys_user.tokenVersion` 严格等于初值;`sys_product_member` 行内容也保持原状 | 事务回滚 | P0 | 关键:tokenVersion 增量必须与 member 更新在同一事务里;业务失败不得污染 tokenVersion |
 | TC-1136 | POST /api/member/update | no-op 更新不递增 tokenVersion | 传进来的 memberType/status 与 DB 现值相同 | `tokenVersion` 不变;早退分支不进事务 | 正向/幂等 | P0 | `locked.MemberType==nextType && locked.Status==nextStatus` 早退 |
 | TC-1137 | POST /api/member/update | 降级成功后 post-commit 失效 sysUser id-key / username-key 两把缓存 | seed ADMIN→降级 MEMBER,先预热 `FindOne(id)` + `FindOneByUsername(name)` 把缓存灌入 Redis | 事务成功返回后两把 cache key 均被 DEL;下一次 FindOne 取到 DB 中递增后的 tokenVersion | 缓存一致性 | P0 | UD loader 下次 cache-miss 重建时不得从旧 sysUser 缓存把 tokenVersion 抹回 |
-| TC-1107 | POST /api/member/add | 非 ADMIN caller + **不存在的 productCode**:必须 403(不是 404)以消除 productCode 枚举 oracle | `MemberCtx("other_product")` + `ProductCode="does_not_exist"` | `CodeError.Code()==403`(不是 404 "产品不存在");DB 无 `sys_product_member` 新增 | 安全/枚举 | P0 | 反回归:`RequireProductAdminFor` 必须先于 `SysProductModel.FindOneByCode` |
+| TC-1107 | POST /api/member/add | 非超管 req.ProductCode 被忽略,枚举攻击路径从根本上被堵死 | `AdminCtx("some_other_product")` + `ProductCode="does_not_exist"` | 忽略 req.ProductCode,使用 JWT 的 productCode 查询产品,不存在则 404;DB 无 `sys_product_member` 新增 | 安全/枚举 | P0 | 改造后非超管无法指定任意 productCode,枚举攻击路径不存在 |
 | TC-1108 | POST /api/member/add | 非 ADMIN caller + 非法 `MemberType`:返回 403 而不是 400(权限优先于字面校验) | `MemberCtx` + `MemberType="INVALID"` | `CodeError.Code()==403`(不是 400 "无效的成员类型") | 安全/枚举 | P0 | 防通过 400/404 差分探测产品/用户存在性 |
 | TC-1109 | POST /api/member/add | 超管 + 非法 `MemberType`:正常 400 | `SuperAdminCtx` + `MemberType="INVALID"`(产品存在) | `CodeError.Code()==400`,文案含 "无效的成员类型" | 正向回归 | P0 | 确认权限通过后仍走字面 400 检查,不误伤合法路径 |
+| TC-1314 | POST /api/member/add | 非超管不传productCode时从JWT获取并正常添加 | `AdminCtx(pc)` + `{userId:..., memberType:"MEMBER"}` 不传productCode | 从JWT取productCode,添加成功,DB落盘productCode=pc | 正常路径 | P0 | 非超管productCode统一从JWT获取 |
+| TC-1315 | POST /api/member/add | 超管不传productCode时返回400 | `SuperAdminCtx` + `{userId:..., memberType:"MEMBER"}` 不传productCode | `CodeError.Code()==400`,文案含 "必须指定产品编码" | 参数校验 | P0 | 超管写操作必须显式传入productCode |
 | 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 让合法会话被无故踢下线 |
 

+ 19 - 4
test-report.md

@@ -1,6 +1,6 @@
 # 权限管理系统 (perms-system-server) — 测试报告
 
-> 报告日期: 2026-05-19(GetUserPerms 移除自查豁免、超管必须显式传 productCode)
+> 报告日期: 2026-05-19(ProductCode 统一从 JWT 获取改造 + 超管列表全量查询
 > 测试范围: REST API (go-zero) + gRPC + Model 层 (自定义方法 + _gen.go 模板生成) + Logic 单元测试 + util 层 + 访问控制 + UserDetailsLoader + 中间件
 > 测试用例设计详见 [test-design.md](./test-design.md)
 > 执行命令: `go test -count=1 -timeout 600s ./...`
@@ -72,6 +72,8 @@
 > **已修复的回归点**:上一轮报告中的 TC-1078 `TestBindRoles_Vs_DeleteRole_NoOrphanRows` 文案 flake,本轮随 `bindRolesLogic` 三路径统一为 `包含无效的角色ID` 的改动(L-R14-2)一并收敛;并发断言现已与新文案对齐,单独 / 整包执行均稳定 pass。本轮变更:`POST /api/user/bindRoles` 请求体新增 `productCode` 字段。SUPER_ADMIN 的 JWT 中 `productCode` 为空字符串,原实现直接从 JWT context 读取导致 member 查询失败("目标用户不是当前产品的成员")。修复后 logic 优先使用 `req.ProductCode`(非空时覆盖 JWT context 值),所有 `BindRolesReq` 测试用例已同步补充 `ProductCode` 字段。
 >
 > **本轮新增**:用户凭证票据机制(CreateUser 移除 Password 字段改为服务端生成 + ticket 一次性领取、ResetPassword、FetchUserCredentials),新增 TC-1280 ~ TC-1299 共 20 条用例。同时修复 `userrole` 包 4 条测试因 `FindRoleIdsByUserId` 新增 `INNER JOIN sys_role` 过滤导致的 pre-existing 失败(测试数据未在 `sys_role` 表中创建对应角色记录)。
+>
+> **本轮新增(ProductCode 统一改造)**:非超管接口的 productCode 统一从 JWT context 获取,req.ProductCode 被忽略;超管列表接口不传 productCode 时返回全量跨产品数据;超管写操作仍必须显式传入 productCode。Model 层新增 `FindListByPage`(productmember/role/perm 三个 model)。新增 TC-1305 ~ TC-1317 共 13 条用例,更新 TC-0206、TC-0207、TC-1107 的测试语义以匹配改造后行为。
 
 ---
 
@@ -381,6 +383,9 @@
 | TC-0114 | 默认分页 | ✅ pass |
 | TC-0115 | pageSize超过上限 | ✅ pass |
 | TC-0116 | 不存在的productCode | ✅ pass |
+| TC-1311 | 非超管不传productCode时从JWT获取 | ✅ pass |
+| TC-1312 | 非超管传了其他产品code时被忽略 | ✅ pass |
+| TC-1313 | 超管不传productCode时返回全量数据 | ✅ pass |
 
 ### 2.11 角色管理
 
@@ -393,6 +398,9 @@
 | TC-0121 | 不存在 | ✅ pass |
 | TC-0122 | 正常查询 | ✅ pass |
 | TC-0123 | pageSize超过上限 | ✅ pass |
+| TC-1308 | 非超管不传productCode时从JWT获取 | ✅ pass |
+| TC-1309 | 非超管传了其他产品code时被忽略 | ✅ pass |
+| TC-1310 | 超管不传productCode时返回全量数据 | ✅ pass |
 | TC-0124 | 正常查询 | ✅ pass |
 | TC-0125 | 不存在 | ✅ pass |
 | TC-0730 | 非超管 admin 把 roleA.PermsLevel 从 100 调到 10(数字变小 = 提升权级) | ✅ pass |
@@ -412,6 +420,8 @@
 | TC-1197 | 非超管 product ADMIN 不得创建 `PermsLevel=1` 顶格角色(H-R17-3) | ✅ pass |
 | TC-1198 | 非超管 product ADMIN 可创建 `PermsLevel>=2` 角色(正向回归) | ✅ pass |
 | TC-1199 | SuperAdmin 仍可创建 `PermsLevel=1` 顶格角色(正向回归) | ✅ pass |
+| TC-1316 | createRole 非超管不传productCode时从JWT获取并正常创建 | ✅ pass |
+| TC-1317 | createRole 超管不传productCode时返回400 | ✅ pass |
 | TC-1204 | UpdateRole rename 后旧 name 索引缓存必须被 InvalidateRoleCache(prevName) 显式清除(H-R18-1) | ✅ pass |
 
 ### 2.12 删除角色 `POST /api/role/delete`
@@ -583,8 +593,8 @@
 | TC-0203 | 冻结自己 | ✅ pass |
 | TC-0204 | 冻结超管 | ✅ pass |
 | TC-0205 | userList-非超管仅可见产品成员 | ✅ pass |
-| TC-0206 | userList-非超管未指定productCode被拒绝 | ✅ pass |
-| TC-0207 | userList-非超管使用错误productCode被拒绝 | ✅ pass |
+| TC-0206 | userList-非超管不传productCode时从JWT获取 | ✅ pass |
+| TC-0207 | userList-非超管传了其他产品code时被忽略 | ✅ pass |
 | TC-0208 | bindRoles-permsLevel越权拒绝 | ✅ pass |
 | TC-0209 | bindRoles-超管可分配任意级别角色 | ✅ pass |
 | TC-0210 | 同一权限ID冲突Effect被拒绝 | ✅ pass |
@@ -660,6 +670,9 @@
 | TC-0223 | 成员用户已删除 | ✅ pass |
 | TC-0224 | pageSize超过上限 | ✅ pass |
 | TC-0225 | 空成员列表 | ✅ pass |
+| TC-1305 | 非超管不传productCode时从JWT获取 | ✅ pass |
+| TC-1306 | 非超管传了其他产品code时被忽略 | ✅ pass |
+| TC-1307 | 超管不传productCode时返回全量数据 | ✅ pass |
 | TC-0226 | 正常移除+级联(事务内) | ✅ pass |
 | TC-0227 | 跨产品隔离 | ✅ pass |
 | TC-0228 | 成员不存在 | ✅ pass |
@@ -694,9 +707,11 @@
 | TC-1058 | DEVELOPER → 只改 Status 时跳过"分配校验" | ✅ pass |
 | TC-1059 | 非法 Status 值(例如 7)→ 400 | ✅ pass |
 | TC-1060 | 完全 no-op(传进来的值与 DB 现值相同)→ 返 nil 且 updateTime 不前进 | ✅ pass |
-| TC-1107 | addMember 非 ADMIN caller + 不存在 productCode → 403(阻断 productCode 枚举) | ✅ pass |
+| TC-1107 | addMember 非超管 req.ProductCode 被忽略,枚举攻击路径堵死 | ✅ pass |
 | TC-1108 | addMember 非 ADMIN caller + 非法 MemberType → 403(权限优先于字面校验) | ✅ pass |
 | TC-1109 | addMember 超管 + 非法 MemberType → 400(正向回归,权限闸没误伤字面 400) | ✅ pass |
+| TC-1314 | addMember 非超管不传productCode时从JWT获取并正常添加 | ✅ pass |
+| TC-1315 | addMember 超管不传productCode时返回400 | ✅ pass |
 | TC-1130 | 降级 ADMIN→MEMBER 同事务递增 sys_user.tokenVersion | ✅ pass |
 | TC-1131 | 禁用启用成员(Status 1→2)时 tokenVersion+1 | ✅ pass |
 | TC-1132 | 降级 DEVELOPER→MEMBER 时 tokenVersion+1(privileged 并集) | ✅ pass |