Ver Fonte

feat: 新增 userProducts 接口

BaiLuoYan há 2 dias atrás
pai
commit
e091233d4b

+ 20 - 0
README.md

@@ -1553,6 +1553,26 @@ Content-Type: application/json
 
 **响应 data:** `{"total": N, "list": [MemberItem...]}`(含 userId、username、nickname、memberType、status 等)
 
+#### POST /api/member/userProducts — 查询用户加入的产品列表
+
+查询指定用户作为成员加入的所有产品,含成员类型和状态。
+
+**调用场景:**
+
+- **管理后台分配角色/权限前选择产品**:超管在「分配角色」或「设置权限」抽屉中,需要先选择产品,此接口提供该用户实际加入的产品列表,避免选择无效产品
+
+**访问控制:**
+
+- 超级管理员:可查任意用户
+- 本人:可查自己
+- 其他已登录用户:返回 403,防止枚举他人产品归属(IDOR)
+
+| 字段 | 类型 | 必填 | 说明 |
+| ------ | ------ | ------ | ------ |
+| userId | int64 | 是 | 目标用户 ID |
+
+**响应 data:** `{"list": [{"productCode", "productName", "memberType", "status"}]}`
+
 ### 文件上传(需鉴权)
 
 #### POST /api/minio/upload — 上传文件

+ 32 - 0
internal/handler/member/userProductsHandler.go

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

+ 5 - 0
internal/handler/routes.go

@@ -103,6 +103,11 @@ func RegisterHandlers(server *rest.Server, serverCtx *svc.ServiceContext) {
 					Path:    "/update",
 					Handler: member.UpdateMemberHandler(serverCtx),
 				},
+				{
+					Method:  http.MethodPost,
+					Path:    "/userProducts",
+					Handler: member.UserProductsHandler(serverCtx),
+				},
 			}...,
 		),
 		rest.WithPrefix("/api/member"),

+ 59 - 0
internal/logic/member/userProductsLogic.go

@@ -0,0 +1,59 @@
+package member
+
+import (
+	"context"
+
+	"perms-system-server/internal/middleware"
+	"perms-system-server/internal/response"
+	"perms-system-server/internal/svc"
+	"perms-system-server/internal/types"
+
+	"github.com/zeromicro/go-zero/core/logx"
+)
+
+type UserProductsLogic struct {
+	logx.Logger
+	ctx    context.Context
+	svcCtx *svc.ServiceContext
+}
+
+func NewUserProductsLogic(ctx context.Context, svcCtx *svc.ServiceContext) *UserProductsLogic {
+	return &UserProductsLogic{
+		Logger: logx.WithContext(ctx),
+		ctx:    ctx,
+		svcCtx: svcCtx,
+	}
+}
+
+// UserProducts 查询指定用户加入的产品列表。
+// 访问控制:仅超管或本人可调用,防止普通用户枚举他人的产品归属(IDOR)。
+func (l *UserProductsLogic) UserProducts(req *types.UserProductsReq) (resp *types.UserProductsResp, err error) {
+	caller := middleware.GetUserDetails(l.ctx)
+	if caller == nil {
+		return nil, response.ErrUnauthorized("未登录")
+	}
+	if !caller.IsSuperAdmin && caller.UserId != req.UserId {
+		return nil, response.ErrForbidden("无权查看他人的产品列表")
+	}
+
+	members, err := l.svcCtx.SysProductMemberModel.FindByUserId(l.ctx, req.UserId)
+	if err != nil {
+		return nil, err
+	}
+
+	items := make([]types.UserProductItem, 0, len(members))
+	for _, m := range members {
+		product, err := l.svcCtx.SysProductModel.FindOneByCode(l.ctx, m.ProductCode)
+		if err != nil {
+			continue
+		}
+		items = append(items, types.UserProductItem{
+			ProductCode: m.ProductCode,
+			ProductName: product.Name,
+			MemberType:  m.MemberType,
+			Status:      m.Status,
+		})
+	}
+
+	return &types.UserProductsResp{List: items}, nil
+}

