Browse Source

feat: 静态代码审计,修复逻辑bug和安全漏洞

BaiLuoYan 3 tuần trước cách đây
mục cha
commit
30b0369738
38 tập tin đã thay đổi với 862 bổ sung138 xóa
  1. 60 8
      internal/logic/auth/access.go
  2. 108 0
      internal/logic/auth/changePasswordConflict_audit_test.go
  3. 8 0
      internal/logic/auth/changePasswordLogic.go
  4. 170 0
      internal/logic/auth/loadCallerAssignableLevel_audit_test.go
  5. 9 1
      internal/logic/auth/logoutLogic.go
  6. 13 6
      internal/logic/product/createProductCompensation_audit_test.go
  7. 10 2
      internal/logic/product/createProductConflict_audit_test.go
  8. 96 1
      internal/logic/product/createProductLogic.go
  9. 19 4
      internal/logic/product/createProductLogic_mock_test.go
  10. 22 13
      internal/logic/product/createProductLogic_test.go
  11. 8 8
      internal/logic/product/fetchInitialCredentialsLogic_audit_test.go
  12. 37 0
      internal/logic/product/helper_test.go
  13. 4 2
      internal/logic/pub/loginLogic_test.go
  14. 7 8
      internal/logic/pub/loginService.go
  15. 9 0
      internal/logic/pub/refreshTokenLogic.go
  16. 2 1
      internal/logic/pub/syncPerms404_audit_test.go
  17. 2 1
      internal/logic/pub/syncPermsDedup_audit_test.go
  18. 2 1
      internal/logic/pub/syncPermsLogic_mock_test.go
  19. 12 1
      internal/logic/pub/syncPermsService.go
  20. 2 1
      internal/logic/pub/syncPermsTxLock_audit_test.go
  21. 39 24
      internal/logic/role/bindRolePermsLogic.go
  22. 5 1
      internal/logic/role/bindRolePermsLogic_mock_test.go
  23. 4 2
      internal/logic/role/postCommitCacheDegraded_audit_test.go
  24. 40 26
      internal/logic/user/bindRolesLogic.go
  25. 4 1
      internal/logic/user/bindRolesLogic_mock_test.go
  26. 40 6
      internal/logic/user/setUserPermsLogic.go
  27. 7 4
      internal/model/productmember/sysProductMemberModel.go
  28. 15 0
      internal/model/role/sysRoleModel.go
  29. 13 0
      internal/model/roleperm/sysRolePermModel.go
  30. 9 1
      internal/model/user/sysUserModel.go
  31. 14 0
      internal/model/userrole/sysUserRoleModel.go
  32. 9 5
      internal/server/permserver.go
  33. 11 6
      internal/server/permserver_test.go
  34. 3 1
      internal/server/syncPermissions404_audit_test.go
  35. 15 0
      internal/testutil/mocks/mock_role_model.go
  36. 15 0
      internal/testutil/mocks/mock_roleperm_model.go
  37. 15 0
      internal/testutil/mocks/mock_userrole_model.go
  38. 4 3
      internal/types/types.go

+ 60 - 8
internal/logic/auth/access.go

@@ -9,6 +9,7 @@ import (
 	"perms-system-server/internal/consts"
 	"perms-system-server/internal/loaders"
 	"perms-system-server/internal/middleware"
+	memberModel "perms-system-server/internal/model/productmember"
 	userModel "perms-system-server/internal/model/user"
 	"perms-system-server/internal/response"
 	"perms-system-server/internal/svc"
@@ -37,6 +38,7 @@ type ManageAccessOption func(*manageAccessOpts)
 
 type manageAccessOpts struct {
 	prefetchedTarget *userModel.SysUser
+	memberSink       **memberModel.SysProductMember
 }
 
 // WithPrefetchedTarget 供调用方透传已获取的目标用户数据。仅在 target.Id == targetUserId 时有效,
@@ -47,6 +49,17 @@ func WithPrefetchedTarget(target *userModel.SysUser) ManageAccessOption {
 	}
 }
 
+// WithMemberSink 让调用方接住 checkPermLevel 内部顺带 FindOneByProductCodeUserId 取到的
+// targetMember,避免调用方再打一次同查询(审计 M-R10-5)。注意:
+//   - 仅在走进 checkPermLevel 的分支(caller 非 SuperAdmin 且非本人)才会被写入;
+//   - caller=SuperAdmin 时整个 CheckManageAccess 短路不查 member,sink 将保持 nil,调用方
+//     需要在 sink==nil 的情况下自己兜底 FindOneByProductCodeUserId(或按业务规则跳过)。
+func WithMemberSink(sink **memberModel.SysProductMember) ManageAccessOption {
+	return func(o *manageAccessOpts) {
+		o.memberSink = sink
+	}
+}
+
 // CheckManageAccess 检查当前操作者是否有权管理目标用户。
 // 规则:
 //  1. SUPER_ADMIN 完全豁免
@@ -81,7 +94,7 @@ func CheckManageAccess(ctx context.Context, svcCtx *svc.ServiceContext, targetUs
 		return err
 	}
 
-	return checkPermLevel(ctx, svcCtx, caller, targetUserId, productCode)
+	return checkPermLevel(ctx, svcCtx, caller, targetUserId, productCode, options.memberSink)
 }
 
 // CheckMemberTypeAssignment 检查操作者是否有权分配指定的 memberType。
