Browse Source

feat: userDetail 和 bindRoles 接口调整

BaiLuoYan 2 ngày trước cách đây
mục cha
commit
e9ae2c82ea

+ 13 - 11
README.md

@@ -594,10 +594,10 @@ sequenceDiagram
 
 ```bash
 POST /api/product/create
-{"code": "crm", "name": "CRM 系统", "remark": "客户关系管理"}
+{"code": "crm", "name": "CRM 系统", "remark": "客户关系管理", "adminDeptId": 1}
 ```
 
-响应中包含 `appKey`、`appSecret`、`adminUser`、`adminPassword`,**请立即保存,后续不再展示**
+响应中包含 `credentialsTicket`(一次性凭证票据,5 分钟内有效),请立即用该 ticket 调用 `/api/product/fetchInitialCredentials` 领取 `appKey`、`appSecret`、`adminUser`、`adminPassword`。ticket 一次性消费,过期或已使用后无法重新获取
 
 ### 阶段二:产品启动时同步权限
 
@@ -1010,15 +1010,16 @@ Content-Type: application/json
 - **新业务系统接入**:公司新开发了一个 CRM/OA/电商后台等系统,超管在管理后台创建产品,获取接入凭证交给产品开发团队
 - **多租户场景拓展**:SaaS 平台新增一个租户,通过创建产品实现权限隔离
 
-**注意事项:** 响应中的 `appKey`、`appSecret`、`adminUser`、`adminPassword` 仅在创建时返回一次,请立即保存
+**注意事项:** 响应中直接返回 `appKey` 和 `adminUser`,但 `appSecret` 与初始 `adminPassword` 不在响应体中——响应携带一次性 `credentialsTicket`(5 分钟有效),需立即调用 `/api/product/fetchInitialCredentials` 领取完整凭证。ticket 一次消费即失效,过期或已使用后无法重新获取
 
 | 字段 | 类型 | 必填 | 说明 |
 | ------ | ------ | ------ | ------ |
 | code | string | 是 | 产品编码(全局唯一,仅允许字母/数字/下划线/中划线,不能以数字开头,上限 64 字符) |
 | name | string | 是 | 产品名称 |
 | remark | string | 否 | 备注 |
+| adminDeptId | int64 | 是 | 初始管理员账号所属部门 ID(归属研发部门的管理员将自动拥有全部权限) |
 
-**响应 data:** `{"id", "code", "appKey", "appSecret", "adminUser", "adminPassword"}`
+**响应 data:** `{"id", "code", "appKey", "adminUser", "credentialsTicket", "credentialsExpiresAt"}`
 
 #### POST /api/product/update — 更新产品
 
@@ -1135,14 +1136,14 @@ Content-Type: application/json
 
 #### POST /api/dept/delete — 删除部门
 
-永久删除一个部门。存在子部门时拒绝删除,必须先删除或迁移子部门。
+永久删除一个部门。存在子部门或关联用户时拒绝删除,必须先迁移或删除子部门及部门下的用户
 
 **调用场景:**
 
 - **清理空部门**:组织架构调整后,将人员迁移到新部门后删除空的旧部门
 - **撤销误创建的部门**:部门创建有误时及时删除
 
-**约束:** 存在子部门时返回 400 错误,防止意外删除整个子树。删除前应先通过部门树接口确认该部门无子节点。
+**约束:** 存在子部门或关联用户时返回 400 错误,防止意外删除整个子树。删除前应先通过部门树接口确认该部门无子节点且无用户归属
 
 | 字段 | 类型 | 必填 | 说明 |
 | ------ | ------ | ------ | ------ |
@@ -1370,8 +1371,9 @@ Content-Type: application/json
 | 字段 | 类型 | 必填 | 说明 |
 | ------ | ------ | ------ | ------ |
 | id | int64 | 是 | 用户 ID |