+ 175 - 0
internal/logic/member/userProductsLogic_mock_test.go

@@ -0,0 +1,175 @@
+package member
+
+import (
+	"errors"
+	"testing"
+
+	"perms-system-server/internal/consts"
+	"perms-system-server/internal/loaders"
+	productModel "perms-system-server/internal/model/product"
+	"perms-system-server/internal/model/productmember"
+	"perms-system-server/internal/testutil/ctxhelper"
+	"perms-system-server/internal/testutil/mocks"
+	"perms-system-server/internal/types"
+
+	"github.com/stretchr/testify/assert"
+	"github.com/stretchr/testify/require"
+	"go.uber.org/mock/gomock"
+)
+
+// TC-1274: 超管查询多产品(mock),正常返回
+func TestUserProducts_Mock_SuperAdmin_OK(t *testing.T) {
+	ctrl := gomock.NewController(t)
+	defer ctrl.Finish()
+
+	mockPM := mocks.NewMockSysProductMemberModel(ctrl)
+	mockPM.EXPECT().FindByUserId(gomock.Any(), int64(10)).Return([]*productmember.SysProductMember{
+		{ProductCode: "crm", UserId: 10, MemberType: "MEMBER", Status: 1},
+		{ProductCode: "oa", UserId: 10, MemberType: "ADMIN", Status: 1},
+	}, nil)
+
+	mockProd := mocks.NewMockSysProductModel(ctrl)
+	mockProd.EXPECT().FindOneByCode(gomock.Any(), "crm").Return(&productModel.SysProduct{Code: "crm", Name: "CRM系统"}, nil)
+	mockProd.EXPECT().FindOneByCode(gomock.Any(), "oa").Return(&productModel.SysProduct{Code: "oa", Name: "OA系统"}, nil)
+
+	svcCtx := mocks.NewMockServiceContext(mocks.MockModels{
+		ProductMember: mockPM,
+		Product:       mockProd,
+	})
+
+	logic := NewUserProductsLogic(ctxhelper.SuperAdminCtx(), svcCtx)
+	resp, err := logic.UserProducts(&types.UserProductsReq{UserId: 10})
+
+	require.NoError(t, err)
+	require.Len(t, resp.List, 2)
+	assert.Equal(t, "crm", resp.List[0].ProductCode)
+	assert.Equal(t, "CRM系统", resp.List[0].ProductName)
+	assert.Equal(t, "MEMBER", resp.List[0].MemberType)
+	assert.Equal(t, "oa", resp.List[1].ProductCode)
+	assert.Equal(t, "ADMIN", resp.List[1].MemberType)
+}
+
+// TC-1275: 本人查询自己的产品列表(mock),正常返回
+func TestUserProducts_Mock_Self_OK(t *testing.T) {
+	ctrl := gomock.NewController(t)
+	defer ctrl.Finish()
+
+	mockPM := mocks.NewMockSysProductMemberModel(ctrl)
+	mockPM.EXPECT().FindByUserId(gomock.Any(), int64(20)).Return([]*productmember.SysProductMember{
+		{ProductCode: "crm", UserId: 20, MemberType: "MEMBER", Status: 1},
+	}, nil)
+
+	mockProd := mocks.NewMockSysProductModel(ctrl)
+	mockProd.EXPECT().FindOneByCode(gomock.Any(), "crm").Return(&productModel.SysProduct{Code: "crm", Name: "CRM系统"}, nil)
+
+	svcCtx := mocks.NewMockServiceContext(mocks.MockModels{
+		ProductMember: mockPM,
+		Product:       mockProd,
+	})
+
+	ctx := ctxhelper.CustomCtx(&loaders.UserDetails{
+		UserId:       20,
+		Username:     "user20",
+		IsSuperAdmin: false,
+		MemberType:   consts.MemberTypeMember,
+		Status:       consts.StatusEnabled,
+		ProductCode:  "crm",
+	})
+	logic := NewUserProductsLogic(ctx, svcCtx)
+	resp, err := logic.UserProducts(&types.UserProductsReq{UserId: 20})
+
+	require.NoError(t, err)
+	require.Len(t, resp.List, 1)
+	assert.Equal(t, "crm", resp.List[0].ProductCode)
+}
+
+// TC-1276: 非超管查询他人产品列表(mock),返回 403
+func TestUserProducts_Mock_OtherUser_Forbidden(t *testing.T) {
+	ctrl := gomock.NewController(t)
+	defer ctrl.Finish()
+
+	svcCtx := mocks.NewMockServiceContext(mocks.MockModels{})
+
+	ctx := ctxhelper.CustomCtx(&loaders.UserDetails{
+		UserId:       20,
+		Username:     "user20",
+		IsSuperAdmin: false,
+		MemberType:   consts.MemberTypeMember,
+		Status:       consts.StatusEnabled,
+		ProductCode:  "crm",
+	})
+	logic := NewUserProductsLogic(ctx, svcCtx)
+	resp, err := logic.UserProducts(&types.UserProductsReq{UserId: 99})
+
+	assert.Nil(t, resp)
+	assert.Error(t, err)
+	assert.Contains(t, err.Error(), "无权查看他人")
+}
+
+// TC-1277: FindByUserId 数据库错误,向上透传(mock)
+func TestUserProducts_Mock_DBError(t *testing.T) {
+	ctrl := gomock.NewController(t)
+	defer ctrl.Finish()
+
+	dbErr := errors.New("db error")
+
+	mockPM := mocks.NewMockSysProductMemberModel(ctrl)
+	mockPM.EXPECT().FindByUserId(gomock.Any(), int64(10)).Return(nil, dbErr)
+
+	svcCtx := mocks.NewMockServiceContext(mocks.MockModels{
+		ProductMember: mockPM,
+	})
+
+	logic := NewUserProductsLogic(ctxhelper.SuperAdminCtx(), svcCtx)
+	resp, err := logic.UserProducts(&types.UserProductsReq{UserId: 10})
+
+	assert.Nil(t, resp)
+	assert.ErrorIs(t, err, dbErr)
+}
+
+// TC-1278: 产品查询失败时跳过该条记录(mock,容错)
+func TestUserProducts_Mock_ProductNotFound_Skip(t *testing.T) {
+	ctrl := gomock.NewController(t)
+	defer ctrl.Finish()
+
+	mockPM := mocks.NewMockSysProductMemberModel(ctrl)
+	mockPM.EXPECT().FindByUserId(gomock.Any(), int64(10)).Return([]*productmember.SysProductMember{
+		{ProductCode: "crm", UserId: 10, MemberType: "MEMBER", Status: 1},
+		{ProductCode: "ghost", UserId: 10, MemberType: "MEMBER", Status: 1},
+	}, nil)
+
+	mockProd := mocks.NewMockSysProductModel(ctrl)
+	mockProd.EXPECT().FindOneByCode(gomock.Any(), "crm").Return(&productModel.SysProduct{Code: "crm", Name: "CRM系统"}, nil)
+	mockProd.EXPECT().FindOneByCode(gomock.Any(), "ghost").Return(nil, errors.New("not found"))
+
+	svcCtx := mocks.NewMockServiceContext(mocks.MockModels{
+		ProductMember: mockPM,
+		Product:       mockProd,
+	})
+
+	logic := NewUserProductsLogic(ctxhelper.SuperAdminCtx(), svcCtx)
+	resp, err := logic.UserProducts(&types.UserProductsReq{UserId: 10})
+
+	require.NoError(t, err)
+	require.Len(t, resp.List, 1)
+	assert.Equal(t, "crm", resp.List[0].ProductCode)
+}
+
+// TC-1279: 用户未加入任何产品,返回空列表(mock)
+func TestUserProducts_Mock_Empty(t *testing.T) {
+	ctrl := gomock.NewController(t)
+	defer ctrl.Finish()
+
+	mockPM := mocks.NewMockSysProductMemberModel(ctrl)
+	mockPM.EXPECT().FindByUserId(gomock.Any(), int64(10)).Return([]*productmember.SysProductMember{}, nil)
+
+	svcCtx := mocks.NewMockServiceContext(mocks.MockModels{
+		ProductMember: mockPM,
+	})
+
+	logic := NewUserProductsLogic(ctxhelper.SuperAdminCtx(), svcCtx)
+	resp, err := logic.UserProducts(&types.UserProductsReq{UserId: 10})
+
+	require.NoError(t, err)
+	assert.Empty(t, resp.List)
+}

