feat(ops): ECR-007 行为分析与 ECR-008 内容运营后台
落地埋点 ingest/数据看板、首页宫格 CMS 与测评上下架;含账号引导、问答流式与免责声明去重,以及 review P1 审计同事务修复。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -115,12 +115,18 @@ func (r *AdminRepo) InsertAudit(ctx context.Context, adminID uuid.UUID, action,
|
||||
|
||||
// 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"`
|
||||
ID uuid.UUID `json:"id"`
|
||||
Status string `json:"status"`
|
||||
Phone *string `json:"phone,omitempty"`
|
||||
Nickname *string `json:"nickname,omitempty"`
|
||||
AskPaidQuotaLeft int `json:"ask_paid_quota_left"`
|
||||
ProfileCount int `json:"profile_count"`
|
||||
MembershipActive bool `json:"membership_active"`
|
||||
MembershipPlan *string `json:"membership_plan,omitempty"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
}
|
||||
|
||||
// ListUsers returns users newest first; q matches id when UUID.
|
||||
// ListUsers returns users newest first; q matches id / phone / nickname.
|
||||
func (r *AdminRepo) ListUsers(ctx context.Context, q string, limit, offset int) ([]UserListItem, error) {
|
||||
if limit <= 0 || limit > 100 {
|
||||
limit = 20
|
||||
@@ -129,10 +135,26 @@ func (r *AdminRepo) ListUsers(ctx context.Context, q string, limit, offset int)
|
||||
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
|
||||
SELECT u.id, u.status, u.phone, u.nickname, u.ask_paid_quota_left, u.created_at,
|
||||
(SELECT count(*) FROM profiles p WHERE p.user_id=u.id AND p.deleted_at IS NULL) AS profile_count,
|
||||
EXISTS(
|
||||
SELECT 1 FROM memberships m
|
||||
WHERE m.user_id=u.id AND m.deleted_at IS NULL AND m.status='active' AND m.expires_at > now()
|
||||
) AS membership_active,
|
||||
(
|
||||
SELECT m.plan FROM memberships m
|
||||
WHERE m.user_id=u.id AND m.deleted_at IS NULL
|
||||
LIMIT 1
|
||||
) AS membership_plan
|
||||
FROM users u
|
||||
WHERE u.deleted_at IS NULL
|
||||
AND (
|
||||
$1 = ''
|
||||
OR u.id::text = $1
|
||||
OR COALESCE(u.phone,'') ILIKE '%' || $1 || '%'
|
||||
OR COALESCE(u.nickname,'') ILIKE '%' || $1 || '%'
|
||||
)
|
||||
ORDER BY u.created_at DESC
|
||||
LIMIT $2 OFFSET $3`, q, limit, offset)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -141,7 +163,10 @@ func (r *AdminRepo) ListUsers(ctx context.Context, q string, limit, offset int)
|
||||
var out []UserListItem
|
||||
for rows.Next() {
|
||||
var u UserListItem
|
||||
if err := rows.Scan(&u.ID, &u.Status, &u.CreatedAt); err != nil {
|
||||
if err := rows.Scan(
|
||||
&u.ID, &u.Status, &u.Phone, &u.Nickname, &u.AskPaidQuotaLeft, &u.CreatedAt,
|
||||
&u.ProfileCount, &u.MembershipActive, &u.MembershipPlan,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, u)
|
||||
@@ -149,6 +174,126 @@ func (r *AdminRepo) ListUsers(ctx context.Context, q string, limit, offset int)
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// DashboardStats is ops overview counters.
|
||||
type DashboardStats struct {
|
||||
UsersTotal int `json:"users_total"`
|
||||
MembershipActive int `json:"membership_active"`
|
||||
OrdersToday int `json:"orders_today"`
|
||||
PaidCentsToday int `json:"paid_cents_today"`
|
||||
AskRepliesToday int `json:"ask_replies_today"`
|
||||
ProfilesTotal int `json:"profiles_total"`
|
||||
ReportsTotal int `json:"reports_total"`
|
||||
Series []DashboardDay `json:"series"`
|
||||
ReportsByType []ReportTypeCnt `json:"reports_by_type"`
|
||||
}
|
||||
|
||||
// DashboardDay is one day of trend metrics.
|
||||
type DashboardDay struct {
|
||||
Day string `json:"day"`
|
||||
NewUsers int `json:"new_users"`
|
||||
Orders int `json:"orders"`
|
||||
PaidCents int `json:"paid_cents"`
|
||||
AskReplies int `json:"ask_replies"`
|
||||
}
|
||||
|
||||
// ReportTypeCnt counts reports by type.
|
||||
type ReportTypeCnt struct {
|
||||
Type string `json:"type"`
|
||||
Count int `json:"count"`
|
||||
}
|
||||
|
||||
// GetDashboardStats aggregates key ops metrics.
|
||||
func (r *AdminRepo) GetDashboardStats(ctx context.Context) (*DashboardStats, error) {
|
||||
s := &DashboardStats{}
|
||||
if err := r.Pool.QueryRow(ctx, `
|
||||
SELECT count(*) FROM users WHERE deleted_at IS NULL`).Scan(&s.UsersTotal); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := r.Pool.QueryRow(ctx, `
|
||||
SELECT count(*) FROM memberships
|
||||
WHERE deleted_at IS NULL AND status='active' AND expires_at > now()`).Scan(&s.MembershipActive); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := r.Pool.QueryRow(ctx, `
|
||||
SELECT count(*) FROM orders
|
||||
WHERE deleted_at IS NULL AND created_at >= date_trunc('day', now())`).Scan(&s.OrdersToday); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := r.Pool.QueryRow(ctx, `
|
||||
SELECT coalesce(sum(amount_cents),0) FROM orders
|
||||
WHERE deleted_at IS NULL AND status='paid' AND created_at >= date_trunc('day', now())`).Scan(&s.PaidCentsToday); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := r.Pool.QueryRow(ctx, `
|
||||
SELECT count(*) FROM ask_messages m
|
||||
JOIN ask_threads t ON t.id=m.thread_id AND t.deleted_at IS NULL
|
||||
WHERE m.deleted_at IS NULL AND m.role='assistant'
|
||||
AND m.created_at >= date_trunc('day', now())`).Scan(&s.AskRepliesToday); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := r.Pool.QueryRow(ctx, `
|
||||
SELECT count(*) FROM profiles WHERE deleted_at IS NULL`).Scan(&s.ProfilesTotal); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := r.Pool.QueryRow(ctx, `
|
||||
SELECT count(*) FROM growth_reports WHERE deleted_at IS NULL`).Scan(&s.ReportsTotal); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
rows, err := r.Pool.Query(ctx, `
|
||||
WITH days AS (
|
||||
SELECT generate_series(
|
||||
date_trunc('day', now()) - interval '6 day',
|
||||
date_trunc('day', now()),
|
||||
interval '1 day'
|
||||
)::date AS d
|
||||
)
|
||||
SELECT to_char(d.d, 'YYYY-MM-DD') AS day,
|
||||
(SELECT count(*) FROM users u
|
||||
WHERE u.deleted_at IS NULL AND u.created_at::date = d.d) AS new_users,
|
||||
(SELECT count(*) FROM orders o
|
||||
WHERE o.deleted_at IS NULL AND o.created_at::date = d.d) AS orders,
|
||||
(SELECT coalesce(sum(o.amount_cents),0) FROM orders o
|
||||
WHERE o.deleted_at IS NULL AND o.status='paid' AND o.created_at::date = d.d) AS paid_cents,
|
||||
(SELECT count(*) FROM ask_messages m
|
||||
JOIN ask_threads t ON t.id=m.thread_id AND t.deleted_at IS NULL
|
||||
WHERE m.deleted_at IS NULL AND m.role='assistant' AND m.created_at::date = d.d) AS ask_replies
|
||||
FROM days d
|
||||
ORDER BY d.d ASC`)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
for rows.Next() {
|
||||
var p DashboardDay
|
||||
if err := rows.Scan(&p.Day, &p.NewUsers, &p.Orders, &p.PaidCents, &p.AskReplies); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
s.Series = append(s.Series, p)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
trows, err := r.Pool.Query(ctx, `
|
||||
SELECT type, count(*) FROM growth_reports
|
||||
WHERE deleted_at IS NULL
|
||||
GROUP BY type
|
||||
ORDER BY count(*) DESC`)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer trows.Close()
|
||||
for trows.Next() {
|
||||
var c ReportTypeCnt
|
||||
if err := trows.Scan(&c.Type, &c.Count); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
s.ReportsByType = append(s.ReportsByType, c)
|
||||
}
|
||||
return s, trows.Err()
|
||||
}
|
||||
|
||||
// UserExists reports whether user id is present.
|
||||
func (r *AdminRepo) UserExists(ctx context.Context, id uuid.UUID) (bool, error) {
|
||||
var n int
|
||||
@@ -165,12 +310,14 @@ type ProfileBrief struct {
|
||||
ID uuid.UUID `json:"id"`
|
||||
Relation string `json:"relation"`
|
||||
DisplayName string `json:"display_name"`
|
||||
BirthDate string `json:"birth_date,omitempty"`
|
||||
}
|
||||
|
||||
// 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
|
||||
SELECT id, relation, display_name, to_char(birth_date, 'YYYY-MM-DD')
|
||||
FROM profiles
|
||||
WHERE user_id=$1 AND deleted_at IS NULL
|
||||
ORDER BY created_at ASC`, userID)
|
||||
if err != nil {
|
||||
@@ -180,7 +327,7 @@ func (r *AdminRepo) ListProfilesForUser(ctx context.Context, userID uuid.UUID) (
|
||||
var out []ProfileBrief
|
||||
for rows.Next() {
|
||||
var p ProfileBrief
|
||||
if err := rows.Scan(&p.ID, &p.Relation, &p.DisplayName); err != nil {
|
||||
if err := rows.Scan(&p.ID, &p.Relation, &p.DisplayName, &p.BirthDate); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, p)
|
||||
@@ -188,6 +335,83 @@ func (r *AdminRepo) ListProfilesForUser(ctx context.Context, userID uuid.UUID) (
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// ReportBrief for admin user detail.
|
||||
type ReportBrief struct {
|
||||
ID uuid.UUID `json:"id"`
|
||||
Type string `json:"type"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
}
|
||||
|
||||
// ListReportsForUser returns recent growth reports.
|
||||
func (r *AdminRepo) ListReportsForUser(ctx context.Context, userID uuid.UUID, limit int) ([]ReportBrief, error) {
|
||||
if limit <= 0 || limit > 50 {
|
||||
limit = 20
|
||||
}
|
||||
rows, err := r.Pool.Query(ctx, `
|
||||
SELECT id, type, created_at FROM growth_reports
|
||||
WHERE user_id=$1 AND deleted_at IS NULL
|
||||
ORDER BY created_at DESC
|
||||
LIMIT $2`, userID, limit)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var out []ReportBrief
|
||||
for rows.Next() {
|
||||
var rep ReportBrief
|
||||
if err := rows.Scan(&rep.ID, &rep.Type, &rep.CreatedAt); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, rep)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// GetUserAccount loads phone/nickname/paid ask quota for one user.
|
||||
func (r *AdminRepo) GetUserAccount(ctx context.Context, userID uuid.UUID) (phone, nickname *string, paidLeft int, status string, createdAt time.Time, err error) {
|
||||
err = r.Pool.QueryRow(ctx, `
|
||||
SELECT phone, nickname, ask_paid_quota_left, status, created_at
|
||||
FROM users WHERE id=$1 AND deleted_at IS NULL`, userID,
|
||||
).Scan(&phone, &nickname, &paidLeft, &status, &createdAt)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return nil, nil, 0, "", time.Time{}, err
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// GrantAskQuotaWithAudit adds paid ask quota and writes audit.
|
||||
func (r *AdminRepo) GrantAskQuotaWithAudit(ctx context.Context, adminID, userID uuid.UUID, delta int, meta json.RawMessage) (int, error) {
|
||||
if delta <= 0 {
|
||||
return 0, errors.New("delta must be positive")
|
||||
}
|
||||
tx, err := r.Pool.Begin(ctx)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
defer tx.Rollback(ctx)
|
||||
var left int
|
||||
err = tx.QueryRow(ctx, `
|
||||
UPDATE users SET ask_paid_quota_left = ask_paid_quota_left + $2, updated_at=now()
|
||||
WHERE id=$1 AND deleted_at IS NULL
|
||||
RETURNING ask_paid_quota_left`, userID, delta,
|
||||
).Scan(&left)
|
||||
if err != nil {
|
||||
return 0, 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,'ask_quota.grant','user',$2,$3)`, adminID, userID.String(), meta); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
if err := tx.Commit(ctx); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return left, nil
|
||||
}
|
||||
|
||||
// OrderListItem for admin order tables.
|
||||
type OrderListItem struct {
|
||||
ID uuid.UUID `json:"id"`
|
||||
|
||||
Reference in New Issue
Block a user