落地管理员 RBAC/封禁/推送任务 stub,logout 解绑 device 并统一各页 ensureAccount,同时收口 P2 生日生成与状态文档。 Co-authored-by: Cursor <cursoragent@cursor.com>
277 lines
7.4 KiB
Go
277 lines
7.4 KiB
Go
package repository
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"time"
|
|
|
|
"github.com/google/uuid"
|
|
"github.com/jackc/pgx/v5"
|
|
)
|
|
|
|
var (
|
|
// ErrAdminNotFound is returned when an admin account row is missing.
|
|
ErrAdminNotFound = errors.New("admin not found")
|
|
// ErrPushJobNotFound is returned when a push_jobs row is missing.
|
|
ErrPushJobNotFound = errors.New("push job not found")
|
|
// ErrUserStatusNotFound is returned when users row is missing for status update.
|
|
ErrUserStatusNotFound = errors.New("user not found")
|
|
)
|
|
|
|
// AdminListItem is a public admin row (no password).
|
|
type AdminListItem struct {
|
|
ID uuid.UUID `json:"id"`
|
|
Username string `json:"username"`
|
|
Role string `json:"role"`
|
|
Status string `json:"status"`
|
|
CreatedAt time.Time `json:"created_at"`
|
|
}
|
|
|
|
// PushJob is a push campaign stub (never dispatched).
|
|
type PushJob struct {
|
|
ID uuid.UUID `json:"id"`
|
|
Title string `json:"title"`
|
|
Body string `json:"body"`
|
|
Audience string `json:"audience"`
|
|
Status string `json:"status"`
|
|
CreatedBy uuid.UUID `json:"created_by"`
|
|
CreatedAt time.Time `json:"created_at"`
|
|
UpdatedAt time.Time `json:"updated_at"`
|
|
}
|
|
|
|
// SetUserStatusWithAudit updates users.status and writes audit.
|
|
func (r *AdminRepo) SetUserStatusWithAudit(
|
|
ctx context.Context,
|
|
adminID, userID uuid.UUID,
|
|
status, action 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`, userID, status)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if tag.RowsAffected() == 0 {
|
|
return ErrUserStatusNotFound
|
|
}
|
|
if status == "banned" {
|
|
_, _ = tx.Exec(ctx, `
|
|
UPDATE user_sessions SET revoked_at=now()
|
|
WHERE user_id=$1 AND revoked_at IS NULL`, userID)
|
|
}
|
|
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,$2,'user',$3,$4)`, adminID, action, userID.String(), meta); err != nil {
|
|
return err
|
|
}
|
|
return tx.Commit(ctx)
|
|
}
|
|
|
|
// ListAdmins returns non-deleted admin accounts.
|
|
func (r *AdminRepo) ListAdmins(ctx context.Context) ([]AdminListItem, error) {
|
|
rows, err := r.Pool.Query(ctx, `
|
|
SELECT id, username, role, status, created_at
|
|
FROM admin_accounts
|
|
WHERE deleted_at IS NULL
|
|
ORDER BY created_at ASC`)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
var out []AdminListItem
|
|
for rows.Next() {
|
|
var a AdminListItem
|
|
if err := rows.Scan(&a.ID, &a.Username, &a.Role, &a.Status, &a.CreatedAt); err != nil {
|
|
return nil, err
|
|
}
|
|
out = append(out, a)
|
|
}
|
|
return out, rows.Err()
|
|
}
|
|
|
|
// CountAdminsByRole counts non-deleted admins with the given role.
|
|
func (r *AdminRepo) CountAdminsByRole(ctx context.Context, role string) (int, error) {
|
|
var n int
|
|
err := r.Pool.QueryRow(ctx, `
|
|
SELECT count(*) FROM admin_accounts
|
|
WHERE deleted_at IS NULL AND role=$1`, role).Scan(&n)
|
|
return n, err
|
|
}
|
|
|
|
// UpdateAdminRoleWithAudit sets role for an admin account.
|
|
func (r *AdminRepo) UpdateAdminRoleWithAudit(
|
|
ctx context.Context,
|
|
actorID, targetID uuid.UUID,
|
|
role 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 admin_accounts SET role=$2, updated_at=now()
|
|
WHERE id=$1 AND deleted_at IS NULL`, targetID, role)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if tag.RowsAffected() == 0 {
|
|
return ErrAdminNotFound
|
|
}
|
|
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,'admin.role_update','admin',$2,$3)`, actorID, targetID.String(), meta); err != nil {
|
|
return err
|
|
}
|
|
return tx.Commit(ctx)
|
|
}
|
|
|
|
// ListPushJobs returns newest push stubs.
|
|
func (r *AdminRepo) ListPushJobs(ctx context.Context, limit, offset int) ([]PushJob, error) {
|
|
if limit <= 0 || limit > 100 {
|
|
limit = 20
|
|
}
|
|
if offset < 0 {
|
|
offset = 0
|
|
}
|
|
rows, err := r.Pool.Query(ctx, `
|
|
SELECT id, title, body, audience, status, created_by, created_at, updated_at
|
|
FROM push_jobs
|
|
ORDER BY created_at DESC
|
|
LIMIT $1 OFFSET $2`, limit, offset)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
var out []PushJob
|
|
for rows.Next() {
|
|
var j PushJob
|
|
if err := rows.Scan(
|
|
&j.ID, &j.Title, &j.Body, &j.Audience, &j.Status, &j.CreatedBy, &j.CreatedAt, &j.UpdatedAt,
|
|
); err != nil {
|
|
return nil, err
|
|
}
|
|
out = append(out, j)
|
|
}
|
|
return out, rows.Err()
|
|
}
|
|
|
|
// CreatePushJobWithAudit inserts a draft push job.
|
|
func (r *AdminRepo) CreatePushJobWithAudit(
|
|
ctx context.Context,
|
|
adminID uuid.UUID,
|
|
title, body, audience string,
|
|
meta json.RawMessage,
|
|
) (*PushJob, error) {
|
|
if audience == "" {
|
|
audience = "all"
|
|
}
|
|
tx, err := r.Pool.Begin(ctx)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer tx.Rollback(ctx)
|
|
var j PushJob
|
|
err = tx.QueryRow(ctx, `
|
|
INSERT INTO push_jobs(title, body, audience, status, created_by)
|
|
VALUES ($1,$2,$3,'draft',$4)
|
|
RETURNING id, title, body, audience, status, created_by, created_at, updated_at`,
|
|
title, body, audience, adminID,
|
|
).Scan(&j.ID, &j.Title, &j.Body, &j.Audience, &j.Status, &j.CreatedBy, &j.CreatedAt, &j.UpdatedAt)
|
|
if err != nil {
|
|
return nil, 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,'push_job.create','push_job',$2,$3)`, adminID, j.ID.String(), meta); err != nil {
|
|
return nil, err
|
|
}
|
|
if err := tx.Commit(ctx); err != nil {
|
|
return nil, err
|
|
}
|
|
return &j, nil
|
|
}
|
|
|
|
// UpdatePushJobWithAudit updates title/body/status (draft|cancelled only).
|
|
func (r *AdminRepo) UpdatePushJobWithAudit(
|
|
ctx context.Context,
|
|
adminID, jobID uuid.UUID,
|
|
title, body, status *string,
|
|
meta json.RawMessage,
|
|
) (*PushJob, error) {
|
|
tx, err := r.Pool.Begin(ctx)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer tx.Rollback(ctx)
|
|
var j PushJob
|
|
err = tx.QueryRow(ctx, `
|
|
SELECT id, title, body, audience, status, created_by, created_at, updated_at
|
|
FROM push_jobs WHERE id=$1`, jobID,
|
|
).Scan(&j.ID, &j.Title, &j.Body, &j.Audience, &j.Status, &j.CreatedBy, &j.CreatedAt, &j.UpdatedAt)
|
|
if errors.Is(err, pgx.ErrNoRows) {
|
|
return nil, ErrPushJobNotFound
|
|
}
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if title != nil {
|
|
j.Title = *title
|
|
}
|
|
if body != nil {
|
|
j.Body = *body
|
|
}
|
|
if status != nil {
|
|
j.Status = *status
|
|
}
|
|
err = tx.QueryRow(ctx, `
|
|
UPDATE push_jobs SET title=$2, body=$3, status=$4, updated_at=now()
|
|
WHERE id=$1
|
|
RETURNING id, title, body, audience, status, created_by, created_at, updated_at`,
|
|
jobID, j.Title, j.Body, j.Status,
|
|
).Scan(&j.ID, &j.Title, &j.Body, &j.Audience, &j.Status, &j.CreatedBy, &j.CreatedAt, &j.UpdatedAt)
|
|
if err != nil {
|
|
return nil, 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,'push_job.update','push_job',$2,$3)`, adminID, jobID.String(), meta); err != nil {
|
|
return nil, err
|
|
}
|
|
if err := tx.Commit(ctx); err != nil {
|
|
return nil, err
|
|
}
|
|
return &j, nil
|
|
}
|
|
|
|
// UserStatus returns users.status or empty if missing.
|
|
func (r *AdminRepo) UserStatus(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 "", errors.New("user not found")
|
|
}
|
|
return status, err
|
|
}
|