Prechádzať zdrojové kódy

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

BaiLuoYan 3 týždňov pred
rodič
commit
24373b23da
36 zmenil súbory, kde vykonal 2130 pridanie a 237 odobranie
  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,
 		}, nil)
 	// 关键:强制底层返回 ErrUpdateConflict。
+	// H-R11-1:签名增加 username 与 expectedUpdateTime 两个透传参数。
 	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)
 
 	svcCtx := mocks.NewMockServiceContext(mocks.MockModels{User: mockUser})
@@ -89,7 +90,7 @@ func TestChangePassword_GenericUpdateError_StillPropagates(t *testing.T) {
 		}, nil)
 	genericErr := errors.New("driver: bad connection")
 	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)
 
 	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
 	}
 
-	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 口径对齐,
 		// 把乐观锁失败显式映射成 409,避免 raw error 被 rest 框架兜成 500、前端错把"并发冲突"
 		// 当作系统故障处理,告警看板也不会把这类事件归到 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 令牌立即失效,并清除用户缓存。
 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("未登录")
 	}
+	userId := ud.UserId
 
 	if l.svcCtx.TokenOpLimiter != nil {
 		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。
 		// Logout 的语义目标本就是"让该账号的旧令牌立即失效",用户已经消失等同语义已达成,
 		// 按幂等成功处理并继续清缓存,不要让一次正常的注销因为极罕见的删号竞态回 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"
 )
 
+// strPtr / int64Ptr 是 L-R11-1 后 UpdateMemberReq.MemberType / Status 指针化的 helper。
+// 若 nil 表示不改该字段,两者都 nil 会被 Logic 400。
+func strPtr(s string) *string { return &s }
+
 type seededProduct struct {
 	code  string
 	pId   int64
@@ -170,7 +174,7 @@ func TestUpdateMember_DemoteLastAdminRejected(t *testing.T) {
 	t.Cleanup(func() { cleanupSeeded(t, svcCtx, sp) })
 
 	err := NewUpdateMemberLogic(ctx, svcCtx).UpdateMember(&types.UpdateMemberReq{
-		Id: sp.mId, MemberType: "MEMBER",
+		Id: sp.mId, MemberType: strPtr("MEMBER"),
 	})
 	require.Error(t, err)
 	var ce *response.CodeError
@@ -224,7 +228,7 @@ func TestUpdateMember_DemoteAdmin_WhenMultiple_Allowed(t *testing.T) {
 	})
 
 	err = NewUpdateMemberLogic(ctx, svcCtx).UpdateMember(&types.UpdateMemberReq{
-		Id: mIds[0], MemberType: "MEMBER",
+		Id: mIds[0], MemberType: strPtr("MEMBER"),
 	})
 	require.NoError(t, err)
 
@@ -277,7 +281,7 @@ func TestUpdateMember_DemoteLastActiveAdmin_Rejected(t *testing.T) {
 
 	// 启用中的那个 ADMIN (mIds[0]) 降级应被拒绝
 	err = NewUpdateMemberLogic(ctx, svcCtx).UpdateMember(&types.UpdateMemberReq{
-		Id: mIds[0], MemberType: "DEVELOPER",
+		Id: mIds[0], MemberType: strPtr("DEVELOPER"),
 	})
 	require.Error(t, err)
 	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 时会被拒绝以保证产品始终有管理员。
+// 审计 L-R11-1:memberType / status 均为指针可选,nil 表示不改该字段;两者都为 nil 时直接 400。
 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)
 	if err != nil {
 		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 {
 		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
-	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(禁用)")
 		}
-		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 {

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

@@ -55,10 +55,12 @@ func TestUpdateMember_Normal(t *testing.T) {
 	})
 
 	logic := NewUpdateMemberLogic(ctx, svcCtx)
+	mt := "ADMIN"
+	st := int64(2)
 	err = logic.UpdateMember(&types.UpdateMemberReq{
 		Id:         mId,
-		MemberType: "ADMIN",
-		Status:     2,
+		MemberType: &mt,
+		Status:     &st,
 	})
 	require.NoError(t, err)
 
@@ -105,9 +107,10 @@ func TestUpdateMember_InvalidMemberType(t *testing.T) {
 	})
 
 	logic := NewUpdateMemberLogic(ctx, svcCtx)
+	mt := "INVALID"
 	err = logic.UpdateMember(&types.UpdateMemberReq{
 		Id:         mId,
-		MemberType: "INVALID",
+		MemberType: &mt,
 	})
 	require.Error(t, err)
 	ce, ok := err.(*response.CodeError)
@@ -122,9 +125,10 @@ func TestUpdateMember_NotFound(t *testing.T) {
 	svcCtx := svc.NewServiceContext(testutil.GetTestConfig())
 
 	logic := NewUpdateMemberLogic(ctx, svcCtx)
+	mt := "ADMIN"
 	err := logic.UpdateMember(&types.UpdateMemberReq{
 		Id:         999999999,
-		MemberType: "ADMIN",
+		MemberType: &mt,
 	})
 	require.Error(t, err)
 	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 errors.Is(err, userModel.ErrTokenVersionMismatch) {
 			return nil, response.ErrUnauthorized("登录状态已失效,请重新登录")
 		}
 		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{
-		AccessToken:  accessToken,
-		RefreshToken: newRefreshToken,
+		AccessToken:  tokens.AccessToken,
+		RefreshToken: tokens.RefreshToken,
 		Expires:      time.Now().Unix() + l.svcCtx.Config.Auth.AccessExpire,
 		UserInfo: types.UserInfo{
 			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: "同步权限事务失败"}
 	}
 
-	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)
 	}
 

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

