Browse Source

feat: 超管管理成员权限/角色时,需传productCode字段

BaiLuoYan 2 days ago
parent
commit
96ea269cc7

+ 3 - 2
README.md

@@ -321,7 +321,7 @@ POST /api/user/create  {"username": "lisi", "password": "123456", "nickname": "
 # 返回 userId=11
 
 POST /api/member/add    {"productCode": "crm", "userId": 11, "memberType": "MEMBER"}
-POST /api/user/bindRoles  {"userId": 11, "roleIds": [2]}  # 分配"普通销售"角色
+POST /api/user/bindRoles  {"userId": 11, "roleIds": [2], "productCode": "crm"}  # 分配"普通销售"角色
 ```
 
 李四登录 CRM 后拥有的权限 = 普通销售角色的权限 = `[customer:list, customer:create, order:list, order:create]`。
@@ -348,7 +348,7 @@ POST /api/user/create   {"username": "temp_wang", "password": "123456", "nicknam
 # deptId 不传,不归属任何部门
 
 POST /api/member/add    {"productCode": "crm", "userId": 12, "memberType": "MEMBER"}
-POST /api/user/bindRoles  {"userId": 12, "roleIds": [3]}  # 分配"客服"角色(只读)
+POST /api/user/bindRoles  {"userId": 12, "roleIds": [3], "productCode": "crm"}  # 分配"客服"角色(只读)
 ```
 
 ### 权限配置决策树
@@ -1392,6 +1392,7 @@ Content-Type: application/json
 | ------ | ------ | ------ | ------ |
 | userId | int64 | 是 | 用户 ID |
 | roleIds | []int64 | 是 | 角色 ID 列表(全量替换) |
+| productCode | string | 否 | 产品编码;仅 SUPER_ADMIN 有效,非空时覆盖 JWT context 中的 productCode。SUPER_ADMIN 的 JWT 不携带 productCode,调用时必须显式传入此字段;非超管调用者忽略此字段,始终使用 JWT context 中的 productCode |
 
 #### POST /api/user/setPerms — 设置用户权限覆盖(需管理权限)
 

+ 3 - 0
internal/logic/user/bindRolesLogic.go

@@ -54,6 +54,9 @@ func (l *BindRolesLogic) BindRoles(req *types.BindRolesReq) error {
 	}
 
 	productCode := middleware.GetProductCode(l.ctx)
+	if caller.IsSuperAdmin && req.ProductCode != "" {
+		productCode = req.ProductCode
+	}
 	if err := authHelper.CheckManageAccess(l.ctx, l.svcCtx, req.UserId, productCode, authHelper.WithPrefetchedTarget(targetUser)); err != nil {
 		return err
 	}

+ 117 - 33
internal/logic/user/bindRolesLogic_test.go

@@ -80,8 +80,9 @@ func TestBindRoles_Success(t *testing.T) {
 
 	logic := NewBindRolesLogic(ctx, svcCtx)
 	err := logic.BindRoles(&types.BindRolesReq{
-		UserId:  userId,
-		RoleIds: []int64{r1, r2},
+		UserId:      userId,
+		RoleIds:     []int64{r1, r2},
+		ProductCode: "test_product",
 	})
 	require.NoError(t, err)
 
@@ -129,14 +130,16 @@ func TestBindRoles_EmptyRoleIds_ClearsAll(t *testing.T) {
 
 	logic := NewBindRolesLogic(ctx, svcCtx)
 	err := logic.BindRoles(&types.BindRolesReq{
-		UserId:  userId,
-		RoleIds: []int64{r1},
+		UserId:      userId,
+		RoleIds:     []int64{r1},
+		ProductCode: "test_product",
 	})
 	require.NoError(t, err)
 
 	err = logic.BindRoles(&types.BindRolesReq{
-		UserId:  userId,
-		RoleIds: []int64{},
+		UserId:      userId,
+		RoleIds:     []int64{},
+		ProductCode: "test_product",
 	})
 	require.NoError(t, err)
 
@@ -168,14 +171,16 @@ func TestBindRoles_Rebind(t *testing.T) {
 
 	logic := NewBindRolesLogic(ctx, svcCtx)
 	err := logic.BindRoles(&types.BindRolesReq{
-		UserId:  userId,
-		RoleIds: []int64{r1, r2},
+		UserId:      userId,
+		RoleIds:     []int64{r1, r2},
+		ProductCode: "test_product",
 	})
 	require.NoError(t, err)
 
 	err = logic.BindRoles(&types.BindRolesReq{
-		UserId:  userId,
-		RoleIds: []int64{r2, r3},
+		UserId:      userId,
+		RoleIds:     []int64{r2, r3},
+		ProductCode: "test_product",
 	})
 	require.NoError(t, err)
 
@@ -205,8 +210,9 @@ func TestBindRoles_RoleBelongsToOtherProduct(t *testing.T) {
 
 	logic := NewBindRolesLogic(ctx, svcCtx)
 	err := logic.BindRoles(&types.BindRolesReq{
-		UserId:  userId,
-		RoleIds: []int64{otherRole},
+		UserId:      userId,
+		RoleIds:     []int64{otherRole},
+		ProductCode: "test_product",
 	})
 	require.Error(t, err)
 
@@ -237,8 +243,9 @@ func TestBindRoles_RoleDisabled(t *testing.T) {
 
 	logic := NewBindRolesLogic(ctx, svcCtx)
 	err := logic.BindRoles(&types.BindRolesReq{
-		UserId:  userId,
-		RoleIds: []int64{disabledRole},
+		UserId:      userId,
+		RoleIds:     []int64{disabledRole},
+		ProductCode: "test_product",
 	})
 	require.Error(t, err)
 
@@ -283,7 +290,7 @@ func TestBindRoles_L_R14_2_InvalidIdsUnifiedMessage(t *testing.T) {
 	var codes []int
 	var msgs []string
 	for _, c := range cases {
-		err := logic.BindRoles(&types.BindRolesReq{UserId: userId, RoleIds: c.roleIds})
+		err := logic.BindRoles(&types.BindRolesReq{UserId: userId, RoleIds: c.roleIds, ProductCode: "test_product"})
 		require.Error(t, err, c.name)
 		var ce *response.CodeError
 		require.True(t, errors.As(err, &ce), c.name)
@@ -317,8 +324,9 @@ func TestBindRoles_RoleNotExists(t *testing.T) {
 
 	logic := NewBindRolesLogic(ctx, svcCtx)
 	err := logic.BindRoles(&types.BindRolesReq{
-		UserId:  userId,
-		RoleIds: []int64{999999999},
+		UserId:      userId,
+		RoleIds:     []int64{999999999},
+		ProductCode: "test_product",
 	})
 	require.Error(t, err)
 
@@ -426,8 +434,9 @@ func TestBindRoles_PermsLevelEscalation_Rejected(t *testing.T) {
 
 	logic := NewBindRolesLogic(ctx, svcCtx)
 	err := logic.BindRoles(&types.BindRolesReq{
-		UserId:  targetUserId,
-		RoleIds: []int64{highLevelRole},
+		UserId:      targetUserId,
+		RoleIds:     []int64{highLevelRole},
+		ProductCode: productCode,
 	})
 	require.Error(t, err)
 	var ce *response.CodeError
@@ -476,8 +485,9 @@ func TestBindRoles_AdminBypassesPermsLevelCheck(t *testing.T) {
 
 	logic := NewBindRolesLogic(ctx, svcCtx)
 	err := logic.BindRoles(&types.BindRolesReq{
-		UserId:  userId,
-		RoleIds: []int64{lowLevelRole},
+		UserId:      userId,
+		RoleIds:     []int64{lowLevelRole},
+		ProductCode: productCode,
 	})
 	require.NoError(t, err, "ADMIN 调用者应当能绑定任意级别的角色")
 
@@ -527,8 +537,9 @@ func TestBindRoles_DeveloperBypassesPermsLevelCheck(t *testing.T) {
 
 	logic := NewBindRolesLogic(ctx, svcCtx)
 	err := logic.BindRoles(&types.BindRolesReq{
-		UserId:  userId,
-		RoleIds: []int64{lowLevelRole},
+		UserId:      userId,
+		RoleIds:     []int64{lowLevelRole},
+		ProductCode: productCode,
 	})
 	require.NoError(t, err, "DEVELOPER 调用者应当能绑定任意级别的角色")
 }
@@ -575,8 +586,9 @@ func TestBindRoles_MemberWithSentinelMinLevel_NotBlocked(t *testing.T) {
 	// bindRoles 内部的 permsLevel 分支。实际发生于 ADMIN 通过上层校验但 MemberType 上下文异常时的防御。
 	// 这里只断言:"sentinel 路径不应报 403 '不能分配权限级别高于自身的角色'"。
 	err := logic.BindRoles(&types.BindRolesReq{
-		UserId:  userId,
-		RoleIds: []int64{role},
+		UserId:      userId,
+		RoleIds:     []int64{role},
+		ProductCode: productCode,
 	})
 	// 调用者非 ADMIN,且是 MEMBER,上游会拦 403 "仅ADMIN/超管可绑定角色";
 	// 此处我们只校验"即使走到 permsLevel 分支,sentinel MinPermsLevel 不应命中"
@@ -610,8 +622,9 @@ func TestBindRoles_SuperAdminCanAssignAnyLevel(t *testing.T) {
 
 	logic := NewBindRolesLogic(ctx, svcCtx)
 	err := logic.BindRoles(&types.BindRolesReq{
-		UserId:  userId,
-		RoleIds: []int64{highLevelRole},
+		UserId:      userId,
+		RoleIds:     []int64{highLevelRole},
+		ProductCode: productCode,
 	})
 	require.NoError(t, err)
 
@@ -632,8 +645,9 @@ func TestBindRoles_NonMemberRejected(t *testing.T) {
 
 	logic := NewBindRolesLogic(ctx, svcCtx)
 	err := logic.BindRoles(&types.BindRolesReq{
-		UserId:  userId,
-		RoleIds: []int64{},
+		UserId:      userId,
+		RoleIds:     []int64{},
+		ProductCode: "test_product",
 	})
 	require.Error(t, err)
 
@@ -711,8 +725,9 @@ func TestBindRoles_Vs_DeleteRole_NoOrphanRows(t *testing.T) {
 			defer wg.Done()
 			<-start
 			err := NewBindRolesLogic(superCtx, svcCtx).BindRoles(&types.BindRolesReq{
-				UserId:  userId,
-				RoleIds: []int64{roleId},
+				UserId:      userId,
+				RoleIds:     []int64{roleId},
+				ProductCode: productCode,
 			})
 			if err == nil {
 				bindOK.Store(true)
@@ -862,8 +877,9 @@ func TestBindRoles_EqualPermsLevel_Rejected(t *testing.T) {
 	})
 
 	err := NewBindRolesLogic(ctx, svcCtx).BindRoles(&types.BindRolesReq{
-		UserId:  targetUserId,
-		RoleIds: []int64{sameLevelRole},
+		UserId:      targetUserId,
+		RoleIds:     []int64{sameLevelRole},
+		ProductCode: productCode,
 	})
 	require.Error(t, err, "同级角色分配必须被拒绝(含同级)")
 	var ce *response.CodeError
@@ -933,3 +949,71 @@ func TestBindRoles_L_R13_1_SuperAdminWithEmptyMemberTypeStillProceeds(t *testing
 		"超管不应被 L-R13-1 闸误伤,应穿透到 SysUserModel.FindOne 并返 404")
 	assert.Equal(t, "用户不存在", ce.Error())
 }
+
+// TC-1265: 非超管(ADMIN)传入 req.ProductCode 指向其他产品时,该字段必须被忽略,
+// 始终使用 JWT context 中的 productCode,不允许跨产品操作。
+// 安全约束:若非超管能通过 req.ProductCode 切换产品,则可绕过 CheckManageAccess 的产品隔离。
+func TestBindRoles_NonSuperAdmin_ReqProductCodeIgnored(t *testing.T) {
+	svcCtx := svc.NewServiceContext(testutil.GetTestConfig())
+	conn := testutil.GetTestSqlConn()
+	superCtx := ctxhelper.SuperAdminCtx()
+
+	productCode := "test_product"
+	username := testutil.UniqueId()
+	userId := insertTestUser(t, superCtx, username, testutil.HashPassword("pass"))
+	mId := insertTestMember(t, svcCtx, productCode, userId)
+	role := insertTestRole(t, svcCtx, productCode, consts.StatusEnabled)
+
+	t.Cleanup(func() {
+		testutil.CleanTableByField(superCtx, conn, "`sys_user_role`", "userId", userId)
+		testutil.CleanTable(superCtx, conn, "`sys_product_member`", mId)
+		testutil.CleanTable(superCtx, conn, "`sys_user`", userId)
+		testutil.CleanTable(superCtx, conn, "`sys_role`", role)
+	})
+
+	// ADMIN caller 的 JWT context productCode = "test_product",
+	// 请求体传入 "other_product"——非超管时该字段必须被忽略。
+	ctx := ctxhelper.AdminCtx(productCode)
+	err := NewBindRolesLogic(ctx, svcCtx).BindRoles(&types.BindRolesReq{
+		UserId:      userId,
+		RoleIds:     []int64{role},
+		ProductCode: "other_product", // 非超管时此字段应被忽略
+	})
+	require.NoError(t, err, "非超管传入其他产品的 productCode 应被忽略,仍按 JWT context 产品操作")
+
+	roleIds, err := svcCtx.SysUserRoleModel.FindRoleIdsByUserId(ctx, userId)
+	require.NoError(t, err)
+	assert.Contains(t, roleIds, role, "角色应成功绑定到 JWT context 对应的产品下")
+}
+
+// TC-1266: 非超管(ADMIN)不传 req.ProductCode,使用 JWT context 中的 productCode 正常绑定。
+func TestBindRoles_NonSuperAdmin_UsesCtxProductCode(t *testing.T) {
+	svcCtx := svc.NewServiceContext(testutil.GetTestConfig())
+	conn := testutil.GetTestSqlConn()
+	superCtx := ctxhelper.SuperAdminCtx()
+
+	productCode := "test_product"
+	username := testutil.UniqueId()
+	userId := insertTestUser(t, superCtx, username, testutil.HashPassword("pass"))
+	mId := insertTestMember(t, svcCtx, productCode, userId)
+	role := insertTestRole(t, svcCtx, productCode, consts.StatusEnabled)
+
+	t.Cleanup(func() {
+		testutil.CleanTableByField(superCtx, conn, "`sys_user_role`", "userId", userId)
+		testutil.CleanTable(superCtx, conn, "`sys_product_member`", mId)
+		testutil.CleanTable(superCtx, conn, "`sys_user`", userId)
+		testutil.CleanTable(superCtx, conn, "`sys_role`", role)
+	})
+
+	ctx := ctxhelper.AdminCtx(productCode)
+	err := NewBindRolesLogic(ctx, svcCtx).BindRoles(&types.BindRolesReq{
+		UserId:  userId,
+		RoleIds: []int64{role},
+		// ProductCode 不传,应自动使用 JWT context 中的 "test_product"
+	})
+	require.NoError(t, err)
+
+	roleIds, err := svcCtx.SysUserRoleModel.FindRoleIdsByUserId(ctx, userId)
+	require.NoError(t, err)
+	assert.Contains(t, roleIds, role)
+}

+ 3 - 2
internal/types/types.go

@@ -30,8 +30,9 @@ type BindPermsReq struct {
 }
 
 type BindRolesReq struct {
-	UserId  int64   `json:"userId"`
-	RoleIds []int64 `json:"roleIds"`
+	UserId      int64   `json:"userId"`
+	RoleIds     []int64 `json:"roleIds"`
+	ProductCode string  `json:"productCode,optional"`
 }
 
 type CapEndpointResp struct {

+ 3 - 2
perm.api

@@ -292,8 +292,9 @@ type (
 		CreateTime int64    `json:"createTime"`
 	}
 	BindRolesReq {
-		UserId  int64   `json:"userId"`
-		RoleIds []int64 `json:"roleIds"`
+		UserId      int64   `json:"userId"`
+		RoleIds     []int64 `json:"roleIds"`
+		ProductCode string  `json:"productCode,optional"`
 	}
 	UserPermItem {
 		PermId int64  `json:"permId"`

+ 8 - 6
test-design.md

@@ -505,6 +505,8 @@ MySQL (InnoDB) + Redis Cache
 | TC-1101 | POST /api/user/update | 拒绝 `*req.DeptId < 0` 透传成脏 deptId | 超管 + `DeptId=Int64Ptr(-1)` | 400 "部门ID必须为非负整数";DB `sys_user.deptId` 不变 | 输入校验 | P0 | 与 CreateUser 对齐;防 FindOne(-1) ErrNotFound → 5xx / 僵尸账号 |
 | TC-1102 | POST /api/user/bindRoles | 非超管且 `caller.MemberType==""`("游离" JWT)不得通过 404 枚举 userId 存在性 | 自定义 caller:`IsSuperAdmin=false, MemberType=""`;userId 取**不存在**的值 | 403 "缺少产品成员上下文"(不是 404 "用户不存在") | 安全/枚举 | P0 | 修复前:MEMBER 空上下文会先 `FindOne(userId)` 返 404,暴露 userId 空间 |
 | TC-1103 | POST /api/user/bindRoles | 超管即便 `MemberType==""` 也必须继续走 `FindOne`(不能被 L-R13-1 误伤) | 超管 ctx (MemberType=SuperAdmin) + 不存在 userId | 404 "用户不存在"(超管应继续原路径) | 正向回归 | P0 | 防 L-R13-1 闸门把超管正常链路误拦 |
+| TC-1265 | POST /api/user/bindRoles | 非超管传入 `req.ProductCode` 指向其他产品时该字段必须被忽略 | `AdminCtx(productCode="test_product")` + `req.ProductCode="other_product"` | 绑定成功,角色落在 JWT context 的 `test_product` 下,不跨产品 | 安全/产品隔离 | P0 | 非超管不得通过 req.ProductCode 绕过产品隔离;只有超管才允许显式覆盖 productCode |
+| TC-1266 | POST /api/user/bindRoles | 非超管不传 `req.ProductCode`,使用 JWT context 中的 productCode 正常绑定 | `AdminCtx(productCode="test_product")` + 不传 `productCode` | 绑定成功 | 正向回归 | P0 | 非超管正常路径回归 |
 | TC-1124 | POST /api/user/update | H-R14-1 ADMIN 把目标调入 DEV 部门必须 403 | `AdminCtx + DeptPath="/"`(豁免子树校验)+ `req.DeptId=<DEV 部门>` | 403 "仅超级管理员可将用户调入研发部门";DB `sys_user.deptId` 不变 | 安全/跨产品升权 | P0 | 堵死 ADMIN 借 DeptPath 子树豁免 + `DeptType=DEV` 全权分支对他产品共有成员升权的攻击链 |
 | TC-1125 | POST /api/user/update | H-R14-1 SuperAdmin 仍可把目标调入 DEV 部门(正向回归) | SuperAdmin + `req.DeptId=<DEV 部门>` | 200 OK;DB `sys_user.deptId` 落到 DEV 部门 id | 正常路径 | P0 | 保留 SuperAdmin 跨产品调度语义 |
 | TC-1126 | POST /api/user/update | 产品 ADMIN 在自己子树内挪动 target(非 DEV)放行 | `AdminCtx + DeptPath="/300/"`,`req.DeptId=<非 DEV、DeptPath="/300/400/"`> | 200 OK;DB 落盘新 deptId | 正常路径 | P0 | ADMIN 快速通道仅限"同子树内 + 非 DEV";保证合法子树内调动不被误伤 |
@@ -526,14 +528,14 @@ MySQL (InnoDB) + Redis Cache
 | TC-0181 | POST /api/user/detail | 正常查询 | `{"id":1}` | 含roleIds | 正常路径 | P0 | userDetailLogic |
 | TC-0182 | POST /api/user/detail | 正常查询-含Avatar | 有Avatar用户 | avatar字段非空 | 分支覆盖 | P1 | Avatar.Valid=true |
 | TC-0183 | POST /api/user/detail | 不存在 | `{"id":9999}` | code=404 | 异常路径 | P0 | FindOne失败 |
-| TC-0184 | POST /api/user/bindRoles | 正常绑定 | `{"userId":1,"roleIds":[1,2]}` | code=0 | 正常路径 | P0 | TransactCtx |
+| TC-0184 | POST /api/user/bindRoles | 正常绑定(超管调用,显式传 productCode) | `{"userId":1,"roleIds":[1,2],"productCode":"test_product"}` | code=0 | 正常路径 | P0 | TransactCtx;超管 JWT 无 productCode,需显式传入 |
 | TC-0185 | POST /api/user/bindRoles | 用户不存在 | `{"userId":9999,"roleIds":[1]}` | code=404, "用户不存在" | 存在性校验 | P0 | FindOne预检 |
-| TC-0186 | POST /api/user/bindRoles | 清空角色 | `{"userId":1,"roleIds":[]}` | code=0 | 分支覆盖 | P1 | len==0 |
+| TC-0186 | POST /api/user/bindRoles | 清空角色(超管调用) | `{"userId":1,"roleIds":[],"productCode":"test_product"}` | code=0 | 分支覆盖 | P1 | len==0 |
 | TC-0187 | POST /api/user/bindRoles | 事务回滚 | 模拟失败 | 旧数据还原 | 事务验证 | P0 | TransactCtx |
-| TC-0188 | POST /api/user/bindRoles | 角色不属于当前产品 | roleId属于其他产品 | code=400, "包含无效的角色ID"(三路径折叠) | 安全 | P0 | L-R14-2:不再以独立文案暴露"跨产品"分支 |
-| TC-0189 | POST /api/user/bindRoles | 角色已禁用 | roleId状态为禁用 | code=400, "包含无效的角色ID"(三路径折叠) | 安全 | P0 | L-R14-2:不再以独立文案暴露"已禁用"分支 |
-| TC-0190 | POST /api/user/bindRoles | 角色不存在 | roleId不存在 | code=400, "包含无效的角色ID" | 安全 | P0 | L-R14-2:与跨产品/禁用路径文案一致 |
-| TC-0191 | POST /api/user/bindRoles | 非产品成员绑定角色被拒绝 | 目标用户非当前产品成员 | 400 "不是当前产品的成员" | 安全 | P0 | BindRoles |
+| TC-0188 | POST /api/user/bindRoles | 角色不属于当前产品(超管调用) | `{"userId":1,"roleIds":[otherId],"productCode":"test_product"}` | code=400, "包含无效的角色ID"(三路径折叠) | 安全 | P0 | L-R14-2:不再以独立文案暴露"跨产品"分支 |
+| TC-0189 | POST /api/user/bindRoles | 角色已禁用(超管调用) | `{"userId":1,"roleIds":[disabledId],"productCode":"test_product"}` | code=400, "包含无效的角色ID"(三路径折叠) | 安全 | P0 | L-R14-2:不再以独立文案暴露"已禁用"分支 |
+| TC-0190 | POST /api/user/bindRoles | 角色不存在(超管调用) | `{"userId":1,"roleIds":[9999],"productCode":"test_product"}` | code=400, "包含无效的角色ID" | 安全 | P0 | L-R14-2:与跨产品/禁用路径文案一致 |
+| TC-0191 | POST /api/user/bindRoles | 非产品成员绑定角色被拒绝(超管调用) | `{"userId":1,"roleIds":[],"productCode":"test_product"}` 目标用户非当前产品成员 | 400 "不是当前产品的成员" | 安全 | P0 | BindRoles |
 | TC-0192 | POST /api/user/setPerms | 正常ALLOW | `{"userId":1,"perms":[{"permId":1,"effect":"ALLOW"}]}` | code=0 | 正常路径 | P0 | TransactCtx |
 | TC-0193 | POST /api/user/setPerms | 用户不存在 | `{"userId":9999,"perms":[...]}` | code=404, "用户不存在" | 存在性校验 | P0 | FindOne预检 |
 | TC-0194 | POST /api/user/setPerms | DENY权限 | effect="DENY" | code=0 | 正常路径 | P0 | effect="DENY" |

+ 3 - 1
test-report.md

@@ -68,7 +68,7 @@
 | :--- | :--- | :--- | :--- |
 | TC-0820(`UserModel_IncrementTokenVersionIfMatch`) | `TestSysUserModel_IncrementTokenVersionIfMatch_ConcurrentSingleWinner` 在整包并发压下偶发 `circuit breaker is open`(go-zero `breaker`)失败 | 本用例靠 `wg+8 goroutine` 同时冲同一行走 CAS `UPDATE ... WHERE tokenVersion=?`,在整包全量并发压下 go-zero SQL 断路器被其它测试累计的错误触达打开,导致 8 路里若干路直接被 breaker 短路拒绝;`-run` 单独跑则断路器计数窗口没攒满,必过 | 在测试 setup 里显式重置 breaker 统计(或注入允许更高错误率的 breaker 配置),也可在断言里把 `circuit breaker is open` 视作"并发压力副作用"跳过重试;非业务 bug,生产路径的 CAS 正确性已由 `_Match` / `_Mismatch_NoSideEffect` 两条稳定用例覆盖 |
 
-> **已修复的回归点**:上一轮报告中的 TC-1078 `TestBindRoles_Vs_DeleteRole_NoOrphanRows` 文案 flake,本轮随 `bindRolesLogic` 三路径统一为 `包含无效的角色ID` 的改动(L-R14-2)一并收敛;并发断言现已与新文案对齐,单独 / 整包执行均稳定 pass。
+> **已修复的回归点**:上一轮报告中的 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` 字段。
 
 ---
 
@@ -583,6 +583,8 @@
 | TC-1078 | BindRoles 与 DeleteRole 并发 6 轮(统一文案为"包含无效的角色ID"后稳定) | ✅ pass |
 | TC-1102 | BindRoles 非超管 + 空 MemberType + 不存在 userId → 403 "缺少产品成员上下文" | ✅ pass |
 | TC-1103 | BindRoles 超管 + 空 MemberType 仍穿透到 FindOne → 404 | ✅ pass |
+| TC-1265 | BindRoles 非超管传入 req.ProductCode 指向其他产品 → 字段被忽略,仍按 JWT context 产品操作 | ✅ pass |
+| TC-1266 | BindRoles 非超管不传 req.ProductCode → 使用 JWT context productCode 正常绑定 | ✅ pass |
 | TC-1104 | SetUserPerms 非 ADMIN caller + 不存在 userId → 403(阻断 userId 枚举) | ✅ pass |
 | TC-1105 | SetUserPerms DENY TOCTOU:事务内读到 ADMIN → 400 + 事务回滚(无 DENY 脏行) | ✅ pass |
 | TC-1106 | SetUserPerms 纯 ALLOW 必须短路、不调 FindOneForShareTx(S 锁开销不扩散) | ✅ pass |