Files
digital-psychology/apps/api/internal/repository/admin_ops_system.go
T
jackyu66gitandCursor 5ceb3ce749
ci / h5 (push) Canceled after 0s
ci / api (push) Canceled after 0s
ci / ess-docs (push) Canceled after 0s
feat(ECR-010): Ops-E 系统运营;修复登出解绑;P2 Complete
落地管理员 RBAC/封禁/推送任务 stub,logout 解绑 device 并统一各页 ensureAccount,同时收口 P2 生日生成与状态文档。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-11 18:54:59 +08:00

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
}