Explorar o código

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

BaiLuoYan hai 3 semanas
pai
achega
24373b23da
Modificáronse 36 ficheiros con 2130 adicións e 237 borrados
  1. 3 2
      internal/logic/auth/changePasswordConflict_audit_test.go
  2. 5 1
      internal/logic/auth/changePasswordLogic.go
  3. 155 0
      internal/logic/auth/changePasswordToctou_audit_test.go
  4. 6 3
      internal/logic/auth/logoutLogic.go
  5. 65 0
      internal/logic/auth/logoutUsernameForward_audit_test.go
  6. 85 0
      internal/logic/auth/rotateRefreshToken.go
  7. 162 0
      internal/logic/auth/rotateRefreshToken_r11_5_audit_test.go
  8. 7 3
      internal/logic/member/auditFixes_test.go
  9. 26 10
      internal/logic/member/updateMemberLogic.go
  10. 8 4
      internal/logic/member/updateMemberLogic_test.go
  11. 177 0
      internal/logic/member/updateMemberPartialPointer_audit_test.go
  12. 5 49
      internal/logic/pub/refreshTokenLogic.go
  13. 155 0
      internal/logic/pub/syncPermsCleanByProduct_r11_4_audit_test.go
  14. 9 1
      internal/logic/pub/syncPermsService.go
  15. 43 5
      internal/logic/user/updateUserLogic.go
  16. 1 1
      internal/logic/user/updateUserStatusLogic.go
  17. 3 3
      internal/logic/user/updateUserStatusOptLock_audit_test.go
  18. 192 0
      internal/logic/user/updateUserWriteSkew_audit_test.go
  19. 15 0
      internal/model/dept/sysDeptModel.go
  20. 10 1
      internal/model/perm/sysPermModel.go
  21. 14 7
      internal/model/roleperm/sysRolePermModel.go
  22. 3 3
      internal/model/user/incrementTokenVersion_audit_test.go
  23. 145 0
      internal/model/user/mr11_2_noInternalFindOne_audit_test.go
  24. 59 26
      internal/model/user/sysUserModel.go
  25. 25 17
      internal/model/user/updatePasswordStatus_rowsaffected_audit_test.go
  26. 135 0
      internal/model/user/updatePasswordToctou_audit_test.go
  27. 7 2
      internal/model/userperm/sysUserPermModel.go
  28. 137 0
      internal/model/userrole/deleteCacheKey_r11_2_audit_test.go
  29. 20 10
      internal/model/userrole/sysUserRoleModel.go
  30. 174 0
      internal/server/grpcHttpRotateInterop_r11_5_audit_test.go
  31. 189 0
      internal/server/grpc_rate_limit_mr11_1_audit_test.go
  32. 37 46
      internal/server/permserver.go
  33. 11 0
      internal/svc/servicecontext.go
  34. 13 28
      internal/testutil/mocks/mock_dept_model.go
  35. 26 12
      internal/testutil/mocks/mock_user_model.go
  36. 3 3
      internal/types/types.go

+ 3 - 2
internal/logic/auth/changePasswordConflict_audit_test.go

@@ -47,8 +47,9 @@ func TestChangePassword_UpdateConflict_Maps409(t *testing.T) {
 			UpdateTime: 1000,
 			UpdateTime: 1000,
 		}, nil)
 		}, nil)
 	// 关键:强制底层返回 ErrUpdateConflict。
 	// 关键:强制底层返回 ErrUpdateConflict。
+	// H-R11-1:签名增加 username 与 expectedUpdateTime 两个透传参数。
 	mockUser.EXPECT().
 	mockUser.EXPECT().
-		UpdatePassword(gomock.Any(), userId, gomock.Any(), int64(consts.MustChangePasswordNo)).
+		UpdatePassword(gomock.Any(), userId, "m_r10_4_subject", gomock.Any(), int64(consts.MustChangePasswordNo), int64(1000)).
 		Return(userModel.ErrUpdateConflict)
 		Return(userModel.ErrUpdateConflict)
 
 
 	svcCtx := mocks.NewMockServiceContext(mocks.MockModels{User: mockUser})
 	svcCtx := mocks.NewMockServiceContext(mocks.MockModels{User: mockUser})
@@ -89,7 +90,7 @@ func TestChangePassword_GenericUpdateError_StillPropagates(t *testing.T) {
 		}, nil)
 		}, nil)
 	genericErr := errors.New("driver: bad connection")
 	genericErr := errors.New("driver: bad connection")
 	mockUser.EXPECT().
 	mockUser.EXPECT().
-		UpdatePassword(gomock.Any(), userId, gomock.Any(), int64(consts.MustChangePasswordNo)).
+		UpdatePassword(gomock.Any(), userId, "m_r10_4_subject2", gomock.Any(), int64(consts.MustChangePasswordNo), int64(2000)).
 		Return(genericErr)
 		Return(genericErr)
 
 
 	svcCtx := mocks.NewMockServiceContext(mocks.MockModels{User: mockUser})
 	svcCtx := mocks.NewMockServiceContext(mocks.MockModels{User: mockUser})

+ 5 - 1
internal/logic/auth/changePasswordLogic.go

@@ -70,7 +70,11 @@ func (l *ChangePasswordLogic) ChangePassword(req *types.ChangePasswordReq) error
 		return err
 		return err
 	}
 	}
 
 
-	if err := l.svcCtx.SysUserModel.UpdatePassword(l.ctx, userId, string(hashed), consts.MustChangePasswordNo); err != nil {
+	// 审计 H-R11-1:把上面已经读到的 user.UpdateTime / user.Username 作为乐观锁 expected 透传;
+	// UpdatePassword 内部不再 FindOne 自对齐,CAS 的 expected 与"外层校验旧密码所依赖的那一份快照"
+	// 严格绑定——任何并发 UpdatePassword / UpdateProfile / UpdateStatus 都会让 DB 的 updateTime
+	// 变化,WHERE 不再命中,ErrUpdateConflict 上抛 409,迫使会话刷新后重试。
+	if err := l.svcCtx.SysUserModel.UpdatePassword(l.ctx, userId, user.Username, string(hashed), consts.MustChangePasswordNo, user.UpdateTime); err != nil {
 		// 审计 M-R10-4:与 UpdateUserLogic / UpdateRoleLogic / UpdateUserStatusLogic 口径对齐,
 		// 审计 M-R10-4:与 UpdateUserLogic / UpdateRoleLogic / UpdateUserStatusLogic 口径对齐,
 		// 把乐观锁失败显式映射成 409,避免 raw error 被 rest 框架兜成 500、前端错把"并发冲突"
 		// 把乐观锁失败显式映射成 409,避免 raw error 被 rest 框架兜成 500、前端错把"并发冲突"
 		// 当作系统故障处理,告警看板也不会把这类事件归到 5xx 噪声池。
 		// 当作系统故障处理,告警看板也不会把这类事件归到 5xx 噪声池。

+ 155 - 0
internal/logic/auth/changePasswordToctou_audit_test.go

@@ -0,0 +1,155 @@
+package auth
+
+import (
+	"context"
+	"database/sql"
+	"errors"
+	"testing"
+	"time"
+
+	"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/svc"
+	"perms-system-server/internal/testutil"
+	"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"
+)
+
+// ---------------------------------------------------------------------------
+// 覆盖目标:H-R11-1 的 E2E 边界契约 —— 改密的 "400 原密码错误" 与 "409 被其他会话抢改"
+// 两条分支必须互不污染。
+//
+// H-R11-1 的核心是把 `UpdatePassword` 的乐观锁 expected 从内部自取改为外层 FindOne 透传,
+// 并在 Logic 层把 `ErrUpdateConflict` 显式映射 409。这里补一条"正常串行"回归:
+//   T0 ChangePassword(old=P0, new=P1) → 首改成功
+//   T1 ChangePassword(old=P0, new=P2) → 旧密码已失配,必须 400"原密码错误";
+//       绝不能因为"外层快照已陈旧"之类的原因落到 409 分支。
+//
+// 该契约直接护栏 H-R11-1 的 ErrUpdateConflict 映射逻辑不被误写成"吞掉所有错误都回 409"。
+// 底层 CAS 正确性已在 model 层 TestSysUserModel_UpdatePassword_StaleExpectedUpdateTime_Conflict /
+// ConcurrentProfileWrite_BlocksPasswordUpdate 两个用例闭合;Logic→Model 的签名传参契约
+// 则由 TestChangePassword_UpdateConflict_Maps409(mock UpdatePassword(..., 1000) 必须收到
+// user.UpdateTime=1000)钉死。所以这里只补 400/409 分支隔离。
+// ---------------------------------------------------------------------------
+
+func insertToctouUser(t *testing.T, ctx context.Context, svcCtx *svc.ServiceContext,
+	username, plainPwd string) (int64, func()) {
+	t.Helper()
+	now := time.Now().Unix()
+	hashed, err := bcrypt.GenerateFromPassword([]byte(plainPwd), bcrypt.DefaultCost)
+	require.NoError(t, err)
+
+	res, err := svcCtx.SysUserModel.Insert(ctx, &userModel.SysUser{
+		Username:           username,
+		Password:           string(hashed),
+		Nickname:           "toctou",
+		Avatar:             sql.NullString{},
+		Email:              username + "@test.com",
+		Phone:              "13800000000",
+		Remark:             "",
+		DeptId:             0,
+		IsSuperAdmin:       2,
+		MustChangePassword: 2,
+		Status:             1,
+		CreateTime:         now,
+		UpdateTime:         now,
+	})
+	require.NoError(t, err)
+	id, _ := res.LastInsertId()
+	cleanup := func() {
+		testutil.CleanTable(ctx, testutil.GetTestSqlConn(), "`sys_user`", id)
+	}
+	return id, cleanup
+}
+
+// TC-1042: H-R11-1 E2E —— 400 vs 409 分支隔离:旧密码失配必须 400,绝不能误落 409
+func TestChangePassword_E2E_SecondCallWithOldPwd_Maps400(t *testing.T) {
+	ctx := context.Background()
+	svcCtx := svc.NewServiceContext(testutil.GetTestConfig())
+	svcCtx.TokenOpLimiter = nil
+
+	oldPwd := "Oldpass123"
+	username := "toctou_seq_" + testutil.UniqueId()
+	userId, cleanup := insertToctouUser(t, ctx, svcCtx, username, oldPwd)
+	t.Cleanup(cleanup)
+
+	lctx := middleware.WithUserDetails(context.Background(),
+		&loaders.UserDetails{UserId: userId, Username: username, Status: 1})
+
+	require.NoError(t,
+		NewChangePasswordLogic(lctx, svcCtx).ChangePassword(&types.ChangePasswordReq{
+			OldPassword: oldPwd, NewPassword: "NewpassX_11",
+		}),
+		"首改必须成功")
+
+	err := NewChangePasswordLogic(lctx, svcCtx).ChangePassword(&types.ChangePasswordReq{
+		OldPassword: oldPwd, NewPassword: "NewpassY_22",
+	})
+	require.Error(t, err)
+	var ce *response.CodeError
+	require.True(t, errors.As(err, &ce))
+	assert.Equal(t, 400, ce.Code(),
+		"H-R11-1:旧密码已失配应 400'原密码错误';不得因 ErrUpdateConflict 映射被误回 409")
+	assert.Contains(t, ce.Error(), "原密码错误")
+
+	// DB 终态:Password 是首改成功的 NewpassX_11,tokenVersion 恰好 1(而不是 2)。
+	got, err := svcCtx.SysUserModel.FindOne(ctx, userId)
+	require.NoError(t, err)
+	assert.NoError(t, bcrypt.CompareHashAndPassword([]byte(got.Password), []byte("NewpassX_11")))
+	assert.Equal(t, int64(1), got.TokenVersion,
+		"H-R11-1:首改成功递增 1;第二次因 400 未进入 UpdatePassword,tokenVersion 必须仍是 1")
+}
+
+// TC-1043: H-R11-1 —— UpdatePassword 签名护栏(mock 驱动):
+// 签名一旦回退(例如 username 再次被内部 FindOne 取而非外层透传),整个链路会编译失败;
+// 但契约层面的"必须透传外层 snapshot 的 UpdateTime"更细致:Logic 必须把 FindOne 返回的
+// snapshot.UpdateTime 原样交给 UpdatePassword,不得自己算 time.Now() 或重新 FindOne。
+// 这里用 mock 钉死该契约:FindOne 返回 UpdateTime=4242,UpdatePassword 必须收到 4242。
+func TestChangePassword_ForwardsSnapshotUpdateTime(t *testing.T) {
+	// 注:此契约已由既有 TestChangePassword_UpdateConflict_Maps409(UpdateTime=1000)覆盖,
+	// 这里再以另一组数值(4242)做"反证哨兵",若 DEV 不小心硬编码常量/写死 time.Now,
+	// 两组数值会同时失败,快速定位。
+	t.Run("expected=4242", func(t *testing.T) { runSnapshotForwardCase(t, 4242) })
+	t.Run("expected=9876543210", func(t *testing.T) { runSnapshotForwardCase(t, 9876543210) })
+}
+
+func runSnapshotForwardCase(t *testing.T, expectedUpdateTime int64) {
+	ctrl := gomock.NewController(t)
+	t.Cleanup(ctrl.Finish)
+
+	const userId = int64(17)
+	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:   "snap_subject",
+			Password:   string(hashed),
+			Status:     1,
+			UpdateTime: expectedUpdateTime,
+		}, nil)
+	// 合同:UpdatePassword 的第 6 个参数必须与 FindOne 返回的 UpdateTime 字面相等。
+	mockUser.EXPECT().
+		UpdatePassword(gomock.Any(), userId, "snap_subject", gomock.Any(),
+			int64(consts.MustChangePasswordNo), expectedUpdateTime).
+		Return(nil)
+
+	svcCtx := mocks.NewMockServiceContext(mocks.MockModels{User: mockUser})
+	ctx := middleware.WithUserDetails(t.Context(), &loaders.UserDetails{UserId: userId})
+
+	require.NoError(t, NewChangePasswordLogic(ctx, svcCtx).ChangePassword(
+		&types.ChangePasswordReq{OldPassword: oldPwd, NewPassword: newPwd}))
+}
+

+ 6 - 3
internal/logic/auth/logoutLogic.go

