2026-06-08 19:00:23 +08:00

371 lines
13 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"
)
// SetUserStatus 原子更新用户主状态,并在非 active 状态下吊销该用户所有 active refresh session。
func (r *Repository) SetUserStatus(ctx context.Context, command userservice.UserStatusCommand) (userservice.UserStatusPersistenceResult, error) {
if r == nil || r.db == nil {
return userservice.UserStatusPersistenceResult{}, xerr.New(xerr.Unavailable, "mysql repository is not configured")
}
appCode := appcode.Normalize(command.AppCode)
if appCode == "" {
appCode = appcode.FromContext(ctx)
}
tx, err := r.db.BeginTx(ctx, nil)
if err != nil {
return userservice.UserStatusPersistenceResult{}, err
}
defer 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 err == sql.ErrNoRows {
return userservice.UserStatusPersistenceResult{}, xerr.New(xerr.NotFound, "user not found")
}
return userservice.UserStatusPersistenceResult{}, err
}
_, err = tx.ExecContext(ctx, `
UPDATE users
SET status = ?, updated_at_ms = ?
WHERE app_code = ? AND user_id = ?
`, string(command.Status), command.NowMs, appCode, command.TargetUserID)
if err != nil {
return userservice.UserStatusPersistenceResult{}, 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 (?, ?, ?, ?, ?, ?, ?, ?, ?)
`, appCode, command.TargetUserID, oldStatus, string(command.Status), command.OperatorType, command.OperatorUserID, command.Reason, command.RequestID, command.NowMs); err != nil {
return userservice.UserStatusPersistenceResult{}, err
}
sessionIDs := make([]string, 0)
if command.Status != userdomain.StatusActive {
rows, err := tx.QueryContext(ctx, `
SELECT session_id
FROM auth_sessions
WHERE app_code = ? AND user_id = ? AND expires_at_ms > ?
FOR UPDATE
`, appCode, command.TargetUserID, command.NowMs)
if err != nil {
return userservice.UserStatusPersistenceResult{}, err
}
for rows.Next() {
var sessionID string
if err := rows.Scan(&sessionID); err != nil {
_ = rows.Close()
return userservice.UserStatusPersistenceResult{}, err
}
sessionIDs = append(sessionIDs, sessionID)
}
if err := rows.Close(); err != nil {
return userservice.UserStatusPersistenceResult{}, err
}
if _, err := tx.ExecContext(ctx, `
UPDATE auth_sessions
SET revoked_at_ms = ?, revoked_reason = ?, revoked_request_id = ?, revoked_by = ?, updated_at_ms = ?
WHERE app_code = ? AND user_id = ? AND revoked_at_ms IS NULL
`, command.NowMs, userStatusRevokeReason(command.Status), command.RequestID, revokedBy(command.OperatorUserID), command.NowMs, appCode, command.TargetUserID); err != nil {
return userservice.UserStatusPersistenceResult{}, err
}
}
if err := tx.Commit(); err != nil {
return userservice.UserStatusPersistenceResult{}, err
}
user, err := r.GetUser(appcode.WithContext(ctx, appCode), command.TargetUserID)
if err != nil {
return userservice.UserStatusPersistenceResult{}, err
}
return userservice.UserStatusPersistenceResult{User: user, RevokedSessionIDs: sessionIDs}, nil
}
func userStatusRevokeReason(status userdomain.Status) string {
switch status {
case userdomain.StatusBanned:
return "USER_BANNED"
case userdomain.StatusDisabled:
return "USER_DISABLED"
default:
return "USER_STATUS_CHANGED"
}
}
func revokedBy(operatorUserID int64) string {
if operatorUserID > 0 {
return "user_status_operator"
}
return "user_status"
}
// CreateManagerUserBlock 写入经理封禁记录;用户主状态修改由 service 层随后调用 SetUserStatus 完成。
func (r *Repository) CreateManagerUserBlock(ctx context.Context, command userservice.ManagerUserBlockCommand, target userdomain.User) (userservice.ManagerUserBlock, error) {
if r == nil || r.db == nil {
return userservice.ManagerUserBlock{}, xerr.New(xerr.Unavailable, "mysql repository is not configured")
}
appCode := appcode.FromContext(ctx)
blockID := idgen.New("mblock")
_, err := r.db.ExecContext(ctx, `
INSERT INTO manager_user_blocks (
app_code, block_id, command_id, manager_user_id, target_user_id,
target_display_user_id, target_username, target_avatar, country, target_old_status,
blocked_until_ms, status, reason, created_at_ms, updated_at_ms
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'active', ?, ?, ?)`,
appCode, blockID, command.CommandID, command.ManagerUserID, command.TargetUserID,
target.CurrentDisplayUserID, target.Username, target.Avatar, target.Country, string(target.Status),
command.BlockedUntilMS, command.Reason, command.NowMS, command.NowMS,
)
if err != nil {
if managerBlockDuplicate(err) {
return r.getManagerUserBlockByCommand(ctx, command.CommandID)
}
return userservice.ManagerUserBlock{}, err
}
return r.getManagerUserBlock(ctx, blockID)
}
func (r *Repository) ListManagerUserBlocks(ctx context.Context, managerUserID int64, status string, pageSize int32) ([]userservice.ManagerUserBlock, error) {
if r == nil || r.db == nil {
return nil, xerr.New(xerr.Unavailable, "mysql repository is not configured")
}
rows, err := r.db.QueryContext(ctx, managerBlockSelectSQL()+`
WHERE app_code = ? AND manager_user_id = ? AND status = ?
ORDER BY created_at_ms DESC, block_id DESC
LIMIT ?`,
appcode.FromContext(ctx), managerUserID, status, pageSize,
)
if err != nil {
return nil, err
}
defer rows.Close()
return scanManagerUserBlocks(rows)
}
func (r *Repository) ReleaseManagerUserBlock(ctx context.Context, command userservice.ReleaseManagerUserBlockCommand) (userservice.ManagerUserBlockRelease, error) {
return r.releaseManagerUserBlock(ctx, command.BlockID, command.ManagerUserID, command.Status, command.Reason, command.NowMS)
}
func (r *Repository) ExpireManagerUserBlocks(ctx context.Context, nowMS int64, limit int32) ([]userservice.ManagerUserBlockRelease, 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 block_id, manager_user_id
FROM manager_user_blocks
WHERE app_code = ? AND status = 'active' AND blocked_until_ms <= ?
ORDER BY blocked_until_ms ASC, block_id ASC
LIMIT ?`,
appcode.FromContext(ctx), nowMS, limit,
)
if err != nil {
return nil, err
}
pending := make([]struct {
blockID string
managerUserID int64
}, 0)
for rows.Next() {
var item struct {
blockID string
managerUserID int64
}
if err := rows.Scan(&item.blockID, &item.managerUserID); err != nil {
_ = rows.Close()
return nil, err
}
pending = append(pending, item)
}
if err := rows.Close(); err != nil {
return nil, err
}
releases := make([]userservice.ManagerUserBlockRelease, 0, len(pending))
for _, item := range pending {
// 逐条释放而不是批量 UPDATE是为了给每个目标用户单独计算是否还存在其他 active 经理封禁。
release, err := r.releaseManagerUserBlock(ctx, item.blockID, item.managerUserID, userservice.ManagerUserBlockStatusExpired, "manager_center_block_expired", nowMS)
if err != nil {
return releases, err
}
releases = append(releases, release)
}
return releases, nil
}
func (r *Repository) releaseManagerUserBlock(ctx context.Context, blockID string, managerUserID int64, status string, reason string, nowMS int64) (userservice.ManagerUserBlockRelease, error) {
tx, err := r.db.BeginTx(ctx, nil)
if err != nil {
return userservice.ManagerUserBlockRelease{}, err
}
defer func() { _ = tx.Rollback() }()
block, err := r.getManagerUserBlockForUpdate(ctx, tx, blockID, managerUserID)
if err != nil {
return userservice.ManagerUserBlockRelease{}, err
}
if block.Status != userservice.ManagerUserBlockStatusActive {
if err := tx.Commit(); err != nil {
return userservice.ManagerUserBlockRelease{}, err
}
return userservice.ManagerUserBlockRelease{Block: block}, nil
}
_, err = tx.ExecContext(ctx, `
UPDATE manager_user_blocks
SET status = ?, released_reason = ?, released_at_ms = ?, updated_at_ms = ?
WHERE app_code = ? AND block_id = ?`,
status, reason, nowMS, nowMS, appcode.FromContext(ctx), blockID,
)
if err != nil {
return userservice.ManagerUserBlockRelease{}, err
}
block.Status = status
block.UpdatedAtMS = nowMS
activeCount, err := r.countActiveManagerBlocks(ctx, tx, block.TargetUserID)
if err != nil {
return userservice.ManagerUserBlockRelease{}, err
}
// 只在“没有其他经理封禁 + 原状态是 active + 最近一次 ban 由经理封禁写入”时恢复,避免误解后台封禁。
latestManagerBan, err := r.latestBanBelongsToManagerBlock(ctx, tx, block.TargetUserID)
if err != nil {
return userservice.ManagerUserBlockRelease{}, err
}
if err := tx.Commit(); err != nil {
return userservice.ManagerUserBlockRelease{}, err
}
return userservice.ManagerUserBlockRelease{
Block: block,
ShouldRestore: activeCount == 0 && block.TargetOldStatus == userdomain.StatusActive && latestManagerBan,
}, nil
}
func (r *Repository) getManagerUserBlock(ctx context.Context, blockID string) (userservice.ManagerUserBlock, error) {
row := r.db.QueryRowContext(ctx, managerBlockSelectSQL()+`WHERE app_code = ? AND block_id = ?`, appcode.FromContext(ctx), blockID)
return scanManagerUserBlock(row)
}
func (r *Repository) getManagerUserBlockByCommand(ctx context.Context, commandID string) (userservice.ManagerUserBlock, error) {
row := r.db.QueryRowContext(ctx, managerBlockSelectSQL()+`WHERE app_code = ? AND command_id = ?`, appcode.FromContext(ctx), commandID)
return scanManagerUserBlock(row)
}
func (r *Repository) getManagerUserBlockForUpdate(ctx context.Context, tx *sql.Tx, blockID string, managerUserID int64) (userservice.ManagerUserBlock, error) {
row := tx.QueryRowContext(ctx, managerBlockSelectSQL()+`
WHERE app_code = ? AND block_id = ? AND manager_user_id = ?
FOR UPDATE`,
appcode.FromContext(ctx), blockID, managerUserID,
)
return scanManagerUserBlock(row)
}
func (r *Repository) countActiveManagerBlocks(ctx context.Context, tx *sql.Tx, targetUserID int64) (int64, error) {
var count int64
err := tx.QueryRowContext(ctx, `
SELECT COUNT(*)
FROM manager_user_blocks
WHERE app_code = ? AND target_user_id = ? AND status = 'active'`,
appcode.FromContext(ctx), targetUserID,
).Scan(&count)
return count, err
}
func (r *Repository) latestBanBelongsToManagerBlock(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:"), nil
}
func managerBlockSelectSQL() string {
return `
SELECT block_id, manager_user_id, target_user_id, target_display_user_id,
target_username, target_avatar, country, target_old_status, blocked_until_ms,
status, reason, created_at_ms, updated_at_ms
FROM manager_user_blocks
`
}
func scanManagerUserBlocks(rows *sql.Rows) ([]userservice.ManagerUserBlock, error) {
items := make([]userservice.ManagerUserBlock, 0)
for rows.Next() {
item, err := scanManagerUserBlockRows(rows)
if err != nil {
return nil, err
}
items = append(items, item)
}
return items, rows.Err()
}
type managerBlockScanner interface {
Scan(dest ...any) error
}
func scanManagerUserBlock(row managerBlockScanner) (userservice.ManagerUserBlock, error) {
item, err := scanManagerUserBlockRows(row)
if errors.Is(err, sql.ErrNoRows) {
return userservice.ManagerUserBlock{}, xerr.New(xerr.NotFound, "manager block not found")
}
return item, err
}
func scanManagerUserBlockRows(row managerBlockScanner) (userservice.ManagerUserBlock, error) {
var item userservice.ManagerUserBlock
var oldStatus string
if err := row.Scan(
&item.BlockID,
&item.ManagerUserID,
&item.TargetUserID,
&item.TargetDisplayUserID,
&item.TargetUsername,
&item.TargetAvatar,
&item.Country,
&oldStatus,
&item.BlockedUntilMS,
&item.Status,
&item.Reason,
&item.CreatedAtMS,
&item.UpdatedAtMS,
); err != nil {
return userservice.ManagerUserBlock{}, err
}
item.TargetOldStatus = userdomain.Status(oldStatus)
return item, nil
}
func managerBlockDuplicate(err error) bool {
if err == nil {
return false
}
return strings.Contains(err.Error(), "Duplicate entry") || strings.Contains(err.Error(), "Error 1062")
}