feat(ECR-010): Ops-E 系统运营;修复登出解绑;P2 Complete
落地管理员 RBAC/封禁/推送任务 stub,logout 解绑 device 并统一各页 ensureAccount,同时收口 P2 生日生成与状态文档。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -0,0 +1,276 @@
|
||||
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
|
||||
}
|
||||
Reference in New Issue
Block a user