落地输入合规、探索题库、报告日/时辰刷新、账号头像、OEJTS 量表,并补齐 H5 埋点与 Admin 漏斗;同步 ESS 工件、切至自建 Git、清理 GitHub Actions。 Co-authored-by: Cursor <cursoragent@cursor.com>
346 lines
11 KiB
Go
346 lines
11 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 (optional peer for pair reports).
|
|
func (r *ReportRepo) Create(ctx context.Context, userID, profileID uuid.UUID, typ string, summary, detail json.RawMessage) (*model.GrowthReport, error) {
|
|
return r.CreateWithPeer(ctx, userID, profileID, nil, typ, summary, detail)
|
|
}
|
|
|
|
// CreateWithPeer inserts a report with optional peer_profile_id.
|
|
func (r *ReportRepo) CreateWithPeer(ctx context.Context, userID, profileID uuid.UUID, peer *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, peer_profile_id, type, summary, detail)
|
|
VALUES ($1,$2,$3,$4,$5,$6)
|
|
RETURNING id, user_id, profile_id, type, summary, detail, created_at`,
|
|
userID, profileID, peer, typ, summary, detail,
|
|
).Scan(&rep.ID, &rep.UserID, &rep.ProfileID, &rep.Type, &rep.Summary, &rep.Detail, &rep.CreatedAt)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
rep.PeerProfileID = peer
|
|
return rep, nil
|
|
}
|
|
|
|
// SoftDeleteMatching soft-deletes prior reports for overwrite semantics.
|
|
func (r *ReportRepo) SoftDeleteMatching(ctx context.Context, userID, profileID uuid.UUID, peer *uuid.UUID, typ string) error {
|
|
if peer == nil {
|
|
_, err := r.Pool.Exec(ctx, `
|
|
UPDATE growth_reports SET deleted_at=now(), updated_at=now()
|
|
WHERE user_id=$1 AND profile_id=$2 AND type=$3
|
|
AND peer_profile_id IS NULL AND deleted_at IS NULL`,
|
|
userID, profileID, typ)
|
|
return err
|
|
}
|
|
_, err := r.Pool.Exec(ctx, `
|
|
UPDATE growth_reports SET deleted_at=now(), updated_at=now()
|
|
WHERE user_id=$1 AND type=$2 AND deleted_at IS NULL
|
|
AND (
|
|
(profile_id=$3 AND peer_profile_id=$4) OR
|
|
(profile_id=$4 AND peer_profile_id=$3)
|
|
)`,
|
|
userID, typ, profileID, *peer)
|
|
return err
|
|
}
|
|
|
|
// UpsertWithPeer soft-deletes matching then inserts.
|
|
func (r *ReportRepo) UpsertWithPeer(ctx context.Context, userID, profileID uuid.UUID, peer *uuid.UUID, typ string, summary, detail json.RawMessage) (*model.GrowthReport, error) {
|
|
if err := r.SoftDeleteMatching(ctx, userID, profileID, peer, typ); err != nil {
|
|
return nil, err
|
|
}
|
|
return r.CreateWithPeer(ctx, userID, profileID, peer, typ, summary, detail)
|
|
}
|
|
|
|
// UpdateContent patches summary/detail in place (keeps report id for deep access).
|
|
func (r *ReportRepo) UpdateContent(ctx context.Context, userID, reportID uuid.UUID, summary, detail json.RawMessage) error {
|
|
tag, err := r.Pool.Exec(ctx, `
|
|
UPDATE growth_reports SET summary=$3, detail=$4, updated_at=now()
|
|
WHERE id=$1 AND user_id=$2 AND deleted_at IS NULL`,
|
|
reportID, userID, summary, detail)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if tag.RowsAffected() == 0 {
|
|
return pgx.ErrNoRows
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// GetLatest returns newest non-deleted report for profile+type(+peer).
|
|
func (r *ReportRepo) GetLatest(ctx context.Context, userID, profileID uuid.UUID, typ string, peer *uuid.UUID) (*model.GrowthReport, error) {
|
|
rep := &model.GrowthReport{}
|
|
var peerOut *uuid.UUID
|
|
var err error
|
|
if peer == nil {
|
|
err = r.Pool.QueryRow(ctx, `
|
|
SELECT id, user_id, profile_id, peer_profile_id, type, summary, detail, created_at
|
|
FROM growth_reports
|
|
WHERE user_id=$1 AND profile_id=$2 AND type=$3
|
|
AND peer_profile_id IS NULL AND deleted_at IS NULL
|
|
ORDER BY created_at DESC LIMIT 1`,
|
|
userID, profileID, typ,
|
|
).Scan(&rep.ID, &rep.UserID, &rep.ProfileID, &peerOut, &rep.Type, &rep.Summary, &rep.Detail, &rep.CreatedAt)
|
|
} else {
|
|
err = r.Pool.QueryRow(ctx, `
|
|
SELECT id, user_id, profile_id, peer_profile_id, type, summary, detail, created_at
|
|
FROM growth_reports
|
|
WHERE user_id=$1 AND type=$2 AND deleted_at IS NULL
|
|
AND (
|
|
(profile_id=$3 AND peer_profile_id=$4) OR
|
|
(profile_id=$4 AND peer_profile_id=$3)
|
|
)
|
|
ORDER BY created_at DESC LIMIT 1`,
|
|
userID, typ, profileID, *peer,
|
|
).Scan(&rep.ID, &rep.UserID, &rep.ProfileID, &peerOut, &rep.Type, &rep.Summary, &rep.Detail, &rep.CreatedAt)
|
|
}
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
rep.PeerProfileID = peerOut
|
|
return rep, nil
|
|
}
|
|
|
|
// SoftDeleteForProfile marks all reports involving a profile as deleted.
|
|
func (r *ReportRepo) SoftDeleteForProfile(ctx context.Context, userID, profileID uuid.UUID) error {
|
|
_, err := r.Pool.Exec(ctx, `
|
|
UPDATE growth_reports SET deleted_at=now(), updated_at=now()
|
|
WHERE user_id=$1 AND deleted_at IS NULL
|
|
AND (profile_id=$2 OR peer_profile_id=$2)`,
|
|
userID, profileID)
|
|
return 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, "a)
|
|
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
|
|
}
|
|
case "ask_pack":
|
|
p := "pack10"
|
|
if plan != nil && *plan != "" {
|
|
p = *plan
|
|
}
|
|
delta := AskPackQuota(p)
|
|
if delta <= 0 {
|
|
return errString("invalid ask_pack plan")
|
|
}
|
|
if _, err := tx.Exec(ctx, `
|
|
UPDATE users SET ask_paid_quota_left = ask_paid_quota_left + $2, updated_at=now()
|
|
WHERE id=$1 AND deleted_at IS NULL`, userID, delta); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
return tx.Commit(ctx)
|
|
}
|
|
|
|
// AskPackQuota returns how many ask replies a pack plan grants.
|
|
func AskPackQuota(plan string) int {
|
|
switch plan {
|
|
case "pack10":
|
|
return 10
|
|
case "pack30":
|
|
return 30
|
|
case "pack100":
|
|
return 100
|
|
default:
|
|
return 0
|
|
}
|
|
}
|
|
|
|
// AskPackAmountCents is mock price for an ask pack plan.
|
|
func AskPackAmountCents(plan string) int {
|
|
switch plan {
|
|
case "pack10":
|
|
return 990
|
|
case "pack30":
|
|
return 1980
|
|
case "pack100":
|
|
return 4990
|
|
default:
|
|
return 0
|
|
}
|
|
}
|
|
|
|
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
|
|
}
|