package user import ( "context" "database/sql" "errors" "strings" "hyapp/pkg/appcode" "hyapp/pkg/idgen" "hyapp/pkg/xerr" userdomain "hyapp/services/user-service/internal/domain/user" userservice "hyapp/services/user-service/internal/service/user" ) const adminBanStatusReasonPrefix = "admin_user_ban:" // CreateAdminUserBan 在一个事务内写封禁事实、切换 users.status、写审计并吊销 refresh session。 // 目标已经因其他事实处于 banned 时只叠加事实,不重复写 banned->banned 日志;该约束也是历史永久封禁不被误恢复的判据。 func (r *Repository) CreateAdminUserBan(ctx context.Context, command userservice.AdminUserBanCommand) (userservice.AdminUserBanPersistenceResult, error) { if r == nil || r.db == nil { return userservice.AdminUserBanPersistenceResult{}, xerr.New(xerr.Unavailable, "mysql repository is not configured") } appCode := appcode.FromContext(ctx) tx, err := r.db.BeginTx(ctx, nil) if err != nil { return userservice.AdminUserBanPersistenceResult{}, err } defer func() { _ = tx.Rollback() }() var oldStatus string if err := tx.QueryRowContext(ctx, ` SELECT status FROM users WHERE app_code = ? AND user_id = ? FOR UPDATE`, appCode, command.TargetUserID).Scan(&oldStatus); err != nil { if errors.Is(err, sql.ErrNoRows) { return userservice.AdminUserBanPersistenceResult{}, xerr.New(xerr.NotFound, "user not found") } return userservice.AdminUserBanPersistenceResult{}, err } if userdomain.Status(oldStatus) != userdomain.StatusActive && userdomain.Status(oldStatus) != userdomain.StatusBanned { // disabled 是独立平台停用语义,不能用封禁事实覆盖;后台应先显式恢复后再封禁。 return userservice.AdminUserBanPersistenceResult{}, xerr.New(xerr.InvalidArgument, "target user status cannot be banned") } banID := idgen.New("admin_ban") _, err = tx.ExecContext(ctx, ` INSERT INTO admin_user_bans ( app_code, ban_id, command_id, target_user_id, target_old_status, expires_at_ms, status, reason, operator_admin_id, created_at_ms, updated_at_ms ) VALUES (?, ?, ?, ?, ?, ?, 'active', ?, ?, ?, ?)`, appCode, banID, command.CommandID, command.TargetUserID, oldStatus, command.ExpiresAtMs, command.Reason, command.OperatorAdminID, command.NowMs, command.NowMs, ) if err != nil { if adminBanDuplicate(err) { // 幂等命令可能在上一次调用中已经完整提交;回滚当前事务后读取既有事实和当前用户状态即可。 _ = tx.Rollback() ban, lookupErr := r.getAdminUserBanByCommand(ctx, command.CommandID) if lookupErr != nil { return userservice.AdminUserBanPersistenceResult{}, lookupErr } if ban.TargetUserID != command.TargetUserID || ban.OperatorAdminID != command.OperatorAdminID || ban.ExpiresAtMs != command.ExpiresAtMs || ban.Reason != command.Reason { // command_id 是完整请求的幂等边界;同一 key 不能静默复用到另一位用户或另一种封禁期限。 return userservice.AdminUserBanPersistenceResult{}, xerr.New(xerr.Conflict, "admin ban command payload does not match existing fact") } user, lookupErr := r.GetUser(ctx, ban.TargetUserID) if lookupErr != nil { return userservice.AdminUserBanPersistenceResult{}, lookupErr } return userservice.AdminUserBanPersistenceResult{Ban: ban, Status: userservice.UserStatusPersistenceResult{User: user}}, nil } return userservice.AdminUserBanPersistenceResult{}, err } sessionIDs := make([]string, 0) statusChanged := userdomain.Status(oldStatus) == userdomain.StatusActive if statusChanged { if _, err := tx.ExecContext(ctx, ` UPDATE users SET status = 'banned', updated_at_ms = ? WHERE app_code = ? AND user_id = ?`, command.NowMs, appCode, command.TargetUserID); err != nil { return userservice.AdminUserBanPersistenceResult{}, err } if _, err := tx.ExecContext(ctx, ` INSERT INTO user_status_change_logs ( app_code, target_user_id, old_status, new_status, operator_type, operator_user_id, reason, request_id, created_at_ms ) VALUES (?, ?, ?, 'banned', 'admin', ?, ?, ?, ?)`, appCode, command.TargetUserID, oldStatus, command.OperatorAdminID, adminBanStatusReasonPrefix+banID, command.RequestID, command.NowMs, ); err != nil { return userservice.AdminUserBanPersistenceResult{}, err } var revokeErr error sessionIDs, revokeErr = revokeActiveUserSessions(ctx, tx, appCode, command.TargetUserID, command.OperatorAdminID, command.RequestID, command.NowMs) if revokeErr != nil { return userservice.AdminUserBanPersistenceResult{}, revokeErr } } if err := tx.Commit(); err != nil { return userservice.AdminUserBanPersistenceResult{}, err } ban, err := r.getAdminUserBan(ctx, banID) if err != nil { return userservice.AdminUserBanPersistenceResult{}, err } user, err := r.GetUser(ctx, command.TargetUserID) if err != nil { return userservice.AdminUserBanPersistenceResult{}, err } return userservice.AdminUserBanPersistenceResult{ Ban: ban, Status: userservice.UserStatusPersistenceResult{User: user, RevokedSessionIDs: sessionIDs}, StatusChanged: statusChanged, }, nil } func (r *Repository) ReleaseAdminUserBan(ctx context.Context, command userservice.ReleaseAdminUserBanCommand) (userservice.AdminUserBanRelease, error) { return r.releaseAdminUserBan(ctx, command) } func (r *Repository) ExpireAdminUserBans(ctx context.Context, nowMS int64, limit int32) ([]userservice.AdminUserBanRelease, error) { if r == nil || r.db == nil { return nil, xerr.New(xerr.Unavailable, "mysql repository is not configured") } rows, err := r.db.QueryContext(ctx, ` SELECT ban_id, target_user_id FROM admin_user_bans WHERE app_code = ? AND status = 'active' AND expires_at_ms > 0 AND expires_at_ms <= ? ORDER BY expires_at_ms ASC, ban_id ASC LIMIT ?`, appcode.FromContext(ctx), nowMS, limit) if err != nil { return nil, err } type pendingBan struct { banID string targetUserID int64 } pending := make([]pendingBan, 0) for rows.Next() { var item pendingBan if err := rows.Scan(&item.banID, &item.targetUserID); err != nil { _ = rows.Close() return nil, err } pending = append(pending, item) } if err := rows.Close(); err != nil { return nil, err } releases := make([]userservice.AdminUserBanRelease, 0, len(pending)) for _, item := range pending { release, err := r.releaseAdminUserBan(ctx, userservice.ReleaseAdminUserBanCommand{ BanID: item.banID, TargetUserID: item.targetUserID, Status: userservice.AdminUserBanStatusExpired, Reason: "admin_user_ban_expired", RequestID: "admin_user_ban_expire:" + item.banID, NowMs: nowMS, }) if err != nil { return releases, err } releases = append(releases, release) } return releases, nil } func (r *Repository) releaseAdminUserBan(ctx context.Context, command userservice.ReleaseAdminUserBanCommand) (userservice.AdminUserBanRelease, error) { tx, err := r.db.BeginTx(ctx, nil) if err != nil { return userservice.AdminUserBanRelease{}, err } defer func() { _ = tx.Rollback() }() // 先锁定命令幂等事实,再选择具体 ban。空 ban_id 的重试会复用第一次解析出的 resolved_ban_id, // 不会因为上一条已 released 而误释放下一条重叠管理员封禁。 existingCommand, resolvedBanID, err := claimAdminBanReleaseCommand(ctx, tx, command) if err != nil { return userservice.AdminUserBanRelease{}, err } var ban userservice.AdminUserBan if existingCommand { ban, err = r.getAdminUserBanForUpdate(ctx, tx, resolvedBanID, command.TargetUserID) } else if strings.TrimSpace(command.BanID) == "" { // 后台摘要可能正在展示更晚到期的经理封禁;按 target 选最新管理员事实,使重叠场景仍能逐条手动释放。 ban, err = r.getLatestActiveAdminUserBanForUpdate(ctx, tx, command.TargetUserID) } else { ban, err = r.getAdminUserBanForUpdate(ctx, tx, command.BanID, command.TargetUserID) } if err != nil { return userservice.AdminUserBanRelease{}, err } if !existingCommand { if _, err := tx.ExecContext(ctx, ` UPDATE admin_user_ban_release_commands SET resolved_ban_id = ?, updated_at_ms = ? WHERE app_code = ? AND command_id = ?`, ban.BanID, command.NowMs, appcode.FromContext(ctx), command.RequestID, ); err != nil { return userservice.AdminUserBanRelease{}, err } } if ban.Status != userservice.AdminUserBanStatusActive { if err := tx.Commit(); err != nil { return userservice.AdminUserBanRelease{}, err } user, err := r.GetUser(ctx, ban.TargetUserID) return userservice.AdminUserBanRelease{Ban: ban, User: user}, err } currentStatus, err := lockUserStatus(ctx, tx, appcode.FromContext(ctx), ban.TargetUserID) if err != nil { return userservice.AdminUserBanRelease{}, err } if _, err := tx.ExecContext(ctx, ` UPDATE admin_user_bans SET status = ?, released_by_admin_id = ?, released_reason = ?, released_at_ms = ?, updated_at_ms = ? WHERE app_code = ? AND ban_id = ?`, command.Status, command.OperatorAdminID, command.Reason, command.NowMs, command.NowMs, appcode.FromContext(ctx), ban.BanID, ); err != nil { return userservice.AdminUserBanRelease{}, err } ban.Status = command.Status ban.ReleasedByAdminID = command.OperatorAdminID ban.ReleasedReason = command.Reason ban.ReleasedAtMs = command.NowMs ban.UpdatedAtMs = command.NowMs restored, err := restoreUserAfterManagedBanRelease(ctx, tx, managedBanRestoreCommand{ TargetUserID: ban.TargetUserID, CurrentStatus: currentStatus, OperatorType: releaseOperatorType(command.Status), OperatorID: command.OperatorAdminID, Reason: "admin_user_unban:" + ban.BanID, RequestID: command.RequestID, NowMs: command.NowMs, }) if err != nil { return userservice.AdminUserBanRelease{}, err } if err := tx.Commit(); err != nil { return userservice.AdminUserBanRelease{}, err } user, err := r.GetUser(ctx, ban.TargetUserID) if err != nil { return userservice.AdminUserBanRelease{}, err } return userservice.AdminUserBanRelease{Ban: ban, User: user, Restored: restored}, nil } // claimAdminBanReleaseCommand 把一次手动/自动释放固定为不可变命令载荷。 // INSERT IGNORE 会在同一 command_id 的并发请求间形成行级串行化,第二个请求提交后只读取首个 resolved_ban_id。 func claimAdminBanReleaseCommand(ctx context.Context, tx *sql.Tx, command userservice.ReleaseAdminUserBanCommand) (bool, string, error) { commandID := strings.TrimSpace(command.RequestID) if commandID == "" { return false, "", xerr.New(xerr.InvalidArgument, "admin unban request_id is required") } requestedBanID := strings.TrimSpace(command.BanID) result, err := tx.ExecContext(ctx, ` INSERT IGNORE INTO admin_user_ban_release_commands ( app_code, command_id, target_user_id, requested_ban_id, resolved_ban_id, release_status, operator_admin_id, reason, created_at_ms, updated_at_ms ) VALUES (?, ?, ?, ?, '', ?, ?, ?, ?, ?)`, appcode.FromContext(ctx), commandID, command.TargetUserID, requestedBanID, command.Status, command.OperatorAdminID, command.Reason, command.NowMs, command.NowMs, ) if err != nil { return false, "", err } inserted, err := result.RowsAffected() if err != nil { return false, "", err } if inserted == 1 { return false, "", nil } var targetUserID int64 var storedRequestedBanID, resolvedBanID, releaseStatus, reason string var operatorAdminID int64 if err := tx.QueryRowContext(ctx, ` SELECT target_user_id, requested_ban_id, resolved_ban_id, release_status, operator_admin_id, reason FROM admin_user_ban_release_commands WHERE app_code = ? AND command_id = ? FOR UPDATE`, appcode.FromContext(ctx), commandID).Scan( &targetUserID, &storedRequestedBanID, &resolvedBanID, &releaseStatus, &operatorAdminID, &reason, ); err != nil { return false, "", err } if targetUserID != command.TargetUserID || storedRequestedBanID != requestedBanID || releaseStatus != command.Status || operatorAdminID != command.OperatorAdminID || reason != command.Reason { return false, "", xerr.New(xerr.Conflict, "admin unban command payload does not match existing command") } if strings.TrimSpace(resolvedBanID) == "" { return false, "", xerr.New(xerr.Conflict, "admin unban command has no resolved ban") } return true, resolvedBanID, nil } type managedBanRestoreCommand struct { TargetUserID int64 CurrentStatus userdomain.Status OperatorType string OperatorID int64 Reason string RequestID string NowMs int64 } // restoreUserAfterManagedBanRelease 在持有 users 行锁时检查全部可管理封禁来源并恢复账号。 // 最新 banned 日志必须来自 admin/manager 事实;历史无日志或其他平台封禁即使没有 active 事实也不会被自动恢复。 func restoreUserAfterManagedBanRelease(ctx context.Context, tx *sql.Tx, command managedBanRestoreCommand) (bool, error) { if command.CurrentStatus != userdomain.StatusBanned { return false, nil } activeCount, err := countActiveManagedBans(ctx, tx, command.TargetUserID) if err != nil || activeCount > 0 { return false, err } managed, err := latestBanBelongsToManagedFact(ctx, tx, command.TargetUserID) if err != nil || !managed { return false, err } if _, err := tx.ExecContext(ctx, ` UPDATE users SET status = 'active', updated_at_ms = ? WHERE app_code = ? AND user_id = ? AND status = 'banned'`, command.NowMs, appcode.FromContext(ctx), command.TargetUserID, ); err != nil { return false, err } if _, err := tx.ExecContext(ctx, ` INSERT INTO user_status_change_logs ( app_code, target_user_id, old_status, new_status, operator_type, operator_user_id, reason, request_id, created_at_ms ) VALUES (?, ?, 'banned', 'active', ?, ?, ?, ?, ?)`, appcode.FromContext(ctx), command.TargetUserID, command.OperatorType, command.OperatorID, command.Reason, command.RequestID, command.NowMs, ); err != nil { return false, err } return true, nil } func lockUserStatus(ctx context.Context, tx *sql.Tx, appCode string, userID int64) (userdomain.Status, error) { var status string if err := tx.QueryRowContext(ctx, ` SELECT status FROM users WHERE app_code = ? AND user_id = ? FOR UPDATE`, appCode, userID).Scan(&status); err != nil { if errors.Is(err, sql.ErrNoRows) { return "", xerr.New(xerr.NotFound, "user not found") } return "", err } return userdomain.Status(status), nil } func countActiveManagedBans(ctx context.Context, tx *sql.Tx, targetUserID int64) (int64, error) { var count int64 err := tx.QueryRowContext(ctx, ` SELECT (SELECT COUNT(*) FROM manager_user_blocks WHERE app_code = ? AND target_user_id = ? AND status = 'active') + (SELECT COUNT(*) FROM admin_user_bans WHERE app_code = ? AND target_user_id = ? AND status = 'active')`, appcode.FromContext(ctx), targetUserID, appcode.FromContext(ctx), targetUserID, ).Scan(&count) return count, err } func latestBanBelongsToManagedFact(ctx context.Context, tx *sql.Tx, targetUserID int64) (bool, error) { var reason string err := tx.QueryRowContext(ctx, ` SELECT reason FROM user_status_change_logs WHERE app_code = ? AND target_user_id = ? AND new_status = 'banned' ORDER BY created_at_ms DESC, id DESC LIMIT 1`, appcode.FromContext(ctx), targetUserID).Scan(&reason) if errors.Is(err, sql.ErrNoRows) { return false, nil } if err != nil { return false, err } return strings.HasPrefix(reason, "manager_center_block:") || strings.HasPrefix(reason, adminBanStatusReasonPrefix), nil } func releaseOperatorType(status string) string { if status == userservice.AdminUserBanStatusExpired || status == userservice.ManagerUserBlockStatusExpired { return userservice.OperatorTypeSystem } return userservice.OperatorTypeAdmin } func revokeActiveUserSessions(ctx context.Context, tx *sql.Tx, appCode string, targetUserID int64, operatorUserID int64, requestID string, nowMs int64) ([]string, error) { rows, err := tx.QueryContext(ctx, ` SELECT session_id FROM auth_sessions WHERE app_code = ? AND user_id = ? AND expires_at_ms > ? AND revoked_at_ms IS NULL FOR UPDATE`, appCode, targetUserID, nowMs) if err != nil { return nil, err } sessionIDs := make([]string, 0) for rows.Next() { var sessionID string if err := rows.Scan(&sessionID); err != nil { _ = rows.Close() return nil, err } sessionIDs = append(sessionIDs, sessionID) } if err := rows.Close(); err != nil { return nil, err } _, err = tx.ExecContext(ctx, ` UPDATE auth_sessions SET revoked_at_ms = ?, revoked_reason = 'USER_BANNED', revoked_request_id = ?, revoked_by = ?, updated_at_ms = ? WHERE app_code = ? AND user_id = ? AND revoked_at_ms IS NULL`, nowMs, requestID, revokedBy(operatorUserID), nowMs, appCode, targetUserID) return sessionIDs, err } func (r *Repository) getAdminUserBan(ctx context.Context, banID string) (userservice.AdminUserBan, error) { return scanAdminUserBan(r.db.QueryRowContext(ctx, adminBanSelectSQL()+` WHERE app_code = ? AND ban_id = ?`, appcode.FromContext(ctx), banID)) } func (r *Repository) getAdminUserBanByCommand(ctx context.Context, commandID string) (userservice.AdminUserBan, error) { return scanAdminUserBan(r.db.QueryRowContext(ctx, adminBanSelectSQL()+` WHERE app_code = ? AND command_id = ?`, appcode.FromContext(ctx), commandID)) } func (r *Repository) getAdminUserBanForUpdate(ctx context.Context, tx *sql.Tx, banID string, targetUserID int64) (userservice.AdminUserBan, error) { return scanAdminUserBan(tx.QueryRowContext(ctx, adminBanSelectSQL()+` WHERE app_code = ? AND ban_id = ? AND target_user_id = ? FOR UPDATE`, appcode.FromContext(ctx), banID, targetUserID)) } func (r *Repository) getLatestActiveAdminUserBanForUpdate(ctx context.Context, tx *sql.Tx, targetUserID int64) (userservice.AdminUserBan, error) { return scanAdminUserBan(tx.QueryRowContext(ctx, adminBanSelectSQL()+` WHERE app_code = ? AND target_user_id = ? AND status = 'active' ORDER BY created_at_ms DESC, ban_id DESC LIMIT 1 FOR UPDATE`, appcode.FromContext(ctx), targetUserID)) } func adminBanSelectSQL() string { return `SELECT ban_id, command_id, target_user_id, target_old_status, expires_at_ms, status, reason, operator_admin_id, created_at_ms, updated_at_ms, released_by_admin_id, released_reason, released_at_ms FROM admin_user_bans` } type adminBanScanner interface { Scan(dest ...any) error } func scanAdminUserBan(scanner adminBanScanner) (userservice.AdminUserBan, error) { var ban userservice.AdminUserBan var oldStatus string if err := scanner.Scan( &ban.BanID, &ban.CommandID, &ban.TargetUserID, &oldStatus, &ban.ExpiresAtMs, &ban.Status, &ban.Reason, &ban.OperatorAdminID, &ban.CreatedAtMs, &ban.UpdatedAtMs, &ban.ReleasedByAdminID, &ban.ReleasedReason, &ban.ReleasedAtMs, ); err != nil { if errors.Is(err, sql.ErrNoRows) { return userservice.AdminUserBan{}, xerr.New(xerr.NotFound, "admin user ban not found") } return userservice.AdminUserBan{}, err } ban.TargetOldStatus = userdomain.Status(oldStatus) return ban, nil } func adminBanDuplicate(err error) bool { if err == nil { return false } return strings.Contains(err.Error(), "Duplicate entry") || strings.Contains(err.Error(), "Error 1062") }