480 lines
19 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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")
}