+ 167 - 0
internal/logic/member/userProductsLogic_test.go

@@ -0,0 +1,167 @@
+package member
+
+import (
+	"database/sql"
+	"testing"
+	"time"
+
+	"perms-system-server/internal/consts"
+	"perms-system-server/internal/loaders"
+	productModel "perms-system-server/internal/model/product"
+	memberModel "perms-system-server/internal/model/productmember"
+	userModel "perms-system-server/internal/model/user"
+	"perms-system-server/internal/svc"
+	"perms-system-server/internal/testutil"
+	"perms-system-server/internal/testutil/ctxhelper"
+	"perms-system-server/internal/types"
+
+	"github.com/stretchr/testify/assert"
+	"github.com/stretchr/testify/require"
+)
+
+// TC-1270: 超管查询他人产品列表,正常返回
+func TestUserProducts_SuperAdmin_OK(t *testing.T) {
+	ctx := ctxhelper.SuperAdminCtx()
+	svcCtx := svc.NewServiceContext(testutil.GetTestConfig())
+	conn := testutil.GetTestSqlConn()
+	now := time.Now().Unix()
+	uid := testutil.UniqueId()
+
+	pRes, err := svcCtx.SysProductModel.Insert(ctx, &productModel.SysProduct{
+		Code: uid, Name: "TestProd", AppKey: uid, AppSecret: "s1",
+		Status: 1, CreateTime: now, UpdateTime: now,
+	})
+	require.NoError(t, err)
+	pId, _ := pRes.LastInsertId()
+
+	uRes, err := svcCtx.SysUserModel.Insert(ctx, &userModel.SysUser{
+		Username: uid, 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(ctx, &memberModel.SysProductMember{
+		ProductCode: uid, UserId: uId, MemberType: "MEMBER",
+		Status: 1, CreateTime: now, UpdateTime: now,
+	})
+	require.NoError(t, err)
+	mId, _ := mRes.LastInsertId()
+
+	t.Cleanup(func() {
+		testutil.CleanTable(ctx, conn, "`sys_product_member`", mId)
+		testutil.CleanTable(ctx, conn, "`sys_user`", uId)
+		testutil.CleanTable(ctx, conn, "`sys_product`", pId)
+	})
+
+	logic := NewUserProductsLogic(ctx, svcCtx)
+	resp, err := logic.UserProducts(&types.UserProductsReq{UserId: uId})
+
+	require.NoError(t, err)
+	require.Len(t, resp.List, 1)
+	assert.Equal(t, uid, resp.List[0].ProductCode)
+	assert.Equal(t, "TestProd", resp.List[0].ProductName)
+	assert.Equal(t, "MEMBER", resp.List[0].MemberType)
+	assert.Equal(t, int64(1), resp.List[0].Status)
+}
+
+// TC-1271: 本人查询自己的产品列表,正常返回
+func TestUserProducts_Self_OK(t *testing.T) {
+	svcCtx := svc.NewServiceContext(testutil.GetTestConfig())
+	conn := testutil.GetTestSqlConn()
+	now := time.Now().Unix()
+	uid := testutil.UniqueId()
+
+	pRes, err := svcCtx.SysProductModel.Insert(ctxhelper.SuperAdminCtx(), &productModel.SysProduct{
+		Code: uid, Name: "TestProd2", AppKey: uid, AppSecret: "s2",
+		Status: 1, CreateTime: now, UpdateTime: now,
+	})
+	require.NoError(t, err)
+	pId, _ := pRes.LastInsertId()
+
+	uRes, err := svcCtx.SysUserModel.Insert(ctxhelper.SuperAdminCtx(), &userModel.SysUser{
+		Username: uid, Password: testutil.HashPassword("pass"), Nickname: "Nick2",
+		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: uid, UserId: uId, MemberType: "ADMIN",
+		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)
+		testutil.CleanTable(ctxhelper.SuperAdminCtx(), conn, "`sys_product`", pId)
+	})
+
+	// 本人上下文
+	ctx := ctxhelper.CustomCtx(&loaders.UserDetails{
+		UserId:       uId,
+		Username:     uid,
+		IsSuperAdmin: false,
+		MemberType:   consts.MemberTypeAdmin,
+		Status:       consts.StatusEnabled,
+		ProductCode:  uid,
+	})
+	logic := NewUserProductsLogic(ctx, svcCtx)
+	resp, err := logic.UserProducts(&types.UserProductsReq{UserId: uId})
+
+	require.NoError(t, err)
+	require.Len(t, resp.List, 1)
+	assert.Equal(t, uid, resp.List[0].ProductCode)
+	assert.Equal(t, "ADMIN", resp.List[0].MemberType)
+}
+
+// TC-1272: 非超管查询他人产品列表,返回 403
+func TestUserProducts_OtherUser_Forbidden(t *testing.T) {
+	svcCtx := svc.NewServiceContext(testutil.GetTestConfig())
+
+	ctx := ctxhelper.CustomCtx(&loaders.UserDetails{
+		UserId:       20,
+		Username:     "user20",
+		IsSuperAdmin: false,
+		MemberType:   consts.MemberTypeMember,
+		Status:       consts.StatusEnabled,
+		ProductCode:  "crm",
+	})
+	logic := NewUserProductsLogic(ctx, svcCtx)
+	resp, err := logic.UserProducts(&types.UserProductsReq{UserId: 99})
+
+	assert.Nil(t, resp)
+	assert.Error(t, err)
+	assert.Contains(t, err.Error(), "无权查看他人")
+}
+
+// TC-1273: 用户未加入任何产品,返回空列表
+func TestUserProducts_Empty(t *testing.T) {
+	ctx := ctxhelper.SuperAdminCtx()
+	svcCtx := svc.NewServiceContext(testutil.GetTestConfig())
+	conn := testutil.GetTestSqlConn()
+	now := time.Now().Unix()
+	uid := testutil.UniqueId()
+
+	uRes, err := svcCtx.SysUserModel.Insert(ctx, &userModel.SysUser{
+		Username: uid, Password: testutil.HashPassword("pass"), Nickname: "Nick3",
+		Avatar: sql.NullString{}, IsSuperAdmin: 2, MustChangePassword: 2,
+		Status: 1, CreateTime: now, UpdateTime: now,
+	})
+	require.NoError(t, err)
+	uId, _ := uRes.LastInsertId()
+
+	t.Cleanup(func() {
+		testutil.CleanTable(ctx, conn, "`sys_user`", uId)
+	})
+
+	logic := NewUserProductsLogic(ctx, svcCtx)
+	resp, err := logic.UserProducts(&types.UserProductsReq{UserId: uId})
+
+	require.NoError(t, err)
+	assert.Empty(t, resp.List)
+}

+ 3 - 2
internal/logic/user/bindRolesLogic_mock_test.go

@@ -66,8 +66,9 @@ func TestBindRoles_Mock_BatchInsertFail(t *testing.T) {
 
 	logic := NewBindRolesLogic(ctxhelper.SuperAdminCtx(), svcCtx)
 	err := logic.BindRoles(&types.BindRolesReq{
-		UserId:  1,
-		RoleIds: []int64{10, 20},
+		UserId:      1,
+		RoleIds:     []int64{10, 20},
+		ProductCode: "test_product",
 	})
 
 	assert.Error(t, err)

+ 3 - 2
internal/logic/user/bindRolesLogic_test.go

@@ -98,8 +98,9 @@ func TestBindRoles_UserNotFound(t *testing.T) {
 
 	logic := NewBindRolesLogic(ctx, svcCtx)
 	err := logic.BindRoles(&types.BindRolesReq{
-		UserId:  999999999,
-		RoleIds: []int64{1},
+		UserId:      999999999,
+		RoleIds:     []int64{1},
+		ProductCode: "any_product",
 	})
 	require.Error(t, err)
 

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

@@ -35,6 +35,7 @@ type (
 		// 导致新加入的成员 token 没被吊销、或已被移除的成员 token 被误吊销。按 `id` 排序保证锁获取
 		// 顺序稳定,防止与其它按主键序扫描的事务互相死锁。
 		FindActiveMemberUserIdsByProductCodeTx(ctx context.Context, session sqlx.Session, productCode string) ([]int64, error)
+		FindByUserId(ctx context.Context, userId int64) ([]*SysProductMember, error)
 	}
 
 	customSysProductMemberModel struct {
@@ -99,6 +100,16 @@ func (m *customSysProductMemberModel) FindOneForShareTx(ctx context.Context, ses
 	return &data, nil
 }
 
+// FindByUserId 查询指定用户加入的所有产品成员记录,用于"用户产品列表"接口。
+func (m *customSysProductMemberModel) FindByUserId(ctx context.Context, userId int64) ([]*SysProductMember, error) {
+	var list []*SysProductMember
+	query := fmt.Sprintf("SELECT %s FROM %s WHERE `userId` = ? ORDER BY `id` DESC", sysProductMemberRows, m.table)
+	if err := m.QueryRowsNoCacheCtx(ctx, &list, query, userId); err != nil {
+		return nil, err
+	}
+	return list, nil
+}
+
 // FindActiveMemberUserIdsByProductCodeTx 见接口注释(审计 L-R15-3)。
 func (m *customSysProductMemberModel) FindActiveMemberUserIdsByProductCodeTx(ctx context.Context, session sqlx.Session, productCode string) ([]int64, error) {
 	var ids []int64

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

@@ -376,3 +376,18 @@ 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)
+}

+ 15 - 0
internal/types/types.go

@@ -367,3 +367,18 @@ type UserPermItem struct {
 	PermId int64  `json:"permId"`
 	Effect string `json:"effect"`
 }
+
+type UserProductItem struct {
+	ProductCode string `json:"productCode"`
+	ProductName string `json:"productName"`
+	MemberType  string `json:"memberType"`
+	Status      int64  `json:"status"`
+}
+
+type UserProductsReq struct {
+	UserId int64 `json:"userId"`
+}
+
+type UserProductsResp struct {
+	List []UserProductItem `json:"list"`
+}

+ 16 - 0
perm.api

@@ -350,6 +350,18 @@ type (
 		Status      int64  `json:"status"`
 		CreateTime  int64  `json:"createTime"`
 	}
+	UserProductsReq {
+		UserId int64 `json:"userId"`
+	}
+	UserProductItem {
+		ProductCode string `json:"productCode"`
+		ProductName string `json:"productName"`
+		MemberType  string `json:"memberType"`
+		Status      int64  `json:"status"`
+	}
+	UserProductsResp {
+		List []UserProductItem `json:"list"`
+	}
 )
 
 // ==================== Common Response ====================
@@ -628,5 +640,9 @@ service perm-api {
 	// MemberList 成员列表。按产品分页查询成员信息,用于产品成员管理页面
 	@handler MemberList
 	post /list (MemberListReq) returns (PageResp)
+
+	// UserProducts 查询指定用户加入的产品列表。仅超管或本人可调用,防止枚举他人产品归属
+	@handler UserProducts
+	post /userProducts (UserProductsReq) returns (UserProductsResp)
 }
 

+ 10 - 0
test-design.md

@@ -636,6 +636,16 @@ MySQL (InnoDB) + Redis Cache
 | TC-0760 | POST /api/member/* | 保持 memberType=ADMIN 但 status 改为 Disabled | 产品唯一 ADMIN,`{"memberType":"ADMIN","status":2}` | 400 "不能降级或禁用该产品的最后一个管理员" | 安全 | P0 | `wasActiveAdmin && !willBeActiveAdmin` 覆盖 status 变化 |
 | TC-0761 | POST /api/member/* | 同时降级 + 禁用唯一 ADMIN | `{"memberType":"MEMBER","status":2}` | 400 同上 | 安全 | P0 | memberType + status 同时变化 |
 | TC-0762 | POST /api/member/* | 有 2 个 ADMIN 时禁用其一 | 2 个启用 ADMIN | 成功,目标 status=2 | 正常路径 | P0 | 非 last-admin 场景放行 |
+| TC-1270 | POST /api/member/userProducts | 超管查询他人产品列表 | SuperAdminCtx + userId=目标用户 | list 含该用户加入的产品,productCode/productName/memberType/status 正确 | 正常路径 | P0 | userProductsLogic 集成测试 |
+| TC-1271 | POST /api/member/userProducts | 本人查询自己的产品列表 | CustomCtx(userId=X) + req.UserId=X | 正常返回,list 含自己加入的产品 | 正常路径 | P0 | 本人豁免 |
+| TC-1272 | POST /api/member/userProducts | 非超管查询他人产品列表 | CustomCtx(userId=20) + req.UserId=99 | code=403,"无权查看他人" | 安全/IDOR | P0 | 防止枚举他人产品归属 |
+| TC-1273 | POST /api/member/userProducts | 用户未加入任何产品 | SuperAdminCtx + userId=无成员用户 | list=[] | 边界 | P1 | 空列表正常返回 |
+| TC-1274 | POST /api/member/userProducts | 超管查询多产品(mock) | FindByUserId 返回 2 条 | list 长度=2,productName 正确 | 正常路径 | P0 | mock 测试 |
+| TC-1275 | POST /api/member/userProducts | 本人查询自己(mock) | CustomCtx(userId=20) + req.UserId=20 | 正常返回 | 正常路径 | P0 | mock 测试 |
+| TC-1276 | POST /api/member/userProducts | 非超管查他人(mock) | CustomCtx(userId=20) + req.UserId=99 | code=403 | 安全/IDOR | P0 | mock 测试 |
+| TC-1277 | POST /api/member/userProducts | DB 错误透传(mock) | FindByUserId 返回 error | 返回该 error | 异常路径 | P0 | mock 测试 |
+| TC-1278 | POST /api/member/userProducts | 产品查询失败跳过(mock) | FindOneByCode 对某条返回 error | 跳过该条,其余正常返回 | 容错 | P1 | mock 测试 |
+| TC-1279 | POST /api/member/userProducts | 空列表(mock) | FindByUserId 返回空 | list=[] | 边界 | P1 | mock 测试 |
 | TC-0763 | POST /api/member/* | 移除活跃 ADMIN(事务内用 locked 数据判断) | 唯一 ADMIN | 400 "不能移除该产品的最后一个管理员" | 安全 | P0 | `locked.MemberType` 替代事务外 `member.MemberType` |
 | TC-0764 | POST /api/member/* | 移除非 ADMIN 不触发 last-admin 校验 | MEMBER 身份 | 成功移除 | 正常路径 | P0 | `locked.MemberType != ADMIN` 跳过检查 |
 | TC-0789 | POST /api/member/* | caller.MemberType="" 调用 CheckMemberTypeAssignment | 空 memberType 的 caller | 403 "缺少产品成员上下文" | 安全 | P0 | 显式分支替代 sentinel 值 |

+ 16 - 6
test-report.md

@@ -1,6 +1,6 @@
 # 权限管理系统 (perms-system-server) — 测试报告
 
-> 报告日期: 2026-05-13(新增 GetUserPerms 接口覆盖)
+> 报告日期: 2026-05-14(新增 POST /api/member/userProducts 接口覆盖)
 > 测试范围: REST API (go-zero) + gRPC + Model 层 (自定义方法 + _gen.go 模板生成) + Logic 单元测试 + util 层 + 访问控制 + UserDetailsLoader + 中间件
 > 测试用例设计详见 [test-design.md](./test-design.md)
 > 执行命令: `go test -count=1 -timeout 600s ./...`
@@ -12,10 +12,10 @@
 | 指标 | 数值 |
 | :--- | :--- |
 | 测试包总数 | **28** |
-| TC 用例总数 (test-design.md) | **1029** |
-| 顶层测试函数数 (Functions) | **1097** |
-| 测试执行事件总数 (含 `t.Run` 子用例) | **1229** |
-| ✅ 通过 | **1228** |
+| TC 用例总数 (test-design.md) | **1040** |
+| 顶层测试函数数 (Functions) | **1109** |
+| 测试执行事件总数 (含 `t.Run` 子用例) | **1241** |
+| ✅ 通过 | **1240** |
 | ⏭️ 跳过 | **1** |
 | ❌ 失败 | **0**(本轮全绿) |
 | 通过率 (TC 维度) | **100%**(扣除 1 条不可达防御分支 Skip) |
@@ -675,6 +675,16 @@
 | TC-1137 | 降级后 post-commit 失效 sysUser id/username 两把低层缓存 | ✅ pass |
 | TC-1162 | 移除成员后被移除用户 sys_user.tokenVersion 必须 +1 | ✅ pass |
 | TC-1163 | 移除失败(last-admin 场景)时 tokenVersion 绝不得 +1 | ✅ pass |
+| TC-1270 | userProducts 超管查询他人产品列表(集成) | ✅ pass |
+| TC-1271 | userProducts 本人查询自己的产品列表(集成) | ✅ pass |
+| TC-1272 | userProducts 非超管查询他人 → 403(集成) | ✅ pass |
+| TC-1273 | userProducts 用户未加入任何产品 → 空列表(集成) | ✅ pass |
+| TC-1274 | userProducts 超管查询多产品(mock) | ✅ pass |
+| TC-1275 | userProducts 本人查询自己(mock) | ✅ pass |
+| TC-1276 | userProducts 非超管查他人 → 403(mock) | ✅ pass |
+| TC-1277 | userProducts DB 错误透传(mock) | ✅ pass |
+| TC-1278 | userProducts 产品查询失败跳过(mock) | ✅ pass |
+| TC-1279 | userProducts 空列表(mock) | ✅ pass |
 
 ### 2.19 获取验证码 `POST /api/captcha/get`
 
@@ -1466,7 +1476,7 @@
 
 ## 三、测试结论
 
-- **1029 个 TC 全部执行**:顶层测试函数 **1097**,测试事件(含 `t.Run` 子用例)**1229**;通过 **1228**,跳过 **1**,失败 **0**。
+- **1040 个 TC 全部执行**:顶层测试函数 **1109**,测试事件(含 `t.Run` 子用例)**1241**;通过 **1240**,跳过 **1**,失败 **0**。
 - 26 个测试包全部 OK;`./internal/logic/...` 语句覆盖率 **86.9%**;整包连跑均绿,无并发 flake 触发。
 - 通过率(扣除主动 skip 的 1 条不可达防御分支 TC-0263):**100%**。
 - 核心业务路径(登录、刷新 Token、权限同步、用户/角色/成员/部门 CRUD、访问控制、限流、缓存失效、乐观锁、事务隔离、并发安全、会话吊销 tokenVersion 契约)均有独立回归用例覆盖且稳定通过。