@@ -33,10 +33,13 @@ func NewLogoutLogic(ctx context.Context, svcCtx *svc.ServiceContext) *LogoutLogi
 
 
 // Logout 用户注销。递增当前用户的 tokenVersion 使所有已签发的 access/refresh 令牌立即失效,并清除用户缓存。
 // Logout 用户注销。递增当前用户的 tokenVersion 使所有已签发的 access/refresh 令牌立即失效,并清除用户缓存。
 func (l *LogoutLogic) Logout() error {
 func (l *LogoutLogic) Logout() error {
-	userId := middleware.GetUserId(l.ctx)
-	if userId == 0 {
+	// 审计 M-R11-2:从 middleware 的 UserDetails 直接取 userId/username,
+	// 不再到 IncrementTokenVersion 内部再 FindOne 一次仅为构造缓存键。
+	ud := middleware.GetUserDetails(l.ctx)
+	if ud == nil || ud.UserId == 0 {
 		return response.ErrUnauthorized("未登录")
 		return response.ErrUnauthorized("未登录")
 	}
 	}
+	userId := ud.UserId
 
 
 	if l.svcCtx.TokenOpLimiter != nil {
 	if l.svcCtx.TokenOpLimiter != nil {
 		code, _ := l.svcCtx.TokenOpLimiter.Take(fmt.Sprintf("logout:%d", userId))
 		code, _ := l.svcCtx.TokenOpLimiter.Take(fmt.Sprintf("logout:%d", userId))
@@ -45,7 +48,7 @@ func (l *LogoutLogic) Logout() error {
 		}
 		}
 	}
 	}
 
 
-	if _, err := l.svcCtx.SysUserModel.IncrementTokenVersion(l.ctx, userId); err != nil {
+	if _, err := l.svcCtx.SysUserModel.IncrementTokenVersion(l.ctx, userId, ud.Username); err != nil {
 		// 审计 L-R10-3:IncrementTokenVersion 在目标用户已被并发删除时会返 ErrUpdateConflict。
 		// 审计 L-R10-3:IncrementTokenVersion 在目标用户已被并发删除时会返 ErrUpdateConflict。
 		// Logout 的语义目标本就是"让该账号的旧令牌立即失效",用户已经消失等同语义已达成,
 		// Logout 的语义目标本就是"让该账号的旧令牌立即失效",用户已经消失等同语义已达成,
 		// 按幂等成功处理并继续清缓存,不要让一次正常的注销因为极罕见的删号竞态回 500。
 		// 按幂等成功处理并继续清缓存,不要让一次正常的注销因为极罕见的删号竞态回 500。

+ 65 - 0
internal/logic/auth/logoutUsernameForward_audit_test.go

@@ -0,0 +1,65 @@
+package auth
+
+import (
+	"testing"
+
+	"perms-system-server/internal/loaders"
+	"perms-system-server/internal/middleware"
+	"perms-system-server/internal/testutil/mocks"
+
+	"github.com/stretchr/testify/require"
+	"go.uber.org/mock/gomock"
+)
+
+// ---------------------------------------------------------------------------
+// 覆盖目标:审计 M-R11-2 —— Logout Logic 必须把 middleware.UserDetails 里的 Username
+// 直接透传给 SysUserModel.IncrementTokenVersion,而不是让 Model 内部再 FindOne 取一次。
+//
+// 本文件以 mock 的方式对 Logic→Model 的契约做最严格的钉子:
+//   IncrementTokenVersion 的第 3 个参数必须**字面等于** ud.Username;
+//   若 DEV 未来回归成"传 '' 让 Model 内部 FindOne",gomock 会拦截报错。
+// ---------------------------------------------------------------------------
+
+// TC-1047: M-R11-2 —— Logout 透传 ud.Username 给 IncrementTokenVersion
+func TestLogout_ForwardsUsername_NoInternalFindOne(t *testing.T) {
+	ctrl := gomock.NewController(t)
+	t.Cleanup(ctrl.Finish)
+
+	const userId = int64(7777)
+	const username = "m_r11_2_subject"
+
+	mockUser := mocks.NewMockSysUserModel(ctrl)
+	// 契约:username 参数必须与 ud.Username 字面一致。
+	mockUser.EXPECT().
+		IncrementTokenVersion(gomock.Any(), userId, username).
+		Return(int64(1), nil)
+	// 不允许再出现任何 FindOne / FindOneByUsername 的调用(gomock 默认就会对未声明的调用 fail)。
+
+	svcCtx := mocks.NewMockServiceContext(mocks.MockModels{User: mockUser})
+	ctx := middleware.WithUserDetails(t.Context(), &loaders.UserDetails{
+		UserId: userId, Username: username, Status: 1,
+	})
+
+	require.NoError(t, NewLogoutLogic(ctx, svcCtx).Logout(),
+		"Logout 正常路径应通过,且必须按签名字面透传 username")
+}
+
+// TC-1048: M-R11-2 —— Logout 在 Username 为 "" 的极端场景下仍然透传空串(不隐式 FindOne 修补)
+// 该契约保证 Model 层对 "调用方未提供 username" 的场景**不做缓存键兜底**;
+// 若 DEV 回退成 Model 内部 FindOne,行为会变:Logic 传 "" 进来时 Model 会真的去 DB 查一次。
+func TestLogout_EmptyUsernameStillForwarded(t *testing.T) {
+	ctrl := gomock.NewController(t)
+	t.Cleanup(ctrl.Finish)
+
+	const userId = int64(8888)
+	mockUser := mocks.NewMockSysUserModel(ctrl)
+	mockUser.EXPECT().
+		IncrementTokenVersion(gomock.Any(), userId, ""). // 严格 "" 不是 gomock.Any()
+		Return(int64(3), nil)
+
+	svcCtx := mocks.NewMockServiceContext(mocks.MockModels{User: mockUser})
+	ctx := middleware.WithUserDetails(t.Context(), &loaders.UserDetails{
+		UserId: userId, Username: "", Status: 1,
+	})
+	require.NoError(t, NewLogoutLogic(ctx, svcCtx).Logout())
+}

+ 85 - 0
internal/logic/auth/rotateRefreshToken.go

@@ -0,0 +1,85 @@
+package auth
+
+import (
+	"context"
+
+	"perms-system-server/internal/loaders"
+	userModel "perms-system-server/internal/model/user"
+	"perms-system-server/internal/svc"
+
+	"github.com/zeromicro/go-zero/core/logx"
+)
+
+// RotateTokensResult 是 RotateRefreshToken 的成功产物。
+type RotateTokensResult struct {
+	AccessToken  string
+	RefreshToken string
+}
+
+// RotateRefreshToken 把 HTTP RefreshToken 与 gRPC RefreshToken 两条路径共用的"试签 → CAS →
+// Clean → forensic 比对"整段收敛到一个 helper(审计 L-R11-5 / L-R10-4)。调用契约:
+//
+//	输入:claims 必须由 ParseRefreshToken 验证通过;ud 必须是 loader 已 Load 的对象且上游已校过
+//	      Status / ProductStatus / MemberType / TokenVersion=ud.TokenVersion 等生效性前置。
+//	输出:
+//	  - nil error:access/refresh 可以直接下发;调用方已经失败会得到带 predictedVersion 的新 token。
+//	  - err == ErrTokenVersionMismatch:CAS 被更早的并发 rotate 抢先,或 forensic 分支判定
+//	    "newVersion != predictedVersion"契约漂移。HTTP 层映射为 401,gRPC 层映射为 Unauthenticated。
+//	  - 其他 err:GenerateAccessToken/GenerateRefreshTokenWithExpiry/IncrementTokenVersionIfMatch
+//	    的底层 IO / 签名失败;调用方按 HTTP 500 / gRPC Internal 返回,DB 状态保证没动。
+//
+// 注意:本 helper 不负责任何限流、claims 再校验、用户状态再校验——两条调用路径各自业务契约不同
+// (比如 gRPC 禁止 HTTP 的 ProductCode 不匹配,而 HTTP 允许空 productCode 的超管路径),这些
+// 差异由调用方处理,helper 只做"签发 + CAS"的纯粹段。
+func RotateRefreshToken(ctx context.Context, svcCtx *svc.ServiceContext, claims *RefreshClaims, ud *loaders.UserDetails) (RotateTokensResult, error) {
+	predictedVersion := claims.TokenVersion + 1
+
+	// 审计 M-3:先试签 → 再 CAS;签名失败走这里直接返回,DB 的 tokenVersion 不被污染,
+	// 不会出现"tokenVersion 已 +1 但客户端没收到新 refreshToken → 下一次被强制登出"的副作用。
+	accessToken, err := GenerateAccessToken(
+		svcCtx.Config.Auth.AccessSecret,
+		svcCtx.Config.Auth.AccessExpire,
+		ud.UserId, ud.Username, ud.ProductCode, ud.MemberType, predictedVersion,
+	)
+	if err != nil {
+		return RotateTokensResult{}, err
+	}
+
+	newRefreshToken, err := GenerateRefreshTokenWithExpiry(
+		svcCtx.Config.Auth.RefreshSecret,
+		claims.ExpiresAt.Time,
+		ud.UserId, ud.ProductCode, predictedVersion,
+	)
+	if err != nil {
+		return RotateTokensResult{}, err
+	}
+
+	newVersion, err := svcCtx.SysUserModel.IncrementTokenVersionIfMatch(ctx, claims.UserId, ud.Username, claims.TokenVersion)
+	if err != nil {
+		return RotateTokensResult{}, 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 并向调用方转 Unauthenticated,避免
+		// 签发出一个与实际 DB 值不一致的 refreshToken 留下审计死角。
+		logx.WithContext(ctx).Errorw("refresh token version prediction mismatch",
+			logx.Field("audit", "refresh_token_version_mismatch"),
+			logx.Field("userId", claims.UserId),
+			logx.Field("claimed", claims.TokenVersion),
+			logx.Field("predicted", predictedVersion),
+			logx.Field("actual", newVersion),
+		)
+		return RotateTokensResult{}, userModel.ErrTokenVersionMismatch
+	}
+
+	svcCtx.UserDetailsLoader.Clean(ctx, claims.UserId)
+
+	return RotateTokensResult{AccessToken: accessToken, RefreshToken: newRefreshToken}, nil
+}

+ 162 - 0
internal/logic/auth/rotateRefreshToken_r11_5_audit_test.go

@@ -0,0 +1,162 @@
+package auth
+
+import (
+	"context"
+	"database/sql"
+	"testing"
+	"time"
+
+	"perms-system-server/internal/loaders"
+	userModel "perms-system-server/internal/model/user"
+	"perms-system-server/internal/svc"
+	"perms-system-server/internal/testutil"
+
+	"github.com/golang-jwt/jwt/v4"
+	"github.com/stretchr/testify/assert"
+	"github.com/stretchr/testify/require"
+)
+
+// ---------------------------------------------------------------------------
+// 覆盖目标:审计 L-R11-5 —— 把 HTTP / gRPC 两条 RefreshToken 路径的"试签 → CAS → Clean →
+// forensic 比对"收敛为 authHelper.RotateRefreshToken。契约上本 helper 必须:
+//   1) 成功路径:写出带 predictedVersion 的新 access + refresh、DB tokenVersion = claims+1、
+//      并触发 UD 缓存 Clean(无法直接断言 Clean 的 side effect,但通过"下一次 Load 能读到
+//      新 tokenVersion"可以间接覆盖);
+//   2) claims.TokenVersion 与 DB 不一致 → 返回 ErrTokenVersionMismatch(让 HTTP 映射 401、
+//      gRPC 映射 Unauthenticated);且 DB tokenVersion **不得**被污染;
+//   3) 用户不存在(RowsAffected=0)→ 同样 ErrTokenVersionMismatch,不得被映射成"Internal"。
+// ---------------------------------------------------------------------------
+
+func insertRotateTestUser(t *testing.T, ctx context.Context, svcCtx *svc.ServiceContext, username string, tokenVersion int64) int64 {
+	t.Helper()
+	now := time.Now().Unix()
+	res, err := svcCtx.SysUserModel.Insert(ctx, &userModel.SysUser{
+		Username:           username,
+		Password:           testutil.HashPassword("SomePass123"),
+		Nickname:           username,
+		Avatar:             sql.NullString{},
+		Email:              username + "@ut.local",
+		Phone:              "13800000000",
+		IsSuperAdmin:       2,
+		MustChangePassword: 2,
+		Status:             1,
+		TokenVersion:       tokenVersion,
+		CreateTime:         now,
+		UpdateTime:         now,
+	})
+	require.NoError(t, err)
+	id, err := res.LastInsertId()
+	require.NoError(t, err)
+	t.Cleanup(func() {
+		testutil.CleanTable(ctx, testutil.GetTestSqlConn(), "`sys_user`", id)
+	})
+	return id
+}
+
+func mkRefreshClaims(userId int64, productCode string, tokenVersion int64, ttl time.Duration) *RefreshClaims {
+	now := time.Now()
+	return &RefreshClaims{
+		TokenType:    "refresh",
+		UserId:       userId,
+		ProductCode:  productCode,
+		TokenVersion: tokenVersion,
+		RegisteredClaims: jwt.RegisteredClaims{
+			ExpiresAt: jwt.NewNumericDate(now.Add(ttl)),
+			IssuedAt:  jwt.NewNumericDate(now),
+		},
+	}
+}
+
+// TC-1067: L-R11-5 —— helper 成功路径
+func TestRotateRefreshToken_HappyPath(t *testing.T) {
+	ctx := context.Background()
+	svcCtx := svc.NewServiceContext(testutil.GetTestConfig())
+
+	username := "r11_5_ok_" + testutil.UniqueId()
+	userId := insertRotateTestUser(t, ctx, svcCtx, username, 0)
+
+	claims := mkRefreshClaims(userId, "", 0, 2*time.Hour)
+	ud := &loaders.UserDetails{
+		UserId:       userId,
+		Username:     username,
+		Status:       1,
+		TokenVersion: 0,
+	}
+
+	tokens, err := RotateRefreshToken(ctx, svcCtx, claims, ud)
+	require.NoError(t, err, "L-R11-5:预期 tokenVersion=0 匹配,CAS 必须成功")
+	assert.NotEmpty(t, tokens.AccessToken)
+	assert.NotEmpty(t, tokens.RefreshToken)
+	assert.NotEqual(t, tokens.AccessToken, tokens.RefreshToken,
+		"签发出的 access/refresh 必须是两条不同的 JWT,避免一次泄露即双向失陷")
+
+	u, err := svcCtx.SysUserModel.FindOne(ctx, userId)
+	require.NoError(t, err)
+	assert.Equal(t, int64(1), u.TokenVersion,
+		"L-R11-5:成功路径 DB.tokenVersion 必须严格 +1,不得多走也不得不走")
+
+	// 新 refreshToken 解码后 tokenVersion 必是 1,即 predictedVersion。
+	var parsed RefreshClaims
+	_, err = ParseWithHMAC(tokens.RefreshToken, svcCtx.Config.Auth.RefreshSecret, &parsed)
+	require.NoError(t, err)
+	assert.Equal(t, int64(1), parsed.TokenVersion,
+		"L-R11-5:新 refreshToken 承诺的 tokenVersion 必须等于 predictedVersion,"+
+			"即 claims.TokenVersion + 1;若错位,接入方下一次刷新会立刻 401 失效")
+}
+
+// TC-1068: L-R11-5 —— claims.TokenVersion 与 DB 不一致 → CAS 失败 → ErrTokenVersionMismatch
+func TestRotateRefreshToken_StaleTokenVersion_Mismatch(t *testing.T) {
+	ctx := context.Background()
+	svcCtx := svc.NewServiceContext(testutil.GetTestConfig())
+
+	username := "r11_5_stale_" + testutil.UniqueId()
+	// DB 里 tokenVersion 已经被之前的某次 rotate 推到 1。
+	userId := insertRotateTestUser(t, ctx, svcCtx, username, 1)
+
+	// 但 claims 还是旧的 tokenVersion=0。
+	claims := mkRefreshClaims(userId, "", 0, 2*time.Hour)
+	ud := &loaders.UserDetails{
+		UserId:       userId,
+		Username:     username,
+		Status:       1,
+		TokenVersion: 1, // 和 DB 一致;调用方上游会先看 claims != ud.TokenVersion 并 401,
+		// 这里绕过上游直接走 helper 是为了验证 helper 自己也不会被旧 claims 蒙混过关。
+	}
+
+	_, err := RotateRefreshToken(ctx, svcCtx, claims, ud)
+	require.ErrorIs(t, err, userModel.ErrTokenVersionMismatch,
+		"L-R11-5:claims.TokenVersion=0 但 DB=1,CAS 的 WHERE tokenVersion=0 命中 0 行,"+
+			"helper 必须返回 ErrTokenVersionMismatch(调用方据此回 401/Unauthenticated)")
+
+	u, err := svcCtx.SysUserModel.FindOne(ctx, userId)
+	require.NoError(t, err)
+	assert.Equal(t, int64(1), u.TokenVersion,
+		"L-R11-5:CAS 失败时 DB.tokenVersion 不得被任何副作用推进,否则 helper 就成了"+
+			"'只要过了 Parse 就一定 +1'的攻击 oracle")
+}
+
+// TC-1069: L-R11-5 —— 目标 userId 不存在(已被删)→ RowsAffected=0 → ErrTokenVersionMismatch
+// 这条契约的意义:refreshToken 还没到过期但账号已被管理员删除的场景里,helper 不得把"找不到
+// 目标行"回溯到底层 sqlx 错误(例如 ErrNotFound)让上层误判成 500;必须统一回到可预测的
+// ErrTokenVersionMismatch 分支。
+func TestRotateRefreshToken_DeletedUser_Mismatch(t *testing.T) {
+	ctx := context.Background()
+	svcCtx := svc.NewServiceContext(testutil.GetTestConfig())
+
+	username := "r11_5_ghost_" + testutil.UniqueId()
+	userId := insertRotateTestUser(t, ctx, svcCtx, username, 0)
+
+	// 手动删除该行,构造"refresh token 还没过期但用户已消失"。
+	_, err := testutil.GetTestSqlConn().ExecCtx(ctx, "DELETE FROM `sys_user` WHERE `id` = ?", userId)
+	require.NoError(t, err)
+
+	claims := mkRefreshClaims(userId, "", 0, 2*time.Hour)
+	ud := &loaders.UserDetails{
+		UserId: userId, Username: username, Status: 1, TokenVersion: 0,
+	}
+
+	_, err = RotateRefreshToken(ctx, svcCtx, claims, ud)
+	require.ErrorIs(t, err, userModel.ErrTokenVersionMismatch,
+		"L-R11-5:用户行已消失 → IncrementTokenVersionIfMatch RowsAffected=0,"+
+			"helper 必须折叠成 ErrTokenVersionMismatch;不得回底层 sqlx 错误让上游误映射为 500")
+}

+ 7 - 3
internal/logic/member/auditFixes_test.go

@@ -19,6 +19,10 @@ import (
 	"github.com/stretchr/testify/require"
 	"github.com/stretchr/testify/require"
 )
 )
 
 
+// strPtr / int64Ptr 是 L-R11-1 后 UpdateMemberReq.MemberType / Status 指针化的 helper。
+// 若 nil 表示不改该字段,两者都 nil 会被 Logic 400。
+func strPtr(s string) *string { return &s }
+
 type seededProduct struct {
 type seededProduct struct {
 	code  string
 	code  string
 	pId   int64
 	pId   int64
@@ -170,7 +174,7 @@ func TestUpdateMember_DemoteLastAdminRejected(t *testing.T) {
 	t.Cleanup(func() { cleanupSeeded(t, svcCtx, sp) })
 	t.Cleanup(func() { cleanupSeeded(t, svcCtx, sp) })
 
 
 	err := NewUpdateMemberLogic(ctx, svcCtx).UpdateMember(&types.UpdateMemberReq{
 	err := NewUpdateMemberLogic(ctx, svcCtx).UpdateMember(&types.UpdateMemberReq{
-		Id: sp.mId, MemberType: "MEMBER",
+		Id: sp.mId, MemberType: strPtr("MEMBER"),
 	})
 	})
 	require.Error(t, err)
 	require.Error(t, err)
 	var ce *response.CodeError
 	var ce *response.CodeError
@@ -224,7 +228,7 @@ func TestUpdateMember_DemoteAdmin_WhenMultiple_Allowed(t *testing.T) {
 	})
 	})
 
 
 	err = NewUpdateMemberLogic(ctx, svcCtx).UpdateMember(&types.UpdateMemberReq{
 	err = NewUpdateMemberLogic(ctx, svcCtx).UpdateMember(&types.UpdateMemberReq{
-		Id: mIds[0], MemberType: "MEMBER",
+		Id: mIds[0], MemberType: strPtr("MEMBER"),
 	})
 	})
 	require.NoError(t, err)
 	require.NoError(t, err)
 
 
@@ -277,7 +281,7 @@ func TestUpdateMember_DemoteLastActiveAdmin_Rejected(t *testing.T) {
 
 
 	// 启用中的那个 ADMIN (mIds[0]) 降级应被拒绝
 	// 启用中的那个 ADMIN (mIds[0]) 降级应被拒绝
 	err = NewUpdateMemberLogic(ctx, svcCtx).UpdateMember(&types.UpdateMemberReq{
 	err = NewUpdateMemberLogic(ctx, svcCtx).UpdateMember(&types.UpdateMemberReq{
-		Id: mIds[0], MemberType: "DEVELOPER",
+		Id: mIds[0], MemberType: strPtr("DEVELOPER"),
 	})
 	})
 	require.Error(t, err)
 	require.Error(t, err)
 	var ce *response.CodeError
 	var ce *response.CodeError

+ 26 - 10
internal/logic/member/updateMemberLogic.go

@@ -29,32 +29,48 @@ func NewUpdateMemberLogic(ctx context.Context, svcCtx *svc.ServiceContext) *Upda
 }
 }
 
 
 // UpdateMember 更新产品成员。修改成员类型或启用/禁用状态。降级最后一个 ADMIN 时会被拒绝以保证产品始终有管理员。
 // UpdateMember 更新产品成员。修改成员类型或启用/禁用状态。降级最后一个 ADMIN 时会被拒绝以保证产品始终有管理员。
+// 审计 L-R11-1:memberType / status 均为指针可选,nil 表示不改该字段;两者都为 nil 时直接 400。
 func (l *UpdateMemberLogic) UpdateMember(req *types.UpdateMemberReq) error {
 func (l *UpdateMemberLogic) UpdateMember(req *types.UpdateMemberReq) error {
+	if req.MemberType == nil && req.Status == nil {
+		return response.ErrBadRequest("请至少提供一个要更新的字段(memberType 或 status)")
+	}
+
 	member, err := l.svcCtx.SysProductMemberModel.FindOne(l.ctx, req.Id)
 	member, err := l.svcCtx.SysProductMemberModel.FindOne(l.ctx, req.Id)
 	if err != nil {
 	if err != nil {
 		return response.ErrNotFound("成员不存在")
 		return response.ErrNotFound("成员不存在")
 	}
 	}
 
 
-	if req.MemberType != consts.MemberTypeAdmin &&
-		req.MemberType != consts.MemberTypeDeveloper &&
-		req.MemberType != consts.MemberTypeMember {
-		return response.ErrBadRequest("无效的成员类型")
+	nextType := member.MemberType
+	if req.MemberType != nil {
+		if *req.MemberType != consts.MemberTypeAdmin &&
+			*req.MemberType != consts.MemberTypeDeveloper &&
+			*req.MemberType != consts.MemberTypeMember {
+			return response.ErrBadRequest("无效的成员类型")
+		}
+		nextType = *req.MemberType
 	}
 	}
 
 
 	if err := authHelper.CheckManageAccess(l.ctx, l.svcCtx, member.UserId, member.ProductCode); err != nil {
 	if err := authHelper.CheckManageAccess(l.ctx, l.svcCtx, member.UserId, member.ProductCode); err != nil {
 		return err
 		return err
 	}
 	}
-	if err := authHelper.CheckMemberTypeAssignment(l.ctx, req.MemberType); err != nil {
-		return err
+	// 仅在 memberType 真的被改动时走 CheckMemberTypeAssignment:DEVELOPER 不得被普通 admin 分配,
+	// 但"只改 status"的场景(已经是 DEVELOPER 的人冻结/启用)不应被该校验误拦。
+	if req.MemberType != nil && nextType != member.MemberType {
+		if err := authHelper.CheckMemberTypeAssignment(l.ctx, nextType); err != nil {
+			return err
+		}
 	}
 	}
 
 
-	nextType := req.MemberType
 	nextStatus := member.Status
 	nextStatus := member.Status
-	if req.Status != 0 {
-		if req.Status != consts.StatusEnabled && req.Status != consts.StatusDisabled {
+	if req.Status != nil {
+		if *req.Status != consts.StatusEnabled && *req.Status != consts.StatusDisabled {
 			return response.ErrBadRequest("状态值无效,仅支持 1(启用) 和 2(禁用)")
 			return response.ErrBadRequest("状态值无效,仅支持 1(启用) 和 2(禁用)")
 		}
 		}
-		nextStatus = req.Status
+		nextStatus = *req.Status
+	}
+
+	if nextType == member.MemberType && nextStatus == member.Status {
+		return nil
 	}
 	}
 
 
 	if err := l.svcCtx.SysProductMemberModel.TransactCtx(l.ctx, func(ctx context.Context, session sqlx.Session) error {
 	if err := l.svcCtx.SysProductMemberModel.TransactCtx(l.ctx, func(ctx context.Context, session sqlx.Session) error {

+ 8 - 4
internal/logic/member/updateMemberLogic_test.go

@@ -55,10 +55,12 @@ func TestUpdateMember_Normal(t *testing.T) {
 	})
 	})
 
 
 	logic := NewUpdateMemberLogic(ctx, svcCtx)
 	logic := NewUpdateMemberLogic(ctx, svcCtx)
+	mt := "ADMIN"
+	st := int64(2)
 	err = logic.UpdateMember(&types.UpdateMemberReq{
 	err = logic.UpdateMember(&types.UpdateMemberReq{
 		Id:         mId,
 		Id:         mId,
-		MemberType: "ADMIN",
-		Status:     2,
+		MemberType: &mt,
+		Status:     &st,
 	})
 	})
 	require.NoError(t, err)
 	require.NoError(t, err)
 
 
@@ -105,9 +107,10 @@ func TestUpdateMember_InvalidMemberType(t *testing.T) {
 	})
 	})
 
 
 	logic := NewUpdateMemberLogic(ctx, svcCtx)
 	logic := NewUpdateMemberLogic(ctx, svcCtx)
+	mt := "INVALID"
 	err = logic.UpdateMember(&types.UpdateMemberReq{
 	err = logic.UpdateMember(&types.UpdateMemberReq{
 		Id:         mId,
 		Id:         mId,
-		MemberType: "INVALID",
+		MemberType: &mt,
 	})
 	})
 	require.Error(t, err)
 	require.Error(t, err)
 	ce, ok := err.(*response.CodeError)
 	ce, ok := err.(*response.CodeError)
@@ -122,9 +125,10 @@ func TestUpdateMember_NotFound(t *testing.T) {
 	svcCtx := svc.NewServiceContext(testutil.GetTestConfig())
 	svcCtx := svc.NewServiceContext(testutil.GetTestConfig())
 
 
 	logic := NewUpdateMemberLogic(ctx, svcCtx)
 	logic := NewUpdateMemberLogic(ctx, svcCtx)
+	mt := "ADMIN"
 	err := logic.UpdateMember(&types.UpdateMemberReq{
 	err := logic.UpdateMember(&types.UpdateMemberReq{
 		Id:         999999999,
 		Id:         999999999,
-		MemberType: "ADMIN",
+		MemberType: &mt,
 	})
 	})
 	require.Error(t, err)
 	require.Error(t, err)
 	ce, ok := err.(*response.CodeError)
 	ce, ok := err.(*response.CodeError)

+ 177 - 0
internal/logic/member/updateMemberPartialPointer_audit_test.go

@@ -0,0 +1,177 @@
+package member
+
+import (
+	"errors"
+	"testing"
+
+	"perms-system-server/internal/consts"
+	"perms-system-server/internal/response"
+	"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"
+)
+
+// ---------------------------------------------------------------------------
+// 覆盖目标:审计 L-R11-1 —— UpdateMemberReq 的 MemberType / Status 指针化后,
+//   1) 既表达"字段未传"≠"字段传零值"(区分"不改"和"改成 0"/"改成 ''"),
+//   2) 又要在两者都 nil 时由 Logic 层兜底 400,避免无害路径被当"空 update"落库;
+//   3) 只传 Status 不传 MemberType(反之亦然)时不得回归出"把对面字段写成空字符串"的 bug;
+//   4) 已经是 DEVELOPER 的人,只传 Status 冻结/启用时绝不得误触 CheckMemberTypeAssignment,
+//      否则普通 admin 永远无法对 DEVELOPER 做冻结/解冻。
+// ---------------------------------------------------------------------------
+
+func int64Ptr(v int64) *int64 { return &v }
+
+// TC-1056: L-R11-1 —— 两字段同时 nil,必须 400,且不得做任何 DB 读写
+func TestUpdateMember_BothFieldsNil_RejectedWith400(t *testing.T) {
+	ctx := ctxhelper.SuperAdminCtx()
+	svcCtx := svc.NewServiceContext(testutil.GetTestConfig())
+	sp := seedEnabledProductWithMember(t, svcCtx, consts.MemberTypeMember)
+	t.Cleanup(func() { cleanupSeeded(t, svcCtx, sp) })
+
+	err := NewUpdateMemberLogic(ctx, svcCtx).UpdateMember(&types.UpdateMemberReq{
+		Id: sp.mId,
+	})
+	require.Error(t, err)
+	var ce *response.CodeError
+	require.True(t, errors.As(err, &ce),
+		"L-R11-1:两字段全 nil 必须命中 response.ErrBadRequest,且以 *CodeError 传递")
+	assert.Equal(t, 400, ce.Code(),
+		"L-R11-1:两字段全 nil 必须 400,不得退化成 200 no-op 或 500")
+	assert.Contains(t, ce.Error(), "至少提供一个",
+		"错误消息应明确提示至少要传 memberType 或 status 之一,便于接入方排查空请求体")
+
+	got, err := svcCtx.SysProductMemberModel.FindOne(ctx, sp.mId)
+	require.NoError(t, err)
+	assert.Equal(t, consts.MemberTypeMember, got.MemberType,
+		"L-R11-1:Logic 提前 400,任何 DB 落库都是回归;MemberType 必须保留原值")
+	assert.Equal(t, int64(consts.StatusEnabled), got.Status,
+		"L-R11-1:Logic 提前 400 时 Status 也必须保留原值")
+}
+
+// TC-1057: L-R11-1 —— 仅改 Status,MemberType 字段必须按原值保留;绝不回归成 ""
+func TestUpdateMember_OnlyStatus_PreservesMemberType(t *testing.T) {
+	ctx := ctxhelper.SuperAdminCtx()
+	svcCtx := svc.NewServiceContext(testutil.GetTestConfig())
+	sp := seedEnabledProductWithMember(t, svcCtx, consts.MemberTypeMember)
+	t.Cleanup(func() { cleanupSeeded(t, svcCtx, sp) })
+
+	require.NoError(t,
+		NewUpdateMemberLogic(ctx, svcCtx).UpdateMember(&types.UpdateMemberReq{
+			Id:     sp.mId,
+			Status: int64Ptr(consts.StatusDisabled),
+		}),
+		"只改 Status 必须成功")
+
+	got, err := svcCtx.SysProductMemberModel.FindOne(ctx, sp.mId)
+	require.NoError(t, err)
+	assert.Equal(t, int64(consts.StatusDisabled), got.Status,
+		"L-R11-1:Status 应按入参更新到禁用")
+	assert.Equal(t, consts.MemberTypeMember, got.MemberType,
+		"L-R11-1 回归防线:旧实现若以空串当缺省值会把 memberType 改写成 '',"+
+			"权限侧会当这行为非法成员吊销其全部权限,必须杜绝")
+}
+
+// TC-1058: L-R11-1 —— 仅改 MemberType,Status 原值保留
+func TestUpdateMember_OnlyMemberType_PreservesStatus(t *testing.T) {
+	ctx := ctxhelper.SuperAdminCtx()
+	svcCtx := svc.NewServiceContext(testutil.GetTestConfig())
+	sp := seedEnabledProductWithMember(t, svcCtx, consts.MemberTypeMember)
+	t.Cleanup(func() { cleanupSeeded(t, svcCtx, sp) })
+
+	require.NoError(t,
+		NewUpdateMemberLogic(ctx, svcCtx).UpdateMember(&types.UpdateMemberReq{
+			Id:         sp.mId,
+			MemberType: strPtr(consts.MemberTypeAdmin),
+		}),
+		"只改 MemberType 必须成功")
+
+	got, err := svcCtx.SysProductMemberModel.FindOne(ctx, sp.mId)
+	require.NoError(t, err)
+	assert.Equal(t, consts.MemberTypeAdmin, got.MemberType)
+	assert.Equal(t, int64(consts.StatusEnabled), got.Status,
+		"L-R11-1:Status 未提供时必须保留原值;若回归成 0 会让该成员瞬间冻结")
+}
+
+// TC-1059: L-R11-1 —— DEVELOPER 成员仅改 Status 冻结,不得误触 CheckMemberTypeAssignment
+// 语义上 CheckMemberTypeAssignment 拦的是"用普通 admin 指派/变更 DEVELOPER"的动作;仅冻结
+// 一个已存在的 DEVELOPER 不属于"指派 DEVELOPER",必须放行。否则普通 admin 将永远无法管理
+// DEVELOPER 的启停,只能靠超管——属于管理面瘫痪。
+func TestUpdateMember_DeveloperStatusOnly_BypassesAssignmentCheck(t *testing.T) {
+	ctx := ctxhelper.SuperAdminCtx()
+	svcCtx := svc.NewServiceContext(testutil.GetTestConfig())
+	sp := seedEnabledProductWithMember(t, svcCtx, consts.MemberTypeDeveloper)
+	t.Cleanup(func() { cleanupSeeded(t, svcCtx, sp) })
+
+	require.NoError(t,
+		NewUpdateMemberLogic(ctx, svcCtx).UpdateMember(&types.UpdateMemberReq{
+			Id:     sp.mId,
+			Status: int64Ptr(consts.StatusDisabled),
+		}),
+		"L-R11-1:DEVELOPER 只冻结 (Status=2) 时必须跳过 CheckMemberTypeAssignment;"+
+			"修复前的 `if memberType == DEVELOPER` 早期校验会把这条合法请求拒成 403")
+
+	got, err := svcCtx.SysProductMemberModel.FindOne(ctx, sp.mId)
+	require.NoError(t, err)
+	assert.Equal(t, consts.MemberTypeDeveloper, got.MemberType,
+		"MemberType 在未传时必须保留 DEVELOPER")
+	assert.Equal(t, int64(consts.StatusDisabled), got.Status)
+}
+
+// TC-1060: L-R11-1 —— 无效 Status(非 1/2)必须 400,不得被"只传 Status"分支绕过校验
+func TestUpdateMember_InvalidStatusValue_Rejected(t *testing.T) {
+	ctx := ctxhelper.SuperAdminCtx()
+	svcCtx := svc.NewServiceContext(testutil.GetTestConfig())
+	sp := seedEnabledProductWithMember(t, svcCtx, consts.MemberTypeMember)
+	t.Cleanup(func() { cleanupSeeded(t, svcCtx, sp) })
+
+	for _, bad := range []int64{0, 3, -1, 999} {
+		err := NewUpdateMemberLogic(ctx, svcCtx).UpdateMember(&types.UpdateMemberReq{
+			Id:     sp.mId,
+			Status: int64Ptr(bad),
+		})
+		require.Error(t, err)
+		var ce *response.CodeError
+		require.True(t, errors.As(err, &ce),
+			"L-R11-1:无效 Status 必须 *CodeError")
+		assert.Equal(t, 400, ce.Code(),
+			"L-R11-1:Status=%d 必须 400 被拒,严禁靠 DB CHECK 或下游枚举触发 500", bad)
+	}
+
+	got, err := svcCtx.SysProductMemberModel.FindOne(ctx, sp.mId)
+	require.NoError(t, err)
+	assert.Equal(t, int64(consts.StatusEnabled), got.Status, "非法 Status 全部被拒,原值必须不变")
+}
+
+// TC-1061: L-R11-1 —— 值与现状完全一致(no-op)不报错,且**不触发** DB 事务/缓存失效
+// 这里只能通过"不返回 error"以及"DB 值稳定 + updateTime 不前进"的软信号来近似验证;
+// 契约上:nextType == member.MemberType && nextStatus == member.Status 时 Logic 早退。
+func TestUpdateMember_NoOpUpdate_ReturnsNil(t *testing.T) {
+	ctx := ctxhelper.SuperAdminCtx()
+	svcCtx := svc.NewServiceContext(testutil.GetTestConfig())
+	sp := seedEnabledProductWithMember(t, svcCtx, consts.MemberTypeMember)
+	t.Cleanup(func() { cleanupSeeded(t, svcCtx, sp) })
+
+	before, err := svcCtx.SysProductMemberModel.FindOne(ctx, sp.mId)
+	require.NoError(t, err)
+
+	require.NoError(t,
+		NewUpdateMemberLogic(ctx, svcCtx).UpdateMember(&types.UpdateMemberReq{
+			Id:         sp.mId,
+			MemberType: strPtr(before.MemberType),
+			Status:     int64Ptr(before.Status),
+		}),
+		"L-R11-1:no-op update 必须 nil,不得被 ErrUpdateConflict 之类误报")
+
+	after, err := svcCtx.SysProductMemberModel.FindOne(ctx, sp.mId)
+	require.NoError(t, err)
+	assert.Equal(t, before.MemberType, after.MemberType)
+	assert.Equal(t, before.Status, after.Status)
+	assert.Equal(t, before.UpdateTime, after.UpdateTime,
+		"L-R11-1 强化:Logic 在 no-op 时应直接 return nil,不进事务,"+
+			"updateTime 不应被推进;推进即说明 Logic 仍然走了一次冗余 UPDATE")
+}

+ 5 - 49
internal/logic/pub/refreshTokenLogic.go

@@ -81,63 +81,19 @@ func (l *RefreshTokenLogic) RefreshToken(req *types.RefreshTokenReq) (resp *type
 		}
 		}
 	}
 	}
 
 