@@ -140,21 +153,55 @@ func RequireProductAdminFor(ctx context.Context, targetProductCode string) error
 // (审计 M-3 TOCTOU + 缓存失效延迟)。这里按"最小代价避开缓存"的原则,只在 assignment 决策点
 // 打一条 FindMinPermsLevelByUserIdAndProductCode 走 NoCache 查询。
 func GuardRoleLevelAssignable(ctx context.Context, svcCtx *svc.ServiceContext, caller *loaders.UserDetails, rolePermsLevel int64) error {
+	snap, err := LoadCallerAssignableLevel(ctx, svcCtx, caller)
+	if err != nil {
+		return err
+	}
+	return CheckRoleLevelAgainst(snap, rolePermsLevel)
+}
+
+// AssignableLevelSnapshot 是 "caller 在分配角色时的可分配等级快照"。调用方通常在一次业务
+// 请求里对多个 role 做同一 caller 的等级校验,拿着这个快照重复 CheckRoleLevelAgainst 不再打 DB。
+type AssignableLevelSnapshot struct {
+	// HasFullPerms 为 true 时直接全放行(SuperAdmin / ADMIN / DEVELOPER)。
+	HasFullPerms bool
+	// NoRole 表示 caller 在当前产品下没有任何活跃角色;即便 HasFullPerms=false 也不能分配任何角色。
+	NoRole bool
+	// Level 是 caller 的最小 permsLevel;仅在 HasFullPerms=false && NoRole=false 时有效。
+	Level int64
+}
+
+// LoadCallerAssignableLevel 封装"拉取一次 caller 的可分配等级 snapshot"的 DB 读。用于把 BindRoles
+// 这类批量分配循环里的 N 次 loadFreshMinPermsLevel 合并为 1 次(见审计 M-R10-3)。
+// 对 SuperAdmin / ADMIN / DEVELOPER 走短路,不打 DB;其他情况走 NoCache DB 读,保持与
+// GuardRoleLevelAssignable 完全一致的 TOCTOU 闭环契约。
+func LoadCallerAssignableLevel(ctx context.Context, svcCtx *svc.ServiceContext, caller *loaders.UserDetails) (AssignableLevelSnapshot, error) {
 	if HasFullProductPerms(caller) {
-		return nil
+		return AssignableLevelSnapshot{HasFullPerms: true}, nil
 	}
 	if caller == nil {
-		return response.ErrForbidden("您没有可分配的角色等级")
+		return AssignableLevelSnapshot{NoRole: true}, nil
 	}
-
-	freshLevel, notFound, err := loadFreshMinPermsLevel(ctx, svcCtx, caller.UserId, caller.ProductCode)
+	level, notFound, err := loadFreshMinPermsLevel(ctx, svcCtx, caller.UserId, caller.ProductCode)
 	if err != nil {
-		return err
+		return AssignableLevelSnapshot{}, err
 	}
 	if notFound {
+		return AssignableLevelSnapshot{NoRole: true}, nil
+	}
+	return AssignableLevelSnapshot{Level: level}, nil
+}
+
+// CheckRoleLevelAgainst 用预取的 snapshot 判定一个角色等级是否可被 caller 分配。保持与
+// GuardRoleLevelAssignable 完全一致的错误文案与边界条件(">=" 含同级拦截,ErrNotFound→403)。
+func CheckRoleLevelAgainst(snap AssignableLevelSnapshot, rolePermsLevel int64) error {
+	if snap.HasFullPerms {
+		return nil
+	}
+	if snap.NoRole {
 		return response.ErrForbidden("您没有可分配的角色等级")
 	}
-	if rolePermsLevel <= freshLevel {
+	if rolePermsLevel <= snap.Level {
 		return response.ErrForbidden("不能分配权限级别高于自身的角色(含同级)")
 	}
 	return nil
@@ -299,7 +346,7 @@ func checkDeptHierarchy(ctx context.Context, svcCtx *svc.ServiceContext, caller
 	return nil
 }
 
-func checkPermLevel(ctx context.Context, svcCtx *svc.ServiceContext, caller *loaders.UserDetails, targetUserId int64, productCode string) error {
+func checkPermLevel(ctx context.Context, svcCtx *svc.ServiceContext, caller *loaders.UserDetails, targetUserId int64, productCode string, memberSink **memberModel.SysProductMember) error {
 	if productCode == "" {
 		return response.ErrBadRequest("缺少产品上下文,无法进行权限级别判定")
 	}
@@ -308,6 +355,11 @@ func checkPermLevel(ctx context.Context, svcCtx *svc.ServiceContext, caller *loa
 	if err != nil {
 		return response.ErrForbidden("目标用户不是当前产品的成员,无法执行管理操作")
 	}
+	// 把 targetMember 写回调用方的 sink(若请求了),避免调用方再 FindOneByProductCodeUserId
+	// 一次做 status 或 memberType 判定(审计 M-R10-5)。
+	if memberSink != nil {
+		*memberSink = targetMember
+	}
 	targetMemberType := targetMember.MemberType
 
 	callerPri := memberTypePriority(caller.MemberType)

+ 108 - 0
internal/logic/auth/changePasswordConflict_audit_test.go

@@ -0,0 +1,108 @@
+package auth
+
+import (
+	"errors"
+	"testing"
+
+	"perms-system-server/internal/consts"
+	"perms-system-server/internal/loaders"
+	"perms-system-server/internal/middleware"
+	userModel "perms-system-server/internal/model/user"
+	"perms-system-server/internal/response"
+	"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"
+	"golang.org/x/crypto/bcrypt"
+)
+
+// ---------------------------------------------------------------------------
+// 覆盖目标:审计 M-R10-4 —— ChangePassword 必须把底层 `userModel.ErrUpdateConflict`
+// 显式映射为 409 "密码已被其他会话修改...";修复前 raw error 会被 rest 兜成 500,
+// 导致前端把"并发冲突"误判为系统故障,也会把告警归到 5xx 噪声池。
+//
+// 口径与 UpdateUserLogic / UpdateUserStatusLogic / UpdateRoleLogic 完全对齐。
+// ---------------------------------------------------------------------------
+
+// TC-1015: M-R10-4 —— UpdatePassword 返回 ErrUpdateConflict 时,ChangePassword 必须回 409。
+func TestChangePassword_UpdateConflict_Maps409(t *testing.T) {
+	ctrl := gomock.NewController(t)
+	t.Cleanup(ctrl.Finish)
+
+	const userId = int64(777)
+	oldPwd := "Oldpass123"
+	newPwd := "Newpass456"
+	hashed, err := bcrypt.GenerateFromPassword([]byte(oldPwd), bcrypt.DefaultCost)
+	require.NoError(t, err)
+
+	mockUser := mocks.NewMockSysUserModel(ctrl)
+	mockUser.EXPECT().FindOne(gomock.Any(), userId).
+		Return(&userModel.SysUser{
+			Id:         userId,
+			Username:   "m_r10_4_subject",
+			Password:   string(hashed),
+			Status:     consts.StatusEnabled,
+			UpdateTime: 1000,
+		}, nil)
+	// 关键:强制底层返回 ErrUpdateConflict。
+	mockUser.EXPECT().
+		UpdatePassword(gomock.Any(), userId, gomock.Any(), int64(consts.MustChangePasswordNo)).
+		Return(userModel.ErrUpdateConflict)
+
+	svcCtx := mocks.NewMockServiceContext(mocks.MockModels{User: mockUser})
+	ctx := middleware.WithUserDetails(t.Context(), &loaders.UserDetails{UserId: userId})
+
+	logic := NewChangePasswordLogic(ctx, svcCtx)
+	err = logic.ChangePassword(&types.ChangePasswordReq{
+		OldPassword: oldPwd,
+		NewPassword: newPwd,
+	})
+
+	var codeErr *response.CodeError
+	require.True(t, errors.As(err, &codeErr), "审计 M-R10-4:必须是 *response.CodeError,否则会被 rest 兜成 500")
+	assert.Equal(t, 409, codeErr.Code(), "审计 M-R10-4:ErrUpdateConflict 必须映射为 409 Conflict")
+	assert.Contains(t, codeErr.Error(), "密码已被其他会话修改", "审计 M-R10-4:文案与业务契约对齐")
+}
+
+// TC-1016: M-R10-4 —— 非 ErrUpdateConflict 的原生错误仍应透传(500 由 rest 兜底),
+// 防止修复把所有底层错误都误吞为 409。
+func TestChangePassword_GenericUpdateError_StillPropagates(t *testing.T) {
+	ctrl := gomock.NewController(t)
+	t.Cleanup(ctrl.Finish)
+
+	const userId = int64(778)
+	oldPwd := "Oldpass123"
+	newPwd := "Newpass456"
+	hashed, err := bcrypt.GenerateFromPassword([]byte(oldPwd), bcrypt.DefaultCost)
+	require.NoError(t, err)
+
+	mockUser := mocks.NewMockSysUserModel(ctrl)
+	mockUser.EXPECT().FindOne(gomock.Any(), userId).
+		Return(&userModel.SysUser{
+			Id:         userId,
+			Username:   "m_r10_4_subject2",
+			Password:   string(hashed),
+			Status:     consts.StatusEnabled,
+			UpdateTime: 2000,
+		}, nil)
+	genericErr := errors.New("driver: bad connection")
+	mockUser.EXPECT().
+		UpdatePassword(gomock.Any(), userId, gomock.Any(), int64(consts.MustChangePasswordNo)).
+		Return(genericErr)
+
+	svcCtx := mocks.NewMockServiceContext(mocks.MockModels{User: mockUser})
+	ctx := middleware.WithUserDetails(t.Context(), &loaders.UserDetails{UserId: userId})
+
+	logic := NewChangePasswordLogic(ctx, svcCtx)
+	err = logic.ChangePassword(&types.ChangePasswordReq{
+		OldPassword: oldPwd,
+		NewPassword: newPwd,
+	})
+
+	require.Error(t, err)
+	assert.ErrorIs(t, err, genericErr, "审计 M-R10-4:只把 ErrUpdateConflict 映射 409,其余错误原样透传(由 rest 兜 500)")
+	var codeErr *response.CodeError
+	assert.False(t, errors.As(err, &codeErr), "审计 M-R10-4:非冲突错误不得伪装成 CodeError")
+}

+ 8 - 0
internal/logic/auth/changePasswordLogic.go

@@ -2,10 +2,12 @@ package auth
 
 import (
 	"context"
+	"errors"
 	"fmt"
 
 	"perms-system-server/internal/consts"
 	"perms-system-server/internal/middleware"
+	userModel "perms-system-server/internal/model/user"
 	"perms-system-server/internal/response"
 	"perms-system-server/internal/svc"
 	"perms-system-server/internal/types"
@@ -69,6 +71,12 @@ func (l *ChangePasswordLogic) ChangePassword(req *types.ChangePasswordReq) error
 	}
 
 	if err := l.svcCtx.SysUserModel.UpdatePassword(l.ctx, userId, string(hashed), consts.MustChangePasswordNo); err != nil {
+		// 审计 M-R10-4:与 UpdateUserLogic / UpdateRoleLogic / UpdateUserStatusLogic 口径对齐,
+		// 把乐观锁失败显式映射成 409,避免 raw error 被 rest 框架兜成 500、前端错把"并发冲突"
+		// 当作系统故障处理,告警看板也不会把这类事件归到 5xx 噪声池。
+		if errors.Is(err, userModel.ErrUpdateConflict) {
+			return response.ErrConflict("密码已被其他会话修改,请刷新后重试")
+		}
 		return err
 	}
 

+ 170 - 0
internal/logic/auth/loadCallerAssignableLevel_audit_test.go

@@ -0,0 +1,170 @@
+package auth
+
+import (
+	"context"
+	"errors"
+	"testing"
+
+	"perms-system-server/internal/consts"
+	"perms-system-server/internal/loaders"
+	"perms-system-server/internal/response"
+	"perms-system-server/internal/testutil/mocks"
+
+	"github.com/stretchr/testify/assert"
+	"github.com/stretchr/testify/require"
+	"github.com/zeromicro/go-zero/core/stores/sqlx"
+	"go.uber.org/mock/gomock"
+)
+
+// ---------------------------------------------------------------------------
+// 覆盖目标:审计 M-R10-3 —— LoadCallerAssignableLevel 在一次请求内对同一 caller 只做
+// 一次 DB 读;CheckRoleLevelAgainst 不再访问 DB,给 BindRoles 这种"批量覆盖"的接口把
+// N 次 loadFreshMinPermsLevel 合并为 1 次。
+//
+// 核心断言口径:
+//   1. SuperAdmin / ADMIN / DEVELOPER 等全权调用者不打 DB(HasFullPerms=true 短路);
+//   2. MEMBER caller 打 1 次 FindMinPermsLevelByUserIdAndProductCode;
+//   3. caller.ErrNotFound → NoRole=true(不打翻 500);
+//   4. caller 其他 DB 错误 → fail-close 500(保持与 loadFreshMinPermsLevel 一致的口径,
+//      避免降级为"无角色 = 最低级"放行)。
+//   5. CheckRoleLevelAgainst 是纯函数,不访问 svcCtx。
+// ---------------------------------------------------------------------------
+
+// TC-1017: M-R10-3 —— SuperAdmin / ADMIN / DEVELOPER 走 HasFullPerms 短路,不触碰 DB。
+func TestLoadCallerAssignableLevel_FullPermsShortCircuit_NoDB(t *testing.T) {
+	ctrl := gomock.NewController(t)
+	t.Cleanup(ctrl.Finish)
+
+	mockRole := mocks.NewMockSysRoleModel(ctrl)
+	// 关键:没有 EXPECT.FindMinPermsLevelByUserIdAndProductCode —— 一旦被调用会 fail。
+	svcCtx := mocks.NewMockServiceContext(mocks.MockModels{Role: mockRole})
+
+	cases := []struct {
+		name   string
+		caller *loaders.UserDetails
+	}{
+		{
+			name:   "SuperAdmin",
+			caller: &loaders.UserDetails{UserId: 1, IsSuperAdmin: true, ProductCode: "p"},
+		},
+		{
+			name:   "ADMIN",
+			caller: &loaders.UserDetails{UserId: 2, MemberType: consts.MemberTypeAdmin, ProductCode: "p"},
+		},
+		{
+			name:   "DEVELOPER",
+			caller: &loaders.UserDetails{UserId: 3, MemberType: consts.MemberTypeDeveloper, ProductCode: "p"},
+		},
+	}
+	for _, c := range cases {
+		t.Run(c.name, func(t *testing.T) {
+			snap, err := LoadCallerAssignableLevel(context.Background(), svcCtx, c.caller)
+			require.NoError(t, err)
+			assert.True(t, snap.HasFullPerms, "全权调用者必须落 HasFullPerms 分支")
+			assert.False(t, snap.NoRole)
+		})
+	}
+}
+
+// TC-1018: M-R10-3 —— MEMBER caller 仅打 1 次 FindMinPermsLevelByUserIdAndProductCode;
+// 循环内对 N 个角色走 CheckRoleLevelAgainst 不再打 DB。
+func TestLoadCallerAssignableLevel_Member_ReadsDBOnce_ThenConstantTime(t *testing.T) {
+	ctrl := gomock.NewController(t)
+	t.Cleanup(ctrl.Finish)
+
+	const callerId = int64(1001)
+	const productCode = "pc_m_r10_3"
+
+	mockRole := mocks.NewMockSysRoleModel(ctrl)
+	// 关键断言:Times(1) 保证 N 个角色场景不会退化为 N 次 DB 读。
+	mockRole.EXPECT().
+		FindMinPermsLevelByUserIdAndProductCode(gomock.Any(), callerId, productCode).
+		Return(int64(100), nil).
+		Times(1)
+
+	svcCtx := mocks.NewMockServiceContext(mocks.MockModels{Role: mockRole})
+
+	caller := &loaders.UserDetails{
+		UserId:      callerId,
+		MemberType:  consts.MemberTypeMember,
+		ProductCode: productCode,
+	}
+	snap, err := LoadCallerAssignableLevel(context.Background(), svcCtx, caller)
+	require.NoError(t, err)
+	assert.False(t, snap.HasFullPerms)
+	assert.False(t, snap.NoRole)
+	assert.Equal(t, int64(100), snap.Level)
+
+	// 模拟 BindRoles 批量覆盖的循环:5 个角色,全部走 CheckRoleLevelAgainst 的纯比较,
+	// 任何一个角色额外打 DB 都会命中 gomock 的 "unexpected call" 断言。
+	roleLevels := []int64{200, 150, 300, 120, 999}
+	for _, rl := range roleLevels {
+		if err := CheckRoleLevelAgainst(snap, rl); err != nil {
+			t.Fatalf("role level %d should be assignable against caller level %d: %v", rl, snap.Level, err)
+		}
+	}
+
+	// 同级与更高级一律拒绝(与 GuardRoleLevelAssignable 对称):
+	for _, rl := range []int64{100, 50, 1} {
+		err := CheckRoleLevelAgainst(snap, rl)
+		var codeErr *response.CodeError
+		require.True(t, errors.As(err, &codeErr), "同级或更高级必须返回 CodeError")
+		assert.Equal(t, 403, codeErr.Code())
+	}
+}
+
+// TC-1019: M-R10-3 —— caller 在该产品下无角色 → NoRole=true,不回滚 500。
+func TestLoadCallerAssignableLevel_Member_ErrNotFound_MapsToNoRole(t *testing.T) {
+	ctrl := gomock.NewController(t)
+	t.Cleanup(ctrl.Finish)
+
+	mockRole := mocks.NewMockSysRoleModel(ctrl)
+	mockRole.EXPECT().
+		FindMinPermsLevelByUserIdAndProductCode(gomock.Any(), int64(42), "pc").
+		Return(int64(0), sqlx.ErrNotFound).
+		Times(1)
+
+	svcCtx := mocks.NewMockServiceContext(mocks.MockModels{Role: mockRole})
+	caller := &loaders.UserDetails{
+		UserId:      42,
+		MemberType:  consts.MemberTypeMember,
+		ProductCode: "pc",
+	}
+	snap, err := LoadCallerAssignableLevel(context.Background(), svcCtx, caller)
+	require.NoError(t, err, "ErrNotFound 必须被归一为 NoRole=true,不得外泄为 500")
+	assert.False(t, snap.HasFullPerms)
+	assert.True(t, snap.NoRole)
+
+	// 验证 NoRole 的 caller 连最低级角色也无法分配(与修复前保持的业务语义一致)。
+	err = CheckRoleLevelAgainst(snap, 999)
+	var codeErr *response.CodeError
+	require.True(t, errors.As(err, &codeErr))
+	assert.Equal(t, 403, codeErr.Code())
+	assert.Contains(t, codeErr.Error(), "没有可分配的角色等级")
+}
+
+// TC-1020: M-R10-3 —— caller 其他 DB 错误必须 fail-close 500,不得降级为 NoRole 放行。
+// 保证修复没有把"DB 抖动"悄悄压成"无角色 → 最低级 → 403"这种语义欺骗。
+func TestLoadCallerAssignableLevel_Member_DBError_FailClose500(t *testing.T) {
+	ctrl := gomock.NewController(t)
+	t.Cleanup(ctrl.Finish)
+
+	dbErr := errors.New("driver: bad connection")
+	mockRole := mocks.NewMockSysRoleModel(ctrl)
+	mockRole.EXPECT().
+		FindMinPermsLevelByUserIdAndProductCode(gomock.Any(), int64(7), "pc").
+		Return(int64(0), dbErr).
+		Times(1)
+
+	svcCtx := mocks.NewMockServiceContext(mocks.MockModels{Role: mockRole})
+	caller := &loaders.UserDetails{
+		UserId:      7,
+		MemberType:  consts.MemberTypeMember,
+		ProductCode: "pc",
+	}
+
+	_, err := LoadCallerAssignableLevel(context.Background(), svcCtx, caller)
+	var codeErr *response.CodeError
+	require.True(t, errors.As(err, &codeErr), "DB 抖动必须 fail-close 为 CodeError 而非 nil")
+	assert.Equal(t, 500, codeErr.Code())
+}

+ 9 - 1
internal/logic/auth/logoutLogic.go

@@ -5,9 +5,11 @@ package auth
 
 import (
 	"context"
+	"errors"
 	"fmt"
 
 	"perms-system-server/internal/middleware"
+	userModel "perms-system-server/internal/model/user"
 	"perms-system-server/internal/response"
 	"perms-system-server/internal/svc"
 
@@ -44,7 +46,13 @@ func (l *LogoutLogic) Logout() error {
 	}
 
 	if _, err := l.svcCtx.SysUserModel.IncrementTokenVersion(l.ctx, userId); err != nil {
-		return err
+		// 审计 L-R10-3:IncrementTokenVersion 在目标用户已被并发删除时会返 ErrUpdateConflict。
+		// Logout 的语义目标本就是"让该账号的旧令牌立即失效",用户已经消失等同语义已达成,
+		// 按幂等成功处理并继续清缓存,不要让一次正常的注销因为极罕见的删号竞态回 500。
+		if !errors.Is(err, userModel.ErrUpdateConflict) {
+			return err
+		}
+		logx.WithContext(l.ctx).Infof("logout on already-deleted user userId=%d, treated as idempotent success", userId)
 	}
 
 	l.svcCtx.UserDetailsLoader.Clean(l.ctx, userId)

+ 13 - 6
internal/logic/product/createProductCompensation_audit_test.go

@@ -88,11 +88,15 @@ func TestCreateProduct_RedisSetexFail_CompensatesAllRows(t *testing.T) {
 		testutil.CleanTableByField(ctx, conn, "`sys_product`", "code", code)
 	})
 
+	// 审计 M-1 补偿路径必须在"入库 → SetexCtx 失败 → 补偿"整条链路生效。L-R10-1 要求传 AdminDeptId:
+	// 这里必须用真实 DB(svcCtx.SysDeptModel 走 testutil 底层 conn),所以 seed 一条真实 dept。
+	deptId := seedAdminDept(t, ctx, svcCtx)
 	logic := NewCreateProductLogic(ctx, svcCtx)
 	resp, err := logic.CreateProduct(&types.CreateProductReq{
-		Code:   code,
-		Name:   "m1压测产品",
-		Remark: "审计M-1补偿验证",
+		Code:        code,
+		Name:        "m1压测产品",
+		Remark:      "审计M-1补偿验证",
+		AdminDeptId: deptId,
 	})
 
 	// 审计要求:返回 503 "暂存初始凭证失败,请稍后重试";响应体不得携带 ticket / adminPassword。
@@ -120,9 +124,12 @@ func TestCreateProduct_RedisSetexFail_AfterCompensation_CanRecreate(t *testing.T
 		testutil.CleanTableByField(ctx, conn, "`sys_product`", "code", code)
 	})
 
+	// 审计 L-R10-1:两次 CreateProduct 复用同一个有效 dept,seedAdminDept 在 brokenCtx 下创建
+	// (brokenCtx 的 Models 共享底层 conn,所以 goodCtx 二次查询也能读到)。
+	deptId := seedAdminDept(t, ctx, brokenCtx)
 	// 第一次:Redis 坏 → 补偿 → 503
 	_, err := NewCreateProductLogic(ctx, brokenCtx).CreateProduct(&types.CreateProductReq{
-		Code: code, Name: "first_attempt", Remark: "redis_down",
+		Code: code, Name: "first_attempt", Remark: "redis_down", AdminDeptId: deptId,
 	})
 	require.Error(t, err)
 	assertNoOrphanRowsLeft(t, ctx, conn, code, adminUsername)
@@ -130,7 +137,7 @@ func TestCreateProduct_RedisSetexFail_AfterCompensation_CanRecreate(t *testing.T
 	// 第二次:Redis 好 → 必须成功;若第一次补偿不彻底会在 FindOneByCode/FindOneByUsername 里被拦。
 	goodCtx := svc.NewServiceContext(testutil.GetTestConfig())
 	resp2, err := NewCreateProductLogic(ctx, goodCtx).CreateProduct(&types.CreateProductReq{
-		Code: code, Name: "second_attempt", Remark: "redis_ok",
+		Code: code, Name: "second_attempt", Remark: "redis_ok", AdminDeptId: deptId,
 	})
 	require.NoError(t, err, "M-1:补偿后二次创建必须成功;若失败说明有行没被清干净")
 	require.NotNil(t, resp2)
@@ -164,7 +171,7 @@ func TestCreateProduct_RedisSetexFail_CompensatesInChildFirstOrder(t *testing.T)
 	})
 
 	_, err := NewCreateProductLogic(ctx, svcCtx).CreateProduct(&types.CreateProductReq{
-		Code: code, Name: "m1 order", Remark: "delete order",
+		Code: code, Name: "m1 order", Remark: "delete order", AdminDeptId: seedAdminDept(t, ctx, svcCtx),
 	})
 	require.Error(t, err)
 

+ 10 - 2
internal/logic/product/createProductConflict_audit_test.go

@@ -5,6 +5,7 @@ import (
 	"errors"
 	"testing"
 
+	deptModel "perms-system-server/internal/model/dept"
 	productModel "perms-system-server/internal/model/product"
 	userModel "perms-system-server/internal/model/user"
 	"perms-system-server/internal/response"
@@ -53,14 +54,21 @@ func TestCreateProduct_DuplicateEntry_UnknownIndexName_MapsTo409(t *testing.T) {
 	mockUser.EXPECT().FindOneByUsername(gomock.Any(), "admin_m5_code").
 		Return(nil, userModel.ErrNotFound)
 
+	// 审计 L-R10-1:CreateProduct 现在必填 AdminDeptId,且在入库前 FindOne + 校验启用状态
+	mockDept := mocks.NewMockSysDeptModel(ctrl)
+	mockDept.EXPECT().FindOne(gomock.Any(), int64(77)).
+		Return(&deptModel.SysDept{Id: 77, Path: "/77/", Status: 1}, nil)
+
 	svcCtx := mocks.NewMockServiceContext(mocks.MockModels{
 		Product: mockProduct,
 		User:    mockUser,
+		Dept:    mockDept,
 	})
 
 	resp, err := NewCreateProductLogic(ctxhelper.SuperAdminCtx(), svcCtx).CreateProduct(&types.CreateProductReq{
-		Code: "m5_code",
-		Name: "M5 Product",
+		Code:        "m5_code",
+		Name:        "M5 Product",
+		AdminDeptId: 77,
 	})
 	assert.Nil(t, resp)
 	require.Error(t, err)

+ 96 - 1
internal/logic/product/createProductLogic.go

@@ -71,6 +71,22 @@ func (l *CreateProductLogic) CreateProduct(req *types.CreateProductReq) (resp *t
 	if len(req.Remark) > 255 {
 		return nil, response.ErrBadRequest("备注长度不能超过255个字符")
 	}
+	// 审计 L-R10-1:必须显式携带 adminDeptId,并在入库前核实部门存在 + 启用状态。否则产出的
+	// admin_<code> 账号 DeptId=0 / DeptPath="" 时,
+	//   - CheckAddMemberAccess 要求 caller 有 DeptPath → AddMember 直接 403;
+	//   - CreateUser 非超管分支要求 caller 有 DeptPath 且新 dept 在其前缀下 → CreateUser 直接 403;
+	//   - UpdateUser 禁止改自己部门 → admin 自救也走不通;
+	// 必须由超管在创建阶段就把归属定清楚,避免把后续自助接入流程悬挂。
+	if req.AdminDeptId <= 0 {
+		return nil, response.ErrBadRequest("必须指定管理员账号的初始部门(adminDeptId)")
+	}
+	adminDept, err := l.svcCtx.SysDeptModel.FindOne(l.ctx, req.AdminDeptId)
+	if err != nil {
+		return nil, response.ErrBadRequest("管理员部门不存在")
+	}
+	if adminDept.Status != consts.StatusEnabled {
+		return nil, response.ErrBadRequest("管理员部门已停用")
+	}
 
 	_, findErr := l.svcCtx.SysProductModel.FindOneByCode(l.ctx, req.Code)
 	if findErr == nil {
@@ -95,7 +111,10 @@ func (l *CreateProductLogic) CreateProduct(req *types.CreateProductReq) (resp *t
 	if _, err := l.svcCtx.SysUserModel.FindOneByUsername(l.ctx, adminUsername); err == nil {
 		return nil, response.ErrConflict(fmt.Sprintf("用户名 %s 已存在,无法自动创建管理员账号", adminUsername))
 	}
-	adminPassword, err := generateRandomHex(12)
+	// 审计 L-R10-2:改用混合字符集强密码(大小写 + 数字 + 符号),确保通过 util.ValidatePassword
+	// 的"同时包含大写、小写、数字"规则。旧的 generateRandomHex(12) 只产生 0-9a-f,首登被强制
+	// 改密时本身不影响,但若将来合规/风控链路接入"现有密码强度复核",初始密码会被判定为不合规。
+	adminPassword, err := generateStrongInitialPassword(16)
 	if err != nil {
 		return nil, err
 	}
@@ -130,6 +149,7 @@ func (l *CreateProductLogic) CreateProduct(req *types.CreateProductReq) (resp *t
 			Username:           adminUsername,
 			Password:           string(hashedPwd),
 			Nickname:           fmt.Sprintf("%s管理员", req.Name),
+			DeptId:             req.AdminDeptId,
 			IsSuperAdmin:       consts.IsSuperAdminNo,
 			MustChangePassword: consts.MustChangePasswordYes,
 			Status:             consts.StatusEnabled,
@@ -220,6 +240,81 @@ func generateRandomHex(byteLen int) (string, error) {
 	return hex.EncodeToString(b), nil
 }
 
+// generateStrongInitialPassword 为产品 admin 初始账号生成强密码:大写+小写+数字+少量符号混合,
+// 并通过 util.ValidatePassword 断言,确保任何后续合规复核都不会卡住(见审计 L-R10-2)。
+// 长度要求 n >= 8;实际生成 n 个字符,混合字符集字面量已刻意去掉易混淆的 I/l/O/0/1。
+func generateStrongInitialPassword(n int) (string, error) {
+	if n < 8 {
+		n = 8
+	}
+	const (
+		upper   = "ABCDEFGHJKMNPQRSTUVWXYZ"
+		lower   = "abcdefghjkmnpqrstuvwxyz"
+		digits  = "23456789"
+		symbols = "!@#$%^&*"
+	)
+	alphabet := upper + lower + digits + symbols
+	// 把每个字符类至少各取 1 个放在随机位置,保证强度检查一定通过;其余位从总字母表随机。
+	classes := []string{upper, lower, digits, symbols}
+	pwd := make([]byte, n)
+	used := 0
+	for _, cls := range classes {
+		c, err := randomCharFrom(cls)
+		if err != nil {
+			return "", err
+		}
+		pwd[used] = c
+		used++
+	}
+	for i := used; i < n; i++ {
+		c, err := randomCharFrom(alphabet)
+		if err != nil {
+			return "", err
+		}
+		pwd[i] = c
+	}
+	if err := shuffleBytes(pwd); err != nil {
+		return "", err
+	}
+	out := string(pwd)
+	if msg := util.ValidatePassword(out); msg != "" {
+		// 理论上不可达:上面已按字符类强制填充,保留断言避免字符集未来被人修改后静默失效。
+		return "", fmt.Errorf("generated password failed strength check: %s", msg)
+	}
+	return out, nil
+}
+
+// randomCharFrom 用 crypto/rand 无偏地从字符集中取一个字节。循环直到命中模长内,避免简单取模偏置。
+func randomCharFrom(alphabet string) (byte, error) {
+	max := len(alphabet)
+	bucket := 256 - (256 % max)
+	buf := make([]byte, 1)
+	for {
+		if _, err := rand.Read(buf); err != nil {
+			return 0, err
+		}
+		if int(buf[0]) < bucket {
+			return alphabet[int(buf[0])%max], nil
+		}
+	}
+}
+
+// shuffleBytes Fisher-Yates 洗牌,随机索引基于 crypto/rand;用于打散 generateStrongInitialPassword
+// 里"前 4 个字符恰好是各字符类"的固定前缀,避免格式可预测。
+func shuffleBytes(buf []byte) error {
+	for i := len(buf) - 1; i > 0; i-- {
+		bucket := byte(i + 1)
+		// 与 randomCharFrom 相同的无偏采样策略,上限很小直接 mod 即可(i<=63)。
+		b := make([]byte, 1)
+		if _, err := rand.Read(b); err != nil {
+			return err
+		}
+		j := int(b[0] % bucket)
+		buf[i], buf[j] = buf[j], buf[i]
+	}
+	return nil
+}
+
 // compensateCreatedRows 是审计 M-1 要求的失败补偿:事务已提交后 ticket/Redis 环节失败时,
 // 把刚刚落盘的 sys_product_member / sys_user / sys_product 三行按"子 → 父"顺序全部删掉,
 // 把副作用回到"从未创建"状态。补偿事务本身失败的概率不为 0(DB 再抖一次),因此我们:

+ 19 - 4
internal/logic/product/createProductLogic_mock_test.go

@@ -6,6 +6,7 @@ import (
 	"errors"
 	"testing"
 
+	deptModel "perms-system-server/internal/model/dept"
 	productModel "perms-system-server/internal/model/product"
 	userModel "perms-system-server/internal/model/user"
 	"perms-system-server/internal/testutil/ctxhelper"
@@ -45,15 +46,22 @@ func TestCreateProduct_Mock_UserInsertFail(t *testing.T) {
 	mockUser.EXPECT().InsertWithTx(gomock.Any(), nil, gomock.Any()).
 		Return(nil, dbErr)
 
+	// 审计 L-R10-1:CreateProduct 必填 AdminDeptId,入库前 FindOne + 启用校验
+	mockDept := mocks.NewMockSysDeptModel(ctrl)
+	mockDept.EXPECT().FindOne(gomock.Any(), int64(88)).
+		Return(&deptModel.SysDept{Id: 88, Path: "/88/", Status: 1}, nil)
+
 	svcCtx := mocks.NewMockServiceContext(mocks.MockModels{
 		Product: mockProduct,
 		User:    mockUser,
+		Dept:    mockDept,
 	})
 
 	logic := NewCreateProductLogic(ctxhelper.SuperAdminCtx(), svcCtx)
 	resp, err := logic.CreateProduct(&types.CreateProductReq{
-		Code: "test_code",
-		Name: "Test Product",
+		Code:        "test_code",
+		Name:        "Test Product",
+		AdminDeptId: 88,
 	})
 
 	assert.Error(t, err)
@@ -88,16 +96,23 @@ func TestCreateProduct_Mock_MemberInsertFail(t *testing.T) {
 	mockPM.EXPECT().InsertWithTx(gomock.Any(), nil, gomock.Any()).
 		Return(sql.Result(nil), dbErr)
 
+	// 审计 L-R10-1:CreateProduct 必填 AdminDeptId
+	mockDept := mocks.NewMockSysDeptModel(ctrl)
+	mockDept.EXPECT().FindOne(gomock.Any(), int64(88)).
+		Return(&deptModel.SysDept{Id: 88, Path: "/88/", Status: 1}, nil)
+
 	svcCtx := mocks.NewMockServiceContext(mocks.MockModels{
 		Product:       mockProduct,
 		User:          mockUser,
 		ProductMember: mockPM,
+		Dept:          mockDept,
 	})
 
 	logic := NewCreateProductLogic(ctxhelper.SuperAdminCtx(), svcCtx)
 	resp, err := logic.CreateProduct(&types.CreateProductReq{
-		Code: "test_code",
-		Name: "Test Product",
+		Code:        "test_code",
+		Name:        "Test Product",
+		AdminDeptId: 88,
 	})
 
 	assert.Error(t, err)

+ 22 - 13
internal/logic/product/createProductLogic_test.go

@@ -27,9 +27,10 @@ func TestCreateProduct_Success(t *testing.T) {
 
 	logic := NewCreateProductLogic(ctx, svcCtx)
 	resp, err := logic.CreateProduct(&types.CreateProductReq{
-		Code:   code,
-		Name:   "测试产品",
-		Remark: "集成测试",
+		Code:        code,
+		Name:        "测试产品",
+		Remark:      "集成测试",
+		AdminDeptId: seedAdminDept(t, ctx, svcCtx),
 	})
 	require.NoError(t, err)
 	require.NotNil(t, resp)
@@ -71,9 +72,10 @@ func TestCreateProduct_VerifyDB(t *testing.T) {
 
 	logic := NewCreateProductLogic(ctx, svcCtx)
 	resp, err := logic.CreateProduct(&types.CreateProductReq{
-		Code:   code,
-		Name:   "DB验证产品",
-		Remark: "验证数据库记录",
+		Code:        code,
+		Name:        "DB验证产品",
+		Remark:      "验证数据库记录",
+		AdminDeptId: seedAdminDept(t, ctx, svcCtx),
 	})
 	require.NoError(t, err)
 
@@ -121,10 +123,12 @@ func TestCreateProduct_DuplicateCode(t *testing.T) {
 	conn := testutil.GetTestSqlConn()
 	code := testutil.UniqueId()
 
+	deptId := seedAdminDept(t, ctx, svcCtx)
 	logic := NewCreateProductLogic(ctx, svcCtx)
 	resp, err := logic.CreateProduct(&types.CreateProductReq{
-		Code: code,
-		Name: "第一个产品",
+		Code:        code,
+		Name:        "第一个产品",
+		AdminDeptId: deptId,
 	})
 	require.NoError(t, err)
 
@@ -136,8 +140,9 @@ func TestCreateProduct_DuplicateCode(t *testing.T) {
 
 	logic2 := NewCreateProductLogic(ctx, svcCtx)
 	_, err = logic2.CreateProduct(&types.CreateProductReq{
-		Code: code,
-		Name: "重复产品",
+		Code:        code,
+		Name:        "重复产品",
+		AdminDeptId: deptId,
 	})
 	require.Error(t, err)
 
@@ -160,6 +165,7 @@ func TestCreateProduct_ConcurrentSameCode(t *testing.T) {
 		testutil.CleanTableByField(ctx, conn, "`sys_product`", "code", code)
 	})
 
+	deptId := seedAdminDept(t, ctx, svcCtx)
 	var wg sync.WaitGroup
 	results := make(chan error, 2)
 	for i := 0; i < 2; i++ {
@@ -168,8 +174,9 @@ func TestCreateProduct_ConcurrentSameCode(t *testing.T) {
 			defer wg.Done()
 			logic := NewCreateProductLogic(ctx, svcCtx)
 			_, err := logic.CreateProduct(&types.CreateProductReq{
-				Code: code,
-				Name: "并发测试产品",
+				Code:        code,
+				Name:        "并发测试产品",
+				AdminDeptId: deptId,
 			})
 			results <- err
 		}()
@@ -264,7 +271,9 @@ func TestCreateProduct_ValidCodeWithSymbols(t *testing.T) {
 
 	code := "a_1-" + testutil.UniqueId()
 	logic := NewCreateProductLogic(ctx, svcCtx)
-	resp, err := logic.CreateProduct(&types.CreateProductReq{Code: code, Name: "x"})
+	resp, err := logic.CreateProduct(&types.CreateProductReq{
+		Code: code, Name: "x", AdminDeptId: seedAdminDept(t, ctx, svcCtx),
+	})
 	require.NoError(t, err)
 	t.Cleanup(func() {
 		testutil.CleanTableByField(ctx, conn, "`sys_product_member`", "productCode", code)

+ 8 - 8
internal/logic/product/fetchInitialCredentialsLogic_audit_test.go

@@ -40,7 +40,7 @@ func TestFetchInitialCredentials_HappyPath(t *testing.T) {
 	code := testutil.UniqueId()
 
 	createResp, err := NewCreateProductLogic(ctx, svcCtx).CreateProduct(&types.CreateProductReq{
-		Code: code, Name: "tic_ok", Remark: "",
+		Code: code, Name: "tic_ok", Remark: "", AdminDeptId: seedAdminDept(t, ctx, svcCtx),
 	})
 	require.NoError(t, err)
 	require.NotNil(t, createResp)
@@ -63,9 +63,9 @@ func TestFetchInitialCredentials_HappyPath(t *testing.T) {
 	assert.Equal(t, createResp.AdminUser, cred.AdminUser, "adminUser 必须与 CreateProduct 响应一致")
 	assert.NotEmpty(t, cred.AppSecret, "必须返回明文 appSecret")
 	assert.NotEmpty(t, cred.AdminPassword, "必须返回明文 adminPassword")
-	// 基础合理性:32 字节 hex = 64 字符;12 字节 hex = 24 字符(与 createProductLogic 生成参数对齐)
+	// 基础合理性:32 字节 hex = 64 字符;审计 L-R10-2 改为混合字符集强密码,长度固定 16
 	assert.Len(t, cred.AppSecret, 64, "appSecret 必须是 32 字节 hex")
-	assert.Len(t, cred.AdminPassword, 24, "adminPassword 必须是 12 字节 hex")
+	assert.Len(t, cred.AdminPassword, 16, "L-R10-2:adminPassword 改由 generateStrongInitialPassword(16) 生成,长度恒为 16")
 }
 
 // TC-0902: FetchInitialCredentials 一次性消费 —— 同一 ticket 第二次消费必须 400。
@@ -76,7 +76,7 @@ func TestFetchInitialCredentials_OneShotConsumption(t *testing.T) {
 	code := testutil.UniqueId()
 
 	createResp, err := NewCreateProductLogic(ctx, svcCtx).CreateProduct(&types.CreateProductReq{
-		Code: code, Name: "tic_once",
+		Code: code, Name: "tic_once", AdminDeptId: seedAdminDept(t, ctx, svcCtx),
 	})
 	require.NoError(t, err)
 
@@ -152,7 +152,7 @@ func TestFetchInitialCredentials_NonSuperAdminRejected(t *testing.T) {
 	code := testutil.UniqueId()
 
 	createResp, err := NewCreateProductLogic(superCtx, svcCtx).CreateProduct(&types.CreateProductReq{
-		Code: code, Name: "tic_403",
+		Code: code, Name: "tic_403", AdminDeptId: seedAdminDept(t, superCtx, svcCtx),
 	})
 	require.NoError(t, err)
 	t.Cleanup(func() {
@@ -224,7 +224,7 @@ func TestFetchInitialCredentials_StoredPayloadIsStructuredJSON(t *testing.T) {
 	code := testutil.UniqueId()
 
 	createResp, err := NewCreateProductLogic(ctx, svcCtx).CreateProduct(&types.CreateProductReq{
-		Code: code, Name: "tic_json",
+		Code: code, Name: "tic_json", AdminDeptId: seedAdminDept(t, ctx, svcCtx),
 	})
 	require.NoError(t, err)
 	t.Cleanup(func() {
@@ -255,7 +255,7 @@ func TestFetchInitialCredentials_TicketTTLWithinWindow(t *testing.T) {
 	code := testutil.UniqueId()
 
 	createResp, err := NewCreateProductLogic(ctx, svcCtx).CreateProduct(&types.CreateProductReq{
-		Code: code, Name: "tic_ttl",
+		Code: code, Name: "tic_ttl", AdminDeptId: seedAdminDept(t, ctx, svcCtx),
 	})
 	require.NoError(t, err)
 	t.Cleanup(func() {
@@ -280,7 +280,7 @@ func TestFetchInitialCredentials_ConcurrentConsumptionSingleWinner(t *testing.T)
 	code := testutil.UniqueId()
 
 	createResp, err := NewCreateProductLogic(ctx, svcCtx).CreateProduct(&types.CreateProductReq{
-		Code: code, Name: "tic_conc",
+		Code: code, Name: "tic_conc", AdminDeptId: seedAdminDept(t, ctx, svcCtx),
 	})
 	require.NoError(t, err)
 	t.Cleanup(func() {

+ 37 - 0
internal/logic/product/helper_test.go

@@ -0,0 +1,37 @@
+package product
+
+import (
+	"context"
+	"testing"
+	"time"
+
+	deptModel "perms-system-server/internal/model/dept"
+	"perms-system-server/internal/svc"
+	"perms-system-server/internal/testutil"
+
+	"github.com/stretchr/testify/require"
+)
+
+// seedAdminDept 插入一个启用状态的部门并登记 cleanup,返回 deptId。
+// 用于 L-R10-1 要求的 CreateProduct.AdminDeptId 入参,避免每个测试重复模板代码。
+func seedAdminDept(t *testing.T, ctx context.Context, svcCtx *svc.ServiceContext) int64 {
+	t.Helper()
+	conn := testutil.GetTestSqlConn()
+	now := time.Now().Unix()
+	res, err := svcCtx.SysDeptModel.Insert(ctx, &deptModel.SysDept{
+		ParentId:   0,
+		Name:       "p_dept_" + testutil.UniqueId(),
+		Path:       "/",
+		Sort:       0,
+		DeptType:   "NORMAL",
+		Status:     1,
+		CreateTime: now,
+		UpdateTime: now,
+	})
+	require.NoError(t, err)
+	id, _ := res.LastInsertId()
+	t.Cleanup(func() {
+		testutil.CleanTable(ctx, conn, "`sys_dept`", id)
+	})
+	return id
+}

+ 4 - 2
internal/logic/pub/loginLogic_test.go

@@ -298,7 +298,8 @@ func TestLogin_NonMemberWithProductCode(t *testing.T) {
 	var codeErr *response.CodeError
 	require.True(t, errors.As(err, &codeErr))
 	assert.Equal(t, 403, codeErr.Code())
-	assert.Equal(t, "您不是该产品的成员", codeErr.Error())
+	// 审计 M-R10-5:loginService 去除重复 FindOneByProductCodeUserId,所有非成员/禁用成员分支合并
+	assert.Equal(t, "您不是该产品的有效成员", codeErr.Error())
 }
 
 // TC-0010: DEVELOPER成员
@@ -398,7 +399,8 @@ func TestLogin_DisabledMemberRejected(t *testing.T) {
 	var codeErr2 *response.CodeError
 	require.True(t, errors.As(err, &codeErr2))
 	assert.Equal(t, 403, codeErr2.Code())
-	assert.Equal(t, "您在该产品下的成员资格已被禁用", codeErr2.Error())
+	// 审计 M-R10-5:禁用成员在 loadMembership 阶段即被清空 MemberType,与"非成员"文案合并
+	assert.Equal(t, "您不是该产品的有效成员", codeErr2.Error())
 }
 
 // TC-0014: 产品已被禁用时拒绝登录

+ 7 - 8
internal/logic/pub/loginService.go

@@ -82,18 +82,17 @@ func ValidateProductLogin(ctx context.Context, svcCtx *svc.ServiceContext, usern
 		return nil, &LoginError{Code: 403, Message: "该产品已被禁用"}
 	}
 
-	member, memberErr := svcCtx.SysProductMemberModel.FindOneByProductCodeUserId(ctx, productCode, u.Id)
-	if memberErr != nil {
-		return nil, &LoginError{Code: 403, Message: "您不是该产品的成员"}
-	}
-	if member.Status != consts.StatusEnabled {
-		return nil, &LoginError{Code: 403, Message: "您在该产品下的成员资格已被禁用"}
-	}
-
+	// 审计 M-R10-5:Load 内部的 loadMembership 已经做了完全等价的判断——
+	// FindOneByProductCodeUserId 未命中 / member.Status != StatusEnabled 都会把 ud.MemberType 置空。
+	// 删除此处重复的 FindOneByProductCodeUserId,把登录路径由 2 次 sys_product_member 查询降到 1 次。
+	// 错误文案合并为"您不是该产品的有效成员",与 jwtauthMiddleware 的同类分支口径一致。
 	ud, err := svcCtx.UserDetailsLoader.Load(ctx, u.Id, productCode)
 	if err != nil {
 		return nil, &LoginError{Code: 503, Message: "服务暂时不可用,请稍后重试"}
 	}
+	if !ud.IsSuperAdmin && ud.MemberType == "" {
+		return nil, &LoginError{Code: 403, Message: "您不是该产品的有效成员"}
+	}
 
 	accessToken, err := authHelper.GenerateAccessToken(
 		svcCtx.Config.Auth.AccessSecret,

+ 9 - 0
internal/logic/pub/refreshTokenLogic.go

@@ -115,6 +115,15 @@ func (l *RefreshTokenLogic) RefreshToken(req *types.RefreshTokenReq) (resp *type
 		return nil, err
 	}
 	if newVersion != predictedVersion {
+		// 审计 L-R10-4:按 IncrementTokenVersionIfMatch 的 UPDATE 语义,CAS 成功时 WHERE 命中
+		// tokenVersion = claims.TokenVersion,新值必然是 claims.TokenVersion + 1 = predictedVersion;
+		// LAST_INSERT_ID() 由同一事务设置,其他连接的写入无法篡改本连接 session 里的值。
+		// 本分支在正常路径下**不可达**,但保留为 forensic 兜底:一旦真的进来,说明:
+		//   (a) sys_user_model 的 IncrementTokenVersionIfMatch 实现被改动(比如 UPDATE 条件
+		//       从 tokenVersion=? 被悄悄改成 tokenVersion>=?),CAS 不再精确;
+		//   (b) 或底层 MySQL 连接被中间件劫持 / session-level 变量被干扰;
+		// 两种都是"签名链契约漂移"级别的事件,直接落 ERROR 并踢到重新登录,避免签发出一个
+		// 与实际 DB 值不一致的 refreshToken 留下审计死角。
 		logx.WithContext(l.ctx).Errorw("refresh token version prediction mismatch",
 			logx.Field("audit", "refresh_token_version_mismatch"),
 			logx.Field("userId", claims.UserId),

+ 2 - 1
internal/logic/pub/syncPerms404_audit_test.go

@@ -87,8 +87,9 @@ func TestSyncPerms_UnmappedSyncPermsErrCode_StillFallsThroughDefault(t *testing.
 			Id: 1, Code: "m2_prod2", AppKey: "m2_key2",
 			AppSecret: string(hashedSecret), Status: 1,
 		}, nil)
+	// 审计 M-R10-1:LockByCodeTx 拿到的行必须 Status=1 才能继续进入 diff 逻辑
 	mockProduct.EXPECT().LockByCodeTx(gomock.Any(), gomock.Any(), "m2_prod2").
-		Return(&productModel.SysProduct{Id: 1, Code: "m2_prod2"}, nil)
+		Return(&productModel.SysProduct{Id: 1, Code: "m2_prod2", Status: 1}, nil)
 
 	mockPerm := mocks.NewMockSysPermModel(ctrl)
 	mockPerm.EXPECT().FindMapByProductCodeWithTx(gomock.Any(), gomock.Any(), "m2_prod2").

+ 2 - 1
internal/logic/pub/syncPermsDedup_audit_test.go

@@ -32,8 +32,9 @@ func TestExecuteSyncPerms_DeduplicatesRequest(t *testing.T) {
 		Return(&productModel.SysProduct{
 			Id: 1, Code: "pc_dedup", AppKey: "ak", AppSecret: string(hashedSecret), Status: 1,
 		}, nil)
+	// 审计 M-R10-1:LockByCodeTx 拿到的行必须 Status=1
 	mockProduct.EXPECT().LockByCodeTx(gomock.Any(), gomock.Any(), "pc_dedup").
-		Return(&productModel.SysProduct{Id: 1, Code: "pc_dedup"}, nil)
+		Return(&productModel.SysProduct{Id: 1, Code: "pc_dedup", Status: 1}, nil)
 
 	mockPerm := mocks.NewMockSysPermModel(ctrl)
 	mockPerm.EXPECT().FindMapByProductCodeWithTx(gomock.Any(), gomock.Any(), "pc_dedup").

+ 2 - 1
internal/logic/pub/syncPermsLogic_mock_test.go

@@ -42,8 +42,9 @@ func TestSyncPerms_Mock_TransactionRollbackOnBatchUpdateFail(t *testing.T) {
 			Status:    1,
 		}, nil)
 	// H-3:tx 内必须先 LockByCodeTx 锁 product 行,再 FindMapByProductCodeWithTx。
+	// 审计 M-R10-1:LockByCodeTx 拿到的行必须 Status=1,否则直接 403 不进入 diff
 	mockProduct.EXPECT().LockByCodeTx(gomock.Any(), gomock.Any(), "test_product").
-		Return(&productModel.SysProduct{Id: 1, Code: "test_product"}, nil)
+		Return(&productModel.SysProduct{Id: 1, Code: "test_product", Status: 1}, nil)
 
 	mockPerm := mocks.NewMockSysPermModel(ctrl)
 	mockPerm.EXPECT().FindMapByProductCodeWithTx(gomock.Any(), gomock.Any(), "test_product").

+ 12 - 1
internal/logic/pub/syncPermsService.go

@@ -72,12 +72,23 @@ func ExecuteSyncPerms(ctx context.Context, svcCtx *svc.ServiceContext, appKey, a
 	// 把同一 product 的并发同步串行化,避免两次同步都认为 code X 不存在并并发 INSERT 撞
 	// sys_perm UNIQUE(productCode, code) 拿 1062(见审计 H-3)。
 	err = svcCtx.SysPermModel.TransactCtx(ctx, func(txCtx context.Context, session sqlx.Session) error {
-		if _, err := svcCtx.SysProductModel.LockByCodeTx(txCtx, session, product.Code); err != nil {
+		locked, err := svcCtx.SysProductModel.LockByCodeTx(txCtx, session, product.Code)
+		if err != nil {
 			if errors.Is(err, sqlx.ErrNotFound) {
 				return &SyncPermsError{Code: 404, Message: "产品不存在"}
 			}
 			return err
 		}
+		// 审计 M-R10-1:事务外的 FindOneByAppKey 只是 cache/stale 读,无法感知"UpdateProduct
+		// 与本同步并发到达、UpdateProduct 后提交"的时序(SyncPerms 先拿到 X 锁时,UpdateProduct 的
+		// UPDATE 会排队等锁,SyncPerms 在锁内读到的 product.Status 仍是 Enabled 但真正提交顺序会
+		// 让 product 行最终是 Disabled)。LockByCodeTx 已经拿到行 X 锁,这里对 locked.Status 再做一
+		// 次事务内复核,任何已禁用的产品都不得继续写 sys_perm,修复"禁用后同一秒仍生成 perm diff"
+		// 把审计告警带偏的问题。安全侧 loadPerms 仍会按 ProductStatus!=Enabled 返空,但运营/审计
+		// 链路需要本处兜底避免假象。
+		if locked.Status != consts.StatusEnabled {
+			return &SyncPermsError{Code: 403, Message: "产品已被禁用"}
+		}
 
 		existingMap, err := svcCtx.SysPermModel.FindMapByProductCodeWithTx(txCtx, session, product.Code)
 		if err != nil {

+ 2 - 1
internal/logic/pub/syncPermsTxLock_audit_test.go

@@ -59,8 +59,9 @@ func TestExecuteSyncPerms_LockBeforeMapReadInTx(t *testing.T) {
 
 	// 关键点 2:gomock 的 Call.After 强制 LockByCodeTx 先于 FindMapByProductCodeWithTx 执行。
 	// 顺序反过来的话 gomock 会在 Finish 时报错。
+	// 审计 M-R10-1:事务内复核 Status 必须 Status=1,否则走 403 分支不写 perm
 	lockCall := productMock.EXPECT().LockByCodeTx(gomock.Any(), gomock.Any(), "pc_tx_order").
-		Return(&productModel.SysProduct{Id: 1, Code: "pc_tx_order"}, nil)
+		Return(&productModel.SysProduct{Id: 1, Code: "pc_tx_order", Status: 1}, nil)
 
 	permMock.EXPECT().FindMapByProductCodeWithTx(gomock.Any(), gomock.Any(), "pc_tx_order").
 		Return(map[string]*permModel.SysPerm{}, nil).

+ 39 - 24
internal/logic/role/bindRolePermsLogic.go

@@ -72,38 +72,49 @@ func (l *BindRolePermsLogic) BindRolePerms(req *types.BindPermsReq) error {
 		}
 	}
 
-	existingPermIds, err := l.svcCtx.SysRolePermModel.FindPermIdsByRoleId(l.ctx, req.RoleId)
-	if err != nil {
-		return err
-	}
-
-	existingSet := make(map[int64]bool, len(existingPermIds))
-	for _, id := range existingPermIds {
-		existingSet[id] = true
-	}
 	newSet := make(map[int64]bool, len(permIds))
 	for _, id := range permIds {
 		newSet[id] = true
 	}
 
-	var toAdd []int64
-	for _, id := range permIds {
-		if !existingSet[id] {
-			toAdd = append(toAdd, id)
-		}
-	}
-	var toRemove []int64
-	for _, id := range existingPermIds {
-		if !newSet[id] {
-			toRemove = append(toRemove, id)
+	// 审计 M-R10-2:把 existing 读 + diff + delete/insert 整段收敛进事务,并以 LockByIdTx
+	// 锁住 sys_role 行。两个并发的"完全覆盖 bind" 会在 role 行级别被串行化,"A 完成 → B 基于
+	// A 的最终态重新覆盖"成为唯一可能的交错,彻底消除"A/B diff 各自的 toRemove/toAdd 在时间
+	// 线上交织、最终态是两者都不想要的第三态"这一 RMW 类 bug。
+	diffCounts := struct {
+		add    int
+		remove int
+	}{}
+	if err := l.svcCtx.SysRolePermModel.TransactCtx(l.ctx, func(ctx context.Context, session sqlx.Session) error {
+		if _, err := l.svcCtx.SysRoleModel.LockByIdTx(ctx, session, req.RoleId); err != nil {
+			return err
 		}
-	}
 
-	if len(toAdd) == 0 && len(toRemove) == 0 {
-		return nil
-	}
+		existingPermIds, err := l.svcCtx.SysRolePermModel.FindPermIdsByRoleIdTx(ctx, session, req.RoleId)
+		if err != nil {
+			return err
+		}
+		existingSet := make(map[int64]bool, len(existingPermIds))
+		for _, id := range existingPermIds {
+			existingSet[id] = true
+		}
+		var toAdd []int64
+		for _, id := range permIds {
+			if !existingSet[id] {
+				toAdd = append(toAdd, id)
+			}
+		}
+		var toRemove []int64
+		for _, id := range existingPermIds {
+			if !newSet[id] {
+				toRemove = append(toRemove, id)
+			}
+		}
+		diffCounts.add, diffCounts.remove = len(toAdd), len(toRemove)
+		if len(toAdd) == 0 && len(toRemove) == 0 {
+			return nil
+		}
 
-	if err := l.svcCtx.SysRolePermModel.TransactCtx(l.ctx, func(ctx context.Context, session sqlx.Session) error {
 		if err := l.svcCtx.SysRolePermModel.DeleteByRoleIdAndPermIdsTx(ctx, session, req.RoleId, toRemove); err != nil {
 			return err
 		}
@@ -125,6 +136,10 @@ func (l *BindRolePermsLogic) BindRolePerms(req *types.BindPermsReq) error {
 		return err
 	}
 
+	if diffCounts.add == 0 && diffCounts.remove == 0 {
+		return nil
+	}
+
 	// 事务已提交成功,缓存清理属于尽力而为:FindUserIdsByRoleId 失败仅记录 Errorf,
 	// 不映射为 500——否则客户端会把"数据已改但缓存未刷"的 degraded 成功状态误判为完全失败
 	// 而发起重试,重试时 diff 出的 toAdd/toRemove 均为空将静默 200,业务语义反而更怪

+ 5 - 1
internal/logic/role/bindRolePermsLogic_mock_test.go

@@ -35,8 +35,12 @@ func TestBindRolePerms_Mock_BatchInsertFail(t *testing.T) {
 			{Id: 20, ProductCode: pc, Code: "perm_20", Status: 1},
 		}, nil)
 
+	// 审计 M-R10-2:事务内以 LockByIdTx 锁 sys_role 行,再走 FindPermIdsByRoleIdTx 读最新 diff 基准
+	mockRole.EXPECT().LockByIdTx(gomock.Any(), nil, int64(1)).
+		Return(&roleModel.SysRole{Id: 1, ProductCode: pc}, nil)
+
 	mockRP := mocks.NewMockSysRolePermModel(ctrl)
-	mockRP.EXPECT().FindPermIdsByRoleId(gomock.Any(), int64(1)).Return([]int64{}, nil)
+	mockRP.EXPECT().FindPermIdsByRoleIdTx(gomock.Any(), nil, int64(1)).Return([]int64{}, nil)
 	mockRP.EXPECT().TableName().Return("`sys_role_perm`").AnyTimes()
 	mockRP.EXPECT().TransactCtx(gomock.Any(), gomock.Any()).
 		DoAndReturn(func(ctx context.Context, fn func(context.Context, sqlx.Session) error) error {

+ 4 - 2
internal/logic/role/postCommitCacheDegraded_audit_test.go

@@ -50,12 +50,14 @@ func TestBindRolePerms_PostCommitUserIdsError_StaysSuccess(t *testing.T) {
 	roleMock.EXPECT().FindOne(gomock.Any(), int64(7)).
 		Return(&roleModel.SysRole{Id: 7, ProductCode: "pc_m4", PermsLevel: 50, Status: 1}, nil)
 
-	// permIds=[] 走 "全部删除" 路径;existingIds=[1] 需触发 tx。
-	rpMock.EXPECT().FindPermIdsByRoleId(gomock.Any(), int64(7)).Return([]int64{1}, nil)
+	// 审计 M-R10-2:existing 读 + diff + delete/insert 全部收敛进事务;事务首步 LockByIdTx 锁 sys_role 行。
 	rpMock.EXPECT().TransactCtx(gomock.Any(), gomock.Any()).
 		DoAndReturn(func(ctx context.Context, fn func(context.Context, sqlx.Session) error) error {
 			return fn(ctx, nil)
 		})
+	roleMock.EXPECT().LockByIdTx(gomock.Any(), nil, int64(7)).
+		Return(&roleModel.SysRole{Id: 7, ProductCode: "pc_m4", PermsLevel: 50, Status: 1}, nil)
+	rpMock.EXPECT().FindPermIdsByRoleIdTx(gomock.Any(), nil, int64(7)).Return([]int64{1}, nil)
 
 	rpMock.EXPECT().DeleteByRoleIdAndPermIdsTx(gomock.Any(), nil, int64(7), []int64{1}).
 		Return(nil)

+ 40 - 26
internal/logic/user/bindRolesLogic.go

@@ -76,6 +76,14 @@ func (l *BindRolesLogic) BindRoles(req *types.BindRolesReq) error {
 		if int64(len(roles)) != int64(len(roleIds)) {
 			return response.ErrBadRequest("包含无效的角色ID")
 		}
+		// 审计 M-R10-3:caller 在一次请求内不变,loadFreshMinPermsLevel 的结果也不变;改由
+		// LoadCallerAssignableLevel 打一次 DB 取 snapshot,循环内对每个角色走 CheckRoleLevelAgainst
+		// 做常数时间比较,把"批量绑 N 个 role → N 次 DB" 降到 1 次,同时缩小 caller 降权期间
+		// 的 TOCTOU 窗口(原实现每次循环都重新读,反而给"超管在 loop 中途降级 caller"N 个窗口)。
+		assignable, err := authHelper.LoadCallerAssignableLevel(l.ctx, l.svcCtx, caller)
+		if err != nil {
+			return err
+		}
 		for _, r := range roles {
 			if r.ProductCode != productCode {
 				return response.ErrBadRequest("不能绑定其他产品的角色")
@@ -83,45 +91,51 @@ func (l *BindRolesLogic) BindRoles(req *types.BindRolesReq) error {
 			if r.Status != consts.StatusEnabled {
 				return response.ErrBadRequest("不能绑定已禁用的角色")
 			}
-			if err := authHelper.GuardRoleLevelAssignable(l.ctx, l.svcCtx, caller, r.PermsLevel); err != nil {
+			if err := authHelper.CheckRoleLevelAgainst(assignable, r.PermsLevel); err != nil {
 				return err
 			}
 		}
 	}
 
-	existingRoleIds, err := l.svcCtx.SysUserRoleModel.FindRoleIdsByUserIdForProduct(l.ctx, req.UserId, productCode)
-	if err != nil {
-		return err
-	}
-
-	existingSet := make(map[int64]bool, len(existingRoleIds))
-	for _, id := range existingRoleIds {
-		existingSet[id] = true
-	}
 	newSet := make(map[int64]bool, len(roleIds))
 	for _, id := range roleIds {
 		newSet[id] = true
 	}
 
-	var toAdd []int64
-	for _, id := range roleIds {
-		if !existingSet[id] {
-			toAdd = append(toAdd, id)
-		}
-	}
-	var toRemove []int64
-	for _, id := range existingRoleIds {
-		if !newSet[id] {
-			toRemove = append(toRemove, id)
+	// 审计 M-R10-2:把"existing 读 + diff + delete/insert"整段收敛进事务,事务第一步以
+	// FindOneForUpdateTx(member.Id) 锁住 sys_product_member 行,相当于把同一 (userId,
+	// productCode) 下的并发 BindRoles 串行化;"A 完整覆盖 → B 基于 A 的最终态覆盖" 是唯一
+	// 可能的交错,消除 RMW 第三态。member 行 lock 也保证了进入事务期间 member 不会被并发
+	// RemoveMember 清零(那条路径本身也持该行 FOR UPDATE)。
+	if err := l.svcCtx.SysUserRoleModel.TransactCtx(l.ctx, func(ctx context.Context, session sqlx.Session) error {
+		if _, err := l.svcCtx.SysProductMemberModel.FindOneForUpdateTx(ctx, session, member.Id); err != nil {
+			return err
 		}
-	}
 
-	if len(toAdd) == 0 && len(toRemove) == 0 {
-		l.svcCtx.UserDetailsLoader.Clean(l.ctx, req.UserId)
-		return nil
-	}
+		existingRoleIds, err := l.svcCtx.SysUserRoleModel.FindRoleIdsByUserIdForProductTx(ctx, session, req.UserId, productCode)
+		if err != nil {
+			return err
+		}
+		existingSet := make(map[int64]bool, len(existingRoleIds))
+		for _, id := range existingRoleIds {
+			existingSet[id] = true
+		}
+		var toAdd []int64
+		for _, id := range roleIds {
+			if !existingSet[id] {
+				toAdd = append(toAdd, id)
+			}
+		}
+		var toRemove []int64
+		for _, id := range existingRoleIds {
+			if !newSet[id] {
+				toRemove = append(toRemove, id)
+			}
+		}
+		if len(toAdd) == 0 && len(toRemove) == 0 {
+			return nil
+		}
 
-	if err := l.svcCtx.SysUserRoleModel.TransactCtx(l.ctx, func(ctx context.Context, session sqlx.Session) error {
 		if err := l.svcCtx.SysUserRoleModel.DeleteByUserIdAndRoleIdsTx(ctx, session, req.UserId, toRemove); err != nil {
 			return err
 		}

+ 4 - 1
internal/logic/user/bindRolesLogic_mock_test.go

@@ -31,6 +31,9 @@ func TestBindRoles_Mock_BatchInsertFail(t *testing.T) {
 	mockPM := mocks.NewMockSysProductMemberModel(ctrl)
 	mockPM.EXPECT().FindOneByProductCodeUserId(gomock.Any(), "test_product", int64(1)).
 		Return(&memberModel.SysProductMember{Id: 1, ProductCode: "test_product", UserId: 1, Status: 1}, nil)
+	// 审计 M-R10-2:事务首步 FindOneForUpdateTx 锁 sys_product_member 行
+	mockPM.EXPECT().FindOneForUpdateTx(gomock.Any(), nil, int64(1)).
+		Return(&memberModel.SysProductMember{Id: 1, ProductCode: "test_product", UserId: 1, Status: 1}, nil)
 
 	mockRole := mocks.NewMockSysRoleModel(ctrl)
 	mockRole.EXPECT().FindByIds(gomock.Any(), []int64{10, 20}).
@@ -40,7 +43,7 @@ func TestBindRoles_Mock_BatchInsertFail(t *testing.T) {
 		}, nil)
 
 	mockUR := mocks.NewMockSysUserRoleModel(ctrl)
-	mockUR.EXPECT().FindRoleIdsByUserIdForProduct(gomock.Any(), int64(1), "test_product").Return([]int64{}, nil)
+	mockUR.EXPECT().FindRoleIdsByUserIdForProductTx(gomock.Any(), nil, int64(1), "test_product").Return([]int64{}, nil)
 	mockUR.EXPECT().TableName().Return("`sys_user_role`").AnyTimes()
 	mockUR.EXPECT().TransactCtx(gomock.Any(), gomock.Any()).
 		DoAndReturn(func(ctx context.Context, fn func(context.Context, sqlx.Session) error) error {

+ 40 - 6
internal/logic/user/setUserPermsLogic.go

@@ -9,6 +9,7 @@ import (
 	"perms-system-server/internal/consts"
 	authHelper "perms-system-server/internal/logic/auth"
 	"perms-system-server/internal/middleware"
+	memberModel "perms-system-server/internal/model/productmember"
 	"perms-system-server/internal/model/userperm"
 	"perms-system-server/internal/response"
 	"perms-system-server/internal/svc"
@@ -53,15 +54,25 @@ func (l *SetUserPermsLogic) SetUserPerms(req *types.SetPermsReq) error {
 		return response.ErrBadRequest("产品已被禁用,无法设置权限")
 	}
 
-	if err := authHelper.CheckManageAccess(l.ctx, l.svcCtx, req.UserId, productCode, authHelper.WithPrefetchedTarget(targetUser)); err != nil {
+	// 审计 M-R10-5:让 CheckManageAccess 的 checkPermLevel 把 targetMember 透传出来,避免下面
+	// 再打一次 FindOneByProductCodeUserId(同一个 (productCode, userId) 唯一索引的重复读)。
+	// caller=SuperAdmin 的短路路径不走 checkPermLevel,member 仍为 nil,需要兜底 FindOne。
+	var targetMember *memberModel.SysProductMember
+	if err := authHelper.CheckManageAccess(l.ctx, l.svcCtx, req.UserId, productCode,
+		authHelper.WithPrefetchedTarget(targetUser),
+		authHelper.WithMemberSink(&targetMember),
+	); err != nil {
 		return err
 	}
-
-	member, memberErr := l.svcCtx.SysProductMemberModel.FindOneByProductCodeUserId(l.ctx, productCode, req.UserId)
-	if memberErr != nil {
-		return response.ErrBadRequest("目标用户不是当前产品的成员")
+	if targetMember == nil {
+		// caller=SuperAdmin 路径,此时尚未读 member;按业务语义兜底一次查询。
+		m, mErr := l.svcCtx.SysProductMemberModel.FindOneByProductCodeUserId(l.ctx, productCode, req.UserId)
+		if mErr != nil {
+			return response.ErrBadRequest("目标用户不是当前产品的成员")
+		}
+		targetMember = m
 	}
-	if member.Status != consts.StatusEnabled {
+	if targetMember.Status != consts.StatusEnabled {
 		return response.ErrBadRequest("目标用户的成员资格已被禁用")
 	}
 
@@ -71,6 +82,29 @@ func (l *SetUserPermsLogic) SetUserPerms(req *types.SetPermsReq) error {
 		}
 	}
 
+	// 审计 L-R10-8:loadPerms 对 SUPER/ADMIN/DEVELOPER 走全权分支,直接返回
+	// FindAllCodesByProductCode(ProductCode) 的全集,不会 JOIN sys_user_perm 的 DENY 行。
+	// 也就是说对这类"全权目标"写入 DENY 是"能写、永远不生效"的语义欺骗,接口会把脏状态保留下来
+	// 误导运维。这里在入口主动拒绝;至于"目标 DeptType 未来变动导致原本不全权的用户变成全权、
+	// 旧 DENY 静默失效" 的长尾,不在本次可闭环的范围,留给后续专项(loadPerms 的全权分支是否
+	// 需要过 DENY 过滤,或者 DeptType 变动时自动清理对应 sys_user_perm)。
+	if targetMember.MemberType == consts.MemberTypeAdmin || targetMember.MemberType == consts.MemberTypeDeveloper {
+		for _, p := range req.Perms {
+			if p.Effect == consts.PermEffectDeny {
+				return response.ErrBadRequest("目标用户是产品管理员或开发者,拥有全部权限,DENY 设置不会生效")
+			}
+		}
+	}
+	// targetUser.IsSuperAdmin 本应在 CheckManageAccess 里已被拦住(非超管管不到超管;超管管自己
+	// 也会被 ValidateStatusChange/自身的 DeptId=0 规则拦住),但保留一条显式拒绝防御未来分支变动。
+	if targetUser.IsSuperAdmin == consts.IsSuperAdminYes {
+		for _, p := range req.Perms {
+			if p.Effect == consts.PermEffectDeny {
+				return response.ErrBadRequest("不能对超级管理员设置 DENY 权限")
+			}
+		}
+	}
+
 	perms := req.Perms
 	if len(perms) > 0 {
 		seen := make(map[int64]string, len(perms))

+ 7 - 4
internal/model/productmember/sysProductMemberModel.go

@@ -56,12 +56,15 @@ func (m *customSysProductMemberModel) FindListByProductCode(ctx context.Context,
 // CountActiveAdminsTx + adminCount <= 1 的反向推理,语义更贴合业务(见审计 L-5 / L-2)。
 // 仍然使用 FOR UPDATE 锁住扫描范围,串行化与并发降级/删除的冲突。
 func (m *customSysProductMemberModel) CountOtherActiveAdminsTx(ctx context.Context, session sqlx.Session, productCode string, excludeId int64) (int64, error) {
-	var ids []int64
-	query := fmt.Sprintf("SELECT `id` FROM %s WHERE `productCode` = ? AND `memberType` = ? AND `status` = ? AND `id` != ? FOR UPDATE", m.table)
-	if err := session.QueryRowsCtx(ctx, &ids, query, productCode, consts.MemberTypeAdmin, consts.StatusEnabled, excludeId); err != nil {
+	// 审计 L-R10-6:直接 SELECT COUNT(*) 即可,无需把匹配行的 id 全部回灌到应用层——后者对极端场景
+	// (一产品 admin 数量异常多)会多一笔可避免的内存开销。FOR UPDATE 仍然保留,串行化"移除/降级最后
+	// 一个 admin"的并发冲突;InnoDB 在 COUNT(*) ... FOR UPDATE 下会对匹配的索引/行同样加写锁。
+	var count int64
+	query := fmt.Sprintf("SELECT COUNT(*) FROM %s WHERE `productCode` = ? AND `memberType` = ? AND `status` = ? AND `id` != ? FOR UPDATE", m.table)
+	if err := session.QueryRowCtx(ctx, &count, query, productCode, consts.MemberTypeAdmin, consts.StatusEnabled, excludeId); err != nil {
 		return 0, err
 	}
-	return int64(len(ids)), nil
+	return count, nil
 }
 
 func (m *customSysProductMemberModel) FindOneForUpdateTx(ctx context.Context, session sqlx.Session, id int64) (*SysProductMember, error) {

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

@@ -24,6 +24,10 @@ type (
 		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
+		// LockByIdTx 在当前事务里锁住 sys_role 行(SELECT ... FOR UPDATE),用于把"同一 role 的
+		// BindRolePerms 并发覆盖"串行化,消除"existing 在事务外读 + 事务内 delete/insert"
+		// 造成的第三态合并问题(见审计 M-R10-2)。
+		LockByIdTx(ctx context.Context, session sqlx.Session, id int64) (*SysRole, error)
 	}
 
 	customSysRoleModel struct {
@@ -88,6 +92,17 @@ func (m *customSysRoleModel) UpdateWithOptLock(ctx context.Context, data *SysRol
 	return nil
 }
 
+// LockByIdTx 见接口注释。注意:本函数不走缓存层,必须在 TransactCtx / Session 下调用;
+// SELECT ... FOR UPDATE 的行锁由 InnoDB 持有到事务结束。
+func (m *customSysRoleModel) LockByIdTx(ctx context.Context, session sqlx.Session, id int64) (*SysRole, error) {
+	var data SysRole
+	query := fmt.Sprintf("SELECT %s FROM %s WHERE `id` = ? LIMIT 1 FOR UPDATE", sysRoleRows, m.table)
+	if err := session.QueryRowCtx(ctx, &data, query, id); err != nil {
+		return nil, err
+	}
+	return &data, nil
+}
+
 func (m *customSysRoleModel) FindMinPermsLevelByUserIdAndProductCode(ctx context.Context, userId int64, productCode string) (int64, error) {
 	var level int64
 	query := fmt.Sprintf(

+ 13 - 0
internal/model/roleperm/sysRolePermModel.go

@@ -17,6 +17,10 @@ type (
 		sysRolePermModel
 		FindPermIdsByRoleId(ctx context.Context, roleId int64) ([]int64, error)
 		FindPermIdsByRoleIds(ctx context.Context, roleIds []int64) ([]int64, error)
+		// FindPermIdsByRoleIdTx 是 FindPermIdsByRoleId 的事务内变体:上游 BindRolePerms 在
+		// LockByIdTx(role) 之后,需要在同一事务里读取当前 existing permIds 再做 diff,否则
+		// "existing 读-外部 / diff 写-内部" 的窗口会产生第三态(见审计 M-R10-2)。
+		FindPermIdsByRoleIdTx(ctx context.Context, session sqlx.Session, roleId int64) ([]int64, error)
 		DeleteByRoleIdTx(ctx context.Context, session sqlx.Session, roleId int64) error
 		DeleteByRoleIdAndPermIdsTx(ctx context.Context, session sqlx.Session, roleId int64, permIds []int64) error
 	}
@@ -41,6 +45,15 @@ func (m *customSysRolePermModel) FindPermIdsByRoleId(ctx context.Context, roleId
 	return ids, nil
 }
 
+func (m *customSysRolePermModel) FindPermIdsByRoleIdTx(ctx context.Context, session sqlx.Session, roleId int64) ([]int64, error) {
+	var ids []int64
+	query := fmt.Sprintf("SELECT `permId` FROM %s WHERE `roleId` = ?", m.table)
+	if err := session.QueryRowsCtx(ctx, &ids, query, roleId); err != nil {
+		return nil, err
+	}
+	return ids, nil
+}
+
 func (m *customSysRolePermModel) FindPermIdsByRoleIds(ctx context.Context, roleIds []int64) ([]int64, error) {
 	if len(roleIds) == 0 {
 		return nil, nil

+ 9 - 1
internal/model/user/sysUserModel.go

@@ -199,9 +199,17 @@ func (m *customSysUserModel) IncrementTokenVersion(ctx context.Context, id int64
 	var newVersion int64
 	err = m.TransactCtx(ctx, func(ctx context.Context, session sqlx.Session) error {
 		query := fmt.Sprintf("UPDATE %s SET `tokenVersion` = LAST_INSERT_ID(`tokenVersion` + 1), `updateTime` = ? WHERE `id` = ?", m.table)
-		if _, err := session.ExecCtx(ctx, query, time.Now().Unix(), id); err != nil {
+		res, err := session.ExecCtx(ctx, query, time.Now().Unix(), id)
+		if err != nil {
 			return err
 		}
+		// 审计 L-R10-3:FindOne 与本次 UPDATE 之间若有并发 DELETE,affected=0 仍会返回 nil 让
+		// SELECT LAST_INSERT_ID() 在全新事务里读出 0,调用方无法区分"真的递增成功"和"目标已被删除"。
+		// 对外统一回 ErrUpdateConflict,调用方据此做日志/告警或跳过后续 Clean,避免对 tokenVersion=0
+		// 之类的伪返回值做错误假设(如"版本从 1 起递增"的默认契约踩坑)。
+		if affected, _ := res.RowsAffected(); affected == 0 {
+			return ErrUpdateConflict
+		}
 		return session.QueryRowCtx(ctx, &newVersion, "SELECT LAST_INSERT_ID()")
 	})
 	if err != nil {

+ 14 - 0
internal/model/userrole/sysUserRoleModel.go

@@ -19,6 +19,11 @@ type (
 		sysUserRoleModel
 		FindRoleIdsByUserId(ctx context.Context, userId int64) ([]int64, error)
 		FindRoleIdsByUserIdForProduct(ctx context.Context, userId int64, productCode string) ([]int64, error)
+		// FindRoleIdsByUserIdForProductTx 是 FindRoleIdsByUserIdForProduct 的事务内变体:
+		// 上游 BindRoles 在 FindOneForUpdateTx(member) 之后,需要在同一事务里读取 existing
+		// roleIds 再做 diff,避免"existing 读-外部 / diff 写-内部"的窗口产生第三态
+		// (见审计 M-R10-2)。此处不写库,不需要 FOR UPDATE;member 行锁已经负责并发串行化。
+		FindRoleIdsByUserIdForProductTx(ctx context.Context, session sqlx.Session, userId int64, productCode string) ([]int64, error)
 		FindUserIdsByRoleId(ctx context.Context, roleId int64) ([]int64, error)
 		FindUserIdsByRoleIdForUpdateTx(ctx context.Context, session sqlx.Session, roleId int64) ([]int64, error)
 		DeleteByRoleIdTx(ctx context.Context, session sqlx.Session, roleId int64) error
@@ -59,6 +64,15 @@ func (m *customSysUserRoleModel) FindRoleIdsByUserIdForProduct(ctx context.Conte
 	return ids, nil
 }
 
+func (m *customSysUserRoleModel) FindRoleIdsByUserIdForProductTx(ctx context.Context, session sqlx.Session, userId int64, productCode string) ([]int64, error) {
+	var ids []int64
+	query := fmt.Sprintf("SELECT ur.`roleId` FROM %s ur INNER JOIN `sys_role` r ON ur.`roleId` = r.`id` WHERE ur.`userId` = ? AND r.`productCode` = ? AND r.`status` = ?", m.table)
+	if err := session.QueryRowsCtx(ctx, &ids, query, userId, productCode, consts.StatusEnabled); err != nil {
+		return nil, err
+	}
+	return ids, nil
+}
+
 func (m *customSysUserRoleModel) FindUserIdsByRoleId(ctx context.Context, roleId int64) ([]int64, error) {
 	var ids []int64
 	query := fmt.Sprintf("SELECT `userId` FROM %s WHERE `roleId` = ?", m.table)

+ 9 - 5
internal/server/permserver.go

@@ -348,15 +348,19 @@ func (s *PermServer) GetUserPerms(ctx context.Context, req *pb.GetUserPermsReq)
 		return nil, status.Error(codes.Unavailable, "服务暂时不可用,请稍后重试")
 	}
 
-	if ud.Username == "" {
-		return nil, status.Error(codes.NotFound, "用户不存在")
+	// 审计 L-R10-10:消除"userId 是否在全局 sys_user 中存在"的枚举 oracle。原实现在 Username=""
+	// 时回 NotFound、在非成员时回 PermissionDenied,持合法 appKey 的产品服务端可遍历 userId 区分
+	// "这个 userId 在全局 sys_user 里存在" vs "不在"。统一回 NotFound "用户不是该产品的有效成员",
+	// 与 REST 侧 RoleDetail 的修复口径对齐(M-N3)。
+	// 保留"用户已被冻结"为显式 PermissionDenied:密码正确才能拿到合法 appKey 这一前提不成立时,
+	// 这个状态已经是上层业务承诺披露的信息,不构成新增枚举面。
+	if ud.Username == "" || (!ud.IsSuperAdmin && ud.MemberType == "") {
+		logx.WithContext(ctx).Infof("getUserPerms not-found or non-member userId=%d productCode=%s", req.UserId, req.ProductCode)
+		return nil, status.Error(codes.NotFound, "用户不是该产品的有效成员")
 	}
 	if ud.Status != consts.StatusEnabled {
 		return nil, status.Error(codes.PermissionDenied, "用户已被冻结")
 	}
-	if !ud.IsSuperAdmin && ud.MemberType == "" {
-		return nil, status.Error(codes.PermissionDenied, "用户不是该产品的有效成员")
-	}
 
 	return &pb.GetUserPermsResp{
 		MemberType: ud.MemberType,

+ 11 - 6
internal/server/permserver_test.go

@@ -823,7 +823,8 @@ func TestGetUserPerms_UserNotFound(t *testing.T) {
 	})
 	require.Error(t, err)
 	assert.Equal(t, codes.NotFound, status.Code(err))
-	assert.Equal(t, "用户不存在", status.Convert(err).Message())
+	// 审计 L-R10-10:userId 不存在与非成员合并为同一响应,消除跨产品枚举 oracle
+	assert.Equal(t, "用户不是该产品的有效成员", status.Convert(err).Message())
 }
 
 // TC-0256: 超管
@@ -1203,7 +1204,9 @@ func TestLogin_DisabledMemberRejected(t *testing.T) {
 	})
 	require.Error(t, err)
 	assert.Equal(t, codes.PermissionDenied, status.Code(err))
-	assert.Equal(t, "您在该产品下的成员资格已被禁用", status.Convert(err).Message())
+	// 审计 M-R10-5:loginService 删除了多余的 FindOneByProductCodeUserId,改由 UD.MemberType==""
+	// 做统一判定,非成员/禁用成员合并为同一文案
+	assert.Equal(t, "您不是该产品的有效成员", status.Convert(err).Message())
 }
 
 // helper: create a JWT with no userId claim
@@ -1310,8 +1313,9 @@ func TestGetUserPerms_NonMember_PermissionDenied(t *testing.T) {
 		UserId: uId, ProductCode: uid, AppKey: uid + "_k", AppSecret: "s",
 	})
 	require.Error(t, err)
-	assert.Equal(t, codes.PermissionDenied, status.Code(err),
-		"audit H-2: 用户不是产品成员时应返回 PermissionDenied")
+	// 审计 L-R10-10:与"userId 不存在"合并为 NotFound,关闭跨产品枚举 oracle
+	assert.Equal(t, codes.NotFound, status.Code(err),
+		"audit L-R10-10: 用户不是产品成员时应返回 NotFound,与 Username 为空的分支同码")
 	assert.Contains(t, status.Convert(err).Message(), "成员")
 }
 
@@ -1379,8 +1383,9 @@ func TestGetUserPerms_DisabledMemberInDevDept_PermissionDenied(t *testing.T) {
 	})
 	require.Error(t, err,
 		"audit H-3: 产品成员被禁用的 DEV 部门用户不应再被 loadPerms 授予全量权限,"+
-			"GetUserPerms 也不应返回 PermissionDenied 以外的结果")
-	assert.Equal(t, codes.PermissionDenied, status.Code(err))
+			"GetUserPerms 也不应继续返回授权状态")
+	// 审计 L-R10-10:非成员合并到 NotFound(禁用成员在 loadMembership 里会把 MemberType 清空)
+	assert.Equal(t, codes.NotFound, status.Code(err))
 }
 
 // TC-0703: GetUserPerms 对"启用的产品成员"返回成功(H-2 回归基准)

+ 3 - 1
internal/server/syncPermissions404_audit_test.go

@@ -82,8 +82,10 @@ func TestSyncPermissions_gRPC_UnmappedCode_StaysInternal(t *testing.T) {
 			Id: 1, Code: "m2_grpc_prod2", AppKey: "m2_grpc_key2",
 			AppSecret: string(hashedSecret), Status: 1,
 		}, nil)
+	// 审计 M-R10-1:LockByCodeTx 拿到的行必须 Status=1 才能继续进入 diff 逻辑;
+	// 否则会在事务内直接返回 SyncPermsError{Code:403},无法命中"未映射 code"这条路径。
 	mockProduct.EXPECT().LockByCodeTx(gomock.Any(), gomock.Any(), "m2_grpc_prod2").
-		Return(&productModel.SysProduct{Id: 1, Code: "m2_grpc_prod2"}, nil)
+		Return(&productModel.SysProduct{Id: 1, Code: "m2_grpc_prod2", Status: 1}, nil)
 
 	mockPerm := mocks.NewMockSysPermModel(ctrl)
 	mockPerm.EXPECT().FindMapByProductCodeWithTx(gomock.Any(), gomock.Any(), "m2_grpc_prod2").

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

@@ -201,6 +201,21 @@ func (mr *MockSysRoleModelMockRecorder) FindMinPermsLevelByUserIdAndProductCode(
 	return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "FindMinPermsLevelByUserIdAndProductCode", reflect.TypeOf((*MockSysRoleModel)(nil).FindMinPermsLevelByUserIdAndProductCode), ctx, userId, productCode)
 }
 
+// LockByIdTx mocks base method.
+func (m *MockSysRoleModel) LockByIdTx(ctx context.Context, session sqlx.Session, id int64) (*role.SysRole, error) {
+	m.ctrl.T.Helper()
+	ret := m.ctrl.Call(m, "LockByIdTx", ctx, session, id)
+	ret0, _ := ret[0].(*role.SysRole)
+	ret1, _ := ret[1].(error)
+	return ret0, ret1
+}
+
+// LockByIdTx indicates an expected call of LockByIdTx.
+func (mr *MockSysRoleModelMockRecorder) LockByIdTx(ctx, session, id any) *gomock.Call {
+	mr.mock.ctrl.T.Helper()
+	return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "LockByIdTx", reflect.TypeOf((*MockSysRoleModel)(nil).LockByIdTx), ctx, session, id)
+}
+
 // FindOne mocks base method.
 func (m *MockSysRoleModel) FindOne(ctx context.Context, id int64) (*role.SysRole, error) {
 	m.ctrl.T.Helper()

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

@@ -258,6 +258,21 @@ func (mr *MockSysRolePermModelMockRecorder) FindPermIdsByRoleId(ctx, roleId any)
 	return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "FindPermIdsByRoleId", reflect.TypeOf((*MockSysRolePermModel)(nil).FindPermIdsByRoleId), ctx, roleId)
 }
 
+// FindPermIdsByRoleIdTx mocks base method.
+func (m *MockSysRolePermModel) FindPermIdsByRoleIdTx(ctx context.Context, session sqlx.Session, roleId int64) ([]int64, error) {
+	m.ctrl.T.Helper()
+	ret := m.ctrl.Call(m, "FindPermIdsByRoleIdTx", ctx, session, roleId)
+	ret0, _ := ret[0].([]int64)
+	ret1, _ := ret[1].(error)
+	return ret0, ret1
+}
+
+// FindPermIdsByRoleIdTx indicates an expected call of FindPermIdsByRoleIdTx.
+func (mr *MockSysRolePermModelMockRecorder) FindPermIdsByRoleIdTx(ctx, session, roleId any) *gomock.Call {
+	mr.mock.ctrl.T.Helper()
+	return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "FindPermIdsByRoleIdTx", reflect.TypeOf((*MockSysRolePermModel)(nil).FindPermIdsByRoleIdTx), ctx, session, roleId)
+}
+
 // FindPermIdsByRoleIds mocks base method.
 func (m *MockSysRolePermModel) FindPermIdsByRoleIds(ctx context.Context, roleIds []int64) ([]int64, error) {
 	m.ctrl.T.Helper()

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

@@ -287,6 +287,21 @@ func (mr *MockSysUserRoleModelMockRecorder) FindRoleIdsByUserIdForProduct(ctx, u
 	return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "FindRoleIdsByUserIdForProduct", reflect.TypeOf((*MockSysUserRoleModel)(nil).FindRoleIdsByUserIdForProduct), ctx, userId, productCode)
 }
 
+// FindRoleIdsByUserIdForProductTx mocks base method.
+func (m *MockSysUserRoleModel) FindRoleIdsByUserIdForProductTx(ctx context.Context, session sqlx.Session, userId int64, productCode string) ([]int64, error) {
+	m.ctrl.T.Helper()
+	ret := m.ctrl.Call(m, "FindRoleIdsByUserIdForProductTx", ctx, session, userId, productCode)
+	ret0, _ := ret[0].([]int64)
+	ret1, _ := ret[1].(error)
+	return ret0, ret1
+}
+
+// FindRoleIdsByUserIdForProductTx indicates an expected call of FindRoleIdsByUserIdForProductTx.
+func (mr *MockSysUserRoleModelMockRecorder) FindRoleIdsByUserIdForProductTx(ctx, session, userId, productCode any) *gomock.Call {
+	mr.mock.ctrl.T.Helper()
+	return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "FindRoleIdsByUserIdForProductTx", reflect.TypeOf((*MockSysUserRoleModel)(nil).FindRoleIdsByUserIdForProductTx), ctx, session, userId, productCode)
+}
+
 // FindUserIdsByRoleId mocks base method.
 func (m *MockSysUserRoleModel) FindUserIdsByRoleId(ctx context.Context, roleId int64) ([]int64, error) {
 	m.ctrl.T.Helper()

+ 4 - 3
internal/types/types.go

@@ -39,9 +39,10 @@ type CreateDeptReq struct {
 }
 
 type CreateProductReq struct {
-	Code   string `json:"code"`
-	Name   string `json:"name"`
-	Remark string `json:"remark,optional"`
+	Code        string `json:"code"`
+	Name        string `json:"name"`
+	Remark      string `json:"remark,optional"`
+	AdminDeptId int64  `json:"adminDeptId"`
 }
 
 type CreateProductResp struct {