+| productCode | string | 否 | 产品编码;仅 SUPER_ADMIN 有效,传入时返回该用户在指定产品下绑定的角色 ID;不传时返回该用户在所有产品下的全量角色 ID。非超管始终使用 JWT 中的产品上下文 |
 
-**响应 data:** 用户信息 + `roleIds`(该用户在当前产品下绑定的角色 ID 数组)。
+**响应 data:** 用户信息 + `roleIds`(超管不传 productCode 时为跨所有产品的角色 ID;否则为指定产品下的角色 ID)。
 
 #### POST /api/user/bindRoles — 绑定用户角色(需管理权限)
 
@@ -1392,7 +1394,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 |
+| productCode | string | 条件必填 | 产品编码;仅 SUPER_ADMIN 有效,超管必须显式传入(为空时返回 400),非超管忽略此字段始终使用 JWT 中的产品上下文 |
 
 #### POST /api/user/setPerms — 设置用户权限覆盖(需管理权限)
 
@@ -1504,13 +1506,13 @@ Content-Type: application/json
 - 操作者不可将成员提升到与自己同级或更高的类型
 - 需通过 `CheckManageAccess` + `CheckMemberTypeAssignment` 权限检查
 
-**副作用:** 更新后清除该用户在该产品下的 UserDetailsLoader 缓存。
+**副作用:** 更新后清除该用户在该产品下的 UserDetailsLoader 缓存。若操作属于"降权"(`ADMIN`/`DEVELOPER` → `MEMBER`,或启用 → 禁用),还会递增该用户的 `tokenVersion` 并失效底层用户缓存,使其已签发的令牌在下次请求时被强制踢出,须重新登录。升权路径(`MEMBER` → `ADMIN` 等)不触发强制重登。
 
 | 字段 | 类型 | 必填 | 说明 |
 | ------ | ------ | ------ | ------ |
 | id | int64 | 是 | 成员记录 ID |
-| memberType | string | 是 | 成员类型 |
-| status | int64 | 否 | 状态 |
+| memberType | string | 否 | 成员类型(不传则不修改) |
+| status | int64 | 否 | 状态(不传则不修改);两字段均不传时返回 400 |
 
 #### POST /api/member/remove — 移除成员
 

+ 5 - 1
internal/logic/user/bindRolesLogic.go

@@ -48,13 +48,17 @@ func (l *BindRolesLogic) BindRoles(req *types.BindRolesReq) error {
 		return response.ErrForbidden("缺少产品成员上下文")
 	}
 
+	if caller.IsSuperAdmin && req.ProductCode == "" {
+		return response.ErrBadRequest("必须指定产品编码")
+	}
+
 	targetUser, err := l.svcCtx.SysUserModel.FindOne(l.ctx, req.UserId)
 	if err != nil {
 		return response.ErrNotFound("用户不存在")
 	}
 
 	productCode := middleware.GetProductCode(l.ctx)