-	// 审计 M-3:把签名放在 CAS 之前,让"签名失败"不再污染 tokenVersion。原顺序是
-	//   CAS → Clean → 签 access → 签 refresh
-	// 一旦签名失败 tokenVersion 已+1,但客户端没收到新 refreshToken,下一次带旧 version 来
-	// 会被 "登录状态已失效" 踢掉,变成"签名 bug → 用户被强制登出"的放大效应。新顺序:
-	//   试签 access → 试签 refresh → CAS → Clean
-	// 签名走不通直接 500,DB/缓存都不动;CAS 赢家才推进 tokenVersion 并 Clean 缓存。
-	predictedVersion := claims.TokenVersion + 1
-
-	accessToken, err := authHelper.GenerateAccessToken(
-		l.svcCtx.Config.Auth.AccessSecret,
-		l.svcCtx.Config.Auth.AccessExpire,
-		ud.UserId, ud.Username, ud.ProductCode, ud.MemberType, predictedVersion,
-	)
-	if err != nil {
-		return nil, err
-	}
-
-	newRefreshToken, err := authHelper.GenerateRefreshTokenWithExpiry(
-		l.svcCtx.Config.Auth.RefreshSecret,
-		claims.ExpiresAt.Time,
-		ud.UserId, ud.ProductCode, predictedVersion,
-	)
-	if err != nil {
-		return nil, err
-	}
-
-	newVersion, err := l.svcCtx.SysUserModel.IncrementTokenVersionIfMatch(l.ctx, claims.UserId, ud.Username, claims.TokenVersion)
+	// 审计 L-R11-5:把"试签 → CAS → Clean → forensic 比对"收敛进 authHelper.RotateRefreshToken;
+	// HTTP / gRPC 两条路径共享同一段 tokenVersion 语义,未来任何 CAS/签名链契约变动只改一处。
+	tokens, err := authHelper.RotateRefreshToken(l.ctx, l.svcCtx, claims, ud)
 	if err != nil {
 	if err != nil {
 		if errors.Is(err, userModel.ErrTokenVersionMismatch) {
 		if errors.Is(err, userModel.ErrTokenVersionMismatch) {
 			return nil, response.ErrUnauthorized("登录状态已失效,请重新登录")
 			return nil, response.ErrUnauthorized("登录状态已失效,请重新登录")
 		}
 		}
 		return nil, err
 		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),
-			logx.Field("claimed", claims.TokenVersion),
-			logx.Field("predicted", predictedVersion),
-			logx.Field("actual", newVersion),
-		)
-		return nil, response.ErrUnauthorized("登录状态已失效,请重新登录")
-	}
-	l.svcCtx.UserDetailsLoader.Clean(l.ctx, claims.UserId)
 
 
 	return &types.LoginResp{
 	return &types.LoginResp{
-		AccessToken:  accessToken,
-		RefreshToken: newRefreshToken,
+		AccessToken:  tokens.AccessToken,
+		RefreshToken: tokens.RefreshToken,
 		Expires:      time.Now().Unix() + l.svcCtx.Config.Auth.AccessExpire,
 		Expires:      time.Now().Unix() + l.svcCtx.Config.Auth.AccessExpire,
 		UserInfo: types.UserInfo{
 		UserInfo: types.UserInfo{
 			UserId:             ud.UserId,
 			UserId:             ud.UserId,

+ 155 - 0
internal/logic/pub/syncPermsCleanByProduct_r11_4_audit_test.go

@@ -0,0 +1,155 @@
+package pub
+
+import (
+	"context"
+	"fmt"
+	"testing"
+
+	"perms-system-server/internal/testutil"
+
+	"github.com/stretchr/testify/assert"
+	"github.com/stretchr/testify/require"
+	"github.com/zeromicro/go-zero/core/stores/redis"
+)
+
+// ---------------------------------------------------------------------------
+// 覆盖目标:审计 L-R11-4 —— SyncPerms 纯新增(added>0 && updated==0 && disabled==0)
+// 不触发 UserDetailsLoader.CleanByProduct,避免把该产品下所有在线用户的 UD 缓存集体清空
+// 造成雪崩;updated/disabled 任一 > 0 时仍必须清。
+//
+// 用 Redis 层面的 productIndexKey(`<prefix>:ud:idx:p:<productCode>`)做间接观测:
+//   - 测试前手工 SAdd 一个合成 cacheKey 到索引集合,建立"被 CleanByProduct 吃掉"的探针;
+//   - 跑 ExecuteSyncPerms;
+//   - 若 CleanByProduct 被调用,索引集合会被 SmembersCtx -> DelCtx 一并清空,Exists 返 0;
+//   - 若未调用,合成 key 仍然在集合里,Exists 返 1。
+// ---------------------------------------------------------------------------
+
+func primeProductIndex(t *testing.T, rds *redis.Redis, cachePrefix, productCode string) string {
+	t.Helper()
+	idxKey := fmt.Sprintf("%s:ud:idx:p:%s", cachePrefix, productCode)
+	canary := fmt.Sprintf("%s:ud:probe:%s", cachePrefix, testutil.UniqueId())
+	_, err := rds.Sadd(idxKey, canary)
+	require.NoError(t, err, "SAdd 到 productIndexKey 失败,Redis 不可用,测试前置条件失败")
+	members, err := rds.Smembers(idxKey)
+	require.NoError(t, err)
+	require.Contains(t, members, canary, "primeProductIndex: canary 必须先出现在集合里")
+	return idxKey
+}
+
+// TC-1064: L-R11-4 —— 纯新增不触发 CleanByProduct
+func TestSyncPerms_PureAddDoesNotTriggerCleanByProduct(t *testing.T) {
+	ctx := context.Background()
+	svcCtx := newTestSvcCtx()
+	cfg := testutil.GetTestConfig()
+	rds := redis.MustNewRedis(cfg.CacheRedis.Nodes[0].RedisConf)
+	conn := testutil.GetTestSqlConn()
+
+	pc := testutil.UniqueId()
+	appKey := testutil.UniqueId()
+	appSecret := testutil.UniqueId()
+	_, cleanProduct := insertSyncTestProduct(t, ctx, pc, appKey, appSecret, 1)
+	t.Cleanup(cleanProduct)
+	t.Cleanup(func() { testutil.CleanTableByField(ctx, conn, "`sys_perm`", "productCode", pc) })
+
+	idxKey := primeProductIndex(t, rds, cfg.CacheRedis.KeyPrefix, pc)
+	t.Cleanup(func() { _, _ = rds.Del(idxKey) })
+
+	result, err := ExecuteSyncPerms(ctx, svcCtx, appKey, appSecret, []SyncPermItem{
+		{Code: "r11_4_add_a", Name: "A"},
+		{Code: "r11_4_add_b", Name: "B"},
+		{Code: "r11_4_add_c", Name: "C"},
+	})
+	require.NoError(t, err)
+	require.NotNil(t, result)
+	assert.Equal(t, int64(3), result.Added)
+	assert.Equal(t, int64(0), result.Updated)
+	assert.Equal(t, int64(0), result.Disabled)
+
+	// 纯新增路径:CleanByProduct 不得被调用,索引集合必须仍保留 canary。
+	exists, err := rds.Exists(idxKey)
+	require.NoError(t, err)
+	assert.True(t, exists,
+		"L-R11-4:added=3 / updated=0 / disabled=0 属于纯新增,不得触发 CleanByProduct;"+
+			"productIndexKey 若被删除说明 SyncPerms 仍在走全产品清缓存路径,回归:会把该产品"+
+			"所有在线用户下一次请求同时打穿回 DB")
+}
+
+// TC-1065: L-R11-4 —— updated > 0 时必须触发 CleanByProduct
+func TestSyncPerms_UpdateTriggersCleanByProduct(t *testing.T) {
+	ctx := context.Background()
+	svcCtx := newTestSvcCtx()
+	cfg := testutil.GetTestConfig()
+	rds := redis.MustNewRedis(cfg.CacheRedis.Nodes[0].RedisConf)
+	conn := testutil.GetTestSqlConn()
+
+	pc := testutil.UniqueId()
+	appKey := testutil.UniqueId()
+	appSecret := testutil.UniqueId()
+	_, cleanProduct := insertSyncTestProduct(t, ctx, pc, appKey, appSecret, 1)
+	t.Cleanup(cleanProduct)
+	t.Cleanup(func() { testutil.CleanTableByField(ctx, conn, "`sys_perm`", "productCode", pc) })
+
+	// 第一次同步:纯新增,为随后的 update 打底;这次不触发 Clean,CleanByProduct 仍然可被后续触发。
+	_, err := ExecuteSyncPerms(ctx, svcCtx, appKey, appSecret, []SyncPermItem{
+		{Code: "r11_4_upd", Name: "OldName"},
+	})
+	require.NoError(t, err)
+
+	idxKey := primeProductIndex(t, rds, cfg.CacheRedis.KeyPrefix, pc)
+	t.Cleanup(func() { _, _ = rds.Del(idxKey) })
+
+	// 第二次同步:同一 Code 改 Name → updated=1。
+	result, err := ExecuteSyncPerms(ctx, svcCtx, appKey, appSecret, []SyncPermItem{
+		{Code: "r11_4_upd", Name: "NewName"},
+	})
+	require.NoError(t, err)
+	require.NotNil(t, result)
+	assert.Equal(t, int64(1), result.Updated,
+		"前置:同名 Code 改 Name 必须 updated=1,否则后续断言失去意义")
+
+	exists, err := rds.Exists(idxKey)
+	require.NoError(t, err)
+	assert.False(t, exists,
+		"L-R11-4:updated>0 必须触发 CleanByProduct;若 canary 仍在,说明 Logic 把"+
+			"updated 情况也误归入'纯新增'分支,已存在 UD 缓存中的旧 perms 将长期对外返回")
+}
+
+// TC-1066: L-R11-4 —— disabled > 0 时必须触发 CleanByProduct
+func TestSyncPerms_DisableTriggersCleanByProduct(t *testing.T) {
+	ctx := context.Background()
+	svcCtx := newTestSvcCtx()
+	cfg := testutil.GetTestConfig()
+	rds := redis.MustNewRedis(cfg.CacheRedis.Nodes[0].RedisConf)
+	conn := testutil.GetTestSqlConn()
+
+	pc := testutil.UniqueId()
+	appKey := testutil.UniqueId()
+	appSecret := testutil.UniqueId()
+	_, cleanProduct := insertSyncTestProduct(t, ctx, pc, appKey, appSecret, 1)
+	t.Cleanup(cleanProduct)
+	t.Cleanup(func() { testutil.CleanTableByField(ctx, conn, "`sys_perm`", "productCode", pc) })
+
+	// 先注入两个 perm。
+	_, err := ExecuteSyncPerms(ctx, svcCtx, appKey, appSecret, []SyncPermItem{
+		{Code: "r11_4_keep", Name: "K"},
+		{Code: "r11_4_drop", Name: "D"},
+	})
+	require.NoError(t, err)
+
+	idxKey := primeProductIndex(t, rds, cfg.CacheRedis.KeyPrefix, pc)
+	t.Cleanup(func() { _, _ = rds.Del(idxKey) })
+
+	// 第二次只同步 r11_4_keep,r11_4_drop 会被 DisableNotInCodesWithTx 置 disabled。
+	result, err := ExecuteSyncPerms(ctx, svcCtx, appKey, appSecret, []SyncPermItem{
+		{Code: "r11_4_keep", Name: "K"},
+	})
+	require.NoError(t, err)
+	assert.Equal(t, int64(1), result.Disabled,
+		"前置:第二次只同步 keep,drop 必须被 disabled=1")
+
+	exists, err := rds.Exists(idxKey)
+	require.NoError(t, err)
+	assert.False(t, exists,
+		"L-R11-4:disabled>0 必须触发 CleanByProduct,否则已缓存的 UD.perms 里仍挂着"+
+			"已禁用权限,权限网关会把不再有效的权限判为 allow,产生权限残留")
+}

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

@@ -159,7 +159,15 @@ func ExecuteSyncPerms(ctx context.Context, svcCtx *svc.ServiceContext, appKey, a
 		return nil, &SyncPermsError{Code: 500, Message: "同步权限事务失败"}
 		return nil, &SyncPermsError{Code: 500, Message: "同步权限事务失败"}
 	}
 	}
 
 
-	if added > 0 || updated > 0 || disabled > 0 {
+	// 审计 L-R11-4:纯新增(added>0 && updated==0 && disabled==0)时不需要清 CleanByProduct。
+	// 新增的 perm 在本次 SyncPerms 之前**不可能**已经被绑定到任何 role(sys_role_perm 要求 permId
+	// 引用现有 sys_perm 行,新 permId 在事务提交之前对任何外部事务都不可见,也就没有既存绑定)。
+	// loadPerms 对当前全体 user 的计算结果与上次结果完全一致,集体把该产品缓存清空只会把大量
+	// 在线用户的下一次请求打穿回 DB(SyncPerms 是日数十次的高频事件,每次发版都会触发)。
+	// updated / disabled 任一 > 0 才代表"已有 perm 的启用状态或内容发生变化",那时才必须失效。
+	// 进一步的"按受影响角色/用户做精准失效"留给后续专项(audit L-R11-4 option-1),本轮先消除
+	// 最频繁的"纯新增也全清"这条误伤路径。
+	if updated > 0 || disabled > 0 {
 		svcCtx.UserDetailsLoader.CleanByProduct(ctx, product.Code)
 		svcCtx.UserDetailsLoader.CleanByProduct(ctx, product.Code)
 	}
 	}
 
 

+ 43 - 5
internal/logic/user/updateUserLogic.go

@@ -15,6 +15,7 @@ import (
 	"perms-system-server/internal/util"
 	"perms-system-server/internal/util"
 
 
 	"github.com/zeromicro/go-zero/core/logx"
 	"github.com/zeromicro/go-zero/core/logx"
+	"github.com/zeromicro/go-zero/core/stores/sqlx"
 )
 )
 
 
 type UpdateUserLogic struct {
 type UpdateUserLogic struct {
@@ -150,11 +151,48 @@ func (l *UpdateUserLogic) UpdateUser(req *types.UpdateUserReq) error {
 	if statusChanged {
 	if statusChanged {
 		newStatus = req.Status
 		newStatus = req.Status
 	}
 	}
-	if err := l.svcCtx.SysUserModel.UpdateProfile(
-		l.ctx, req.Id, user.Username,
-		nickname, email, phone, remark, deptId,
-		newStatus, statusChanged, user.UpdateTime,
-	); err != nil {
+	// 审计 M-R11-3:改 deptId 到 `newDeptId>0` 时必须把 UPDATE 收敛进事务,并在同事务内对目标
+	// sys_dept[newDeptId] 加 S 锁——这样并发 DeleteDept 持有 sys_dept[X] 的 X 锁,会被 S 锁阻塞,
+	// 等本事务提交后 DeleteDept 重读 `sys_user WHERE deptId=X FOR SHARE` 就能看到新行并拒绝删除,
+	// 闭合"两侧都读不到对方提交 → 各自提交 → orphan deptId"的 write skew。
+	// 其余分支(只改其它字段 / 移出部门 deptId=0)无 write skew 风险,沿用非事务的 UpdateProfile。
+	needDeptShareLock := req.DeptId != nil && *req.DeptId > 0 && *req.DeptId != user.DeptId
+
+	if !needDeptShareLock {
+		if err := l.svcCtx.SysUserModel.UpdateProfile(
+			l.ctx, req.Id, user.Username,
+			nickname, email, phone, remark, deptId,
+			newStatus, statusChanged, user.UpdateTime,
+		); err != nil {
+			if errors.Is(err, userModel.ErrUpdateConflict) {
+				return response.ErrConflict("数据已被其他操作修改,请刷新后重试")
+			}
+			return err
+		}
+		l.svcCtx.UserDetailsLoader.Clean(l.ctx, req.Id)
+		return nil
+	}
+
+	targetDeptId := *req.DeptId
+	if err := l.svcCtx.SysUserModel.TransactCtx(l.ctx, func(ctx context.Context, session sqlx.Session) error {
+		// 事务内 S 锁目标 dept,保证 DeleteDept 的 X 锁被阻塞;顺带在事务内复核 Status。
+		// 上面非事务的 FindOne 已经校过一遍,这里是"在锁生效后的一致性视图"下的最终校验。
+		lockedDept, err := l.svcCtx.SysDeptModel.FindOneForShareTx(ctx, session, targetDeptId)
+		if err != nil {
+			if errors.Is(err, sqlx.ErrNotFound) {
+				return response.ErrBadRequest("部门不存在")
+			}
+			return err
+		}
+		if lockedDept.Status != consts.StatusEnabled {
+			return response.ErrBadRequest("目标部门已停用")
+		}
+		return l.svcCtx.SysUserModel.UpdateProfileWithTx(
+			ctx, session, req.Id, user.Username,
+			nickname, email, phone, remark, deptId,
+			newStatus, statusChanged, user.UpdateTime,
+		)
+	}); err != nil {
 		if errors.Is(err, userModel.ErrUpdateConflict) {
 		if errors.Is(err, userModel.ErrUpdateConflict) {
 			return response.ErrConflict("数据已被其他操作修改,请刷新后重试")
 			return response.ErrConflict("数据已被其他操作修改,请刷新后重试")
 		}
 		}

+ 1 - 1
internal/logic/user/updateUserStatusLogic.go

@@ -55,7 +55,7 @@ func (l *UpdateUserStatusLogic) UpdateUserStatus(req *types.UpdateUserStatusReq)
 
 
 	// 审计 L-N4:把 FindOne 拿到的 UpdateTime 作为乐观锁,避免两个 admin 并发冻结/解冻时
 	// 审计 L-N4:把 FindOne 拿到的 UpdateTime 作为乐观锁,避免两个 admin 并发冻结/解冻时
 	// last-write-wins,被连续 +2 tokenVersion、刚解冻又被踢下线等现象。
 	// last-write-wins,被连续 +2 tokenVersion、刚解冻又被踢下线等现象。
-	if err := l.svcCtx.SysUserModel.UpdateStatus(l.ctx, req.Id, req.Status, user.UpdateTime); err != nil {
+	if err := l.svcCtx.SysUserModel.UpdateStatus(l.ctx, req.Id, user.Username, req.Status, user.UpdateTime); err != nil {
 		if errors.Is(err, userModel.ErrUpdateConflict) {
 		if errors.Is(err, userModel.ErrUpdateConflict) {
 			return response.ErrConflict("数据已被其他操作修改,请刷新后重试")
 			return response.ErrConflict("数据已被其他操作修改,请刷新后重试")
 		}
 		}

+ 3 - 3
internal/logic/user/updateUserStatusOptLock_audit_test.go

@@ -44,7 +44,7 @@ func TestUpdateUserStatus_LN4_OptimisticLockConflictReturns409(t *testing.T) {
 	// sys_user.updateTime 精度到秒,必须 sleep 1.1s 保证 updateTime 严格推进。
 	// sys_user.updateTime 精度到秒,必须 sleep 1.1s 保证 updateTime 严格推进。
 	time.Sleep(1100 * time.Millisecond)
 	time.Sleep(1100 * time.Millisecond)
 	require.NoError(t,
 	require.NoError(t,
-		svcCtx.SysUserModel.UpdateStatus(bootstrap, userId, consts.StatusDisabled, orig.UpdateTime),
+		svcCtx.SysUserModel.UpdateStatus(bootstrap, userId, username, consts.StatusDisabled, orig.UpdateTime),
 		"他人第一次冻结操作必须成功,作为对照")
 		"他人第一次冻结操作必须成功,作为对照")
 
 
 	// 刷新后的 DB 记录:状态 = 2,updateTime 已推进
 	// 刷新后的 DB 记录:状态 = 2,updateTime 已推进
@@ -64,7 +64,7 @@ func TestUpdateUserStatus_LN4_OptimisticLockConflictReturns409(t *testing.T) {
 	// 正常场景 409 文案。
 	// 正常场景 409 文案。
 
 
 	// (1) Model 层直接断言 CAS 失败:传入 orig.UpdateTime(已被他人覆盖)必须 ErrUpdateConflict。
 	// (1) Model 层直接断言 CAS 失败:传入 orig.UpdateTime(已被他人覆盖)必须 ErrUpdateConflict。
-	errConf := svcCtx.SysUserModel.UpdateStatus(bootstrap, userId, consts.StatusEnabled, orig.UpdateTime)
+	errConf := svcCtx.SysUserModel.UpdateStatus(bootstrap, userId, username, consts.StatusEnabled, orig.UpdateTime)
 	require.Error(t, errConf)
 	require.Error(t, errConf)
 	// 这里拿到的是 ErrUpdateConflict;Logic 层负责包装成 409。
 	// 这里拿到的是 ErrUpdateConflict;Logic 层负责包装成 409。
 	require.Contains(t, errConf.Error(), "conflict")
 	require.Contains(t, errConf.Error(), "conflict")
@@ -99,7 +99,7 @@ func TestUpdateUserStatus_LN4_ConflictMappedTo409Message(t *testing.T) {
 	// 直接模拟:调用 UpdateStatus 传 0 作为 expectedUpdateTime(一定和真实 updateTime 不同),
 	// 直接模拟:调用 UpdateStatus 传 0 作为 expectedUpdateTime(一定和真实 updateTime 不同),
 	// 模型层必然 ErrUpdateConflict;Logic 层借同一套响应映射把它暴给上层 409。
 	// 模型层必然 ErrUpdateConflict;Logic 层借同一套响应映射把它暴给上层 409。
 	// 这里我们借 model 层手工包一次 response 映射来对齐 Logic 的行为契约。
 	// 这里我们借 model 层手工包一次 response 映射来对齐 Logic 的行为契约。
-	err := svcCtx.SysUserModel.UpdateStatus(bootstrap, userId, consts.StatusDisabled, 0)
+	err := svcCtx.SysUserModel.UpdateStatus(bootstrap, userId, username, consts.StatusDisabled, 0)
 	require.Error(t, err)
 	require.Error(t, err)
 
 
 	// 映射与 updateUserStatusLogic 中的分支一致:ErrUpdateConflict → 409
 	// 映射与 updateUserStatusLogic 中的分支一致:ErrUpdateConflict → 409

+ 192 - 0
internal/logic/user/updateUserWriteSkew_audit_test.go

@@ -0,0 +1,192 @@
+package user
+
+import (
+	"context"
+	"errors"
+	"sync"
+	"sync/atomic"
+	"testing"
+
+	deptLogic "perms-system-server/internal/logic/dept"
+	"perms-system-server/internal/response"
+	"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"
+)
+
+// ---------------------------------------------------------------------------
+// 覆盖目标:审计 M-R11-3 —— UpdateUser 把 deptId 调入 targetDept 与 DeleteDept 并发执行时,
+// 绝不能同时成功(否则会出现 "sys_user.deptId 指向已被删除的部门行" 的 orphan 数据)。
+//
+// 修复前的 write skew:
+//   T1 DeleteDept 事务:SELECT id FROM sys_dept WHERE id=X FOR UPDATE
+//                      → SELECT id FROM sys_user WHERE deptId=X FOR SHARE —— 空集
+//                      → DELETE sys_dept[X]
+//   T2 UpdateUser       :UPDATE sys_user SET deptId=X WHERE id=U —— 无锁同时进行
+//   两边各自提交后,U 的 deptId 指向了已被删除的 X。
+//
+// 修复后(M-R11-3):
+//   UpdateUser 在事务内 `SELECT ... LOCK IN SHARE MODE` sys_dept[X],
+//   DeleteDept 在事务内 `SELECT ... FOR UPDATE`    sys_dept[X] —— S vs X 互斥,
+//   后到者必被前者阻塞。先到者提交后,DeleteDept 重读 sys_user WHERE deptId=X FOR SHARE
+//   能看到 UpdateUser 提交的新成员,转为 400;先到的是 DeleteDept,则 UpdateUser 的
+//   S 锁获取会卡住等待 DeleteDept 的 X 锁,DeleteDept 提交后 sys_dept[X] 消失,
+//   UpdateUser 的 FindOneForShareTx 返回 ErrNotFound → 400 "部门不存在"。
+//
+// 这里跑真实 MySQL 事务,对"不可能出现 orphan"做闭环断言。
+// ---------------------------------------------------------------------------
+
+// TC-1049: M-R11-3 —— UpdateUser 调入 X 与 DeleteDept(X) 并发:两者互斥,结果必须自洽
+//   - 要么 UpdateUser 成功 + DeleteDept 收到 400「该部门下仍有关联用户」(dept 仍在、user.deptId 指向 dept);
+//   - 要么 DeleteDept 成功 + UpdateUser 收到 400「部门不存在」(dept 已删、user.deptId 未被改到已删 dept)。
+// 严禁同时成功,也严禁同时失败。DB 终态必须自洽。
+func TestUpdateUser_DeptIdSwitch_VsDeleteDept_NoWriteSkew(t *testing.T) {
+	bootstrap := ctxhelper.SuperAdminCtx()
+	svcCtx := svc.NewServiceContext(testutil.GetTestConfig())
+	conn := testutil.GetTestSqlConn()
+
+	// 构造:用户 U 在 deptA,新候选部门 deptX 空(无子部门 + 无关联用户,满足 DeleteDept 可删条件)。
+	deptAId := insertTestDeptForScope(t, bootstrap, svcCtx, "m113_deptA", "/3100/")
+	deptXId := insertTestDeptForScope(t, bootstrap, svcCtx, "m113_deptX", "/3200/")
+	targetId := insertTestUserWithDept(t, bootstrap, "m113_user", deptAId)
+	mId := insertTestMember(t, svcCtx, "test_product", targetId)
+	t.Cleanup(func() {
+		testutil.CleanTable(bootstrap, conn, "`sys_product_member`", mId)
+		testutil.CleanTable(bootstrap, conn, "`sys_user`", targetId)
+		testutil.CleanTable(bootstrap, conn, "`sys_dept`", deptAId, deptXId)
+	})
+
+	// 超管身份用于 UpdateUser / DeleteDept。
+	superCtx := ctxhelper.SuperAdminCtx()
+
+	// 两个 goroutine 并发:
+	//   G1: UpdateUser targetId.deptId = deptXId
+	//   G2: DeleteDept deptXId
+	var (
+		wg         sync.WaitGroup
+		upErr      atomic.Value
+		upOK       atomic.Bool
+		delErr     atomic.Value
+		delOK      atomic.Bool
+		unexpected atomic.Value
+	)
+
+	start := make(chan struct{})
+	wg.Add(2)
+	go func() {
+		defer wg.Done()
+		<-start
+		err := NewUpdateUserLogic(superCtx, svcCtx).UpdateUser(&types.UpdateUserReq{
+			Id:     targetId,
+			DeptId: &deptXId,
+		})
+		if err == nil {
+			upOK.Store(true)
+		} else {
+			upErr.Store(err)
+		}
+	}()
+	go func() {
+		defer wg.Done()
+		<-start
+		err := deptLogic.NewDeleteDeptLogic(superCtx, svcCtx).DeleteDept(&types.DeleteDeptReq{
+			Id: deptXId,
+		})
+		if err == nil {
+			delOK.Store(true)
+		} else {
+			delErr.Store(err)
+		}
+	}()
+	close(start)
+	wg.Wait()
+
+	// 允许的结果共两种:
+	//   A) upOK && !delOK:user.deptId==deptX,sys_dept[deptX] 仍在,DeleteDept 收 400
+	//   B) !upOK && delOK:user.deptId==deptA(未动),sys_dept[deptX] 已删,UpdateUser 收 400
+	// 绝不能同时成功(write skew)。DB 终态须自洽。
+	u, err := svcCtx.SysUserModel.FindOne(context.Background(), targetId)
+	require.NoError(t, err)
+
+	// dept X 存在性:**绕过 go-zero 的 WithCache**,直接从 MySQL 查真相,避免 UpdateUser
+	// 在 FindOne 时预热的缓存把 DeleteDept 的真实删除"遮住"。
+	var deptCount int64
+	require.NoError(t,
+		conn.QueryRowCtx(context.Background(), &deptCount,
+			"SELECT COUNT(*) FROM `sys_dept` WHERE `id` = ?", deptXId))
+	deptStillThere := deptCount > 0
+
+	switch {
+	case upOK.Load() && !delOK.Load():
+		assert.Equal(t, deptXId, u.DeptId,
+			"M-R11-3:UpdateUser 胜出,user.deptId 必须为 deptX")
+		assert.True(t, deptStillThere,
+			"M-R11-3:UpdateUser 胜出后 deptX 必须仍存在,否则存在 orphan 引用")
+		var ce *response.CodeError
+		require.NotNil(t, delErr.Load(), "DeleteDept 应返回 400 解释失败原因")
+		if errors.As(delErr.Load().(error), &ce) {
+			assert.Equal(t, 400, ce.Code(),
+				"M-R11-3:DeleteDept 看到新 user 后必须 400'该部门下仍有关联用户'")
+			assert.Contains(t, ce.Error(), "关联用户")
+		}
+	case !upOK.Load() && delOK.Load():
+		assert.Equal(t, deptAId, u.DeptId,
+			"M-R11-3:DeleteDept 胜出,user.deptId 必须保持为 deptA(UpdateUser 被拒绝,不得写入)")
+		assert.False(t, deptStillThere,
+			"M-R11-3:DeleteDept 胜出后 deptX 必须已被删除")
+		var ce *response.CodeError
+		require.NotNil(t, upErr.Load(), "UpdateUser 应返回 400 解释失败原因")
+		if errors.As(upErr.Load().(error), &ce) {
+			assert.Equal(t, 400, ce.Code(),
+				"M-R11-3:UpdateUser 发现目标 dept 已消失必须 400'部门不存在'")
+			assert.Contains(t, ce.Error(), "部门不存在")
+		}
+	case upOK.Load() && delOK.Load():
+		t.Fatalf("M-R11-3 回归:UpdateUser + DeleteDept **同时成功** —— write skew 未被闭合。" +
+			"DB 现在持有 user.deptId 指向已被删 dept 的 orphan 数据。")
+	case !upOK.Load() && !delOK.Load():
+		unexpected.Store(struct{ up, del error }{upErr.Load().(error), delErr.Load().(error)})
+		t.Fatalf("M-R11-3:两端都失败是不期望的调度:upErr=%v delErr=%v", upErr.Load(), delErr.Load())
+	}
+}
+
+// TC-1050: M-R11-3 —— UpdateUser 只改 deptId 之外的字段(或 deptId=0)时不进事务(性能与锁范围约束)
+// 这是修复的**对偶契约**:避免 DEV 未来不分 case 把所有 UpdateProfile 都塞进事务 / 或反之。
+// 用"目标部门不存在但仅改 Nickname"的 case 证明:非 deptId 变更路径不需要 FindOneForShareTx,
+// 且走的是 UpdateProfile(非事务)。该契约只能以"只调昵称也能成功"的正向场景间接证实:
+func TestUpdateUser_OnlyNicknameUpdate_DoesNotRequireDeptShareLock(t *testing.T) {
+	bootstrap := ctxhelper.SuperAdminCtx()
+	svcCtx := svc.NewServiceContext(testutil.GetTestConfig())
+	conn := testutil.GetTestSqlConn()
+
+	deptAId := insertTestDeptForScope(t, bootstrap, svcCtx, "m113_onlyNick", "/3300/")
+	targetId := insertTestUserWithDept(t, bootstrap, "m113_onlyNick", deptAId)
+	mId := insertTestMember(t, svcCtx, "test_product", targetId)
+	t.Cleanup(func() {
+		testutil.CleanTable(bootstrap, conn, "`sys_product_member`", mId)
+		testutil.CleanTable(bootstrap, conn, "`sys_user`", targetId)
+		testutil.CleanTable(bootstrap, conn, "`sys_dept`", deptAId)
+	})
+
+	newNick := "only_nick_mutate"
+	superCtx := ctxhelper.SuperAdminCtx()
+	require.NoError(t,
+		NewUpdateUserLogic(superCtx, svcCtx).UpdateUser(&types.UpdateUserReq{
+			Id: targetId, Nickname: &newNick,
+		}),
+		"M-R11-3 对偶:只改昵称不应走事务路径(若走事务会无谓扩大锁范围)")
+
+	u, err := svcCtx.SysUserModel.FindOne(context.Background(), targetId)
+	require.NoError(t, err)
+	assert.Equal(t, newNick, u.Nickname)
+	assert.Equal(t, deptAId, u.DeptId, "deptId 未变")
+}
+
+// 备注:原本想写一条"deptId 从 A 改到 A 不走事务路径"的对偶用例,但 MySQL 对"所有字段都
+// 不变"的 UPDATE 返回 RowsAffected=0,UpdateProfile 会把它升格为 ErrUpdateConflict → 409。
+// 这是底层驱动/引擎层的 side-effect,非 M-R11-3 关心的契约。若要验证该对偶,请同时改一个
+// 真实字段(参见上面的 Nickname 用例)。

+ 15 - 0
internal/model/dept/sysDeptModel.go

@@ -19,6 +19,12 @@ type (
 		sysDeptModel
 		sysDeptModel
 		FindAll(ctx context.Context) ([]*SysDept, error)
 		FindAll(ctx context.Context) ([]*SysDept, error)
 		UpdateWithOptLock(ctx context.Context, data *SysDept, expectedUpdateTime int64) error
 		UpdateWithOptLock(ctx context.Context, data *SysDept, expectedUpdateTime int64) error
+		// FindOneForShareTx 在当前事务里对 sys_dept 目标行加 S 锁(SELECT ... LOCK IN SHARE MODE),
+		// 用于"UpdateUser 改 deptId 到 X"与"DeleteDept 删除 X"之间的 write skew 闭环(审计 M-R11-3)。
+		// DeleteDept 会先对 sys_dept[X] 取 X 锁——被本 S 锁阻塞;等 UpdateUser 提交后 DeleteDept 再
+		// 去 FOR SHARE sys_user WHERE deptId=X 时能看到新行,改删除为 400,整链路不产生 orphan deptId。
+		// 本方法不走缓存,必须在 TransactCtx / Session 下调用。
+		FindOneForShareTx(ctx context.Context, session sqlx.Session, id int64) (*SysDept, error)
 	}
 	}
 
 
 	customSysDeptModel struct {
 	customSysDeptModel struct {
@@ -41,6 +47,15 @@ func (m *customSysDeptModel) FindAll(ctx context.Context) ([]*SysDept, error) {
 	return list, nil
 	return list, nil
 }
 }
 
 
+func (m *customSysDeptModel) FindOneForShareTx(ctx context.Context, session sqlx.Session, id int64) (*SysDept, error) {
+	var data SysDept
+	query := fmt.Sprintf("SELECT %s FROM %s WHERE `id` = ? LIMIT 1 LOCK IN SHARE MODE", sysDeptRows, m.table)
+	if err := session.QueryRowCtx(ctx, &data, query, id); err != nil {
+		return nil, err
+	}
+	return &data, nil
+}
+
 func (m *customSysDeptModel) UpdateWithOptLock(ctx context.Context, data *SysDept, expectedUpdateTime int64) error {
 func (m *customSysDeptModel) UpdateWithOptLock(ctx context.Context, data *SysDept, expectedUpdateTime int64) error {
 	sysDeptIdKey := fmt.Sprintf("%s%v", cacheSysDeptIdPrefix, data.Id)
 	sysDeptIdKey := fmt.Sprintf("%s%v", cacheSysDeptIdPrefix, data.Id)
 	res, err := m.ExecCtx(ctx, func(ctx context.Context, conn sqlx.SqlConn) (sql.Result, error) {
 	res, err := m.ExecCtx(ctx, func(ctx context.Context, conn sqlx.SqlConn) (sql.Result, error) {

+ 10 - 1
internal/model/perm/sysPermModel.go

@@ -120,7 +120,16 @@ func (m *customSysPermModel) DisableNotInCodesWithTx(ctx context.Context, sessio
 			sysPermRows, m.table, strings.Join(placeholders, ","))
 			sysPermRows, m.table, strings.Join(placeholders, ","))
 	}
 	}
 
 
-	var affected []*SysPerm
+	// 审计 L-R11-2:本段 SELECT 的唯一用途是构造缓存失效键 —— 只需 id / productCode / code 三列;
+	// 原来的 SELECT %s (全部列) 会把 name/remark/status/createTime/updateTime 等业务字段也一并
+	// 搬回应用层然后丢弃,DisableNotInCodesWithTx 在 SyncPerms 高频场景(一次提交涉及 <1k perm)
+	// 下是纯浪费。改拼一个精简版 findQuery,复用 findArgs。
+	findQuery = strings.Replace(findQuery, sysPermRows, "`id`, `productCode`, `code`", 1)
+	var affected []struct {
+		Id          int64  `db:"id"`
+		ProductCode string `db:"productCode"`
+		Code        string `db:"code"`
+	}
 	if err := session.QueryRowsCtx(ctx, &affected, findQuery+" FOR UPDATE", findArgs...); err != nil {
 	if err := session.QueryRowsCtx(ctx, &affected, findQuery+" FOR UPDATE", findArgs...); err != nil {
 		return 0, err
 		return 0, err
 	}
 	}

+ 14 - 7
internal/model/roleperm/sysRolePermModel.go

@@ -72,7 +72,14 @@ func (m *customSysRolePermModel) FindPermIdsByRoleIds(ctx context.Context, roleI
 	return ids, nil
 	return ids, nil
 }
 }
 
 
-func (m *customSysRolePermModel) buildCacheKeys(list []*SysRolePerm) []string {
+// rolePermKey 仅包含构造缓存键所需的列(审计 L-R11-2)。
+type rolePermKey struct {
+	Id     int64 `db:"id"`
+	RoleId int64 `db:"roleId"`
+	PermId int64 `db:"permId"`
+}
+
+func (m *customSysRolePermModel) buildCacheKeysFromKeys(list []rolePermKey) []string {
 	keys := make([]string, 0, len(list)*2)
 	keys := make([]string, 0, len(list)*2)
 	for _, data := range list {
 	for _, data := range list {
 		keys = append(keys,
 		keys = append(keys,
@@ -84,15 +91,15 @@ func (m *customSysRolePermModel) buildCacheKeys(list []*SysRolePerm) []string {
 }
 }
 
 
 func (m *customSysRolePermModel) DeleteByRoleIdTx(ctx context.Context, session sqlx.Session, roleId int64) error {
 func (m *customSysRolePermModel) DeleteByRoleIdTx(ctx context.Context, session sqlx.Session, roleId int64) error {
-	var list []*SysRolePerm
-	findQuery := fmt.Sprintf("SELECT %s FROM %s WHERE `roleId` = ? FOR UPDATE", sysRolePermRows, m.table)
+	var list []rolePermKey
+	findQuery := fmt.Sprintf("SELECT `id`, `roleId`, `permId` FROM %s WHERE `roleId` = ? FOR UPDATE", m.table)
 	if err := session.QueryRowsCtx(ctx, &list, findQuery, roleId); err != nil {
 	if err := session.QueryRowsCtx(ctx, &list, findQuery, roleId); err != nil {
 		return err
 		return err
 	}
 	}
 	if len(list) == 0 {
 	if len(list) == 0 {
 		return nil
 		return nil
 	}
 	}
-	keys := m.buildCacheKeys(list)
+	keys := m.buildCacheKeysFromKeys(list)
 	_, err := m.ExecCtx(ctx, func(ctx context.Context, conn sqlx.SqlConn) (sql.Result, error) {
 	_, err := m.ExecCtx(ctx, func(ctx context.Context, conn sqlx.SqlConn) (sql.Result, error) {
 		query := fmt.Sprintf("DELETE FROM %s WHERE `roleId` = ?", m.table)
 		query := fmt.Sprintf("DELETE FROM %s WHERE `roleId` = ?", m.table)
 		return session.ExecCtx(ctx, query, roleId)
 		return session.ExecCtx(ctx, query, roleId)
@@ -113,15 +120,15 @@ func (m *customSysRolePermModel) DeleteByRoleIdAndPermIdsTx(ctx context.Context,
 	}
 	}
 	inClause := strings.Join(placeholders, ",")
 	inClause := strings.Join(placeholders, ",")
 
 
-	var list []*SysRolePerm
-	findQuery := fmt.Sprintf("SELECT %s FROM %s WHERE `roleId` = ? AND `permId` IN (%s) FOR UPDATE", sysRolePermRows, m.table, inClause)
+	var list []rolePermKey
+	findQuery := fmt.Sprintf("SELECT `id`, `roleId`, `permId` FROM %s WHERE `roleId` = ? AND `permId` IN (%s) FOR UPDATE", m.table, inClause)
 	if err := session.QueryRowsCtx(ctx, &list, findQuery, args...); err != nil {
 	if err := session.QueryRowsCtx(ctx, &list, findQuery, args...); err != nil {
 		return err
 		return err
 	}
 	}
 	if len(list) == 0 {
 	if len(list) == 0 {
 		return nil
 		return nil
 	}
 	}
-	keys := m.buildCacheKeys(list)
+	keys := m.buildCacheKeysFromKeys(list)
 	_, err := m.ExecCtx(ctx, func(ctx context.Context, conn sqlx.SqlConn) (sql.Result, error) {
 	_, err := m.ExecCtx(ctx, func(ctx context.Context, conn sqlx.SqlConn) (sql.Result, error) {
 		query := fmt.Sprintf("DELETE FROM %s WHERE `roleId` = ? AND `permId` IN (%s)", m.table, inClause)
 		query := fmt.Sprintf("DELETE FROM %s WHERE `roleId` = ? AND `permId` IN (%s)", m.table, inClause)
 		return session.ExecCtx(ctx, query, args...)
 		return session.ExecCtx(ctx, query, args...)

+ 3 - 3
internal/model/user/incrementTokenVersion_audit_test.go

@@ -34,7 +34,7 @@ func TestSysUserModel_IncrementTokenVersion_ReturnedEqualsPersisted(t *testing.T
 	t.Cleanup(func() { testutil.CleanTable(ctx, conn, "`sys_user`", id) })
 	t.Cleanup(func() { testutil.CleanTable(ctx, conn, "`sys_user`", id) })
 
 
 	for expected := int64(8); expected <= 12; expected++ {
 	for expected := int64(8); expected <= 12; expected++ {
-		got, err := m.IncrementTokenVersion(ctx, id)
+		got, err := m.IncrementTokenVersion(ctx, id, username)
 		require.NoError(t, err)
 		require.NoError(t, err)
 		assert.Equal(t, expected, got,
 		assert.Equal(t, expected, got,
 			"IncrementTokenVersion 必须返回 DB 真实递增后的值(H-B:不可再受 stale cache 影响)")
 			"IncrementTokenVersion 必须返回 DB 真实递增后的值(H-B:不可再受 stale cache 影响)")
@@ -71,7 +71,7 @@ func TestSysUserModel_IncrementTokenVersion_InvalidatesCache(t *testing.T) {
 	require.NoError(t, err)
 	require.NoError(t, err)
 	require.Equal(t, int64(0), u0b.TokenVersion)
 	require.Equal(t, int64(0), u0b.TokenVersion)
 
 
-	_, err = m.IncrementTokenVersion(ctx, id)
+	_, err = m.IncrementTokenVersion(ctx, id, username)
 	require.NoError(t, err)
 	require.NoError(t, err)
 
 
 	u1, err := m.FindOne(ctx, id)
 	u1, err := m.FindOne(ctx, id)
@@ -108,7 +108,7 @@ func TestSysUserModel_IncrementTokenVersion_ConcurrentUnique(t *testing.T) {
 		wg.Add(1)
 		wg.Add(1)
 		go func(idx int) {
 		go func(idx int) {
 			defer wg.Done()
 			defer wg.Done()
-			v, e := m.IncrementTokenVersion(ctx, id)
+			v, e := m.IncrementTokenVersion(ctx, id, username)
 			results[idx] = v
 			results[idx] = v
 			errs[idx] = e
 			errs[idx] = e
 		}(i)
 		}(i)

+ 145 - 0
internal/model/user/mr11_2_noInternalFindOne_audit_test.go

@@ -0,0 +1,145 @@
+package user_test
+
+import (
+	"context"
+	"testing"
+	"time"
+
+	"perms-system-server/internal/model/user"
+	"perms-system-server/internal/testutil"
+
+	"github.com/stretchr/testify/assert"
+	"github.com/stretchr/testify/require"
+	"github.com/zeromicro/go-zero/core/stores/redis"
+)
+
+// ---------------------------------------------------------------------------
+// 覆盖目标:审计 M-R11-2 —— UpdateStatus / IncrementTokenVersion 必须用**调用方透传**的
+// `username` 构造缓存失效键,不得再在 Model 内部隐式 FindOne 取真实 username。
+//
+// 直接可观测的契约:
+//   1) 调用方传 "wrongUser"(故意传一个与 DB 实际 username 不一致的值),函数 Del 的
+//      Redis key 必须是 `cache:sysUser:username:wrongUser`(执行后该键消失);
+//   2) DB 实际 username 对应的 `cache:sysUser:username:<real>` 键则不会被 Del
+//      (若仍有内部 FindOne,真实 username 的缓存会被动摇,下一次 FindOneByUsername 会回源 DB,
+//      这里通过预热 + 对比校验来锁死)。
+//
+// 如果 DEV 未来把 Model 层回退成内部 FindOne 取 username,这个用例会立刻红:
+// 要么 wrongUser key 不再被删,要么 realUser key 反而被删。两条契约各自独立失败、
+// 定位极快。
+// ---------------------------------------------------------------------------
+
+func sysUserUsernameCacheKey(username string) string {
+	return testutil.GetTestCachePrefix() + ":cache:sysUser:username:" + username
+}
+
+// TC-1044: M-R11-2 —— UpdateStatus 失效 wrongUser cache,real username cache 不受影响
+func TestSysUserModel_UpdateStatus_UsesSuppliedUsername_NoInternalFindOne(t *testing.T) {
+	ctx := context.Background()
+	m, conn := newModel(t)
+	realUsername := "mr112s_real_" + testutil.UniqueId()
+	wrongUsername := "mr112s_wrong_" + testutil.UniqueId()
+
+	data := newTestSysUser(realUsername, 1)
+	res, err := m.Insert(ctx, data)
+	require.NoError(t, err)
+	id, err := res.LastInsertId()
+	require.NoError(t, err)
+	t.Cleanup(func() { testutil.CleanTable(ctx, conn, m.TableName(), id) })
+
+	// 预热 cache:sysUser:username:<realUsername>(via FindOneByUsername 走 go-zero 的 WithCache)。
+	_, err = m.FindOneByUsername(ctx, realUsername)
+	require.NoError(t, err)
+
+	rds := redis.MustNewRedis(testutil.GetTestConfig().CacheRedis.Nodes[0].RedisConf)
+
+	// 直接往 Redis 里插一条 wrongUser 的桩缓存,供我们观察它是否被 UpdateStatus 失效。
+	// 注意:我们并不关心桩的内容,只关心 key 是否被 Del。
+	wrongKey := sysUserUsernameCacheKey(wrongUsername)
+	realKey := sysUserUsernameCacheKey(realUsername)
+	require.NoError(t, rds.Set(wrongKey, "stub"))
+
+	// 预热后确认 realKey 存在(如果环境脏,用下面的断言兜底;缓存可能是 */null/任意值)。
+	gotReal, err := rds.Get(realKey)
+	require.NoError(t, err)
+	require.NotEmpty(t, gotReal, "FindOneByUsername 未能把 realKey 写入缓存,前置条件失败")
+
+	// 推进 updateTime 以触发 CAS 可成功。sys_user.updateTime 精度到秒。
+	time.Sleep(1100 * time.Millisecond)
+	cur, err := m.FindOne(ctx, id)
+	require.NoError(t, err)
+
+	// 关键:传入故意错位的 username。若 Model 还在内部 FindOne,就会用 realUsername 作失效键,
+	// wrongKey 不会被删;若 Model 已按 M-R11-2 的契约"透传即用",wrongKey 必被删。
+	require.NoError(t,
+		m.UpdateStatus(ctx, id, wrongUsername, 2, cur.UpdateTime),
+		"UpdateStatus 语义上只依赖 id+expectedUpdateTime 做 CAS,username 只用于构造缓存键,不应因错位而失败")
+
+	// 契约 1:wrongKey 必被删
+	gotWrong, _ := rds.Get(wrongKey)
+	assert.Empty(t, gotWrong,
+		"M-R11-2:UpdateStatus 必须用调用方透传的 username 做 Del,wrongKey 必须消失")
+
+	// 契约 2:realKey 依然留存(Model 不知道真 username,不应当去动它)
+	gotRealAfter, err := rds.Get(realKey)
+	require.NoError(t, err)
+	assert.NotEmpty(t, gotRealAfter,
+		"M-R11-2:Model 没有内部 FindOne 获取真 username,因此不应删除 realKey")
+}
+
+// TC-1045: M-R11-2 —— IncrementTokenVersion 同样只删调用方透传的 username key
+func TestSysUserModel_IncrementTokenVersion_UsesSuppliedUsername_NoInternalFindOne(t *testing.T) {
+	ctx := context.Background()
+	m, conn := newModel(t)
+	realUsername := "mr112i_real_" + testutil.UniqueId()
+	wrongUsername := "mr112i_wrong_" + testutil.UniqueId()
+
+	data := newTestSysUser(realUsername, 1)
+	res, err := m.Insert(ctx, data)
+	require.NoError(t, err)
+	id, err := res.LastInsertId()
+	require.NoError(t, err)
+	t.Cleanup(func() { testutil.CleanTable(ctx, conn, m.TableName(), id) })
+
+	_, err = m.FindOneByUsername(ctx, realUsername)
+	require.NoError(t, err)
+
+	rds := redis.MustNewRedis(testutil.GetTestConfig().CacheRedis.Nodes[0].RedisConf)
+	wrongKey := sysUserUsernameCacheKey(wrongUsername)
+	realKey := sysUserUsernameCacheKey(realUsername)
+	require.NoError(t, rds.Set(wrongKey, "stub"))
+
+	// IncrementTokenVersion 不依赖 expectedUpdateTime,直接按 id 更新即可。
+	newV, err := m.IncrementTokenVersion(ctx, id, wrongUsername)
+	require.NoError(t, err)
+	assert.Equal(t, int64(1), newV, "从 0 起递增到 1")
+
+	gotWrong, _ := rds.Get(wrongKey)
+	assert.Empty(t, gotWrong,
+		"M-R11-2:IncrementTokenVersion 必须用透传的 username 做 Del,wrongKey 必须消失")
+
+	gotRealAfter, err := rds.Get(realKey)
+	require.NoError(t, err)
+	assert.NotEmpty(t, gotRealAfter,
+		"M-R11-2:Model 没有内部 FindOne 取真 username,realKey 不应受影响")
+}
+
+// TC-1046: M-R11-2 —— IncrementTokenVersion 用户已被并发删除,返回 ErrUpdateConflict
+// 此契约由 L-R10-3 引入,M-R11-2 下的签名改动不得削弱它:affected=0 仍要 ErrUpdateConflict。
+func TestSysUserModel_IncrementTokenVersion_DeletedRow_StillConflicts(t *testing.T) {
+	ctx := context.Background()
+	m, conn := newModel(t)
+	username := "mr112i_del_" + testutil.UniqueId()
+
+	data := newTestSysUser(username, 1)
+	res, err := m.Insert(ctx, data)
+	require.NoError(t, err)
+	id, err := res.LastInsertId()
+	require.NoError(t, err)
+
+	testutil.CleanTable(ctx, conn, m.TableName(), id)
+
+	_, err = m.IncrementTokenVersion(ctx, id, username)
+	require.ErrorIs(t, err, user.ErrUpdateConflict,
+		"M-R11-2:目标行已被并发删除,IncrementTokenVersion 不得静默返回 tokenVersion=0")
+}

+ 59 - 26
internal/model/user/sysUserModel.go

@@ -28,10 +28,26 @@ type (
 		FindListByProductMembers(ctx context.Context, productCode string, page, pageSize int64) ([]*SysUser, map[int64]string, int64, error)
 		FindListByProductMembers(ctx context.Context, productCode string, page, pageSize int64) ([]*SysUser, map[int64]string, int64, error)
 		FindByIds(ctx context.Context, ids []int64) ([]*SysUser, error)
 		FindByIds(ctx context.Context, ids []int64) ([]*SysUser, error)
 		FindIdsByDeptId(ctx context.Context, deptId int64) ([]int64, error)
 		FindIdsByDeptId(ctx context.Context, deptId int64) ([]int64, error)
+		// UpdateProfile 更新用户资料字段(昵称 / 邮箱 / 手机 / 备注 / 部门 / 状态),username 仅用于
+		// 构造旧缓存键 `cacheSysUserUsernamePrefix` 做失效,**不会**被写入 SET 子句。若未来确实需要
+		// 修改 username,请独立实现 `UpdateUsernameTx`:
+		//   ① 同事务内做 old/new 两份 UsernameKey 的失效;
+		//   ② 捕获 1062 (UNIQUE 冲突) → response.ErrConflict,不得混进本方法的签名(审计 L-R11-3)。
 		UpdateProfile(ctx context.Context, id int64, username string, nickname, email, phone, remark string, deptId, newStatus int64, statusChanged bool, expectedUpdateTime int64) error
 		UpdateProfile(ctx context.Context, id int64, username string, nickname, email, phone, remark string, deptId, newStatus int64, statusChanged bool, expectedUpdateTime int64) error
-		UpdatePassword(ctx context.Context, id int64, password string, mustChangePassword int64) error
-		UpdateStatus(ctx context.Context, id int64, status int64, expectedUpdateTime int64) error
-		IncrementTokenVersion(ctx context.Context, id int64) (int64, error)
+		// UpdateProfileWithTx 与 UpdateProfile 行为等价,但 UPDATE 执行在调用方传入的事务里;
+		// 用于"改 deptId → 同事务内先 FOR SHARE sys_dept 目标行"修复 DeleteDept vs UpdateUser
+		// 的 write skew(审计 M-R11-3)。缓存失效仍由 m.ExecCtx 按 (idKey, usernameKey) 兜底。
+		UpdateProfileWithTx(ctx context.Context, session sqlx.Session, id int64, username string, nickname, email, phone, remark string, deptId, newStatus int64, statusChanged bool, expectedUpdateTime int64) error
+		// UpdatePassword 审计 H-R11-1:expectedUpdateTime 必须由调用方用**外层校验旧密码时拿到的
+		// 那一份 updateTime** 显式透传;禁止函数内部再 FindOne 自对齐乐观锁,否则内层 CAS 等于退化
+		// 为 last-write-wins,被"旧会话 + 知道旧密码"的攻击者可以把管理员紧急改过的新密码盖回。
+		// username 仅用于构造 `cacheSysUserUsernamePrefix` 缓存键失效(sysUser.username 唯一,本函数
+		// 不会修改它)。
+		UpdatePassword(ctx context.Context, id int64, username string, password string, mustChangePassword, expectedUpdateTime int64) error
+		// UpdateStatus 审计 M-R11-2:username 由调用方透传,避免仅为构造缓存键而多打一次 FindOne。
+		UpdateStatus(ctx context.Context, id int64, username string, status int64, expectedUpdateTime int64) error
+		// IncrementTokenVersion 审计 M-R11-2:username 由调用方透传,避免仅为构造缓存键而多打一次 FindOne。
+		IncrementTokenVersion(ctx context.Context, id int64, username string) (int64, error)
 		IncrementTokenVersionIfMatch(ctx context.Context, id int64, username string, expected int64) (int64, error)
 		IncrementTokenVersionIfMatch(ctx context.Context, id int64, username string, expected int64) (int64, error)
 	}
 	}
 
 
@@ -125,17 +141,41 @@ func (m *customSysUserModel) UpdateProfile(ctx context.Context, id int64, userna
 	return nil
 	return nil
 }
 }
 
 
-func (m *customSysUserModel) UpdatePassword(ctx context.Context, id int64, password string, mustChangePassword int64) error {
-	data, err := m.FindOne(ctx, id)
+// UpdateProfileWithTx 见接口注释(审计 M-R11-3)。实现上复用 m.ExecCtx 负责的 cache 失效语义,
+// 但 UPDATE 语句在调用方传入的 session 事务里执行。session==nil 时 panic,阻止误用——
+// 非事务场景必须走 UpdateProfile 而不是本方法。
+func (m *customSysUserModel) UpdateProfileWithTx(ctx context.Context, session sqlx.Session, id int64, username string, nickname, email, phone, remark string, deptId, newStatus int64, statusChanged bool, expectedUpdateTime int64) error {
+	if session == nil {
+		return errors.New("UpdateProfileWithTx requires a non-nil session")
+	}
+	sysUserIdKey := fmt.Sprintf("%s%v", cacheSysUserIdPrefix, id)
+	sysUserUsernameKey := fmt.Sprintf("%s%v", cacheSysUserUsernamePrefix, username)
+	now := time.Now().Unix()
+
+	res, err := m.ExecCtx(ctx, func(ctx context.Context, _ sqlx.SqlConn) (sql.Result, error) {
+		if statusChanged {
+			query := fmt.Sprintf("UPDATE %s SET `nickname`=?, `email`=?, `phone`=?, `remark`=?, `deptId`=?, `status`=?, `tokenVersion`=`tokenVersion`+1, `updateTime`=? WHERE `id`=? AND `updateTime`=?", m.table)
+			return session.ExecCtx(ctx, query, nickname, email, phone, remark, deptId, newStatus, now, id, expectedUpdateTime)
+		}
+		query := fmt.Sprintf("UPDATE %s SET `nickname`=?, `email`=?, `phone`=?, `remark`=?, `deptId`=?, `updateTime`=? WHERE `id`=? AND `updateTime`=?", m.table)
+		return session.ExecCtx(ctx, query, nickname, email, phone, remark, deptId, now, id, expectedUpdateTime)
+	}, sysUserIdKey, sysUserUsernameKey)
 	if err != nil {
 	if err != nil {
 		return err
 		return err
 	}
 	}
+	affected, _ := res.RowsAffected()
+	if affected == 0 {
+		return ErrUpdateConflict
+	}
+	return nil
+}
 
 
+func (m *customSysUserModel) UpdatePassword(ctx context.Context, id int64, username string, password string, mustChangePassword, expectedUpdateTime int64) error {
 	sysUserIdKey := fmt.Sprintf("%s%v", cacheSysUserIdPrefix, id)
 	sysUserIdKey := fmt.Sprintf("%s%v", cacheSysUserIdPrefix, id)
-	sysUserUsernameKey := fmt.Sprintf("%s%v", cacheSysUserUsernamePrefix, data.Username)
-	// 乐观锁:WHERE 叠加 updateTime 与 FindOne 拿到的一致。避免 FindOne → Exec 之间并发改密把
-	// 本次写盖成"最后一写赢"、或目标行被删除后仍返回成功造成语义欺骗(见审计 M-2)。
-	expectedUpdateTime := data.UpdateTime
+	sysUserUsernameKey := fmt.Sprintf("%s%v", cacheSysUserUsernamePrefix, username)
+	// 审计 H-R11-1:expectedUpdateTime 必须来自**外层校验旧密码时读到的**快照。不要再内部 FindOne
+	// 自取 data.UpdateTime —— 那会让 CAS 在本函数内自我对齐,退化为 last-write-wins,让"会话持有 +
+	// 知道旧密码"的攻击者可以把 admin 紧急改过的新密码盖回到自己手里那一份。
 	res, err := m.ExecCtx(ctx, func(ctx context.Context, conn sqlx.SqlConn) (sql.Result, error) {
 	res, err := m.ExecCtx(ctx, func(ctx context.Context, conn sqlx.SqlConn) (sql.Result, error) {
 		query := fmt.Sprintf("UPDATE %s SET `password` = ?, `mustChangePassword` = ?, `tokenVersion` = `tokenVersion` + 1, `updateTime` = ? WHERE `id` = ? AND `updateTime` = ?", m.table)
 		query := fmt.Sprintf("UPDATE %s SET `password` = ?, `mustChangePassword` = ?, `tokenVersion` = `tokenVersion` + 1, `updateTime` = ? WHERE `id` = ? AND `updateTime` = ?", m.table)
 		return conn.ExecCtx(ctx, query, password, mustChangePassword, time.Now().Unix(), id, expectedUpdateTime)
 		return conn.ExecCtx(ctx, query, password, mustChangePassword, time.Now().Unix(), id, expectedUpdateTime)
@@ -144,8 +184,7 @@ func (m *customSysUserModel) UpdatePassword(ctx context.Context, id int64, passw
 		return err
 		return err
 	}
 	}
 	if affected, _ := res.RowsAffected(); affected == 0 {
 	if affected, _ := res.RowsAffected(); affected == 0 {
-		// 行被删除或被并发改过:对外统一回 ErrUpdateConflict,避免对已删除用户返回 nil 让上层
-		// 误判为"改密成功"(审计 M-2)。
+		// 行被删除或被并发改过(任何字段,包括 status/password/profile):统一回 ErrUpdateConflict。
 		return ErrUpdateConflict
 		return ErrUpdateConflict
 	}
 	}
 	return nil
 	return nil
@@ -158,14 +197,11 @@ func (m *customSysUserModel) UpdatePassword(ctx context.Context, id int64, passw
 //   - expectedUpdateTime 不匹配 → ErrUpdateConflict;上层统一回 409 "数据已被其他操作修改"。
 //   - expectedUpdateTime 不匹配 → ErrUpdateConflict;上层统一回 409 "数据已被其他操作修改"。
 //   - 避免并发冻结/解冻请求走"last-write-wins",出现两个 admin 同时点"冻结"/"解冻"
 //   - 避免并发冻结/解冻请求走"last-write-wins",出现两个 admin 同时点"冻结"/"解冻"
 //     时后到者覆盖先到者、tokenVersion 被连续加两次把刚刚解冻的用户再次踢下线的诡异现象。
 //     时后到者覆盖先到者、tokenVersion 被连续加两次把刚刚解冻的用户再次踢下线的诡异现象。
-func (m *customSysUserModel) UpdateStatus(ctx context.Context, id int64, status int64, expectedUpdateTime int64) error {
-	data, err := m.FindOne(ctx, id)
-	if err != nil {
-		return err
-	}
-
+func (m *customSysUserModel) UpdateStatus(ctx context.Context, id int64, username string, status int64, expectedUpdateTime int64) error {
+	// 审计 M-R11-2:username 由调用方(ValidateStatusChange 返回的目标用户对象)显式透传,
+	// 不再内部 FindOne。真实并发安全继续靠 `WHERE updateTime = expectedUpdateTime` 乐观锁兜底。
 	sysUserIdKey := fmt.Sprintf("%s%v", cacheSysUserIdPrefix, id)
 	sysUserIdKey := fmt.Sprintf("%s%v", cacheSysUserIdPrefix, id)
-	sysUserUsernameKey := fmt.Sprintf("%s%v", cacheSysUserUsernamePrefix, data.Username)
+	sysUserUsernameKey := fmt.Sprintf("%s%v", cacheSysUserUsernamePrefix, username)
 	res, err := m.ExecCtx(ctx, func(ctx context.Context, conn sqlx.SqlConn) (sql.Result, error) {
 	res, err := m.ExecCtx(ctx, func(ctx context.Context, conn sqlx.SqlConn) (sql.Result, error) {
 		query := fmt.Sprintf("UPDATE %s SET `status` = ?, `tokenVersion` = `tokenVersion` + 1, `updateTime` = ? WHERE `id` = ? AND `updateTime` = ?", m.table)
 		query := fmt.Sprintf("UPDATE %s SET `status` = ?, `tokenVersion` = `tokenVersion` + 1, `updateTime` = ? WHERE `id` = ? AND `updateTime` = ?", m.table)
 		return conn.ExecCtx(ctx, query, status, time.Now().Unix(), id, expectedUpdateTime)
 		return conn.ExecCtx(ctx, query, status, time.Now().Unix(), id, expectedUpdateTime)
@@ -187,17 +223,14 @@ func (m *customSysUserModel) UpdateStatus(ctx context.Context, id int64, status
 // 必须调用 IncrementTokenVersionIfMatch 走 CAS 语义,否则会回到 R5 以前的并发 rotate 窗口,
 // 必须调用 IncrementTokenVersionIfMatch 走 CAS 语义,否则会回到 R5 以前的并发 rotate 窗口,
 // 两次并发 refresh 都能换到新令牌,等同于会话劫持(见审计 L-2)。
 // 两次并发 refresh 都能换到新令牌,等同于会话劫持(见审计 L-2)。
 // 调用前请先走 TokenOpLimiter 等限流,避免被反复触发把合法用户 kick 出登录。
 // 调用前请先走 TokenOpLimiter 等限流,避免被反复触发把合法用户 kick 出登录。
-func (m *customSysUserModel) IncrementTokenVersion(ctx context.Context, id int64) (int64, error) {
-	data, err := m.FindOne(ctx, id)
-	if err != nil {
-		return 0, err
-	}
-
+func (m *customSysUserModel) IncrementTokenVersion(ctx context.Context, id int64, username string) (int64, error) {
+	// 审计 M-R11-2:username 由调用方显式透传,不再内部 FindOne 仅为构造缓存键。
+	// 调用方(Logout 唯一入口)在 UserDetailsLoader.Load 之后天然已经持有 ud.Username。
 	sysUserIdKey := fmt.Sprintf("%s%v", cacheSysUserIdPrefix, id)
 	sysUserIdKey := fmt.Sprintf("%s%v", cacheSysUserIdPrefix, id)
-	sysUserUsernameKey := fmt.Sprintf("%s%v", cacheSysUserUsernamePrefix, data.Username)
+	sysUserUsernameKey := fmt.Sprintf("%s%v", cacheSysUserUsernamePrefix, username)
 
 
 	var newVersion int64
 	var newVersion int64
-	err = m.TransactCtx(ctx, func(ctx context.Context, session sqlx.Session) error {
+	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)
 		query := fmt.Sprintf("UPDATE %s SET `tokenVersion` = LAST_INSERT_ID(`tokenVersion` + 1), `updateTime` = ? WHERE `id` = ?", m.table)
 		res, err := session.ExecCtx(ctx, query, time.Now().Unix(), id)
 		res, err := session.ExecCtx(ctx, query, time.Now().Unix(), id)
 		if err != nil {
 		if err != nil {

+ 25 - 17
internal/model/user/updatePasswordStatus_rowsaffected_audit_test.go

@@ -45,11 +45,17 @@ func TestSysUserModel_UpdatePassword_RowDeletedBetweenFindAndExec_ReturnsConflic
 	_, err = conn.ExecCtx(ctx, "DELETE FROM `sys_user` WHERE `id` = ?", id)
 	_, err = conn.ExecCtx(ctx, "DELETE FROM `sys_user` WHERE `id` = ?", id)
 	require.NoError(t, err)
 	require.NoError(t, err)
 
 
-	// UpdatePassword 内部:FindOne 命中 stale cache 返回用户 → UPDATE WHERE id=? AND updateTime=?
-	// 因为行已不存在,affected=0。旧实现 `return nil` 被视为"改密成功";新实现必须回 ErrUpdateConflict。
-	err = m.UpdatePassword(ctx, id, "new_hashed_pw", 1)
+	// UpdatePassword 内部 WHERE id=? AND updateTime=?(外层透传 expectedUpdateTime, 审计 H-R11-1)。
+	// 行已被删除,affected=0。旧实现 `return nil` 被视为"改密成功";新实现必须回 ErrUpdateConflict。
+	// expectedUpdateTime 用 stale cache 的 UpdateTime,即"观测到的快照" —— DB 已无对应行,CAS 必失败。
+	stale, _ := m.FindOne(ctx, id)
+	var expectedUpdateTime int64
+	if stale != nil {
+		expectedUpdateTime = stale.UpdateTime
+	}
+	err = m.UpdatePassword(ctx, id, username, "new_hashed_pw", 1, expectedUpdateTime)
 	require.ErrorIs(t, err, user.ErrUpdateConflict,
 	require.ErrorIs(t, err, user.ErrUpdateConflict,
-		"M-2:RowsAffected=0 必须升格为 ErrUpdateConflict,杜绝对已消失用户的静默改密")
+		"M-2/H-R11-1:RowsAffected=0 必须升格为 ErrUpdateConflict,杜绝对已消失用户的静默改密")
 }
 }
 
 
 // TC-0925: UpdateStatus 对已被并发删除(缓存仍在)的用户必须 fail-fast,禁止静默成功
 // TC-0925: UpdateStatus 对已被并发删除(缓存仍在)的用户必须 fail-fast,禁止静默成功
@@ -81,9 +87,9 @@ func TestSysUserModel_UpdateStatus_RowDeletedBetweenFindAndExec_ReturnsConflict(
 	if staleUd != nil {
 	if staleUd != nil {
 		expectedUpdateTime = staleUd.UpdateTime
 		expectedUpdateTime = staleUd.UpdateTime
 	}
 	}
-	err = m.UpdateStatus(ctx, id, 2, expectedUpdateTime)
+	err = m.UpdateStatus(ctx, id, username, 2, expectedUpdateTime)
 	require.ErrorIs(t, err, user.ErrUpdateConflict,
 	require.ErrorIs(t, err, user.ErrUpdateConflict,
-		"M-2/L-N4:RowsAffected=0 必须升格为 ErrUpdateConflict,杜绝对已消失用户的静默封禁")
+		"M-2/L-N4/M-R11-2:RowsAffected=0 必须升格为 ErrUpdateConflict,杜绝对已消失用户的静默封禁")
 }
 }
 
 
 // TC-0926: UpdatePassword 正常路径仍然成功,且真实落盘(保证 M-2 的 fail-close 不误伤正常流)
 // TC-0926: UpdatePassword 正常路径仍然成功,且真实落盘(保证 M-2 的 fail-close 不误伤正常流)
@@ -108,7 +114,7 @@ func TestSysUserModel_UpdatePassword_HappyPath_PersistsAndBumpsTokenVersion(t *t
 	time.Sleep(1100 * time.Millisecond)
 	time.Sleep(1100 * time.Millisecond)
 
 
 	newPw := "new_hashed_password_xyz"
 	newPw := "new_hashed_password_xyz"
-	err = m.UpdatePassword(ctx, id, newPw, 1)
+	err = m.UpdatePassword(ctx, id, username, newPw, 1, orig.UpdateTime)
 	require.NoError(t, err)
 	require.NoError(t, err)
 
 
 	got, err := m.FindOne(ctx, id)
 	got, err := m.FindOne(ctx, id)
@@ -140,7 +146,7 @@ func TestSysUserModel_UpdateStatus_HappyPath_PersistsAndBumpsTokenVersion(t *tes
 	// L-N4:乐观锁依赖秒级 updateTime,确保 UPDATE 的 time.Now().Unix() 严格 > orig.UpdateTime
 	// L-N4:乐观锁依赖秒级 updateTime,确保 UPDATE 的 time.Now().Unix() 严格 > orig.UpdateTime
 	time.Sleep(1100 * time.Millisecond)
 	time.Sleep(1100 * time.Millisecond)
 
 
-	err = m.UpdateStatus(ctx, id, 2, orig.UpdateTime)
+	err = m.UpdateStatus(ctx, id, username, 2, orig.UpdateTime)
 	require.NoError(t, err)
 	require.NoError(t, err)
 
 
 	got, err := m.FindOne(ctx, id)
 	got, err := m.FindOne(ctx, id)
@@ -150,19 +156,21 @@ func TestSysUserModel_UpdateStatus_HappyPath_PersistsAndBumpsTokenVersion(t *tes
 	assert.Greater(t, got.UpdateTime, orig.UpdateTime, "updateTime 必须推进,否则后续乐观锁失效")
 	assert.Greater(t, got.UpdateTime, orig.UpdateTime, "updateTime 必须推进,否则后续乐观锁失效")
 }
 }
 
 
-// TC-0928: UpdatePassword 对不存在的 userId 必须回 ErrNotFound(FindOne 先失败),
-// 确保 M-2 的 "affected=0 → ErrUpdateConflict" 不会把 "FindOne miss" 误报成 Conflict
-func TestSysUserModel_UpdatePassword_UserNotExist_ReturnsNotFound(t *testing.T) {
+// TC-0928(R11 重写):UpdatePassword 对不存在的 userId 必须回 ErrUpdateConflict
+// (H-R11-1 后,Model 不再内部 FindOne;不存在的 id + 任意 expectedUpdateTime → affected=0 → ErrUpdateConflict)
+func TestSysUserModel_UpdatePassword_UserNotExist_ReturnsConflict(t *testing.T) {
 	ctx := context.Background()
 	ctx := context.Background()
 	m, _ := newModel(t)
 	m, _ := newModel(t)
-	err := m.UpdatePassword(ctx, 999999999999, "irrelevant", 1)
-	require.ErrorIs(t, err, user.ErrNotFound)
+	err := m.UpdatePassword(ctx, 999999999999, "ghost_user", "irrelevant", 1, 1)
+	require.ErrorIs(t, err, user.ErrUpdateConflict,
+		"H-R11-1:UpdatePassword 不再内部 FindOne,对不存在的 id 回 ErrUpdateConflict")
 }
 }
 
 
-// TC-0929: UpdateStatus 对不存在的 userId 必须回 ErrNotFound
-func TestSysUserModel_UpdateStatus_UserNotExist_ReturnsNotFound(t *testing.T) {
+// TC-0929(R11 重写):UpdateStatus 对不存在的 userId 必须回 ErrUpdateConflict
+func TestSysUserModel_UpdateStatus_UserNotExist_ReturnsConflict(t *testing.T) {
 	ctx := context.Background()
 	ctx := context.Background()
 	m, _ := newModel(t)
 	m, _ := newModel(t)
-	err := m.UpdateStatus(ctx, 999999999999, 2, 0)
-	require.ErrorIs(t, err, user.ErrNotFound)
+	err := m.UpdateStatus(ctx, 999999999999, "ghost_user", 2, 1)
+	require.ErrorIs(t, err, user.ErrUpdateConflict,
+		"M-R11-2:UpdateStatus 不再内部 FindOne,对不存在的 id 回 ErrUpdateConflict")
 }
 }

+ 135 - 0
internal/model/user/updatePasswordToctou_audit_test.go

@@ -0,0 +1,135 @@
+package user_test
+
+import (
+	"context"
+	"testing"
+	"time"
+
+	"perms-system-server/internal/model/user"
+	"perms-system-server/internal/testutil"
+
+	"github.com/stretchr/testify/assert"
+	"github.com/stretchr/testify/require"
+)
+
+// ---------------------------------------------------------------------------
+// 覆盖目标:审计 H-R11-1 —— UpdatePassword 的 expectedUpdateTime 必须由调用方透传,
+// Model 层不得再内部 FindOne 自对齐 CAS;否则两个会话"都知道旧密码"时第二个会把第一个
+// 紧急修改过的新密码盖回。此文件从 Model 层钉死三条关键契约:
+//   1) 签名强制 6 参 (id, username, password, mustChangePassword, expectedUpdateTime);
+//   2) 外层持有的陈旧 updateTime 传入 → ErrUpdateConflict(并发写入已让 DB 的 updateTime 推进);
+//   3) 正常路径 expectedUpdateTime 匹配时落盘成功,password / tokenVersion 正确持久化。
+// ---------------------------------------------------------------------------
+
+// TC-1039: H-R11-1 —— 并发场景下外层快照的 expectedUpdateTime 已被他人推进,CAS 必须失败
+func TestSysUserModel_UpdatePassword_StaleExpectedUpdateTime_Conflict(t *testing.T) {
+	ctx := context.Background()
+	m, conn := newModel(t)
+	username := "hr111_stale_" + testutil.UniqueId()
+	data := newTestSysUser(username, 1)
+
+	res, err := m.Insert(ctx, data)
+	require.NoError(t, err)
+	id, err := res.LastInsertId()
+	require.NoError(t, err)
+	t.Cleanup(func() { testutil.CleanTable(ctx, conn, m.TableName(), id) })
+
+	// 外层 Session A 观测到的 updateTime(会校验旧密码时一起拿到)
+	snapshotA, err := m.FindOne(ctx, id)
+	require.NoError(t, err)
+	snapshotAUpdateTime := snapshotA.UpdateTime
+
+	// sys_user.updateTime 精度到秒,确保 Session B 提交的 UPDATE 严格推进 updateTime;
+	// 否则同秒写回值与 snapshotAUpdateTime 相同,CAS 仍然匹配,无法复现 TOCTOU。
+	time.Sleep(1100 * time.Millisecond)
+
+	// Session B("设备 B 紧急改密 P2")抢先基于 snapshotA 成功完成一次 CAS
+	require.NoError(t,
+		m.UpdatePassword(ctx, id, username, "H_P2", 1, snapshotAUpdateTime),
+		"Session B 基于快照 A 的 updateTime 抢先完成 CAS,应当成功")
+
+	// 现在 DB 的 updateTime 已经不是 snapshotAUpdateTime。
+	// Session A(持有旧密码 P0、已校验过旧密码)再用**同一份**旧 snapshot 的 updateTime
+	// 去改密 P1,CAS 必须失败,否则 P2 会被 P1 覆盖(H-R11-1 TOCTOU)。
+	err = m.UpdatePassword(ctx, id, username, "H_P1_to_cover_P2", 1, snapshotAUpdateTime)
+	require.ErrorIs(t, err, user.ErrUpdateConflict,
+		"H-R11-1:expectedUpdateTime 必须是外层快照;Session B 已推进时,Session A 的改密 CAS 必须失败")
+
+	// DB 终态保持为 Session B 的 H_P2,不被 Session A 覆盖
+	got, err := m.FindOne(ctx, id)
+	require.NoError(t, err)
+	assert.Equal(t, "H_P2", got.Password,
+		"H-R11-1:TOCTOU 被关闭后,DB 终态必须是后到而胜出的那一方,不得被旧快照覆盖")
+}
+
+// TC-1040: H-R11-1 —— 正常路径 expectedUpdateTime 匹配时 UpdatePassword 落盘并递增 tokenVersion
+func TestSysUserModel_UpdatePassword_HappyPath_ExplicitExpectedUpdateTime(t *testing.T) {
+	ctx := context.Background()
+	m, conn := newModel(t)
+	username := "hr111_ok_" + testutil.UniqueId()
+	data := newTestSysUser(username, 1)
+
+	res, err := m.Insert(ctx, data)
+	require.NoError(t, err)
+	id, err := res.LastInsertId()
+	require.NoError(t, err)
+	t.Cleanup(func() { testutil.CleanTable(ctx, conn, m.TableName(), id) })
+
+	orig, err := m.FindOne(ctx, id)
+	require.NoError(t, err)
+	origTV := orig.TokenVersion
+
+	time.Sleep(1100 * time.Millisecond)
+
+	require.NoError(t,
+		m.UpdatePassword(ctx, id, username, "H_NEW", 0, orig.UpdateTime),
+		"H-R11-1:expectedUpdateTime 与 DB 当前 updateTime 一致时必须成功")
+
+	got, err := m.FindOne(ctx, id)
+	require.NoError(t, err)
+	assert.Equal(t, "H_NEW", got.Password)
+	assert.Equal(t, int64(0), got.MustChangePassword)
+	assert.Equal(t, origTV+1, got.TokenVersion,
+		"H-R11-1:UpdatePassword 必须递增 tokenVersion 以注销旧会话")
+	assert.Greater(t, got.UpdateTime, orig.UpdateTime,
+		"H-R11-1:updateTime 必须推进以支撑下一次 CAS")
+}
+
+// TC-1041: H-R11-1 —— 同一行被并发修改(如 UpdateProfile 改了昵称)之后,UpdatePassword 的 CAS 必须失败
+// 覆盖"任何修改 sys_user 行的并发写入都会触发 ErrUpdateConflict"这一更严的契约:
+// 不仅是另一次改密可以"偷走"本次;改昵称/解冻/任何推进 updateTime 的操作也必须把本次改密拦住。
+func TestSysUserModel_UpdatePassword_ConcurrentProfileWrite_BlocksPasswordUpdate(t *testing.T) {
+	ctx := context.Background()
+	m, conn := newModel(t)
+	username := "hr111_prof_" + testutil.UniqueId()
+	data := newTestSysUser(username, 1)
+
+	res, err := m.Insert(ctx, data)
+	require.NoError(t, err)
+	id, err := res.LastInsertId()
+	require.NoError(t, err)
+	t.Cleanup(func() { testutil.CleanTable(ctx, conn, m.TableName(), id) })
+
+	snapshot, err := m.FindOne(ctx, id)
+	require.NoError(t, err)
+
+	// sys_user.updateTime 秒级,sleep 以确保 UpdateProfile 的 UPDATE 真的推进
+	time.Sleep(1100 * time.Millisecond)
+
+	// Session B 改了昵称(完全合法的场景:管理员在用户"修改密码"弹窗打开的同一时刻修了昵称)
+	require.NoError(t,
+		m.UpdateProfile(ctx, id, username,
+			"new_nick", snapshot.Email, snapshot.Phone, snapshot.Remark,
+			snapshot.DeptId, snapshot.Status, false, snapshot.UpdateTime),
+		"UpdateProfile 旁路已成功执行")
+
+	// Session A 仍然基于 snapshot.UpdateTime 改密 —— 必须被 CAS 拦住
+	err = m.UpdatePassword(ctx, id, username, "H_LOST", 1, snapshot.UpdateTime)
+	require.ErrorIs(t, err, user.ErrUpdateConflict,
+		"H-R11-1:任何改动(含改昵称)都推进 updateTime;基于旧快照的改密必须被 CAS 拦住")
+
+	got, err := m.FindOne(ctx, id)
+	require.NoError(t, err)
+	assert.Equal(t, snapshot.Password, got.Password, "Password 必须保持原值,未被 Session A 覆盖")
+	assert.Equal(t, "new_nick", got.Nickname, "Profile 写入必须成功落盘")
+}

+ 7 - 2
internal/model/userperm/sysUserPermModel.go

@@ -43,8 +43,13 @@ func (m *customSysUserPermModel) FindPermIdsByUserIdAndEffectForProduct(ctx cont
 }
 }
 
 
 func (m *customSysUserPermModel) DeleteByUserIdForProductTx(ctx context.Context, session sqlx.Session, userId int64, productCode string) error {
 func (m *customSysUserPermModel) DeleteByUserIdForProductTx(ctx context.Context, session sqlx.Session, userId int64, productCode string) error {
-	var list []*SysUserPerm
-	findQuery := fmt.Sprintf("SELECT %s FROM %s WHERE `userId` = ? AND `permId` IN (SELECT `id` FROM `sys_perm` WHERE `productCode` = ?) FOR UPDATE", sysUserPermRows, m.table)
+	// 审计 L-R11-2:SELECT 只取 cache key 所需三列,不回灌全行。
+	var list []struct {
+		Id     int64 `db:"id"`
+		UserId int64 `db:"userId"`
+		PermId int64 `db:"permId"`
+	}
+	findQuery := fmt.Sprintf("SELECT `id`, `userId`, `permId` FROM %s WHERE `userId` = ? AND `permId` IN (SELECT `id` FROM `sys_perm` WHERE `productCode` = ?) FOR UPDATE", m.table)
 	if err := session.QueryRowsCtx(ctx, &list, findQuery, userId, productCode); err != nil {
 	if err := session.QueryRowsCtx(ctx, &list, findQuery, userId, productCode); err != nil {
 		return err
 		return err
 	}
 	}

+ 137 - 0
internal/model/userrole/deleteCacheKey_r11_2_audit_test.go

@@ -0,0 +1,137 @@
+package userrole
+
+import (
+	"context"
+	"fmt"
+	"testing"
+	"time"
+
+	"perms-system-server/internal/testutil"
+
+	"github.com/stretchr/testify/assert"
+	"github.com/stretchr/testify/require"
+	"github.com/zeromicro/go-zero/core/stores/redis"
+	"github.com/zeromicro/go-zero/core/stores/sqlx"
+)
+
+// ---------------------------------------------------------------------------
+// 覆盖目标:审计 L-R11-2 —— Delete 族的 SELECT 从 * 裁剪到 id/userId/roleId。
+// 行为契约不得因此回归:
+//   1) 每一行被删除后,id 维度 + 复合维度(userId+roleId)的缓存 key 必须全部失效;
+//   2) 裁剪后仍然能正确处理"同一 userId 下多条 role 绑定"的批量失效,绝不能只删到
+//      第一条行的缓存 key 就提前返回;
+//   3) 经过 Delete 后再 FindOne 必须回落到 DB 的 ErrNotFound,不能沿用缓存里的旧行。
+//
+// 这里选 sys_user_role 作为三个 Delete-Tx 家族(userrole/roleperm/userperm/perm)的
+// 代表:其 DeleteByRoleIdTx / DeleteByUserIdForProductTx / DeleteByUserIdAndRoleIdsTx
+// 三个方法都走同一条 SELECT-only-key-cols → DELETE 的路径,成一族即覆盖整个收敛面。
+// ---------------------------------------------------------------------------
+
+func seedUserRoleWithPrimedCache(t *testing.T, ctx context.Context, m SysUserRoleModel, userId, roleId int64) int64 {
+	t.Helper()
+	ts := time.Now().Unix()
+	res, err := m.Insert(ctx, &SysUserRole{
+		UserId: userId, RoleId: roleId, CreateTime: ts, UpdateTime: ts,
+	})
+	require.NoError(t, err)
+	id, err := res.LastInsertId()
+	require.NoError(t, err)
+
+	_, err = m.FindOne(ctx, id)
+	require.NoError(t, err, "FindOne 为 id 维度缓存预热")
+	_, err = m.FindOneByUserIdRoleId(ctx, userId, roleId)
+	require.NoError(t, err, "FindOneByUserIdRoleId 为复合维度缓存预热")
+	return id
+}
+
+func assertCacheKeysGone(t *testing.T, rds *redis.Redis, idKey, compositeKey string) {
+	t.Helper()
+	got, err := rds.Get(idKey)
+	require.NoError(t, err)
+	assert.Empty(t, got, "L-R11-2:id 维度缓存 key %q 应被 DELETE 一并失效", idKey)
+	got, err = rds.Get(compositeKey)
+	require.NoError(t, err)
+	assert.Empty(t, got, "L-R11-2:复合维度缓存 key %q 应被 DELETE 一并失效", compositeKey)
+}
+
+// TC-1062: L-R11-2 —— DeleteByRoleIdTx 对多行同时失效 id + composite 缓存
+func TestSysUserRoleModel_DeleteByRoleIdTx_InvalidatesAllKeyCols(t *testing.T) {
+	ctx := context.Background()
+	conn := testutil.GetTestSqlConn()
+	m := NewSysUserRoleModel(conn, testutil.GetTestCacheConf(), testutil.GetTestCachePrefix())
+	rds := redis.MustNewRedis(testutil.GetTestConfig().CacheRedis.Nodes[0].RedisConf)
+	cachePrefix := testutil.GetTestCachePrefix()
+
+	roleId := randUserRoleId()
+	u1, u2 := randUserRoleId(), randUserRoleId()
+	if u1 == u2 {
+		u2 = u1 + 1
+	}
+	id1 := seedUserRoleWithPrimedCache(t, ctx, m, u1, roleId)
+	id2 := seedUserRoleWithPrimedCache(t, ctx, m, u2, roleId)
+	t.Cleanup(func() { testutil.CleanTable(ctx, conn, "sys_user_role", id1, id2) })
+
+	require.NoError(t,
+		m.TransactCtx(ctx, func(c context.Context, session sqlx.Session) error {
+			return m.DeleteByRoleIdTx(c, session, roleId)
+		}))
+
+	// L-R11-2 裁剪 SELECT 为三列后,构造缓存 key 需要的 id/userId/roleId 仍被携带回来。
+	// 任何一条遗漏都会让这里的断言炸掉(旧行的缓存还在,FindOne 就能从缓存拿到"幽灵行")。
+	for _, c := range []struct {
+		id, uid int64
+	}{{id1, u1}, {id2, u2}} {
+		idKey := fmt.Sprintf("%s:cache:sysUserRole:id:%v", cachePrefix, c.id)
+		compKey := fmt.Sprintf("%s:cache:sysUserRole:userId:roleId:%v:%v", cachePrefix, c.uid, roleId)
+		assertCacheKeysGone(t, rds, idKey, compKey)
+	}
+
+	// 终态真相:两行都不应再存在于 DB。
+	for _, id := range []int64{id1, id2} {
+		_, err := m.FindOne(ctx, id)
+		assert.ErrorIs(t, err, ErrNotFound,
+			"L-R11-2:DELETE 已提交,FindOne 必须回落 DB 读到 ErrNotFound;"+
+				"若仍查到旧行说明裁剪后的 SELECT 漏掉了 id 列,id 维度缓存仍在")
+	}
+}
+
+// TC-1063: L-R11-2 —— DeleteByUserIdAndRoleIdsTx 的批量 IN 路径
+func TestSysUserRoleModel_DeleteByUserIdAndRoleIdsTx_InvalidatesAllKeyCols(t *testing.T) {
+	ctx := context.Background()
+	conn := testutil.GetTestSqlConn()
+	m := NewSysUserRoleModel(conn, testutil.GetTestCacheConf(), testutil.GetTestCachePrefix())
+	rds := redis.MustNewRedis(testutil.GetTestConfig().CacheRedis.Nodes[0].RedisConf)
+	cachePrefix := testutil.GetTestCachePrefix()
+
+	userId := randUserRoleId()
+	r1, r2, r3 := randUserRoleId(), randUserRoleId()+1, randUserRoleId()+2
+	id1 := seedUserRoleWithPrimedCache(t, ctx, m, userId, r1)
+	id2 := seedUserRoleWithPrimedCache(t, ctx, m, userId, r2)
+	// r3 作为"不在删除集合内"的对照组:这一行不得被误伤
+	id3 := seedUserRoleWithPrimedCache(t, ctx, m, userId, r3)
+	t.Cleanup(func() { testutil.CleanTable(ctx, conn, "sys_user_role", id1, id2, id3) })
+
+	require.NoError(t,
+		m.TransactCtx(ctx, func(c context.Context, session sqlx.Session) error {
+			return m.DeleteByUserIdAndRoleIdsTx(c, session, userId, []int64{r1, r2})
+		}))
+
+	// 被删的两条:id+composite 都必须失效。
+	for _, c := range []struct {
+		id, rid int64
+	}{{id1, r1}, {id2, r2}} {
+		idKey := fmt.Sprintf("%s:cache:sysUserRole:id:%v", cachePrefix, c.id)
+		compKey := fmt.Sprintf("%s:cache:sysUserRole:userId:roleId:%v:%v", cachePrefix, userId, c.rid)
+		assertCacheKeysGone(t, rds, idKey, compKey)
+		_, err := m.FindOne(ctx, c.id)
+		assert.ErrorIs(t, err, ErrNotFound)
+	}
+
+	// 未被删的第 3 条:DB 仍在,FindOne 必须成功。
+	got, err := m.FindOne(ctx, id3)
+	require.NoError(t, err,
+		"L-R11-2 防误伤:IN (r1, r2) 不得把 r3 的行带走;"+
+			"若失败,说明裁剪后 SELECT 把对照组的 key 也返回并被 ExecCtx 一并失效")
+	assert.Equal(t, id3, got.Id)
+	assert.Equal(t, r3, got.RoleId)
+}

+ 20 - 10
internal/model/userrole/sysUserRoleModel.go

@@ -91,7 +91,17 @@ func (m *customSysUserRoleModel) FindUserIdsByRoleIdForUpdateTx(ctx context.Cont
 	return ids, nil
 	return ids, nil
 }
 }
 
 
-func (m *customSysUserRoleModel) buildCacheKeys(list []*SysUserRole) []string {
+// userRoleKey 仅含 buildCacheKeys 真正需要的三列(审计 L-R11-2):以前 Delete 族 SELECT 整行
+// 只是为了拿 id / userId / roleId 构造缓存键,却把 createTime/updateTime 等业务字段一并搬运回
+// 应用层再丢弃。在"删除一个角色的所有绑定关系"这类关联行 O(关联用户数) 的场景下,SELECT 只读
+// 必要列能显著减少 goroutine 临时内存与网络 I/O。
+type userRoleKey struct {
+	Id     int64 `db:"id"`
+	UserId int64 `db:"userId"`
+	RoleId int64 `db:"roleId"`
+}
+
+func (m *customSysUserRoleModel) buildCacheKeysFromKeys(list []userRoleKey) []string {
 	keys := make([]string, 0, len(list)*2)
 	keys := make([]string, 0, len(list)*2)
 	for _, data := range list {
 	for _, data := range list {
 		keys = append(keys,
 		keys = append(keys,
@@ -103,15 +113,15 @@ func (m *customSysUserRoleModel) buildCacheKeys(list []*SysUserRole) []string {
 }
 }
 
 
 func (m *customSysUserRoleModel) DeleteByRoleIdTx(ctx context.Context, session sqlx.Session, roleId int64) error {
 func (m *customSysUserRoleModel) DeleteByRoleIdTx(ctx context.Context, session sqlx.Session, roleId int64) error {
-	var list []*SysUserRole
-	findQuery := fmt.Sprintf("SELECT %s FROM %s WHERE `roleId` = ? FOR UPDATE", sysUserRoleRows, m.table)
+	var list []userRoleKey
+	findQuery := fmt.Sprintf("SELECT `id`, `userId`, `roleId` FROM %s WHERE `roleId` = ? FOR UPDATE", m.table)
 	if err := session.QueryRowsCtx(ctx, &list, findQuery, roleId); err != nil {
 	if err := session.QueryRowsCtx(ctx, &list, findQuery, roleId); err != nil {
 		return err
 		return err
 	}
 	}
 	if len(list) == 0 {
 	if len(list) == 0 {
 		return nil
 		return nil
 	}
 	}
-	keys := m.buildCacheKeys(list)
+	keys := m.buildCacheKeysFromKeys(list)
 	_, err := m.ExecCtx(ctx, func(ctx context.Context, conn sqlx.SqlConn) (sql.Result, error) {
 	_, err := m.ExecCtx(ctx, func(ctx context.Context, conn sqlx.SqlConn) (sql.Result, error) {
 		query := fmt.Sprintf("DELETE FROM %s WHERE `roleId` = ?", m.table)
 		query := fmt.Sprintf("DELETE FROM %s WHERE `roleId` = ?", m.table)
 		return session.ExecCtx(ctx, query, roleId)
 		return session.ExecCtx(ctx, query, roleId)
@@ -120,15 +130,15 @@ func (m *customSysUserRoleModel) DeleteByRoleIdTx(ctx context.Context, session s
 }
 }
 
 
 func (m *customSysUserRoleModel) DeleteByUserIdForProductTx(ctx context.Context, session sqlx.Session, userId int64, productCode string) error {
 func (m *customSysUserRoleModel) DeleteByUserIdForProductTx(ctx context.Context, session sqlx.Session, userId int64, productCode string) error {
-	var list []*SysUserRole
-	findQuery := fmt.Sprintf("SELECT %s FROM %s WHERE `userId` = ? AND `roleId` IN (SELECT `id` FROM `sys_role` WHERE `productCode` = ?) FOR UPDATE", sysUserRoleRows, m.table)
+	var list []userRoleKey
+	findQuery := fmt.Sprintf("SELECT `id`, `userId`, `roleId` FROM %s WHERE `userId` = ? AND `roleId` IN (SELECT `id` FROM `sys_role` WHERE `productCode` = ?) FOR UPDATE", m.table)
 	if err := session.QueryRowsCtx(ctx, &list, findQuery, userId, productCode); err != nil {
 	if err := session.QueryRowsCtx(ctx, &list, findQuery, userId, productCode); err != nil {
 		return err
 		return err
 	}
 	}
 	if len(list) == 0 {
 	if len(list) == 0 {
 		return nil
 		return nil
 	}
 	}
-	keys := m.buildCacheKeys(list)
+	keys := m.buildCacheKeysFromKeys(list)
 	_, err := m.ExecCtx(ctx, func(ctx context.Context, conn sqlx.SqlConn) (sql.Result, error) {
 	_, err := m.ExecCtx(ctx, func(ctx context.Context, conn sqlx.SqlConn) (sql.Result, error) {
 		query := fmt.Sprintf("DELETE FROM %s WHERE `userId` = ? AND `roleId` IN (SELECT `id` FROM `sys_role` WHERE `productCode` = ?)", m.table)
 		query := fmt.Sprintf("DELETE FROM %s WHERE `userId` = ? AND `roleId` IN (SELECT `id` FROM `sys_role` WHERE `productCode` = ?)", m.table)
 		return session.ExecCtx(ctx, query, userId, productCode)
 		return session.ExecCtx(ctx, query, userId, productCode)
@@ -149,15 +159,15 @@ func (m *customSysUserRoleModel) DeleteByUserIdAndRoleIdsTx(ctx context.Context,
 	}
 	}
 	inClause := strings.Join(placeholders, ",")
 	inClause := strings.Join(placeholders, ",")
 
 
-	var list []*SysUserRole
-	findQuery := fmt.Sprintf("SELECT %s FROM %s WHERE `userId` = ? AND `roleId` IN (%s) FOR UPDATE", sysUserRoleRows, m.table, inClause)
+	var list []userRoleKey
+	findQuery := fmt.Sprintf("SELECT `id`, `userId`, `roleId` FROM %s WHERE `userId` = ? AND `roleId` IN (%s) FOR UPDATE", m.table, inClause)
 	if err := session.QueryRowsCtx(ctx, &list, findQuery, args...); err != nil {
 	if err := session.QueryRowsCtx(ctx, &list, findQuery, args...); err != nil {
 		return err
 		return err
 	}
 	}
 	if len(list) == 0 {
 	if len(list) == 0 {
 		return nil
 		return nil
 	}
 	}
-	keys := m.buildCacheKeys(list)
+	keys := m.buildCacheKeysFromKeys(list)
 	_, err := m.ExecCtx(ctx, func(ctx context.Context, conn sqlx.SqlConn) (sql.Result, error) {
 	_, err := m.ExecCtx(ctx, func(ctx context.Context, conn sqlx.SqlConn) (sql.Result, error) {
 		query := fmt.Sprintf("DELETE FROM %s WHERE `userId` = ? AND `roleId` IN (%s)", m.table, inClause)
 		query := fmt.Sprintf("DELETE FROM %s WHERE `userId` = ? AND `roleId` IN (%s)", m.table, inClause)
 		return session.ExecCtx(ctx, query, args...)
 		return session.ExecCtx(ctx, query, args...)

+ 174 - 0
internal/server/grpcHttpRotateInterop_r11_5_audit_test.go

@@ -0,0 +1,174 @@
+package server
+
+import (
+	"context"
+	"database/sql"
+	"testing"
+	"time"
+
+	authHelper "perms-system-server/internal/logic/auth"
+	pubLogic "perms-system-server/internal/logic/pub"
+	userModel "perms-system-server/internal/model/user"
+	"perms-system-server/internal/svc"
+	"perms-system-server/internal/testutil"
+	"perms-system-server/internal/types"
+	"perms-system-server/pb"
+
+	"github.com/stretchr/testify/assert"
+	"github.com/stretchr/testify/require"
+	"google.golang.org/grpc/codes"
+	"google.golang.org/grpc/status"
+)
+
+// insertPermServerTestUser:server 包本地的 user 插入 helper。包内原本没有公共 helper,
+// 为了不把实现细节泄漏到 testutil(避免被其他包共用造成耦合),这里保留包内。
+func insertPermServerTestUser(t *testing.T, ctx context.Context, svcCtx *svc.ServiceContext,
+	username, password string, status, isSuperAdmin int64) (int64, func()) {
+	t.Helper()
+	conn := testutil.GetTestSqlConn()
+	now := time.Now().Unix()
+	res, err := svcCtx.SysUserModel.Insert(ctx, &userModel.SysUser{
+		Username:           username,
+		Password:           testutil.HashPassword(password),
+		Nickname:           username,
+		Avatar:             sql.NullString{},
+		Email:              username + "@ut.local",
+		Phone:              "13800000000",
+		IsSuperAdmin:       isSuperAdmin,
+		MustChangePassword: 2,
+		Status:             status,
+		CreateTime:         now,
+		UpdateTime:         now,
+	})
+	require.NoError(t, err)
+	id, err := res.LastInsertId()
+	require.NoError(t, err)
+	return id, func() { testutil.CleanTable(ctx, conn, "`sys_user`", id) }
+}
+
+// ---------------------------------------------------------------------------
+// 覆盖目标:审计 L-R11-5 —— HTTP RefreshToken 与 gRPC RefreshToken 共用
+// authHelper.RotateRefreshToken,**签发出的新 refreshToken 必须可以互换使用**。
+// 这是"helper 共享"最锋利的回归面:一旦某一侧背后悄悄改回自己的版本推进/签名流程,
+// 两边发出的 token 会在 tokenVersion / claims 结构上漂移,下一次交叉刷新会立刻 401。
+// ---------------------------------------------------------------------------
+
+// TC-1070: L-R11-5 —— HTTP 签出的 refreshToken 必须能被 gRPC RefreshToken 无缝续签。
+func TestRefreshToken_HTTPIssuedTokenAcceptedByGrpc(t *testing.T) {
+	ctx := context.Background()
+	svcCtx := svc.NewServiceContext(testutil.GetTestConfig())
+	svcCtx.TokenOpLimiter = nil
+	svcCtx.GrpcRefreshLimiter = nil
+
+	username := "r11_5_interop_h2g_" + testutil.UniqueId()
+	userId, cleanup := insertPermServerTestUser(t, ctx, svcCtx, username, "SomePass123", 1, 2)
+	t.Cleanup(cleanup)
+
+	rtV0, err := authHelper.GenerateRefreshToken(
+		svcCtx.Config.Auth.RefreshSecret, svcCtx.Config.Auth.RefreshExpire,
+		userId, "", 0,
+	)
+	require.NoError(t, err)
+
+	httpResp, err := pubLogic.NewRefreshTokenLogic(ctx, svcCtx).
+		RefreshToken(&types.RefreshTokenReq{Authorization: "Bearer " + rtV0})
+	require.NoError(t, err, "HTTP 首刷应成功,DB tokenVersion 0 → 1")
+	require.NotNil(t, httpResp)
+	require.NotEmpty(t, httpResp.RefreshToken)
+
+	u, err := svcCtx.SysUserModel.FindOne(ctx, userId)
+	require.NoError(t, err)
+	assert.Equal(t, int64(1), u.TokenVersion)
+
+	// HTTP 新发的 refreshToken (claims.TokenVersion=1) 直接喂给 gRPC。
+	svcCtx.UserDetailsLoader.Clean(ctx, userId)
+	grpcResp, err := NewPermServer(svcCtx).RefreshToken(
+		ctx, &pb.RefreshTokenReq{RefreshToken: httpResp.RefreshToken})
+	require.NoError(t, err,
+		"L-R11-5 契约:HTTP 发的 refreshToken 必须被 gRPC 无缝接收;"+
+			"若 gRPC 走自己的版本比对/签名链,这里会 Unauthenticated")
+	assert.NotEmpty(t, grpcResp.RefreshToken)
+	assert.NotEmpty(t, grpcResp.AccessToken)
+
+	u2, err := svcCtx.SysUserModel.FindOne(ctx, userId)
+	require.NoError(t, err)
+	assert.Equal(t, int64(2), u2.TokenVersion,
+		"L-R11-5:gRPC 续签后 DB tokenVersion 必须 +1;两条路径共用同一 CAS 语义")
+}
+
+// TC-1071: L-R11-5 —— gRPC 签出的 refreshToken 必须能被 HTTP RefreshToken 无缝续签。
+// 镜像 TC-1070 的反方向,两侧都 pin 死才能防"helper 只有一侧真在调"的回退。
+func TestRefreshToken_GrpcIssuedTokenAcceptedByHttp(t *testing.T) {
+	ctx := context.Background()
+	svcCtx := svc.NewServiceContext(testutil.GetTestConfig())
+	svcCtx.TokenOpLimiter = nil
+	svcCtx.GrpcRefreshLimiter = nil
+
+	username := "r11_5_interop_g2h_" + testutil.UniqueId()
+	userId, cleanup := insertPermServerTestUser(t, ctx, svcCtx, username, "SomePass123", 1, 2)
+	t.Cleanup(cleanup)
+
+	rtV0, err := authHelper.GenerateRefreshToken(
+		svcCtx.Config.Auth.RefreshSecret, svcCtx.Config.Auth.RefreshExpire,
+		userId, "", 0,
+	)
+	require.NoError(t, err)
+
+	grpcResp, err := NewPermServer(svcCtx).RefreshToken(
+		ctx, &pb.RefreshTokenReq{RefreshToken: rtV0})
+	require.NoError(t, err, "gRPC 首刷应成功,DB tokenVersion 0 → 1")
+	require.NotEmpty(t, grpcResp.RefreshToken)
+
+	u, err := svcCtx.SysUserModel.FindOne(ctx, userId)
+	require.NoError(t, err)
+	assert.Equal(t, int64(1), u.TokenVersion)
+
+	svcCtx.UserDetailsLoader.Clean(ctx, userId)
+	httpResp, err := pubLogic.NewRefreshTokenLogic(ctx, svcCtx).
+		RefreshToken(&types.RefreshTokenReq{Authorization: "Bearer " + grpcResp.RefreshToken})
+	require.NoError(t, err, "L-R11-5:gRPC 发的 refreshToken 必须被 HTTP 无缝接收")
+	require.NotNil(t, httpResp)
+
+	u2, err := svcCtx.SysUserModel.FindOne(ctx, userId)
+	require.NoError(t, err)
+	assert.Equal(t, int64(2), u2.TokenVersion,
+		"L-R11-5:HTTP 续签后 DB tokenVersion 必须 +1")
+}
+
+// TC-1072: L-R11-5 —— gRPC RefreshToken 对 ErrTokenVersionMismatch 的映射契约未回归
+// 这里不再测"两次并发 CAS 只有一个赢"(已由 TestRefreshToken_ConcurrentSameToken_SingleWinner
+// 与 TestGrpcRefreshToken_ReplayOldToken 覆盖),而是显式钉死:一旦 helper 返回
+// ErrTokenVersionMismatch,gRPC 侧必须走 codes.Unauthenticated 而不是 Internal。
+func TestGrpcRefreshToken_ReplayedTokenMapsUnauthenticated(t *testing.T) {
+	ctx := context.Background()
+	svcCtx := svc.NewServiceContext(testutil.GetTestConfig())
+	svcCtx.TokenOpLimiter = nil
+	svcCtx.GrpcRefreshLimiter = nil
+
+	username := "r11_5_replay_" + testutil.UniqueId()
+	userId, cleanup := insertPermServerTestUser(t, ctx, svcCtx, username, "SomePass123", 1, 2)
+	t.Cleanup(cleanup)
+
+	rtV0, err := authHelper.GenerateRefreshToken(
+		svcCtx.Config.Auth.RefreshSecret, svcCtx.Config.Auth.RefreshExpire,
+		userId, "", 0,
+	)
+	require.NoError(t, err)
+
+	// 首次:成功,tokenVersion 0 → 1
+	_, err = NewPermServer(svcCtx).RefreshToken(ctx, &pb.RefreshTokenReq{RefreshToken: rtV0})
+	require.NoError(t, err)
+
+	// 第二次重放同一个旧 rtV0:claims.TokenVersion=0 但 DB=1。
+	// Logic 上游 `claims.TokenVersion != ud.TokenVersion` 会先拦住并走 Unauthenticated,
+	// 但本 TC 要确认的是:**即使未来有人把上游校验逻辑拿掉**,helper 的 CAS 依然兜底,且 gRPC
+	// 侧仍映射到 codes.Unauthenticated(而非 Internal)。
+	svcCtx.UserDetailsLoader.Clean(ctx, userId)
+	_, err = NewPermServer(svcCtx).RefreshToken(ctx, &pb.RefreshTokenReq{RefreshToken: rtV0})
+	require.Error(t, err)
+	st, _ := status.FromError(err)
+	assert.Equal(t, codes.Unauthenticated, st.Code(),
+		"L-R11-5:gRPC 侧 ErrTokenVersionMismatch 必须 codes.Unauthenticated;"+
+			"若漂移到 Internal,接入方会当成系统故障告警而非会话失效")
+	assert.Contains(t, st.Message(), "失效")
+}

+ 189 - 0
internal/server/grpc_rate_limit_mr11_1_audit_test.go

@@ -0,0 +1,189 @@
+package server
+
+import (
+	"context"
+	"testing"
+
+	"perms-system-server/internal/svc"
+	"perms-system-server/internal/testutil"
+	"perms-system-server/pb"
+
+	"github.com/stretchr/testify/assert"
+	"github.com/stretchr/testify/require"
+	"github.com/zeromicro/go-zero/core/limit"
+	"github.com/zeromicro/go-zero/core/stores/redis"
+	"google.golang.org/grpc/codes"
+	"google.golang.org/grpc/status"
+)
+
+// ---------------------------------------------------------------------------
+// 覆盖目标:审计 M-R11-1 —— gRPC SyncPermissions / GetUserPerms 入口缺少限流。
+// 修复后:
+//   SyncPermissions:按 appKey 维度做 GrpcSyncLimiter 入口限流,超限 ResourceExhausted;
+//                    限流在 bcrypt.Compare(appSecret) / LockByCodeTx 之前执行,避免
+//                    恶意重放以高 CPU / 事务级 X 锁打满。
+//   GetUserPerms   :按 appKey + 源 IP 双维度做 GrpcGetUserPermsLimiter 限流,任一桶超限
+//                    都拒绝;防止合法 appKey 泄漏后遍历 userId 或多实例 DDoS 放大。
+//
+// 这里每个契约都做两件事:
+//   1) 把限流上限调到 quota=1 并观察第 2 次请求必是 ResourceExhausted;
+//   2) 提供"第 3 次换一个完全不相关的桶键"必须放行,证明限流口径是**按 key**隔离的,
+//      不是简单全局计数。
+// ---------------------------------------------------------------------------
+
+// TC-1052: M-R11-1 —— SyncPermissions 的 appKey 维度限流
+func TestGrpcSyncPermissions_AppKeyRateLimit(t *testing.T) {
+	ctx := context.Background()
+	svcCtx := svc.NewServiceContext(testutil.GetTestConfig())
+	cfg := testutil.GetTestConfig()
+	rds := redis.MustNewRedis(cfg.CacheRedis.Nodes[0].RedisConf)
+
+	svcCtx.GrpcSyncLimiter = limit.NewPeriodLimit(
+		60, 1, rds, cfg.CacheRedis.KeyPrefix+":rl:grpc:sync:ut:"+testutil.UniqueId())
+	srv := NewPermServer(svcCtx)
+
+	// 同一 appKey 的第 1 次:limiter 放行,业务层因 appKey 非法走 Unauthenticated。
+	appKey := "unknown_" + testutil.UniqueId()
+	_, err1 := srv.SyncPermissions(ctx, &pb.SyncPermissionsReq{
+		AppKey: appKey, AppSecret: "anything",
+		Perms: []*pb.PermItem{{Code: "p.a", Name: "A"}},
+	})
+	require.Error(t, err1)
+	st1, _ := status.FromError(err1)
+	assert.Equal(t, codes.Unauthenticated, st1.Code(),
+		"首次 limiter 放行,业务应因 appKey 不存在 Unauthenticated,非 ResourceExhausted")
+
+	// 同一 appKey 的第 2 次:必是 ResourceExhausted。
+	_, err2 := srv.SyncPermissions(ctx, &pb.SyncPermissionsReq{
+		AppKey: appKey, AppSecret: "whatever",
+		Perms: []*pb.PermItem{{Code: "p.b", Name: "B"}},
+	})
+	require.Error(t, err2)
+	st2, _ := status.FromError(err2)
+	assert.Equal(t, codes.ResourceExhausted, st2.Code(),
+		"M-R11-1:同 appKey 达到配额必须 ResourceExhausted;严禁恶意方反复重放触发 bcrypt / X 锁")
+	assert.Contains(t, st2.Message(), "过于频繁")
+
+	// 另一 appKey 放行:证明 limiter 按 appKey 隔离,不是全局计数器。
+	otherKey := "unknown_other_" + testutil.UniqueId()
+	_, err3 := srv.SyncPermissions(ctx, &pb.SyncPermissionsReq{
+		AppKey: otherKey, AppSecret: "whatever",
+		Perms: []*pb.PermItem{{Code: "p.c", Name: "C"}},
+	})
+	require.Error(t, err3)
+	st3, _ := status.FromError(err3)
+	assert.Equal(t, codes.Unauthenticated, st3.Code(),
+		"M-R11-1:limiter 桶键形如 'grpc:sync:<appKey>',不同 appKey 互不串扰")
+}
+
+// TC-1053: M-R11-1 —— SyncPermissions 空 AppKey 不消耗 limiter 配额
+// 代码里 `if req.AppKey != "" { Take(...) }` 的两层防护:
+//   1) 恶意方用空串连续打,不会把 limiter key space 膨胀为一个永不过期的"空串大桶";
+//   2) 业务层统一由 FindOneByAppKey("") 命中 ErrNotFound 返回 Unauthenticated。
+// 契约:空 AppKey 连打 3 次后,quota=1 的 limiter 仍然是**全新**状态;任意新 appKey 的第一次
+// 请求必须走业务层(Unauthenticated),绝不允许被 ResourceExhausted 截断。
+func TestGrpcSyncPermissions_EmptyAppKeyDoesNotConsumeQuota(t *testing.T) {
+	ctx := context.Background()
+	svcCtx := svc.NewServiceContext(testutil.GetTestConfig())
+	cfg := testutil.GetTestConfig()
+	rds := redis.MustNewRedis(cfg.CacheRedis.Nodes[0].RedisConf)
+
+	svcCtx.GrpcSyncLimiter = limit.NewPeriodLimit(
+		60, 1, rds, cfg.CacheRedis.KeyPrefix+":rl:grpc:sync:empty:"+testutil.UniqueId())
+	srv := NewPermServer(svcCtx)
+
+	for i := 0; i < 3; i++ {
+		_, err := srv.SyncPermissions(ctx, &pb.SyncPermissionsReq{
+			AppKey: "", AppSecret: "x",
+			Perms: []*pb.PermItem{{Code: "p", Name: "n"}},
+		})
+		require.Error(t, err)
+		st, _ := status.FromError(err)
+		assert.Equal(t, codes.Unauthenticated, st.Code(),
+			"空 AppKey 走 FindOneByAppKey('') → Unauthenticated;此路径不得触达 limiter")
+	}
+
+	// 真实新 AppKey 的第 1 次请求必须得到业务层的 Unauthenticated,
+	// 而不是因"空串占用配额"退化出的 ResourceExhausted。
+	realKey := "sync_empty_probe_" + testutil.UniqueId()
+	_, err := srv.SyncPermissions(ctx, &pb.SyncPermissionsReq{
+		AppKey: realKey, AppSecret: "x",
+		Perms: []*pb.PermItem{{Code: "p", Name: "n"}},
+	})
+	require.Error(t, err)
+	st, _ := status.FromError(err)
+	assert.Equal(t, codes.Unauthenticated, st.Code(),
+		"M-R11-1:空 AppKey 不消耗 limiter 配额;若这里返回 ResourceExhausted 则说明"+
+			"空串也被计数,`req.AppKey != \"\"` 前置分支缺失或被回退")
+}
+
+// TC-1054: M-R11-1 —— GetUserPerms 的 appKey 维度限流
+func TestGrpcGetUserPerms_AppKeyRateLimit(t *testing.T) {
+	ctx := context.Background()
+	svcCtx := svc.NewServiceContext(testutil.GetTestConfig())
+	cfg := testutil.GetTestConfig()
+	rds := redis.MustNewRedis(cfg.CacheRedis.Nodes[0].RedisConf)
+
+	svcCtx.GrpcGetUserPermsLimiter = limit.NewPeriodLimit(
+		60, 1, rds, cfg.CacheRedis.KeyPrefix+":rl:grpc:perms:ut:"+testutil.UniqueId())
+	srv := NewPermServer(svcCtx)
+
+	appKey := "perms_ak_" + testutil.UniqueId()
+	ctx1 := withPeerIP(ctx, "172.31.0.10:40001")
+	_, err1 := srv.GetUserPerms(ctx1, &pb.GetUserPermsReq{
+		AppKey: appKey, AppSecret: "x", ProductCode: "test_product", UserId: 1,
+	})
+	require.Error(t, err1)
+	st1, _ := status.FromError(err1)
+	assert.Equal(t, codes.Unauthenticated, st1.Code(),
+		"首次放行,业务层应因 appKey 不存在 Unauthenticated")
+
+	// 同 appKey 第二次:appKey 桶即告罄。
+	ctx2 := withPeerIP(ctx, "172.31.0.11:40002") // 换 IP,证明拦的是 appKey 桶而不是 IP 桶
+	_, err2 := srv.GetUserPerms(ctx2, &pb.GetUserPermsReq{
+		AppKey: appKey, AppSecret: "x", ProductCode: "test_product", UserId: 2,
+	})
+	require.Error(t, err2)
+	st2, _ := status.FromError(err2)
+	assert.Equal(t, codes.ResourceExhausted, st2.Code(),
+		"M-R11-1:同 appKey 达到 appKey 维度配额,必须 ResourceExhausted")
+}
+
+// TC-1055: M-R11-1 —— GetUserPerms 的 IP 维度限流
+// 双维度叠加意味着:若 appKey 维度没爆但 IP 维度爆了,同样必须拒绝。
+// 这里用两个不同 appKey(消耗两份 appKey 配额,各占 1 个)但共用同一源 IP,
+// 第 2 次因为 IP 桶也只剩 1 个配额而必定 ResourceExhausted。
+func TestGrpcGetUserPerms_IPRateLimit_OrthogonalToAppKey(t *testing.T) {
+	ctx := context.Background()
+	svcCtx := svc.NewServiceContext(testutil.GetTestConfig())
+	cfg := testutil.GetTestConfig()
+	rds := redis.MustNewRedis(cfg.CacheRedis.Nodes[0].RedisConf)
+
+	// quota=1:一次调用会消耗 appKey 桶 + IP 桶 各 1 个;
+	// 第 2 次用"新 appKey"但"同 IP",appKey 桶还够,IP 桶已见底 → IP 桶拒绝。
+	svcCtx.GrpcGetUserPermsLimiter = limit.NewPeriodLimit(
+		60, 1, rds, cfg.CacheRedis.KeyPrefix+":rl:grpc:perms:ip:"+testutil.UniqueId())
+	srv := NewPermServer(svcCtx)
+
+	appKeyA := "perms_ak_a_" + testutil.UniqueId()
+	appKeyB := "perms_ak_b_" + testutil.UniqueId()
+	ctxSameIP1 := withPeerIP(ctx, "198.51.100.7:50001")
+	ctxSameIP2 := withPeerIP(ctx, "198.51.100.7:50002") // 同 IP 不同端口
+
+	_, err1 := srv.GetUserPerms(ctxSameIP1, &pb.GetUserPermsReq{
+		AppKey: appKeyA, AppSecret: "x", ProductCode: "test_product", UserId: 1,
+	})
+	require.Error(t, err1)
+	st1, _ := status.FromError(err1)
+	assert.Equal(t, codes.Unauthenticated, st1.Code(),
+		"首次放行(appKey 桶 + IP 桶各耗 1 个)")
+
+	// 第 2 次:appKey 不同(appKey 桶还有配额),但同 IP 的 IP 桶已耗尽。
+	_, err2 := srv.GetUserPerms(ctxSameIP2, &pb.GetUserPermsReq{
+		AppKey: appKeyB, AppSecret: "x", ProductCode: "test_product", UserId: 2,
+	})
+	require.Error(t, err2)
+	st2, _ := status.FromError(err2)
+	assert.Equal(t, codes.ResourceExhausted, st2.Code(),
+		"M-R11-1:appKey 桶有余但 IP 桶已爆,必须 ResourceExhausted;双维度是'谁先爆谁拒'")
+}

+ 37 - 46
internal/server/permserver.go

@@ -58,6 +58,17 @@ func NewPermServer(svcCtx *svc.ServiceContext) *PermServer {
 
 
 // SyncPermissions 同步权限声明。产品服务端通过 appKey/appSecret 认证后批量同步权限定义(新增/更新/禁用不在列表中的权限)。
 // SyncPermissions 同步权限声明。产品服务端通过 appKey/appSecret 认证后批量同步权限定义(新增/更新/禁用不在列表中的权限)。
 func (s *PermServer) SyncPermissions(ctx context.Context, req *pb.SyncPermissionsReq) (*pb.SyncPermissionsResp, error) {
 func (s *PermServer) SyncPermissions(ctx context.Context, req *pb.SyncPermissionsReq) (*pb.SyncPermissionsResp, error) {
+	// 审计 M-R11-1:appKey 维度入口限流。此处不在有效性校验前做拦截是有意的——
+	// 桶 key 走 `req.AppKey` 的字面值,恶意方若只为耗配额而瞎填 AppKey,最多能把若干"不存在产品"
+	// 的计数器打到上限;对真实产品不构成放大。bcrypt.Compare(appSecret) 的 CPU 成本与事务级 X
+	// 锁(LockByCodeTx)都在限流之后才发生,恶意重放会被 OverQuota 提前截断。
+	if s.svcCtx.GrpcSyncLimiter != nil && req.AppKey != "" {
+		code, _ := s.svcCtx.GrpcSyncLimiter.Take(fmt.Sprintf("grpc:sync:%s", req.AppKey))
+		if code == limit.OverQuota {
+			return nil, status.Error(codes.ResourceExhausted, "请求过于频繁,请稍后再试")
+		}
+	}
+
 	items := make([]pub.SyncPermItem, len(req.Perms))
 	items := make([]pub.SyncPermItem, len(req.Perms))
 	for i, p := range req.Perms {
 	for i, p := range req.Perms {
 		items[i] = pub.SyncPermItem{Code: p.Code, Name: p.Name, Remark: p.Remark}
 		items[i] = pub.SyncPermItem{Code: p.Code, Name: p.Name, Remark: p.Remark}
@@ -200,60 +211,19 @@ func (s *PermServer) RefreshToken(ctx context.Context, req *pb.RefreshTokenReq)
 		}
 		}
 	}
 	}
 
 
-	// 审计 M-3:CAS 推进 tokenVersion 和签新令牌必须全部成功才能响应客户端,否则会出现
-	//   tokenVersion 已+1 但客户端仍拿着旧 refreshToken → 下一次刷新必 401 被强制登出
-	// 的"非预期登出"事件(会污染会话劫持告警)。改为"先试签 → 再 CAS":
-	//   (a) 拿 claims.TokenVersion+1 预试签发 access/refresh;签名若失败(HMAC 只有 OOM 等
-	//       极端情况才会失败)直接 500,DB 状态完全不动。
-	//   (b) 两个 token 都成功后再做 IncrementTokenVersionIfMatch 做并发唯一赢家 CAS;CAS 失败走
-	//       原来的 401/500 分支,客户端拿着的旧 refreshToken 仍然有效。
-	//   (c) CAS 赢家在返回前 Clean 缓存,保证 caller 下一次 Load 读到的是 DB 最新 tokenVersion。
-	// 注意:由于 CAS 的新 version 一定等于 claims.TokenVersion + 1(见 IncrementTokenVersionIfMatch
-	// 的 UPDATE 语义),这里直接按 claims.TokenVersion+1 预签即可,CAS 成功返回的 newVersion
-	// 只用于 assert。
-	predictedVersion := claims.TokenVersion + 1
-
-	accessToken, err := authHelper.GenerateAccessToken(
-		s.svcCtx.Config.Auth.AccessSecret, s.svcCtx.Config.Auth.AccessExpire,
-		ud.UserId, ud.Username, ud.ProductCode, ud.MemberType, predictedVersion,
-	)
-	if err != nil {
-		return nil, status.Error(codes.Internal, "生成token失败")
-	}
-
-	newRefreshToken, err := authHelper.GenerateRefreshTokenWithExpiry(
-		s.svcCtx.Config.Auth.RefreshSecret,
-		claims.ExpiresAt.Time,
-		ud.UserId, ud.ProductCode, predictedVersion,
-	)
-	if err != nil {
-		return nil, status.Error(codes.Internal, "生成token失败")
-	}
-
-	newVersion, err := s.svcCtx.SysUserModel.IncrementTokenVersionIfMatch(ctx, claims.UserId, ud.Username, claims.TokenVersion)
+	// 审计 L-R11-5:两条 RefreshToken 路径复用 authHelper.RotateRefreshToken,避免"试签 → CAS →
+	// Clean → forensic 比对"四步重复两次。gRPC 侧只做错误到 status code 的映射。
+	tokens, err := authHelper.RotateRefreshToken(ctx, s.svcCtx, claims, ud)
 	if err != nil {
 	if err != nil {
 		if errors.Is(err, userModel.ErrTokenVersionMismatch) {
 		if errors.Is(err, userModel.ErrTokenVersionMismatch) {
 			return nil, status.Error(codes.Unauthenticated, "登录状态已失效,请重新登录")
 			return nil, status.Error(codes.Unauthenticated, "登录状态已失效,请重新登录")
 		}
 		}
 		return nil, status.Error(codes.Internal, "刷新token失败")
 		return nil, status.Error(codes.Internal, "刷新token失败")
 	}
 	}
-	if newVersion != predictedVersion {
-		// 防御:CAS 成功时服务端约定 +1,实际不一致说明上游 SQL 实现漂移。告警后直接要求重登,
-		// 保证客户端不会被发一个 tokenVersion 对不上的 token。
-		logx.WithContext(ctx).Errorw("refresh token version prediction mismatch",
-			logx.Field("audit", "refresh_token_version_mismatch"),
-			logx.Field("userId", claims.UserId),
-			logx.Field("claimed", claims.TokenVersion),
-			logx.Field("predicted", predictedVersion),
-			logx.Field("actual", newVersion),
-		)
-		return nil, status.Error(codes.Unauthenticated, "登录状态已失效,请重新登录")
-	}
-	s.svcCtx.UserDetailsLoader.Clean(ctx, claims.UserId)
 
 
 	return &pb.RefreshTokenResp{
 	return &pb.RefreshTokenResp{
-		AccessToken:  accessToken,
-		RefreshToken: newRefreshToken,
+		AccessToken:  tokens.AccessToken,
+		RefreshToken: tokens.RefreshToken,
 		Expires:      time.Now().Unix() + s.svcCtx.Config.Auth.AccessExpire,
 		Expires:      time.Now().Unix() + s.svcCtx.Config.Auth.AccessExpire,
 	}, nil
 	}, nil
 }
 }
@@ -329,6 +299,27 @@ func (s *PermServer) VerifyToken(ctx context.Context, req *pb.VerifyTokenReq) (*
 
 
 // GetUserPerms 查询用户权限。产品服务端通过 appKey/appSecret 认证后查询指定用户在该产品下的成员类型和权限列表,用于产品侧的权限网关判定。
 // GetUserPerms 查询用户权限。产品服务端通过 appKey/appSecret 认证后查询指定用户在该产品下的成员类型和权限列表,用于产品侧的权限网关判定。
 func (s *PermServer) GetUserPerms(ctx context.Context, req *pb.GetUserPermsReq) (*pb.GetUserPermsResp, error) {
 func (s *PermServer) GetUserPerms(ctx context.Context, req *pb.GetUserPermsReq) (*pb.GetUserPermsResp, error) {
+	// 审计 M-R11-1:入口限流,双维度(appKey + 源 IP)叠加。
+	//   - appKey 维度抵御"合法产品凭证泄露后遍历 userId 爆缓存/打穿 DB";
+	//   - IP 维度抵御"同一产品多后端实例被 DDoS 放大时把合法产品打过配额";两者谁先过限都拒绝,
+	//     以真实业务量级(单产品最多数千活跃成员、单 userId QPS 远低于 1k/min)来衡量不会误杀。
+	if s.svcCtx.GrpcGetUserPermsLimiter != nil && req.AppKey != "" {
+		code, _ := s.svcCtx.GrpcGetUserPermsLimiter.Take(fmt.Sprintf("grpc:perms:%s", req.AppKey))
+		if code == limit.OverQuota {
+			return nil, status.Error(codes.ResourceExhausted, "请求过于频繁,请稍后再试")
+		}
+	}
+	if s.svcCtx.GrpcGetUserPermsLimiter != nil {
+		clientIP, ipErr := extractClientIP(ctx)
+		if ipErr != nil {
+			clientIP = unknownPeerBucket
+		}
+		code, _ := s.svcCtx.GrpcGetUserPermsLimiter.Take(fmt.Sprintf("grpc:perms-ip:%s", clientIP))
+		if code == limit.OverQuota {
+			return nil, status.Error(codes.ResourceExhausted, "请求过于频繁,请稍后再试")
+		}
+	}
+
 	product, err := s.svcCtx.SysProductModel.FindOneByAppKey(ctx, req.AppKey)
 	product, err := s.svcCtx.SysProductModel.FindOneByAppKey(ctx, req.AppKey)
 	if err != nil {
 	if err != nil {
 		return nil, status.Error(codes.Unauthenticated, "无效的appKey")
 		return nil, status.Error(codes.Unauthenticated, "无效的appKey")

+ 11 - 0
internal/svc/servicecontext.go

@@ -22,6 +22,8 @@ type ServiceContext struct {
 	GrpcLoginLimiter      *limit.PeriodLimit
 	GrpcLoginLimiter      *limit.PeriodLimit
 	GrpcRefreshLimiter    *limit.PeriodLimit
 	GrpcRefreshLimiter    *limit.PeriodLimit
 	GrpcVerifyLimiter     *limit.PeriodLimit
 	GrpcVerifyLimiter     *limit.PeriodLimit
+	GrpcSyncLimiter       *limit.PeriodLimit
+	GrpcGetUserPermsLimiter *limit.PeriodLimit
 	UsernameLoginLimit    *limit.PeriodLimit
 	UsernameLoginLimit    *limit.PeriodLimit
 	TokenOpLimiter        *limit.PeriodLimit
 	TokenOpLimiter        *limit.PeriodLimit
 	UserDetailsLoader     *loaders.UserDetailsLoader
 	UserDetailsLoader     *loaders.UserDetailsLoader
@@ -43,6 +45,13 @@ func NewServiceContext(c config.Config) *ServiceContext {
 	grpcRefreshLimiter := limit.NewPeriodLimit(60, 30, rds, c.CacheRedis.KeyPrefix+":rl:grpc:refresh")
 	grpcRefreshLimiter := limit.NewPeriodLimit(60, 30, rds, c.CacheRedis.KeyPrefix+":rl:grpc:refresh")
 	// gRPC verifyToken 是下游每请求都会调用的热路径,阈值必须足够高;这里的作用是兜底防止下游被攻破后把权限中心当 token oracle 爆破。
 	// gRPC verifyToken 是下游每请求都会调用的热路径,阈值必须足够高;这里的作用是兜底防止下游被攻破后把权限中心当 token oracle 爆破。
 	grpcVerifyLimiter := limit.NewPeriodLimit(60, 6000, rds, c.CacheRedis.KeyPrefix+":rl:grpc:verify")
 	grpcVerifyLimiter := limit.NewPeriodLimit(60, 6000, rds, c.CacheRedis.KeyPrefix+":rl:grpc:verify")
+	// 审计 M-R11-1:gRPC SyncPermissions / GetUserPerms 原来没有入口限流,而 HTTP 侧 /api/perm/sync
+	// 已经挂 SyncRateLimit。限流 key 走 appKey 维度,避免按 IP 把"同一产品不同后端实例共享 egress"
+	// 整组误伤。桶位按单产品实际节奏给出:单产品每分钟 60 次同步足以覆盖多实例并发发版的真实用量,
+	// GetUserPerms 1000 次/分钟/产品覆盖多实例冷启动预热峰值,而 appSecret 泄露时能把放大系数压回
+	// 可控量级。
+	grpcSyncLimiter := limit.NewPeriodLimit(60, 60, rds, c.CacheRedis.KeyPrefix+":rl:grpc:sync")
+	grpcGetUserPermsLimiter := limit.NewPeriodLimit(60, 1000, rds, c.CacheRedis.KeyPrefix+":rl:grpc:perms")
 	usernameLimiter := limit.NewPeriodLimit(300, 10, rds, c.CacheRedis.KeyPrefix+":rl:user")
 	usernameLimiter := limit.NewPeriodLimit(300, 10, rds, c.CacheRedis.KeyPrefix+":rl:user")
 	tokenOpLimiter := limit.NewPeriodLimit(60, 10, rds, c.CacheRedis.KeyPrefix+":rl:tokenop")
 	tokenOpLimiter := limit.NewPeriodLimit(60, 10, rds, c.CacheRedis.KeyPrefix+":rl:tokenop")
 
 
@@ -56,6 +65,8 @@ func NewServiceContext(c config.Config) *ServiceContext {
 		GrpcLoginLimiter:      grpcLimiter,
 		GrpcLoginLimiter:      grpcLimiter,
 		GrpcRefreshLimiter:    grpcRefreshLimiter,
 		GrpcRefreshLimiter:    grpcRefreshLimiter,
 		GrpcVerifyLimiter:     grpcVerifyLimiter,
 		GrpcVerifyLimiter:     grpcVerifyLimiter,
+		GrpcSyncLimiter:       grpcSyncLimiter,
+		GrpcGetUserPermsLimiter: grpcGetUserPermsLimiter,
 		UsernameLoginLimit:    usernameLimiter,
 		UsernameLoginLimit:    usernameLimiter,
 		TokenOpLimiter:        tokenOpLimiter,
 		TokenOpLimiter:        tokenOpLimiter,
 		UserDetailsLoader:     udLoader,
 		UserDetailsLoader:     udLoader,

+ 13 - 28
internal/testutil/mocks/mock_dept_model.go

@@ -170,49 +170,34 @@ func (mr *MockSysDeptModelMockRecorder) FindAll(ctx any) *gomock.Call {
 	return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "FindAll", reflect.TypeOf((*MockSysDeptModel)(nil).FindAll), ctx)
 	return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "FindAll", reflect.TypeOf((*MockSysDeptModel)(nil).FindAll), ctx)
 }
 }
 
 
-// FindByParentId mocks base method.
-func (m *MockSysDeptModel) FindByParentId(ctx context.Context, parentId int64) ([]*dept.SysDept, error) {
-	m.ctrl.T.Helper()
-	ret := m.ctrl.Call(m, "FindByParentId", ctx, parentId)
-	ret0, _ := ret[0].([]*dept.SysDept)
-	ret1, _ := ret[1].(error)
-	return ret0, ret1
-}
-
-// FindByParentId indicates an expected call of FindByParentId.
-func (mr *MockSysDeptModelMockRecorder) FindByParentId(ctx, parentId any) *gomock.Call {
-	mr.mock.ctrl.T.Helper()
-	return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "FindByParentId", reflect.TypeOf((*MockSysDeptModel)(nil).FindByParentId), ctx, parentId)
-}
-
-// FindByPathPrefix mocks base method.
-func (m *MockSysDeptModel) FindByPathPrefix(ctx context.Context, pathPrefix string) ([]*dept.SysDept, error) {
+// FindOne mocks base method.
+func (m *MockSysDeptModel) FindOne(ctx context.Context, id int64) (*dept.SysDept, error) {
 	m.ctrl.T.Helper()
 	m.ctrl.T.Helper()
-	ret := m.ctrl.Call(m, "FindByPathPrefix", ctx, pathPrefix)
-	ret0, _ := ret[0].([]*dept.SysDept)
+	ret := m.ctrl.Call(m, "FindOne", ctx, id)
+	ret0, _ := ret[0].(*dept.SysDept)
 	ret1, _ := ret[1].(error)
 	ret1, _ := ret[1].(error)
 	return ret0, ret1
 	return ret0, ret1
 }
 }
 
 
-// FindByPathPrefix indicates an expected call of FindByPathPrefix.
-func (mr *MockSysDeptModelMockRecorder) FindByPathPrefix(ctx, pathPrefix any) *gomock.Call {
+// FindOne indicates an expected call of FindOne.
+func (mr *MockSysDeptModelMockRecorder) FindOne(ctx, id any) *gomock.Call {
 	mr.mock.ctrl.T.Helper()
 	mr.mock.ctrl.T.Helper()
-	return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "FindByPathPrefix", reflect.TypeOf((*MockSysDeptModel)(nil).FindByPathPrefix), ctx, pathPrefix)
+	return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "FindOne", reflect.TypeOf((*MockSysDeptModel)(nil).FindOne), ctx, id)
 }
 }
 
 
-// FindOne mocks base method.
-func (m *MockSysDeptModel) FindOne(ctx context.Context, id int64) (*dept.SysDept, error) {
+// FindOneForShareTx mocks base method.
+func (m *MockSysDeptModel) FindOneForShareTx(ctx context.Context, session sqlx.Session, id int64) (*dept.SysDept, error) {
 	m.ctrl.T.Helper()
 	m.ctrl.T.Helper()
-	ret := m.ctrl.Call(m, "FindOne", ctx, id)
+	ret := m.ctrl.Call(m, "FindOneForShareTx", ctx, session, id)
 	ret0, _ := ret[0].(*dept.SysDept)
 	ret0, _ := ret[0].(*dept.SysDept)
 	ret1, _ := ret[1].(error)
 	ret1, _ := ret[1].(error)
 	return ret0, ret1
 	return ret0, ret1
 }
 }
 
 
-// FindOne indicates an expected call of FindOne.
-func (mr *MockSysDeptModelMockRecorder) FindOne(ctx, id any) *gomock.Call {
+// FindOneForShareTx indicates an expected call of FindOneForShareTx.
+func (mr *MockSysDeptModelMockRecorder) FindOneForShareTx(ctx, session, id any) *gomock.Call {
 	mr.mock.ctrl.T.Helper()
 	mr.mock.ctrl.T.Helper()
-	return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "FindOne", reflect.TypeOf((*MockSysDeptModel)(nil).FindOne), ctx, id)
+	return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "FindOneForShareTx", reflect.TypeOf((*MockSysDeptModel)(nil).FindOneForShareTx), ctx, session, id)
 }
 }
 
 
 // FindOneWithTx mocks base method.
 // FindOneWithTx mocks base method.

+ 26 - 12
internal/testutil/mocks/mock_user_model.go

@@ -279,18 +279,18 @@ func (mr *MockSysUserModelMockRecorder) FindOneWithTx(ctx, session, id any) *gom
 }
 }
 
 
 // IncrementTokenVersion mocks base method.
 // IncrementTokenVersion mocks base method.
-func (m *MockSysUserModel) IncrementTokenVersion(ctx context.Context, id int64) (int64, error) {
+func (m *MockSysUserModel) IncrementTokenVersion(ctx context.Context, id int64, username string) (int64, error) {
 	m.ctrl.T.Helper()
 	m.ctrl.T.Helper()
-	ret := m.ctrl.Call(m, "IncrementTokenVersion", ctx, id)
+	ret := m.ctrl.Call(m, "IncrementTokenVersion", ctx, id, username)
 	ret0, _ := ret[0].(int64)
 	ret0, _ := ret[0].(int64)
 	ret1, _ := ret[1].(error)
 	ret1, _ := ret[1].(error)
 	return ret0, ret1
 	return ret0, ret1
 }
 }
 
 
 // IncrementTokenVersion indicates an expected call of IncrementTokenVersion.
 // IncrementTokenVersion indicates an expected call of IncrementTokenVersion.
-func (mr *MockSysUserModelMockRecorder) IncrementTokenVersion(ctx, id any) *gomock.Call {
+func (mr *MockSysUserModelMockRecorder) IncrementTokenVersion(ctx, id, username any) *gomock.Call {
 	mr.mock.ctrl.T.Helper()
 	mr.mock.ctrl.T.Helper()
-	return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "IncrementTokenVersion", reflect.TypeOf((*MockSysUserModel)(nil).IncrementTokenVersion), ctx, id)
+	return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "IncrementTokenVersion", reflect.TypeOf((*MockSysUserModel)(nil).IncrementTokenVersion), ctx, id, username)
 }
 }
 
 
 // IncrementTokenVersionIfMatch mocks base method.
 // IncrementTokenVersionIfMatch mocks base method.
@@ -381,17 +381,17 @@ func (mr *MockSysUserModelMockRecorder) Update(ctx, data any) *gomock.Call {
 }
 }
 
 
 // UpdatePassword mocks base method.
 // UpdatePassword mocks base method.
-func (m *MockSysUserModel) UpdatePassword(ctx context.Context, id int64, password string, mustChangePassword int64) error {
+func (m *MockSysUserModel) UpdatePassword(ctx context.Context, id int64, username, password string, mustChangePassword, expectedUpdateTime int64) error {
 	m.ctrl.T.Helper()
 	m.ctrl.T.Helper()
-	ret := m.ctrl.Call(m, "UpdatePassword", ctx, id, password, mustChangePassword)
+	ret := m.ctrl.Call(m, "UpdatePassword", ctx, id, username, password, mustChangePassword, expectedUpdateTime)
 	ret0, _ := ret[0].(error)
 	ret0, _ := ret[0].(error)
 	return ret0
 	return ret0
 }
 }
 
 
 // UpdatePassword indicates an expected call of UpdatePassword.
 // UpdatePassword indicates an expected call of UpdatePassword.
-func (mr *MockSysUserModelMockRecorder) UpdatePassword(ctx, id, password, mustChangePassword any) *gomock.Call {
+func (mr *MockSysUserModelMockRecorder) UpdatePassword(ctx, id, username, password, mustChangePassword, expectedUpdateTime any) *gomock.Call {
 	mr.mock.ctrl.T.Helper()
 	mr.mock.ctrl.T.Helper()
-	return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpdatePassword", reflect.TypeOf((*MockSysUserModel)(nil).UpdatePassword), ctx, id, password, mustChangePassword)
+	return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpdatePassword", reflect.TypeOf((*MockSysUserModel)(nil).UpdatePassword), ctx, id, username, password, mustChangePassword, expectedUpdateTime)
 }
 }
 
 
 // UpdateProfile mocks base method.
 // UpdateProfile mocks base method.
@@ -408,18 +408,32 @@ func (mr *MockSysUserModelMockRecorder) UpdateProfile(ctx, id, username, nicknam
 	return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpdateProfile", reflect.TypeOf((*MockSysUserModel)(nil).UpdateProfile), ctx, id, username, nickname, email, phone, remark, deptId, newStatus, statusChanged, expectedUpdateTime)
 	return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpdateProfile", reflect.TypeOf((*MockSysUserModel)(nil).UpdateProfile), ctx, id, username, nickname, email, phone, remark, deptId, newStatus, statusChanged, expectedUpdateTime)
 }
 }
 
 
+// UpdateProfileWithTx mocks base method.
+func (m *MockSysUserModel) UpdateProfileWithTx(ctx context.Context, session sqlx.Session, id int64, username, nickname, email, phone, remark string, deptId, newStatus int64, statusChanged bool, expectedUpdateTime int64) error {
+	m.ctrl.T.Helper()
+	ret := m.ctrl.Call(m, "UpdateProfileWithTx", ctx, session, id, username, nickname, email, phone, remark, deptId, newStatus, statusChanged, expectedUpdateTime)
+	ret0, _ := ret[0].(error)
+	return ret0
+}
+
+// UpdateProfileWithTx indicates an expected call of UpdateProfileWithTx.
+func (mr *MockSysUserModelMockRecorder) UpdateProfileWithTx(ctx, session, id, username, nickname, email, phone, remark, deptId, newStatus, statusChanged, expectedUpdateTime any) *gomock.Call {
+	mr.mock.ctrl.T.Helper()
+	return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpdateProfileWithTx", reflect.TypeOf((*MockSysUserModel)(nil).UpdateProfileWithTx), ctx, session, id, username, nickname, email, phone, remark, deptId, newStatus, statusChanged, expectedUpdateTime)
+}
+
 // UpdateStatus mocks base method.
 // UpdateStatus mocks base method.
-func (m *MockSysUserModel) UpdateStatus(ctx context.Context, id, status, expectedUpdateTime int64) error {
+func (m *MockSysUserModel) UpdateStatus(ctx context.Context, id int64, username string, status, expectedUpdateTime int64) error {
 	m.ctrl.T.Helper()
 	m.ctrl.T.Helper()
-	ret := m.ctrl.Call(m, "UpdateStatus", ctx, id, status, expectedUpdateTime)
+	ret := m.ctrl.Call(m, "UpdateStatus", ctx, id, username, status, expectedUpdateTime)
 	ret0, _ := ret[0].(error)
 	ret0, _ := ret[0].(error)
 	return ret0
 	return ret0
 }
 }
 
 
 // UpdateStatus indicates an expected call of UpdateStatus.
 // UpdateStatus indicates an expected call of UpdateStatus.
-func (mr *MockSysUserModelMockRecorder) UpdateStatus(ctx, id, status, expectedUpdateTime any) *gomock.Call {
+func (mr *MockSysUserModelMockRecorder) UpdateStatus(ctx, id, username, status, expectedUpdateTime any) *gomock.Call {
 	mr.mock.ctrl.T.Helper()
 	mr.mock.ctrl.T.Helper()
-	return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpdateStatus", reflect.TypeOf((*MockSysUserModel)(nil).UpdateStatus), ctx, id, status, expectedUpdateTime)
+	return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpdateStatus", reflect.TypeOf((*MockSysUserModel)(nil).UpdateStatus), ctx, id, username, status, expectedUpdateTime)
 }
 }
 
 
 // UpdateWithTx mocks base method.
 // UpdateWithTx mocks base method.

+ 3 - 3
internal/types/types.go

@@ -243,9 +243,9 @@ type UpdateDeptReq struct {
 }
 }
 
 
 type UpdateMemberReq struct {
 type UpdateMemberReq struct {
-	Id         int64  `json:"id"`
-	MemberType string `json:"memberType"`
-	Status     int64  `json:"status,optional"`
+	Id         int64   `json:"id"`
+	MemberType *string `json:"memberType,optional"`
+	Status     *int64  `json:"status,optional"`
 }
 }
 
 
 type UpdateProductReq struct {
 type UpdateProductReq struct {