feat(login-guard): unlock entire scope and report deleted keys
Frontend CI / Frontend (push) Successful in 3m13s
Backend CI / Backend (push) Successful in 15m21s

Admin login-lock reset now SCANs and clears all auth:login:* keys (any
scope) and surfaces redis_deleted_keys / fallback_deleted_keys back to
ops-web so the operator can tell whether the cache was actually hit.
Also tracks paired keys via a per-identifier index plus an unlock marker
so legacy/in-flight pair locks get cleared on the next acquire.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-02 20:56:38 +08:00
parent 22d0cd63a1
commit bbfeabdaa5
5 changed files with 494 additions and 39 deletions
+32 -16
View File
@@ -19,15 +19,15 @@ import (
)
const (
ActionAdminUserCreate = "admin_user.create"
ActionAdminUserUpdateProfile = "admin_user.update_profile"
ActionAdminUserChangePlan = "admin_user.change_plan"
ActionAdminUserChangeRole = "admin_user.change_role"
ActionAdminUserChangeExpiry = "admin_user.change_expiry"
ActionAdminUserResetUsage = "admin_user.reset_usage"
ActionAdminUserSetKOL = "admin_user.set_kol"
ActionAdminUserDisable = "admin_user.disable"
ActionAdminUserEnable = "admin_user.enable"
ActionAdminUserCreate = "admin_user.create"
ActionAdminUserUpdateProfile = "admin_user.update_profile"
ActionAdminUserChangePlan = "admin_user.change_plan"
ActionAdminUserChangeRole = "admin_user.change_role"
ActionAdminUserChangeExpiry = "admin_user.change_expiry"
ActionAdminUserResetUsage = "admin_user.reset_usage"
ActionAdminUserSetKOL = "admin_user.set_kol"
ActionAdminUserDisable = "admin_user.disable"
ActionAdminUserEnable = "admin_user.enable"
ActionAdminUserResetPassword = "admin_user.reset_password"
ActionAdminUserResetLoginLock = "admin_user.reset_login_lock"
)
@@ -179,6 +179,15 @@ type ResetAdminUserPlanUsageResult struct {
Reset ResetPlanUsageView `json:"reset"`
}
type ResetLoginLockResult struct {
ID int64 `json:"id"`
RedisDeletedKeys int64 `json:"redis_deleted_keys"`
FallbackDeletedKeys int `json:"fallback_deleted_keys"`
RedisScanPatterns []string `json:"redis_scan_patterns"`
RedisAddr string `json:"redis_addr,omitempty"`
RedisDB int `json:"redis_db"`
}
func toAdminUserView(u *repository.AdminUserRecord) AdminUserView {
return AdminUserView{
ID: u.ID,
@@ -603,24 +612,31 @@ func (s *AdminUserService) ResetPassword(ctx context.Context, actor *Actor, id i
return nil
}
func (s *AdminUserService) ResetLoginLock(ctx context.Context, actor *Actor, id int64) error {
func (s *AdminUserService) ResetLoginLock(ctx context.Context, actor *Actor, id int64) (*ResetLoginLockResult, error) {
user, err := s.users.GetByID(ctx, id)
if err != nil {
if errors.Is(err, repository.ErrAdminUserNotFound) {
return response.ErrNotFound(40430, "user_not_found", "用户不存在")
return nil, response.ErrNotFound(40430, "user_not_found", "用户不存在")
}
return response.ErrInternal(50034, "query_failed", "failed to get user")
return nil, response.ErrInternal(50034, "query_failed", "failed to get user")
}
result := &ResetLoginLockResult{ID: id}
if s.loginGuard != nil {
_ = s.loginGuard.Unlock(ctx, user.Phone)
if user.Email != nil && *user.Email != "" {
_ = s.loginGuard.Unlock(ctx, *user.Email)
unlock, err := s.loginGuard.UnlockWithResult(ctx, user.Phone)
if err != nil {
return nil, response.ErrServiceUnavailable(50305, "login_guard_unavailable", "登录保护暂时不可用,请稍后重试")
}
result.RedisDeletedKeys = unlock.RedisDeletedKeys
result.FallbackDeletedKeys = unlock.FallbackDeletedKeys
result.RedisScanPatterns = unlock.RedisScanPatterns
result.RedisAddr = unlock.RedisAddr
result.RedisDB = unlock.RedisDB
}
if actor != nil {
_ = s.audits.Append(ctx, actor.audit(ActionAdminUserResetLoginLock, "admin_user", id, nil))
}
return nil
return result, nil
}
func (s *AdminUserService) mapWriteError(err error) error {
@@ -292,10 +292,11 @@ func resetAdminUserLoginLockHandler(svc *app.AdminUserService) gin.HandlerFunc {
response.Error(c, err)
return
}
if err := svc.ResetLoginLock(c.Request.Context(), actorFromGin(c), id); err != nil {
result, err := svc.ResetLoginLock(c.Request.Context(), actorFromGin(c), id)
if err != nil {
response.Error(c, err)
return
}
response.Success(c, gin.H{"id": id})
response.Success(c, result)
}
}