feat(ECR-006): 落地运营后台 Phase A(admin API + admin-h5)
新增独立鉴权的 /api/v1/admin 与 Vue 控制台;会员授予与审计同事务,并补集成/单测。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -0,0 +1,307 @@
|
||||
package repository
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/jackc/pgx/v5"
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
)
|
||||
|
||||
// AdminRepo persists ops-admin accounts, sessions, and audit logs.
|
||||
type AdminRepo struct {
|
||||
Pool *pgxpool.Pool
|
||||
}
|
||||
|
||||
// AdminAccount is an internal operator account.
|
||||
type AdminAccount struct {
|
||||
ID uuid.UUID
|
||||
Username string
|
||||
PasswordHash string
|
||||
Status string
|
||||
}
|
||||
|
||||
// CountAccounts returns non-deleted admin count.
|
||||
func (r *AdminRepo) CountAccounts(ctx context.Context) (int, error) {
|
||||
var n int
|
||||
err := r.Pool.QueryRow(ctx, `
|
||||
SELECT count(*) FROM admin_accounts WHERE deleted_at IS NULL`).Scan(&n)
|
||||
return n, err
|
||||
}
|
||||
|
||||
// CreateAccount inserts an admin account.
|
||||
func (r *AdminRepo) CreateAccount(ctx context.Context, username, hash string) (uuid.UUID, error) {
|
||||
var id uuid.UUID
|
||||
err := r.Pool.QueryRow(ctx, `
|
||||
INSERT INTO admin_accounts(username, password_hash)
|
||||
VALUES ($1,$2) RETURNING id`, username, hash).Scan(&id)
|
||||
return id, err
|
||||
}
|
||||
|
||||
// FindByUsername loads an active admin by username.
|
||||
func (r *AdminRepo) FindByUsername(ctx context.Context, username string) (*AdminAccount, error) {
|
||||
var a AdminAccount
|
||||
err := r.Pool.QueryRow(ctx, `
|
||||
SELECT id, username, password_hash, status
|
||||
FROM admin_accounts
|
||||
WHERE username=$1 AND deleted_at IS NULL`, username,
|
||||
).Scan(&a.ID, &a.Username, &a.PasswordHash, &a.Status)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return nil, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &a, nil
|
||||
}
|
||||
|
||||
// FindAccountByID loads admin by id.
|
||||
func (r *AdminRepo) FindAccountByID(ctx context.Context, id uuid.UUID) (*AdminAccount, error) {
|
||||
var a AdminAccount
|
||||
err := r.Pool.QueryRow(ctx, `
|
||||
SELECT id, username, password_hash, status
|
||||
FROM admin_accounts
|
||||
WHERE id=$1 AND deleted_at IS NULL`, id,
|
||||
).Scan(&a.ID, &a.Username, &a.PasswordHash, &a.Status)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return nil, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &a, nil
|
||||
}
|
||||
|
||||
// CreateSession stores an opaque admin session token.
|
||||
func (r *AdminRepo) CreateSession(ctx context.Context, adminID uuid.UUID, token string, expires time.Time) error {
|
||||
_, err := r.Pool.Exec(ctx, `
|
||||
INSERT INTO admin_sessions(admin_id, token, expires_at)
|
||||
VALUES ($1,$2,$3)`, adminID, token, expires)
|
||||
return err
|
||||
}
|
||||
|
||||
// ResolveSession returns admin_id for a valid token.
|
||||
func (r *AdminRepo) ResolveSession(ctx context.Context, token string) (uuid.UUID, error) {
|
||||
var id uuid.UUID
|
||||
err := r.Pool.QueryRow(ctx, `
|
||||
SELECT s.admin_id FROM admin_sessions s
|
||||
JOIN admin_accounts a ON a.id=s.admin_id AND a.deleted_at IS NULL AND a.status='active'
|
||||
WHERE s.token=$1 AND s.expires_at > now()`, token).Scan(&id)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return uuid.Nil, errors.New("invalid session")
|
||||
}
|
||||
return id, err
|
||||
}
|
||||
|
||||
// DeleteSession removes a session by token.
|
||||
func (r *AdminRepo) DeleteSession(ctx context.Context, token string) error {
|
||||
_, err := r.Pool.Exec(ctx, `DELETE FROM admin_sessions WHERE token=$1`, token)
|
||||
return err
|
||||
}
|
||||
|
||||
// InsertAudit appends an immutable audit row.
|
||||
func (r *AdminRepo) InsertAudit(ctx context.Context, adminID uuid.UUID, action, targetType, targetID string, meta json.RawMessage) error {
|
||||
if meta == nil {
|
||||
meta = json.RawMessage(`{}`)
|
||||
}
|
||||
_, err := r.Pool.Exec(ctx, `
|
||||
INSERT INTO admin_audit_logs(admin_id, action, target_type, target_id, meta)
|
||||
VALUES ($1,$2,$3,$4,$5)`, adminID, action, targetType, targetID, meta)
|
||||
return err
|
||||
}
|
||||
|
||||
// UserListItem is a compact user row for admin tables.
|
||||
type UserListItem struct {
|
||||
ID uuid.UUID `json:"id"`
|
||||
Status string `json:"status"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
}
|
||||
|
||||
// ListUsers returns users newest first; q matches id when UUID.
|
||||
func (r *AdminRepo) ListUsers(ctx context.Context, q string, limit, offset int) ([]UserListItem, error) {
|
||||
if limit <= 0 || limit > 100 {
|
||||
limit = 20
|
||||
}
|
||||
if offset < 0 {
|
||||
offset = 0
|
||||
}
|
||||
rows, err := r.Pool.Query(ctx, `
|
||||
SELECT id, status, created_at FROM users
|
||||
WHERE deleted_at IS NULL
|
||||
AND ($1 = '' OR id::text = $1)
|
||||
ORDER BY created_at DESC
|
||||
LIMIT $2 OFFSET $3`, q, limit, offset)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var out []UserListItem
|
||||
for rows.Next() {
|
||||
var u UserListItem
|
||||
if err := rows.Scan(&u.ID, &u.Status, &u.CreatedAt); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, u)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// UserExists reports whether user id is present.
|
||||
func (r *AdminRepo) UserExists(ctx context.Context, id uuid.UUID) (bool, error) {
|
||||
var n int
|
||||
err := r.Pool.QueryRow(ctx, `
|
||||
SELECT 1 FROM users WHERE id=$1 AND deleted_at IS NULL`, id).Scan(&n)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return false, nil
|
||||
}
|
||||
return err == nil, err
|
||||
}
|
||||
|
||||
// ProfileBrief for admin user detail.
|
||||
type ProfileBrief struct {
|
||||
ID uuid.UUID `json:"id"`
|
||||
Relation string `json:"relation"`
|
||||
DisplayName string `json:"display_name"`
|
||||
}
|
||||
|
||||
// ListProfilesForUser returns profile briefs.
|
||||
func (r *AdminRepo) ListProfilesForUser(ctx context.Context, userID uuid.UUID) ([]ProfileBrief, error) {
|
||||
rows, err := r.Pool.Query(ctx, `
|
||||
SELECT id, relation, display_name FROM profiles
|
||||
WHERE user_id=$1 AND deleted_at IS NULL
|
||||
ORDER BY created_at ASC`, userID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var out []ProfileBrief
|
||||
for rows.Next() {
|
||||
var p ProfileBrief
|
||||
if err := rows.Scan(&p.ID, &p.Relation, &p.DisplayName); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, p)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// OrderListItem for admin order tables.
|
||||
type OrderListItem struct {
|
||||
ID uuid.UUID `json:"id"`
|
||||
UserID uuid.UUID `json:"user_id"`
|
||||
Kind string `json:"kind"`
|
||||
Plan *string `json:"plan,omitempty"`
|
||||
AmountCents int `json:"amount_cents"`
|
||||
Status string `json:"status"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
}
|
||||
|
||||
// ListOrders lists orders; optional user filter.
|
||||
func (r *AdminRepo) ListOrders(ctx context.Context, userID *uuid.UUID, limit, offset int) ([]OrderListItem, error) {
|
||||
if limit <= 0 || limit > 100 {
|
||||
limit = 20
|
||||
}
|
||||
if offset < 0 {
|
||||
offset = 0
|
||||
}
|
||||
rows, err := r.Pool.Query(ctx, `
|
||||
SELECT id, user_id, kind, plan, amount_cents, status, created_at
|
||||
FROM orders
|
||||
WHERE deleted_at IS NULL
|
||||
AND ($1::uuid IS NULL OR user_id = $1)
|
||||
ORDER BY created_at DESC
|
||||
LIMIT $2 OFFSET $3`, userID, limit, offset)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var out []OrderListItem
|
||||
for rows.Next() {
|
||||
var o OrderListItem
|
||||
if err := rows.Scan(&o.ID, &o.UserID, &o.Kind, &o.Plan, &o.AmountCents, &o.Status, &o.CreatedAt); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, o)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// GrantMembershipWithAudit upserts membership and appends audit in one transaction.
|
||||
func (r *AdminRepo) GrantMembershipWithAudit(
|
||||
ctx context.Context,
|
||||
adminID, userID uuid.UUID,
|
||||
plan string,
|
||||
days int,
|
||||
meta json.RawMessage,
|
||||
) error {
|
||||
tx, err := r.Pool.Begin(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer tx.Rollback(ctx)
|
||||
|
||||
if _, err := tx.Exec(ctx, `
|
||||
INSERT INTO memberships(user_id, plan, status, expires_at, ask_quota_left)
|
||||
VALUES ($1,$2,'active', now() + ($3 * interval '1 day'), 100)
|
||||
ON CONFLICT (user_id) DO UPDATE SET
|
||||
plan=EXCLUDED.plan, status='active',
|
||||
expires_at=(CASE
|
||||
WHEN memberships.expires_at IS NOT NULL AND memberships.expires_at > now()
|
||||
THEN memberships.expires_at ELSE now()
|
||||
END) + ($3 * interval '1 day'),
|
||||
ask_quota_left=100, updated_at=now()`,
|
||||
userID, plan, days); 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,'membership.grant','user',$2,$3)`, adminID, userID.String(), meta); err != nil {
|
||||
return err
|
||||
}
|
||||
return tx.Commit(ctx)
|
||||
}
|
||||
|
||||
// AuditListItem for admin audit table.
|
||||
type AuditListItem struct {
|
||||
ID uuid.UUID `json:"id"`
|
||||
AdminID uuid.UUID `json:"admin_id"`
|
||||
Action string `json:"action"`
|
||||
TargetType string `json:"target_type"`
|
||||
TargetID string `json:"target_id"`
|
||||
Meta json.RawMessage `json:"meta"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
}
|
||||
|
||||
// ListAuditLogs returns newest audit rows.
|
||||
func (r *AdminRepo) ListAuditLogs(ctx context.Context, limit, offset int) ([]AuditListItem, error) {
|
||||
if limit <= 0 || limit > 100 {
|
||||
limit = 20
|
||||
}
|
||||
if offset < 0 {
|
||||
offset = 0
|
||||
}
|
||||
rows, err := r.Pool.Query(ctx, `
|
||||
SELECT id, admin_id, action, target_type, target_id, meta, created_at
|
||||
FROM admin_audit_logs
|
||||
ORDER BY created_at DESC
|
||||
LIMIT $1 OFFSET $2`, limit, offset)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var out []AuditListItem
|
||||
for rows.Next() {
|
||||
var a AuditListItem
|
||||
if err := rows.Scan(&a.ID, &a.AdminID, &a.Action, &a.TargetType, &a.TargetID, &a.Meta, &a.CreatedAt); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, a)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
@@ -91,11 +91,11 @@ func (r *ReportRepo) HasActiveMembership(ctx context.Context, userID uuid.UUID)
|
||||
|
||||
// MembershipRow is the current membership snapshot for a user.
|
||||
type MembershipRow struct {
|
||||
Plan string
|
||||
Status string
|
||||
ExpiresAt *time.Time
|
||||
AskQuotaLeft int
|
||||
Active bool
|
||||
Plan string `json:"plan,omitempty"`
|
||||
Status string `json:"status"`
|
||||
ExpiresAt *time.Time `json:"expires_at,omitempty"`
|
||||
AskQuotaLeft int `json:"ask_quota_left,omitempty"`
|
||||
Active bool `json:"active"`
|
||||
}
|
||||
|
||||
// GetMembership returns membership status; missing row → inactive.
|
||||
|
||||
Reference in New Issue
Block a user