@@ -15,6 +15,7 @@ import (
 	"perms-system-server/internal/util"
 
 	"github.com/zeromicro/go-zero/core/logx"
+	"github.com/zeromicro/go-zero/core/stores/sqlx"
 )
 
 type UpdateUserLogic struct {
@@ -150,11 +151,48 @@ func (l *UpdateUserLogic) UpdateUser(req *types.UpdateUserReq) error {
 	if statusChanged {
 		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) {
 			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 并发冻结/解冻时
 	// 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) {
 			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 严格推进。
 	time.Sleep(1100 * time.Millisecond)
 	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 已推进
@@ -64,7 +64,7 @@ func TestUpdateUserStatus_LN4_OptimisticLockConflictReturns409(t *testing.T) {
 	// 正常场景 409 文案。
 
 	// (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)
 	// 这里拿到的是 ErrUpdateConflict;Logic 层负责包装成 409。
 	require.Contains(t, errConf.Error(), "conflict")
@@ -99,7 +99,7 @@ func TestUpdateUserStatus_LN4_ConflictMappedTo409Message(t *testing.T) {
 	// 直接模拟:调用 UpdateStatus 传 0 作为 expectedUpdateTime(一定和真实 updateTime 不同),
 	// 模型层必然 ErrUpdateConflict;Logic 层借同一套响应映射把它暴给上层 409。
 	// 这里我们借 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)
 
 	// 映射与 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
 		FindAll(ctx context.Context) ([]*SysDept, 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 {
@@ -41,6 +47,15 @@ func (m *customSysDeptModel) FindAll(ctx context.Context) ([]*SysDept, error) {
 	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 {
 	sysDeptIdKey := fmt.Sprintf("%s%v", cacheSysDeptIdPrefix, data.Id)
 	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, ","))
 	}
 
-	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 {
 		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
 }
 
-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)
 	for _, data := range list {
 		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 {
-	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 {
 		return err
 	}
 	if len(list) == 0 {
 		return nil
 	}
-	keys := m.buildCacheKeys(list)
+	keys := m.buildCacheKeysFromKeys(list)
 	_, err := m.ExecCtx(ctx, func(ctx context.Context, conn sqlx.SqlConn) (sql.Result, error) {
 		query := fmt.Sprintf("DELETE FROM %s WHERE `roleId` = ?", m.table)
 		return session.ExecCtx(ctx, query, roleId)
@@ -113,15 +120,15 @@ func (m *customSysRolePermModel) DeleteByRoleIdAndPermIdsTx(ctx context.Context,
 	}
 	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 {
 		return err
 	}
 	if len(list) == 0 {
 		return nil
 	}
-	keys := m.buildCacheKeys(list)
+	keys := m.buildCacheKeysFromKeys(list)
 	_, 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)
 		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) })
 
 	for expected := int64(8); expected <= 12; expected++ {
-		got, err := m.IncrementTokenVersion(ctx, id)
+		got, err := m.IncrementTokenVersion(ctx, id, username)
 		require.NoError(t, err)
 		assert.Equal(t, expected, got,
 			"IncrementTokenVersion 必须返回 DB 真实递增后的值(H-B:不可再受 stale cache 影响)")
@@ -71,7 +71,7 @@ func TestSysUserModel_IncrementTokenVersion_InvalidatesCache(t *testing.T) {
 	require.NoError(t, err)
 	require.Equal(t, int64(0), u0b.TokenVersion)
 
-	_, err = m.IncrementTokenVersion(ctx, id)
+	_, err = m.IncrementTokenVersion(ctx, id, username)
 	require.NoError(t, err)
 
 	u1, err := m.FindOne(ctx, id)
@@ -108,7 +108,7 @@ func TestSysUserModel_IncrementTokenVersion_ConcurrentUnique(t *testing.T) {
 		wg.Add(1)
 		go func(idx int) {
 			defer wg.Done()
-			v, e := m.IncrementTokenVersion(ctx, id)
+			v, e := m.IncrementTokenVersion(ctx, id, username)
 			results[idx] = v
 			errs[idx] = e
 		}(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)
 		FindByIds(ctx context.Context, ids []int64) ([]*SysUser, 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
-		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)
 	}
 
@@ -125,17 +141,41 @@ func (m *customSysUserModel) UpdateProfile(ctx context.Context, id int64, userna
 	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 {
 		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)
-	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) {
 		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)
@@ -144,8 +184,7 @@ func (m *customSysUserModel) UpdatePassword(ctx context.Context, id int64, passw
 		return err
 	}
 	if affected, _ := res.RowsAffected(); affected == 0 {
-		// 行被删除或被并发改过:对外统一回 ErrUpdateConflict,避免对已删除用户返回 nil 让上层
-		// 误判为"改密成功"(审计 M-2)。
+		// 行被删除或被并发改过(任何字段,包括 status/password/profile):统一回 ErrUpdateConflict。
 		return ErrUpdateConflict
 	}
 	return nil
@@ -158,14 +197,11 @@ func (m *customSysUserModel) UpdatePassword(ctx context.Context, id int64, passw
 //   - expectedUpdateTime 不匹配 → ErrUpdateConflict;上层统一回 409 "数据已被其他操作修改"。
 //   - 避免并发冻结/解冻请求走"last-write-wins",出现两个 admin 同时点"冻结"/"解冻"
 //     时后到者覆盖先到者、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)
-	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) {
 		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)
@@ -187,17 +223,14 @@ func (m *customSysUserModel) UpdateStatus(ctx context.Context, id int64, status
 // 必须调用 IncrementTokenVersionIfMatch 走 CAS 语义,否则会回到 R5 以前的并发 rotate 窗口,
 // 两次并发 refresh 都能换到新令牌,等同于会话劫持(见审计 L-2)。
 // 调用前请先走 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)
-	sysUserUsernameKey := fmt.Sprintf("%s%v", cacheSysUserUsernamePrefix, data.Username)
+	sysUserUsernameKey := fmt.Sprintf("%s%v", cacheSysUserUsernamePrefix, username)
 
 	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)
 		res, err := session.ExecCtx(ctx, query, time.Now().Unix(), id)
 		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)
 	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,
-		"M-2:RowsAffected=0 必须升格为 ErrUpdateConflict,杜绝对已消失用户的静默改密")
+		"M-2/H-R11-1:RowsAffected=0 必须升格为 ErrUpdateConflict,杜绝对已消失用户的静默改密")
 }
 
 // TC-0925: UpdateStatus 对已被并发删除(缓存仍在)的用户必须 fail-fast,禁止静默成功
@@ -81,9 +87,9 @@ func TestSysUserModel_UpdateStatus_RowDeletedBetweenFindAndExec_ReturnsConflict(
 	if staleUd != nil {
 		expectedUpdateTime = staleUd.UpdateTime
 	}
-	err = m.UpdateStatus(ctx, id, 2, expectedUpdateTime)
+	err = m.UpdateStatus(ctx, id, username, 2, expectedUpdateTime)
 	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 不误伤正常流)
@@ -108,7 +114,7 @@ func TestSysUserModel_UpdatePassword_HappyPath_PersistsAndBumpsTokenVersion(t *t
 	time.Sleep(1100 * time.Millisecond)
 
 	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)
 
 	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
 	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)
 
 	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 必须推进,否则后续乐观锁失效")
 }
 
-// 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()
 	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()
 	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 {
-	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 {
 		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
 }
 
-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)
 	for _, data := range list {
 		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 {
-	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 {
 		return err
 	}
 	if len(list) == 0 {
 		return nil
 	}
-	keys := m.buildCacheKeys(list)
+	keys := m.buildCacheKeysFromKeys(list)
 	_, err := m.ExecCtx(ctx, func(ctx context.Context, conn sqlx.SqlConn) (sql.Result, error) {
 		query := fmt.Sprintf("DELETE FROM %s WHERE `roleId` = ?", m.table)
 		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 {
-	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 {
 		return err
 	}
 	if len(list) == 0 {
 		return nil
 	}
-	keys := m.buildCacheKeys(list)
+	keys := m.buildCacheKeysFromKeys(list)
 	_, 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)
 		return session.ExecCtx(ctx, query, userId, productCode)
@@ -149,15 +159,15 @@ func (m *customSysUserRoleModel) DeleteByUserIdAndRoleIdsTx(ctx context.Context,
 	}
 	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 {
 		return err
 	}
 	if len(list) == 0 {
 		return nil
 	}
-	keys := m.buildCacheKeys(list)
+	keys := m.buildCacheKeysFromKeys(list)
 	_, 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)
 		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 认证后批量同步权限定义(新增/更新/禁用不在列表中的权限)。
 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))
 	for i, p := range req.Perms {
 		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 errors.Is(err, userModel.ErrTokenVersionMismatch) {
 			return nil, status.Error(codes.Unauthenticated, "登录状态已失效,请重新登录")
 		}
 		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{
-		AccessToken:  accessToken,
-		RefreshToken: newRefreshToken,
+		AccessToken:  tokens.AccessToken,
+		RefreshToken: tokens.RefreshToken,
 		Expires:      time.Now().Unix() + s.svcCtx.Config.Auth.AccessExpire,
 	}, nil
 }
@@ -329,6 +299,27 @@ func (s *PermServer) VerifyToken(ctx context.Context, req *pb.VerifyTokenReq) (*
 
 // GetUserPerms 查询用户权限。产品服务端通过 appKey/appSecret 认证后查询指定用户在该产品下的成员类型和权限列表,用于产品侧的权限网关判定。
 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)
 	if err != nil {
 		return nil, status.Error(codes.Unauthenticated, "无效的appKey")

+ 11 - 0
internal/svc/servicecontext.go

@@ -22,6 +22,8 @@ type ServiceContext struct {
 	GrpcLoginLimiter      *limit.PeriodLimit
 	GrpcRefreshLimiter    *limit.PeriodLimit
 	GrpcVerifyLimiter     *limit.PeriodLimit
+	GrpcSyncLimiter       *limit.PeriodLimit
+	GrpcGetUserPermsLimiter *limit.PeriodLimit
 	UsernameLoginLimit    *limit.PeriodLimit
 	TokenOpLimiter        *limit.PeriodLimit
 	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")
 	// gRPC verifyToken 是下游每请求都会调用的热路径,阈值必须足够高;这里的作用是兜底防止下游被攻破后把权限中心当 token oracle 爆破。
 	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")
 	tokenOpLimiter := limit.NewPeriodLimit(60, 10, rds, c.CacheRedis.KeyPrefix+":rl:tokenop")
 
@@ -56,6 +65,8 @@ func NewServiceContext(c config.Config) *ServiceContext {
 		GrpcLoginLimiter:      grpcLimiter,
 		GrpcRefreshLimiter:    grpcRefreshLimiter,
 		GrpcVerifyLimiter:     grpcVerifyLimiter,
+		GrpcSyncLimiter:       grpcSyncLimiter,
+		GrpcGetUserPermsLimiter: grpcGetUserPermsLimiter,
 		UsernameLoginLimit:    usernameLimiter,
 		TokenOpLimiter:        tokenOpLimiter,
 		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)
 }
 
-// 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()
-	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)
 	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()
-	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()
-	ret := m.ctrl.Call(m, "FindOne", ctx, id)
+	ret := m.ctrl.Call(m, "FindOneForShareTx", ctx, session, id)
 	ret0, _ := ret[0].(*dept.SysDept)
 	ret1, _ := ret[1].(error)
 	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()
-	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.

+ 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.
-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()
-	ret := m.ctrl.Call(m, "IncrementTokenVersion", ctx, id)
+	ret := m.ctrl.Call(m, "IncrementTokenVersion", ctx, id, username)
 	ret0, _ := ret[0].(int64)
 	ret1, _ := ret[1].(error)
 	return ret0, ret1
 }
 
 // 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()
-	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.
@@ -381,17 +381,17 @@ func (mr *MockSysUserModelMockRecorder) Update(ctx, data any) *gomock.Call {
 }
 
 // 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()
-	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)
 	return ret0
 }
 
 // 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()
-	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.
@@ -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)
 }
 
+// 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.
-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()
-	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)
 	return ret0
 }
 
 // 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()
-	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.

+ 3 - 3
internal/types/types.go

@@ -243,9 +243,9 @@ type UpdateDeptReq 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 {