-	if caller.IsSuperAdmin && req.ProductCode != "" {
+	if caller.IsSuperAdmin {
 		productCode = req.ProductCode
 	}
 	if err := authHelper.CheckManageAccess(l.ctx, l.svcCtx, req.UserId, productCode, authHelper.WithPrefetchedTarget(targetUser)); err != nil {

+ 6 - 5
internal/logic/user/bindRolesLogic_test.go

@@ -923,8 +923,8 @@ func TestBindRoles_L_R13_1_EmptyMemberTypeForbidsBeforeUserLookup(t *testing.T)
 	assert.Equal(t, "缺少产品成员上下文", ce.Error())
 }
 
-// TC-1103: 超管 + 空 MemberType(理论上不该出现,但要回归 L-R13-1 闸没误伤超管)——
-// 应当正常穿透到 FindOne,不存在的 userId 返 404 "用户不存在"
+// TC-1103: 超管不传 productCode → 400 "必须指定产品编码"(新增前置校验)。
+// 超管 JWT 中 productCode 为空,必须通过 req.ProductCode 显式传入;不传则提前 400,不再穿透到 FindOne
 func TestBindRoles_L_R13_1_SuperAdminWithEmptyMemberTypeStillProceeds(t *testing.T) {
 	svcCtx := svc.NewServiceContext(testutil.GetTestConfig())
 
@@ -940,14 +940,15 @@ func TestBindRoles_L_R13_1_SuperAdminWithEmptyMemberTypeStillProceeds(t *testing
 	err := NewBindRolesLogic(ctx, svcCtx).BindRoles(&types.BindRolesReq{
 		UserId:  999999999,
 		RoleIds: []int64{1},
+		// ProductCode 不传
 	})
 	require.Error(t, err)
 
 	var ce *response.CodeError
 	require.True(t, errors.As(err, &ce))
-	assert.Equal(t, 404, ce.Code(),
-		"超管不应被 L-R13-1 闸误伤,应穿透到 SysUserModel.FindOne 并返 404")
-	assert.Equal(t, "用户不存在", ce.Error())
+	assert.Equal(t, 400, ce.Code(),
+		"超管不传 productCode 应被前置校验拦截返回 400,不再穿透到 FindOne")
+	assert.Equal(t, "必须指定产品编码", ce.Error())
 }
 
 // TC-1265: 非超管(ADMIN)传入 req.ProductCode 指向其他产品时,该字段必须被忽略,

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

@@ -46,6 +46,9 @@ func (l *UserDetailLogic) UserDetail(req *types.UserDetailReq) (resp *types.User
 	}
 
 	productCode := middleware.GetProductCode(l.ctx)
+	if caller.IsSuperAdmin {
+		productCode = req.ProductCode
+	}
 	var roleIds []int64
 	if productCode != "" {
 		roleIds, err = l.svcCtx.SysUserRoleModel.FindRoleIdsByUserIdForProduct(l.ctx, user.Id, productCode)

+ 104 - 21
internal/logic/user/userDetailLogic_test.go

@@ -22,16 +22,59 @@ import (
 	"github.com/stretchr/testify/require"
 )
 
-func TestUserDetail_Success(t *testing.T) {
-	ctx := ctxhelper.SuperAdminCtx()
+// TC-1267: 超管不传 productCode → roleIds 含目标用户所有产品角色(全量)
+func TestUserDetail_SuperAdmin_NoProductCode_ReturnsAllRoles(t *testing.T) {
 	svcCtx := svc.NewServiceContext(testutil.GetTestConfig())
 	conn := testutil.GetTestSqlConn()
 
+	// 超管 ctx 中 ProductCode 为空,模拟真实超管登录态
+	ctx := ctxhelper.CustomCtx(&loaders.UserDetails{
+		UserId:       1,
+		Username:     "superadmin",
+		IsSuperAdmin: true,
+		MemberType:   consts.MemberTypeSuperAdmin,
+		Status:       consts.StatusEnabled,
+		ProductCode:  "",
+	})
+
 	username := testutil.UniqueId()
-	userId := insertTestUser(t, ctx, username, testutil.HashPassword("pass"))
+	userId := insertTestUser(t, ctxhelper.SuperAdminCtx(), username, testutil.HashPassword("pass"))
+
+	roleInP1 := insertTestRole(t, svcCtx, "test_product", 1)
+	roleInP2 := insertTestRole(t, svcCtx, "other_product", 1)
+
+	now := time.Now().Unix()
+	var roleRecordIds []int64
+	for _, roleId := range []int64{roleInP1, roleInP2} {
+		res, err := svcCtx.SysUserRoleModel.Insert(ctxhelper.SuperAdminCtx(), &userrole.SysUserRole{
+			UserId: userId, RoleId: roleId, CreateTime: now, UpdateTime: now,
+		})
+		require.NoError(t, err)
+		id, _ := res.LastInsertId()
+		roleRecordIds = append(roleRecordIds, id)
+	}
+
+	t.Cleanup(func() {
+		testutil.CleanTable(ctx, conn, "`sys_user_role`", roleRecordIds...)
+		testutil.CleanTable(ctx, conn, "`sys_role`", roleInP1, roleInP2)
+		testutil.CleanTable(ctx, conn, "`sys_user`", userId)
+	})
+
+	resp, err := NewUserDetailLogic(ctx, svcCtx).UserDetail(&types.UserDetailReq{Id: userId})
+	require.NoError(t, err)
+	assert.ElementsMatch(t, []int64{roleInP1, roleInP2}, resp.RoleIds,
+		"超管不传 productCode 时应返回全量角色(跨产品)")
+}
+
+// TC-1268: 超管传 productCode → roleIds 只含该产品角色
+func TestUserDetail_SuperAdmin_WithProductCode_FiltersRoles(t *testing.T) {
+	svcCtx := svc.NewServiceContext(testutil.GetTestConfig())
+	conn := testutil.GetTestSqlConn()
+	superCtx := ctxhelper.SuperAdminCtx()
+
+	username := testutil.UniqueId()
+	userId := insertTestUser(t, superCtx, username, testutil.HashPassword("pass"))
 
-	// 插入两条"当前产品"下的真实 sys_role 以及一条属于其它产品的 sys_role,
-	// 用户同时绑定这三个角色。超管在 test_product 上下文下应当只看到前两个。
 	roleInCurrent1 := insertTestRole(t, svcCtx, "test_product", 1)
 	roleInCurrent2 := insertTestRole(t, svcCtx, "test_product", 1)
 	roleInOther := insertTestRole(t, svcCtx, "other_product", 1)
@@ -39,11 +82,8 @@ func TestUserDetail_Success(t *testing.T) {
 	now := time.Now().Unix()
 	var roleRecordIds []int64
 	for _, roleId := range []int64{roleInCurrent1, roleInCurrent2, roleInOther} {
-		res, err := svcCtx.SysUserRoleModel.Insert(ctx, &userrole.SysUserRole{
-			UserId:     userId,
-			RoleId:     roleId,
-			CreateTime: now,
-			UpdateTime: now,
+		res, err := svcCtx.SysUserRoleModel.Insert(superCtx, &userrole.SysUserRole{
+			UserId: userId, RoleId: roleId, CreateTime: now, UpdateTime: now,
 		})
 		require.NoError(t, err)
 		id, _ := res.LastInsertId()
@@ -51,21 +91,64 @@ func TestUserDetail_Success(t *testing.T) {
 	}
 
 	t.Cleanup(func() {
-		testutil.CleanTable(ctx, conn, "`sys_user_role`", roleRecordIds...)
-		testutil.CleanTable(ctx, conn, "`sys_role`", roleInCurrent1, roleInCurrent2, roleInOther)
-		testutil.CleanTable(ctx, conn, "`sys_user`", userId)
+		testutil.CleanTable(superCtx, conn, "`sys_user_role`", roleRecordIds...)
+		testutil.CleanTable(superCtx, conn, "`sys_role`", roleInCurrent1, roleInCurrent2, roleInOther)
+		testutil.CleanTable(superCtx, conn, "`sys_user`", userId)
 	})
 
-	logic := NewUserDetailLogic(ctx, svcCtx)
-	resp, err := logic.UserDetail(&types.UserDetailReq{Id: userId})
+	resp, err := NewUserDetailLogic(superCtx, svcCtx).UserDetail(&types.UserDetailReq{
+		Id:          userId,
+		ProductCode: "test_product",
+	})
 	require.NoError(t, err)
-	require.NotNil(t, resp)
+	assert.ElementsMatch(t, []int64{roleInCurrent1, roleInCurrent2}, resp.RoleIds,
+		"超管传 productCode 时只返回该产品下的角色")
+	assert.NotContains(t, resp.RoleIds, roleInOther,
+		"其他产品的角色不应出现在结果中")
+}
+
+// TC-1269: 非超管不传 productCode → roleIds 只含 JWT context 产品角色;req.productCode 被忽略
+func TestUserDetail_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)
+
+	roleInCtx := insertTestRole(t, svcCtx, productCode, 1)
+	roleInOther := insertTestRole(t, svcCtx, "other_product", 1)
 
-	assert.Equal(t, userId, resp.Id)
-	assert.Equal(t, username, resp.Username)
-	// 修复后:超管在产品上下文里只看到 test_product 的角色;other_product 的角色不应返回
-	assert.ElementsMatch(t, []int64{roleInCurrent1, roleInCurrent2}, resp.RoleIds)
-	assert.NotContains(t, resp.RoleIds, roleInOther, "超管在具体产品上下文不应返回其它产品的 roleIds")
+	now := time.Now().Unix()
+	var roleRecordIds []int64
+	for _, roleId := range []int64{roleInCtx, roleInOther} {
+		res, err := svcCtx.SysUserRoleModel.Insert(superCtx, &userrole.SysUserRole{
+			UserId: userId, RoleId: roleId, CreateTime: now, UpdateTime: now,
+		})
+		require.NoError(t, err)
+		id, _ := res.LastInsertId()
+		roleRecordIds = append(roleRecordIds, id)
+	}
+
+	t.Cleanup(func() {
+		testutil.CleanTable(superCtx, conn, "`sys_user_role`", roleRecordIds...)
+		testutil.CleanTable(superCtx, conn, "`sys_product_member`", mId)
+		testutil.CleanTable(superCtx, conn, "`sys_role`", roleInCtx, roleInOther)
+		testutil.CleanTable(superCtx, conn, "`sys_user`", userId)
+	})
+
+	// ADMIN ctx productCode="test_product",req 里传 "other_product" 应被忽略
+	ctx := ctxhelper.AdminCtx(productCode)
+	resp, err := NewUserDetailLogic(ctx, svcCtx).UserDetail(&types.UserDetailReq{
+		Id:          userId,
+		ProductCode: "other_product", // 非超管时此字段应被忽略
+	})
+	require.NoError(t, err)
+	assert.ElementsMatch(t, []int64{roleInCtx}, resp.RoleIds,
+		"非超管始终用 JWT context productCode,req.productCode 应被忽略")
+	assert.NotContains(t, resp.RoleIds, roleInOther)
 }
 
 // TC-0182: 正常查询-含Avatar

+ 2 - 1
internal/types/types.go

@@ -324,7 +324,8 @@ type UpdateUserStatusReq struct {
 }
 
 type UserDetailReq struct {
-	Id int64 `json:"id"`
+	Id          int64  `json:"id"`
+	ProductCode string `json:"productCode,optional"`
 }
 
 type UserInfo struct {

+ 2 - 1
perm.api

@@ -274,7 +274,8 @@ type (
 		PageSize    int64  `json:"pageSize,optional"`
 	}
 	UserDetailReq {
-		Id int64 `json:"id"`
+		Id          int64  `json:"id"`
+		ProductCode string `json:"productCode,optional"`
 	}
 	UserItem {
 		Id         int64    `json:"id"`

+ 4 - 2
test-design.md

@@ -504,7 +504,7 @@ MySQL (InnoDB) + Redis Cache
 | TC-1050 | POST /api/user/update | 非事务路径:deptId 未变的 UpdateUser 不触发 `FindOneForShareTx` 的 S 锁路径 | 构造"只改 nickname、deptId 不变" 的更新 | 事务只走 `UpdateProfileWithTx`;`SysDeptModel.FindOneForShareTx` 未被打到(观察事务 SQL / mock 无 expect) | 契约/性能 | P1 | 避免"无切换时也打 S 锁" 导致退化 |
 | 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-1103 | POST /api/user/bindRoles | 超管不传 `productCode` → 400(新增前置校验) | 超管 ctx + `{"userId":999,"roleIds":[1]}`(不传 productCode) | 400 "必须指定产品编码" | 输入校验 | P0 | 超管 JWT 无 productCode,必须显式传入;不再穿透到 FindOne |
 | 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` 全权分支对他产品共有成员升权的攻击链 |
@@ -525,9 +525,11 @@ MySQL (InnoDB) + Redis Cache
 | TC-0178 | POST /api/user/list | pageSize超过上限 | `{"pageSize":500}` | 实际pageSize=100 | 边界 | P0 | NormalizePage cap |
 | TC-0179 | POST /api/user/list | 用户不在产品中 | productCode指定,部分用户不是成员 | memberType为空 | 分支覆盖 | P1 | memberMap无对应key |
 | TC-0180 | POST /api/user/list | 批量查询DB异常 | FindMapByProductCodeUserIds失败 | code=500 | 异常路径 | P1 | err→透传 |
-| TC-0181 | POST /api/user/detail | 正常查询 | `{"id":1}` | 含roleIds | 正常路径 | P0 | userDetailLogic |
+| TC-1267 | POST /api/user/detail | 超管不传 productCode → roleIds 含目标用户所有产品角色 | 超管 ctx + `{"id":userId}`(不传 productCode) | 含全量 roleIds(跨产品) | 正常路径 | P0 | userDetailLogic;超管无产品上下文时返回全量 |
+| TC-1268 | POST /api/user/detail | 超管传 productCode → roleIds 只含该产品角色 | 超管 ctx + `{"id":userId,"productCode":"test_product"}` | roleIds 仅含 test_product 下的角色 | 正常路径 | P0 | 超管显式指定产品时按产品过滤 |
 | 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-1269 | POST /api/user/detail | 非超管不传 productCode → roleIds 只含 JWT context 产品角色 | `AdminCtx("test_product")` + `{"id":userId}`(不传 productCode) | roleIds 仅含 test_product 下的角色;req.productCode 被忽略 | 正常路径 | P0 | 非超管始终用 JWT context productCode,req.productCode 无效 |
 | 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":[],"productCode":"test_product"}` | code=0 | 分支覆盖 | P1 | len==0 |

+ 4 - 2
test-report.md

@@ -526,9 +526,11 @@
 | TC-0178 | pageSize超过上限 | ✅ pass |
 | TC-0179 | 用户不在产品中 | ✅ pass |
 | TC-0180 | 批量查询DB异常 | ✅ pass |
-| TC-0181 | 正常查询 | ✅ pass |
+| TC-1267 | userDetail 超管不传 productCode → roleIds 含全量角色(跨产品) | ✅ pass |
+| TC-1268 | userDetail 超管传 productCode → roleIds 只含该产品角色 | ✅ pass |
 | TC-0182 | 正常查询-含Avatar | ✅ pass |
 | TC-0183 | 不存在 | ✅ pass |
+| TC-1269 | userDetail 非超管不传 productCode → roleIds 只含 JWT context 产品角色 | ✅ pass |
 | TC-0184 | 正常绑定 | ✅ pass |
 | TC-0185 | 用户不存在 | ✅ pass |
 | TC-0186 | 清空角色 | ✅ pass |
@@ -582,7 +584,7 @@
 | TC-1028 | 登录时用户成员资格 `Status=Disabled` | ✅ pass |
 | TC-1078 | BindRoles 与 DeleteRole 并发 6 轮(统一文案为"包含无效的角色ID"后稳定) | ✅ pass |
 | TC-1102 | BindRoles 非超管 + 空 MemberType + 不存在 userId → 403 "缺少产品成员上下文" | ✅ pass |
-| TC-1103 | BindRoles 超管 + 空 MemberType 仍穿透到 FindOne → 404 | ✅ pass |
+| TC-1103 | BindRoles 超管不传 productCode → 400 "必须指定产品编码"(前置校验) | ✅ 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 |