Files
digital-psychology/apps/api/internal/repository/report_repo.go
T
jackyu66gitandCursor 879bf70cb7
ci / h5 (push) Canceled after 0s
ci / api (push) Canceled after 0s
ci / ess-docs (push) Canceled after 0s
feat(ECR-006): 落地运营后台 Phase A(admin API + admin-h5)
新增独立鉴权的 /api/v1/admin 与 Vue 控制台;会员授予与审计同事务,并补集成/单测。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-06 18:35:53 +08:00

207 lines
6.3 KiB
Go

package repository
import (
"context"
"encoding/json"
"errors"
"time"
"github.com/google/uuid"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgxpool"
"github.com/yuxingu/digital-psychology/apps/api/internal/model"
)
// ReportRepo persists growth reports and access checks.
type ReportRepo struct {
Pool *pgxpool.Pool
}
// Create inserts a growth report.
func (r *ReportRepo) Create(ctx context.Context, userID, profileID uuid.UUID, typ string, summary, detail json.RawMessage) (*model.GrowthReport, error) {
rep := &model.GrowthReport{}
err := r.Pool.QueryRow(ctx, `
INSERT INTO growth_reports(user_id, profile_id, type, summary, detail)
VALUES ($1,$2,$3,$4,$5)
RETURNING id, user_id, profile_id, type, summary, detail, created_at`,
userID, profileID, typ, summary, detail,
).Scan(&rep.ID, &rep.UserID, &rep.ProfileID, &rep.Type, &rep.Summary, &rep.Detail, &rep.CreatedAt)
return rep, err
}
// GetForUser loads a report owned by user.
func (r *ReportRepo) GetForUser(ctx context.Context, userID, reportID uuid.UUID) (*model.GrowthReport, error) {
rep := &model.GrowthReport{}
err := r.Pool.QueryRow(ctx, `
SELECT id, user_id, profile_id, type, summary, detail, created_at
FROM growth_reports WHERE id=$1 AND user_id=$2 AND deleted_at IS NULL`,
reportID, userID,
).Scan(&rep.ID, &rep.UserID, &rep.ProfileID, &rep.Type, &rep.Summary, &rep.Detail, &rep.CreatedAt)
return rep, err
}
// ListForUser returns recent reports for a user (summary only usage at service layer).
func (r *ReportRepo) ListForUser(ctx context.Context, userID uuid.UUID, limit int) ([]model.GrowthReport, error) {
if limit <= 0 || limit > 100 {
limit = 50
}
rows, err := r.Pool.Query(ctx, `
SELECT id, user_id, profile_id, type, summary, detail, 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 []model.GrowthReport
for rows.Next() {
var rep model.GrowthReport
if err := rows.Scan(&rep.ID, &rep.UserID, &rep.ProfileID, &rep.Type, &rep.Summary, &rep.Detail, &rep.CreatedAt); err != nil {
return nil, err
}
out = append(out, rep)
}
return out, rows.Err()
}
// HasDeepAccess reports whether user purchased deep access for report.
func (r *ReportRepo) HasDeepAccess(ctx context.Context, userID, reportID uuid.UUID) (bool, error) {
var ok bool
err := r.Pool.QueryRow(ctx, `
SELECT EXISTS(
SELECT 1 FROM deep_accesses
WHERE user_id=$1 AND report_id=$2 AND deleted_at IS NULL
)`, userID, reportID).Scan(&ok)
return ok, err
}
// HasActiveMembership checks growth membership.
func (r *ReportRepo) HasActiveMembership(ctx context.Context, userID uuid.UUID) (bool, error) {
var ok bool
err := r.Pool.QueryRow(ctx, `
SELECT EXISTS(
SELECT 1 FROM memberships
WHERE user_id=$1 AND status='active' AND expires_at > now() AND deleted_at IS NULL
)`, userID).Scan(&ok)
return ok, err
}
// MembershipRow is the current membership snapshot for a user.
type MembershipRow struct {
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.
func (r *ReportRepo) GetMembership(ctx context.Context, userID uuid.UUID) (*MembershipRow, error) {
var plan, status string
var expires *time.Time
var quota int
err := r.Pool.QueryRow(ctx, `
SELECT plan, status, expires_at, ask_quota_left
FROM memberships
WHERE user_id=$1 AND deleted_at IS NULL`, userID,
).Scan(&plan, &status, &expires, &quota)
if errors.Is(err, pgx.ErrNoRows) {
return &MembershipRow{Active: false, Status: "none"}, nil
}
if err != nil {
return nil, err
}
active := status == "active" && expires != nil && expires.After(time.Now())
return &MembershipRow{
Plan: plan, Status: status, ExpiresAt: expires, AskQuotaLeft: quota, Active: active,
}, nil
}
// CreateOrder inserts an order.
func (r *ReportRepo) CreateOrder(ctx context.Context, userID uuid.UUID, kind, plan string, reportID *uuid.UUID, amount int) (uuid.UUID, error) {
var id uuid.UUID
err := r.Pool.QueryRow(ctx, `
INSERT INTO orders(user_id, kind, plan, report_id, amount_cents, status)
VALUES ($1,$2,$3,$4,$5,'created') RETURNING id`,
userID, kind, nullIfEmpty(plan), reportID, amount,
).Scan(&id)
return id, err
}
// PayMock marks order paid and grants entitlement.
func (r *ReportRepo) PayMock(ctx context.Context, userID, orderID uuid.UUID) error {
tx, err := r.Pool.Begin(ctx)
if err != nil {
return err
}
defer tx.Rollback(ctx)
var kind string
var reportID *uuid.UUID
var plan *string
err = tx.QueryRow(ctx, `
SELECT kind, report_id, plan FROM orders
WHERE id=$1 AND user_id=$2 AND deleted_at IS NULL FOR UPDATE`,
orderID, userID,
).Scan(&kind, &reportID, &plan)
if err != nil {
return err
}
if _, err := tx.Exec(ctx, `UPDATE orders SET status='paid', updated_at=now() WHERE id=$1`, orderID); err != nil {
return err
}
if _, err := tx.Exec(ctx, `
INSERT INTO payments(order_id, channel, status) VALUES ($1,'mock','paid')`, orderID); err != nil {
return err
}
switch kind {
case "deep_access":
if reportID == nil {
return errMissingReport
}
if _, err := tx.Exec(ctx, `
INSERT INTO deep_accesses(user_id, report_id, order_id)
VALUES ($1,$2,$3)
ON CONFLICT (user_id, report_id) DO NOTHING`, userID, *reportID, orderID); err != nil {
return err
}
case "membership":
p := "month"
if plan != nil && *plan != "" {
p = *plan
}
days := 31
if p == "quarter" {
days = 92
} else if p == "year" {
days = 366
}
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=EXCLUDED.expires_at, ask_quota_left=100, updated_at=now()`,
userID, p, days); err != nil {
return err
}
}
return tx.Commit(ctx)
}
var errMissingReport = errString("report_id required for deep_access")
type errString string
func (e errString) Error() string { return string(e) }
func nullIfEmpty(s string) *string {
if s == "" {
return nil
}
return &s
}