feat(ECR-013B): AccountLifecycle Closed;启用 Loop 连续执行
UserStatus 迁移、DeviceAuth 拒绝非 active、admin-h5 CTA; Reviewer Closed。Human 授权 LOOP_AUTHORIZATION(免逐闸确认)。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -0,0 +1,104 @@
|
||||
package repository
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/jackc/pgx/v5"
|
||||
)
|
||||
|
||||
// AccountTransition is an append-only UserStatus change.
|
||||
type AccountTransition struct {
|
||||
ID uuid.UUID `json:"id"`
|
||||
UserID uuid.UUID `json:"user_id"`
|
||||
FromStatus string `json:"from_status"`
|
||||
ToStatus string `json:"to_status"`
|
||||
AdminID uuid.UUID `json:"admin_id"`
|
||||
Reason string `json:"reason"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
}
|
||||
|
||||
// GetUserStatus returns users.status or empty if missing.
|
||||
func (r *AdminRepo) GetUserStatus(ctx context.Context, userID uuid.UUID) (string, error) {
|
||||
var status string
|
||||
err := r.Pool.QueryRow(ctx, `
|
||||
SELECT status FROM users WHERE id=$1 AND deleted_at IS NULL`, userID,
|
||||
).Scan(&status)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return "", nil
|
||||
}
|
||||
return status, err
|
||||
}
|
||||
|
||||
// TransitionUserStatusWithAudit updates status, inserts transition + audit in one tx.
|
||||
func (r *AdminRepo) TransitionUserStatusWithAudit(
|
||||
ctx context.Context,
|
||||
adminID, userID uuid.UUID,
|
||||
fromStatus, toStatus, reason string,
|
||||
meta json.RawMessage,
|
||||
) error {
|
||||
tx, err := r.Pool.Begin(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer tx.Rollback(ctx)
|
||||
|
||||
tag, err := tx.Exec(ctx, `
|
||||
UPDATE users SET status=$2, updated_at=now()
|
||||
WHERE id=$1 AND deleted_at IS NULL AND status=$3`,
|
||||
userID, toStatus, fromStatus,
|
||||
)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if tag.RowsAffected() == 0 {
|
||||
return errString("status conflict")
|
||||
}
|
||||
if _, err := tx.Exec(ctx, `
|
||||
INSERT INTO account_state_transitions(user_id, from_status, to_status, admin_id, reason)
|
||||
VALUES ($1,$2,$3,$4,$5)`,
|
||||
userID, fromStatus, toStatus, adminID, reason,
|
||||
); err != nil {
|
||||
return err
|
||||
}
|
||||
if meta == nil {
|
||||
meta = json.RawMessage(`{}`)
|
||||
}
|
||||
if _, err := tx.Exec(ctx, `
|
||||
INSERT INTO admin_audit_logs(admin_id, action, target_type, target_id, meta)
|
||||
VALUES ($1,'users.status.transition','user',$2,$3)`,
|
||||
adminID, userID.String(), meta,
|
||||
); err != nil {
|
||||
return err
|
||||
}
|
||||
return tx.Commit(ctx)
|
||||
}
|
||||
|
||||
// ListStatusTransitions returns newest first.
|
||||
func (r *AdminRepo) ListStatusTransitions(ctx context.Context, userID uuid.UUID, limit int) ([]AccountTransition, error) {
|
||||
if limit <= 0 || limit > 100 {
|
||||
limit = 50
|
||||
}
|
||||
rows, err := r.Pool.Query(ctx, `
|
||||
SELECT id, user_id, from_status, to_status, admin_id, reason, created_at
|
||||
FROM account_state_transitions
|
||||
WHERE user_id=$1
|
||||
ORDER BY created_at DESC
|
||||
LIMIT $2`, userID, limit)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var out []AccountTransition
|
||||
for rows.Next() {
|
||||
var t AccountTransition
|
||||
if err := rows.Scan(&t.ID, &t.UserID, &t.FromStatus, &t.ToStatus, &t.AdminID, &t.Reason, &t.CreatedAt); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, t)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
Reference in New Issue
Block a user