feat: P1 合盘/星座/问答与测测完整设计包及模拟器取证工具

落地 synastry/star/ask API 与 H5 页面,补齐 cece-frontend-re complete-design 证据文档,并加入 Android 模拟器截图抓取脚本。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
jackyu66git
2026-08-03 11:37:53 +08:00
co-authored by Cursor
parent 15a9db374a
commit bd22d9dddd
248 changed files with 26309 additions and 842 deletions
+104
View File
@@ -0,0 +1,104 @@
package repository
import (
"context"
"errors"
"github.com/google/uuid"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgxpool"
"github.com/yuxingu/digital-psychology/apps/api/internal/model"
)
// AskRepo persists ask threads and messages.
type AskRepo struct {
Pool *pgxpool.Pool
}
// CreateThread inserts a thread bound to a profile.
func (r *AskRepo) CreateThread(ctx context.Context, userID, profileID uuid.UUID, scene *string) (*model.AskThread, error) {
t := &model.AskThread{}
err := r.Pool.QueryRow(ctx, `
INSERT INTO ask_threads(user_id, profile_id, scene)
VALUES ($1,$2,$3)
RETURNING id, user_id, profile_id, scene, created_at`,
userID, profileID, scene,
).Scan(&t.ID, &t.UserID, &t.ProfileID, &t.Scene, &t.CreatedAt)
return t, err
}
// GetThreadForUser loads a thread owned by user.
func (r *AskRepo) GetThreadForUser(ctx context.Context, userID, threadID uuid.UUID) (*model.AskThread, error) {
t := &model.AskThread{}
err := r.Pool.QueryRow(ctx, `
SELECT id, user_id, profile_id, scene, created_at
FROM ask_threads WHERE id=$1 AND user_id=$2 AND deleted_at IS NULL`,
threadID, userID,
).Scan(&t.ID, &t.UserID, &t.ProfileID, &t.Scene, &t.CreatedAt)
return t, err
}
// ListMessages returns messages in a thread (oldest first).
func (r *AskRepo) ListMessages(ctx context.Context, threadID uuid.UUID) ([]model.AskMessage, error) {
rows, err := r.Pool.Query(ctx, `
SELECT id, thread_id, role, content, created_at
FROM ask_messages WHERE thread_id=$1 AND deleted_at IS NULL
ORDER BY created_at ASC`, threadID)
if err != nil {
return nil, err
}
defer rows.Close()
var out []model.AskMessage
for rows.Next() {
var m model.AskMessage
if err := rows.Scan(&m.ID, &m.ThreadID, &m.Role, &m.Content, &m.CreatedAt); err != nil {
return nil, err
}
out = append(out, m)
}
return out, rows.Err()
}
// InsertMessage stores one message.
func (r *AskRepo) InsertMessage(ctx context.Context, threadID uuid.UUID, role, content string) (*model.AskMessage, error) {
m := &model.AskMessage{}
err := r.Pool.QueryRow(ctx, `
INSERT INTO ask_messages(thread_id, role, content)
VALUES ($1,$2,$3)
RETURNING id, thread_id, role, content, created_at`,
threadID, role, content,
).Scan(&m.ID, &m.ThreadID, &m.Role, &m.Content, &m.CreatedAt)
return m, err
}
// CountUserAssistantMessages counts all assistant replies for quota (free tier).
func (r *AskRepo) CountUserAssistantMessages(ctx context.Context, userID uuid.UUID) (int, error) {
var n int
err := r.Pool.QueryRow(ctx, `
SELECT COUNT(*) FROM ask_messages m
JOIN ask_threads t ON t.id = m.thread_id
WHERE t.user_id=$1 AND m.role='assistant' AND m.deleted_at IS NULL AND t.deleted_at IS NULL`,
userID,
).Scan(&n)
return n, err
}
// ConsumeMembershipQuota decrements ask_quota_left when membership is active.
// Returns false if no active membership or quota is already 0.
func (r *AskRepo) ConsumeMembershipQuota(ctx context.Context, userID uuid.UUID) (ok bool, left int, err error) {
err = r.Pool.QueryRow(ctx, `
UPDATE memberships
SET ask_quota_left = ask_quota_left - 1, updated_at=now()
WHERE user_id=$1 AND status='active' AND expires_at > now()
AND deleted_at IS NULL AND ask_quota_left > 0
RETURNING ask_quota_left`, userID,
).Scan(&left)
if errors.Is(err, pgx.ErrNoRows) {
return false, 0, nil
}
if err != nil {
return false, 0, err
}
return true, left, nil
}
+131
View File
@@ -0,0 +1,131 @@
package repository
import (
"context"
"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"
)
// GrowthRepo persists growth plans and check-ins.
type GrowthRepo struct {
Pool *pgxpool.Pool
}
// CreatePlan inserts a plan.
func (r *GrowthRepo) CreatePlan(ctx context.Context, userID uuid.UUID, title, focus string) (*model.GrowthPlan, error) {
p := &model.GrowthPlan{}
err := r.Pool.QueryRow(ctx, `
INSERT INTO growth_plans(user_id, title, focus)
VALUES ($1,$2,$3)
RETURNING id, user_id, title, focus, status, created_at`,
userID, title, focus,
).Scan(&p.ID, &p.UserID, &p.Title, &p.Focus, &p.Status, &p.CreatedAt)
return p, err
}
// ListPlans returns active plans for user.
func (r *GrowthRepo) ListPlans(ctx context.Context, userID uuid.UUID) ([]model.GrowthPlan, error) {
rows, err := r.Pool.Query(ctx, `
SELECT id, user_id, title, focus, status, created_at
FROM growth_plans
WHERE user_id=$1 AND deleted_at IS NULL AND status='active'
ORDER BY created_at DESC LIMIT 20`, userID)
if err != nil {
return nil, err
}
defer rows.Close()
var out []model.GrowthPlan
for rows.Next() {
var p model.GrowthPlan
if err := rows.Scan(&p.ID, &p.UserID, &p.Title, &p.Focus, &p.Status, &p.CreatedAt); err != nil {
return nil, err
}
out = append(out, p)
}
return out, rows.Err()
}
// Checkin upserts today's check-in.
func (r *GrowthRepo) Checkin(ctx context.Context, userID, planID uuid.UUID, day time.Time, note *string) (*model.GrowthCheckin, error) {
var owner uuid.UUID
err := r.Pool.QueryRow(ctx, `
SELECT user_id FROM growth_plans WHERE id=$1 AND deleted_at IS NULL`, planID,
).Scan(&owner)
if err != nil {
return nil, errors.New("plan not found")
}
if owner != userID {
return nil, errors.New("plan not found")
}
c := &model.GrowthCheckin{}
var dayOut time.Time
err = r.Pool.QueryRow(ctx, `
INSERT INTO growth_checkins(plan_id, user_id, day, note)
VALUES ($1,$2,$3::date,$4)
ON CONFLICT (plan_id, day) DO UPDATE SET note=EXCLUDED.note
RETURNING id, plan_id, user_id, day, note, created_at`,
planID, userID, day.Format("2006-01-02"), note,
).Scan(&c.ID, &c.PlanID, &c.UserID, &dayOut, &c.Note, &c.CreatedAt)
if err != nil {
return nil, err
}
c.Day = dayOut.Format("2006-01-02")
return c, nil
}
// ListCheckinsRecent returns check-ins for a plan in last N days.
func (r *GrowthRepo) ListCheckinsRecent(ctx context.Context, userID, planID uuid.UUID, days int) ([]model.GrowthCheckin, error) {
rows, err := r.Pool.Query(ctx, `
SELECT id, plan_id, user_id, day, note, created_at
FROM growth_checkins
WHERE user_id=$1 AND plan_id=$2 AND day >= (CURRENT_DATE - $3::int)
ORDER BY day DESC`, userID, planID, days)
if err != nil {
return nil, err
}
defer rows.Close()
var out []model.GrowthCheckin
for rows.Next() {
var c model.GrowthCheckin
var dayOut time.Time
if err := rows.Scan(&c.ID, &c.PlanID, &c.UserID, &dayOut, &c.Note, &c.CreatedAt); err != nil {
return nil, err
}
c.Day = dayOut.Format("2006-01-02")
out = append(out, c)
}
return out, rows.Err()
}
// ListMoodsRecent for companion trail (reuse moods table).
func (r *GrowthRepo) ListMoodsRecent(ctx context.Context, userID uuid.UUID, days int) ([]model.Mood, error) {
rows, err := r.Pool.Query(ctx, `
SELECT id, user_id, day, score, note, created_at
FROM moods
WHERE user_id=$1 AND deleted_at IS NULL AND day >= (CURRENT_DATE - $2::int)
ORDER BY day DESC`, userID, days)
if err != nil {
return nil, err
}
defer rows.Close()
var out []model.Mood
for rows.Next() {
var m model.Mood
var dayOut time.Time
if err := rows.Scan(&m.ID, &m.UserID, &dayOut, &m.Score, &m.Note, &m.CreatedAt); err != nil {
return nil, err
}
m.Day = dayOut.Format("2006-01-02")
out = append(out, m)
}
return out, rows.Err()
}
// ErrNoRows re-export helper.
var ErrNoRows = pgx.ErrNoRows
@@ -0,0 +1,77 @@
package repository
import (
"context"
"errors"
"time"
"github.com/google/uuid"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgxpool"
)
const imageCardDailyFree = 3
// ImageCardRepo tracks daily draw quotas.
type ImageCardRepo struct {
Pool *pgxpool.Pool
}
// UsedToday returns how many free draws used today.
func (r *ImageCardRepo) UsedToday(ctx context.Context, userID uuid.UUID, day time.Time) (int, error) {
var n int
err := r.Pool.QueryRow(ctx, `
SELECT used FROM image_card_quotas
WHERE user_id=$1 AND day=$2::date`,
userID, day.Format("2006-01-02"),
).Scan(&n)
if errors.Is(err, pgx.ErrNoRows) {
return 0, nil
}
return n, err
}
// TryConsume increments today's used count when under free limit.
// Returns remaining after consume. ErrQuotaExhausted when free tier is out.
func (r *ImageCardRepo) TryConsume(ctx context.Context, userID uuid.UUID, day time.Time) (remaining int, err error) {
dayStr := day.Format("2006-01-02")
var used int
err = r.Pool.QueryRow(ctx, `
INSERT INTO image_card_quotas(user_id, day, used)
VALUES ($1, $2::date, 1)
ON CONFLICT (user_id, day) DO UPDATE
SET used = image_card_quotas.used + 1
WHERE image_card_quotas.used < $3
RETURNING used`,
userID, dayStr, imageCardDailyFree,
).Scan(&used)
if errors.Is(err, pgx.ErrNoRows) {
return 0, ErrQuotaExhausted
}
if err != nil {
return 0, err
}
return imageCardDailyFree - used, nil
}
// RemainingToday without consuming.
func (r *ImageCardRepo) RemainingToday(ctx context.Context, userID uuid.UUID, day time.Time, unlimited bool) (int, error) {
if unlimited {
return 99, nil
}
used, err := r.UsedToday(ctx, userID, day)
if err != nil {
return 0, err
}
left := imageCardDailyFree - used
if left < 0 {
left = 0
}
return left, nil
}
// DailyFreeLimit is the free-tier cap.
func DailyFreeLimit() int { return imageCardDailyFree }
// ErrQuotaExhausted means free daily draws are used up.
var ErrQuotaExhausted = errors.New("今日免费次数已用完")
+55
View File
@@ -0,0 +1,55 @@
package repository
import (
"context"
"time"
"github.com/google/uuid"
"github.com/jackc/pgx/v5/pgxpool"
"github.com/yuxingu/digital-psychology/apps/api/internal/model"
)
// MoodRepo persists daily moods.
type MoodRepo struct {
Pool *pgxpool.Pool
}
// UpsertToday inserts or updates today's mood for user.
func (r *MoodRepo) UpsertToday(ctx context.Context, userID uuid.UUID, day time.Time, score *int, note *string) (*model.Mood, error) {
m := &model.Mood{}
var dayOut time.Time
err := r.Pool.QueryRow(ctx, `
INSERT INTO moods(user_id, day, score, note)
VALUES ($1, $2::date, $3, $4)
ON CONFLICT (user_id, day) DO UPDATE
SET score = EXCLUDED.score,
note = EXCLUDED.note,
updated_at = now(),
deleted_at = NULL
RETURNING id, user_id, day, score, note, created_at`,
userID, day.Format("2006-01-02"), score, note,
).Scan(&m.ID, &m.UserID, &dayOut, &m.Score, &m.Note, &m.CreatedAt)
if err != nil {
return nil, err
}
m.Day = dayOut.Format("2006-01-02")
return m, nil
}
// GetToday returns today's mood if any.
func (r *MoodRepo) GetToday(ctx context.Context, userID uuid.UUID, day time.Time) (*model.Mood, error) {
m := &model.Mood{}
var dayOut time.Time
err := r.Pool.QueryRow(ctx, `
SELECT id, user_id, day, score, note, created_at
FROM moods
WHERE user_id=$1 AND day=$2::date AND deleted_at IS NULL`,
userID, day.Format("2006-01-02"),
).Scan(&m.ID, &m.UserID, &dayOut, &m.Score, &m.Note, &m.CreatedAt)
if err != nil {
return nil, err
}
m.Day = dayOut.Format("2006-01-02")
return m, nil
}
+136 -18
View File
@@ -2,6 +2,8 @@ package repository
import (
"context"
"errors"
"math"
"time"
"github.com/google/uuid"
@@ -15,26 +17,40 @@ type ProfileRepo struct {
Pool *pgxpool.Pool
}
// Create inserts a profile.
func (r *ProfileRepo) Create(ctx context.Context, userID uuid.UUID, relation, name string, birth time.Time, relationType *string) (*model.Profile, error) {
const profileCols = `
id, user_id, relation, display_name, birth_date, relation_type,
CASE WHEN birth_time IS NULL THEN NULL ELSE to_char(birth_time, 'HH24:MI') END,
birth_place, geo_lat, geo_lng, COALESCE(geo_visible, false), created_at`
func scanProfile(scan func(dest ...any) error) (*model.Profile, error) {
p := &model.Profile{}
err := r.Pool.QueryRow(ctx, `
INSERT INTO profiles(user_id, relation, display_name, birth_date, relation_type)
VALUES ($1,$2,$3,$4,$5)
RETURNING id, user_id, relation, display_name, birth_date, created_at`,
userID, relation, name, birth, relationType,
).Scan(&p.ID, &p.UserID, &p.Relation, &p.DisplayName, &p.BirthDate, &p.CreatedAt)
var bt *string
err := scan(&p.ID, &p.UserID, &p.Relation, &p.DisplayName, &p.BirthDate, &p.RelationType, &bt,
&p.BirthPlace, &p.GeoLat, &p.GeoLng, &p.GeoVisible, &p.CreatedAt)
if err != nil {
return nil, err
}
p.RelationType = relationType
p.BirthTime = bt
return p, nil
}
// Create inserts a profile.
func (r *ProfileRepo) Create(ctx context.Context, userID uuid.UUID, relation, name string, birth time.Time, relationType *string, birthTime *string, birthPlace *string) (*model.Profile, error) {
row := r.Pool.QueryRow(ctx, `
INSERT INTO profiles(user_id, relation, display_name, birth_date, relation_type, birth_time, birth_place)
VALUES ($1,$2,$3,$4,$5,
CASE WHEN $6::text IS NULL OR $6::text = '' THEN NULL ELSE $6::time END,
NULLIF(TRIM($7::text), ''))
RETURNING `+profileCols,
userID, relation, name, birth, relationType, birthTime, birthPlace,
)
return scanProfile(row.Scan)
}
// ListByUser returns non-deleted profiles.
func (r *ProfileRepo) ListByUser(ctx context.Context, userID uuid.UUID) ([]model.Profile, error) {
rows, err := r.Pool.Query(ctx, `
SELECT id, user_id, relation, display_name, birth_date, relation_type, created_at
SELECT `+profileCols+`
FROM profiles WHERE user_id=$1 AND deleted_at IS NULL
ORDER BY created_at DESC`, userID)
if err != nil {
@@ -43,25 +59,127 @@ func (r *ProfileRepo) ListByUser(ctx context.Context, userID uuid.UUID) ([]model
defer rows.Close()
var out []model.Profile
for rows.Next() {
var p model.Profile
if err := rows.Scan(&p.ID, &p.UserID, &p.Relation, &p.DisplayName, &p.BirthDate, &p.RelationType, &p.CreatedAt); err != nil {
p, err := scanProfile(rows.Scan)
if err != nil {
return nil, err
}
out = append(out, p)
out = append(out, *p)
}
return out, rows.Err()
}
// GetForUser loads a profile owned by user.
func (r *ProfileRepo) GetForUser(ctx context.Context, userID, profileID uuid.UUID) (*model.Profile, error) {
p := &model.Profile{}
err := r.Pool.QueryRow(ctx, `
SELECT id, user_id, relation, display_name, birth_date, relation_type, created_at
row := r.Pool.QueryRow(ctx, `
SELECT `+profileCols+`
FROM profiles WHERE id=$1 AND user_id=$2 AND deleted_at IS NULL`,
profileID, userID,
).Scan(&p.ID, &p.UserID, &p.Relation, &p.DisplayName, &p.BirthDate, &p.RelationType, &p.CreatedAt)
)
return scanProfile(row.Scan)
}
// GetByID loads any non-deleted profile (for invite host lookup).
func (r *ProfileRepo) GetByID(ctx context.Context, profileID uuid.UUID) (*model.Profile, error) {
row := r.Pool.QueryRow(ctx, `
SELECT `+profileCols+`
FROM profiles WHERE id=$1 AND deleted_at IS NULL`, profileID)
return scanProfile(row.Scan)
}
// UpdateForUser patches display name / birth / geo.
func (r *ProfileRepo) UpdateForUser(ctx context.Context, userID, profileID uuid.UUID, name string, birth time.Time, relationType *string, birthTime *string, birthPlace *string, geoLat, geoLng *float64, geoVisible *bool) (*model.Profile, error) {
row := r.Pool.QueryRow(ctx, `
UPDATE profiles
SET display_name=$3, birth_date=$4, relation_type=$5,
birth_time=CASE WHEN $6::text IS NULL OR $6::text = '' THEN birth_time ELSE $6::time END,
birth_place=CASE WHEN $7::text IS NULL THEN birth_place ELSE NULLIF(TRIM($7::text), '') END,
geo_lat=CASE WHEN $8::float8 IS NULL THEN geo_lat ELSE $8 END,
geo_lng=CASE WHEN $9::float8 IS NULL THEN geo_lng ELSE $9 END,
geo_visible=CASE WHEN $10::bool IS NULL THEN geo_visible ELSE $10 END,
updated_at=now()
WHERE id=$1 AND user_id=$2 AND deleted_at IS NULL
RETURNING `+profileCols,
profileID, userID, name, birth, relationType, birthTime, birthPlace, geoLat, geoLng, geoVisible,
)
return scanProfile(row.Scan)
}
// NearbyItem is a visible profile with distance.
type NearbyItem struct {
Profile model.Profile `json:"profile"`
Distance float64 `json:"distance_km"`
}
// ListNearby returns other users' self profiles with geo_visible within radius.
func (r *ProfileRepo) ListNearby(ctx context.Context, excludeUserID uuid.UUID, lat, lng, radiusKm float64, limit int) ([]NearbyItem, error) {
if limit <= 0 || limit > 50 {
limit = 20
}
rows, err := r.Pool.Query(ctx, `
SELECT `+profileCols+`
FROM profiles
WHERE deleted_at IS NULL AND geo_visible = true
AND relation = 'self'
AND user_id <> $1
AND geo_lat IS NOT NULL AND geo_lng IS NOT NULL
LIMIT 200`, excludeUserID)
if err != nil {
return nil, err
}
return p, nil
defer rows.Close()
var out []NearbyItem
for rows.Next() {
p, err := scanProfile(rows.Scan)
if err != nil {
return nil, err
}
if p.GeoLat == nil || p.GeoLng == nil {
continue
}
d := haversineKm(lat, lng, *p.GeoLat, *p.GeoLng)
if d <= radiusKm {
out = append(out, NearbyItem{Profile: *p, Distance: math.Round(d*10) / 10})
}
}
if err := rows.Err(); err != nil {
return nil, err
}
// sort by distance
for i := 0; i < len(out); i++ {
for j := i + 1; j < len(out); j++ {
if out[j].Distance < out[i].Distance {
out[i], out[j] = out[j], out[i]
}
}
}
if len(out) > limit {
out = out[:limit]
}
return out, nil
}
func haversineKm(lat1, lng1, lat2, lng2 float64) float64 {
const R = 6371.0
toR := func(d float64) float64 { return d * math.Pi / 180 }
dLat := toR(lat2 - lat1)
dLng := toR(lng2 - lng1)
a := math.Sin(dLat/2)*math.Sin(dLat/2) +
math.Cos(toR(lat1))*math.Cos(toR(lat2))*math.Sin(dLng/2)*math.Sin(dLng/2)
return 2 * R * math.Asin(math.Sqrt(a))
}
// SoftDeleteForUser marks a profile deleted.
func (r *ProfileRepo) SoftDeleteForUser(ctx context.Context, userID, profileID uuid.UUID) error {
tag, err := r.Pool.Exec(ctx, `
UPDATE profiles SET deleted_at=now(), updated_at=now()
WHERE id=$1 AND user_id=$2 AND deleted_at IS NULL`,
profileID, userID,
)
if err != nil {
return err
}
if tag.RowsAffected() == 0 {
return errors.New("profile not found")
}
return nil
}
+61 -1
View File
@@ -3,8 +3,11 @@ 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"
@@ -38,6 +41,32 @@ func (r *ReportRepo) GetForUser(ctx context.Context, userID, reportID uuid.UUID)
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
@@ -60,6 +89,37 @@ func (r *ReportRepo) HasActiveMembership(ctx context.Context, userID uuid.UUID)
return ok, err
}
// MembershipRow is the current membership snapshot for a user.
type MembershipRow struct {
Plan string
Status string
ExpiresAt *time.Time
AskQuotaLeft int
Active bool
}
// 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
@@ -121,7 +181,7 @@ func (r *ReportRepo) PayMock(ctx context.Context, userID, orderID uuid.UUID) err
}
if _, err := tx.Exec(ctx, `
INSERT INTO memberships(user_id, plan, status, expires_at, ask_quota_left)
VALUES ($1,$2,'active', now() + ($3::text || ' days')::interval, 100)
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()`,
@@ -0,0 +1,125 @@
package repository
import (
"context"
"crypto/rand"
"encoding/hex"
"encoding/json"
"errors"
"time"
"github.com/google/uuid"
"github.com/jackc/pgx/v5/pgxpool"
"github.com/yuxingu/digital-psychology/apps/api/internal/model"
)
// SynastryInviteRepo persists synastry invite tokens.
type SynastryInviteRepo struct {
Pool *pgxpool.Pool
}
// Create inserts an invite (7-day expiry).
func (r *SynastryInviteRepo) Create(ctx context.Context, hostUser, hostProfile uuid.UUID) (*model.SynastryInvite, error) {
token, err := randomToken(16)
if err != nil {
return nil, err
}
exp := time.Now().UTC().Add(7 * 24 * time.Hour)
inv := &model.SynastryInvite{}
err = r.Pool.QueryRow(ctx, `
INSERT INTO synastry_invites(token, host_user_id, host_profile_id, expires_at)
VALUES ($1,$2,$3,$4)
RETURNING id, token, host_user_id, host_profile_id, expires_at,
guest_user_id, guest_profile_id, report_id, created_at`,
token, hostUser, hostProfile, exp,
).Scan(&inv.ID, &inv.Token, &inv.HostUserID, &inv.HostProfileID, &inv.ExpiresAt,
&inv.GuestUserID, &inv.GuestProfileID, &inv.ReportID, &inv.CreatedAt)
return inv, err
}
// GetByToken loads a non-deleted invite.
func (r *SynastryInviteRepo) GetByToken(ctx context.Context, token string) (*model.SynastryInvite, error) {
inv := &model.SynastryInvite{}
err := r.Pool.QueryRow(ctx, `
SELECT id, token, host_user_id, host_profile_id, expires_at,
guest_user_id, guest_profile_id, report_id, created_at
FROM synastry_invites WHERE token=$1 AND deleted_at IS NULL`, token,
).Scan(&inv.ID, &inv.Token, &inv.HostUserID, &inv.HostProfileID, &inv.ExpiresAt,
&inv.GuestUserID, &inv.GuestProfileID, &inv.ReportID, &inv.CreatedAt)
if err != nil {
return nil, err
}
return inv, nil
}
// AcceptAtomic creates guest profile + synastry report + marks invite in one transaction.
func (r *SynastryInviteRepo) AcceptAtomic(
ctx context.Context,
inviteID, guestUser uuid.UUID,
name string,
birth time.Time,
birthTime, birthPlace *string,
summary, detail json.RawMessage,
) (*model.GrowthReport, error) {
tx, err := r.Pool.Begin(ctx)
if err != nil {
return nil, err
}
defer tx.Rollback(ctx)
var guestID uuid.UUID
var bt *string
var bp *string
var createdAt time.Time
err = tx.QueryRow(ctx, `
INSERT INTO profiles(user_id, relation, display_name, birth_date, birth_time, birth_place)
VALUES ($1,'other',$2,$3,
CASE WHEN $4::text IS NULL OR $4::text = '' THEN NULL ELSE $4::time END,
NULLIF(TRIM($5::text), ''))
RETURNING id,
CASE WHEN birth_time IS NULL THEN NULL ELSE to_char(birth_time, 'HH24:MI') END,
birth_place, created_at`,
guestUser, name, birth, birthTime, birthPlace,
).Scan(&guestID, &bt, &bp, &createdAt)
if err != nil {
return nil, err
}
rep := &model.GrowthReport{}
err = tx.QueryRow(ctx, `
INSERT INTO growth_reports(user_id, profile_id, type, summary, detail)
VALUES ($1,$2,'synastry',$3,$4)
RETURNING id, user_id, profile_id, type, summary, detail, created_at`,
guestUser, guestID, summary, detail,
).Scan(&rep.ID, &rep.UserID, &rep.ProfileID, &rep.Type, &rep.Summary, &rep.Detail, &rep.CreatedAt)
if err != nil {
return nil, err
}
tag, err := tx.Exec(ctx, `
UPDATE synastry_invites
SET guest_user_id=$2, guest_profile_id=$3, report_id=$4, updated_at=now()
WHERE id=$1 AND deleted_at IS NULL AND report_id IS NULL AND expires_at > now()`,
inviteID, guestUser, guestID, rep.ID,
)
if err != nil {
return nil, err
}
if tag.RowsAffected() == 0 {
return nil, errors.New("invite already used or expired")
}
if err := tx.Commit(ctx); err != nil {
return nil, err
}
return rep, nil
}
func randomToken(n int) (string, error) {
b := make([]byte, n)
if _, err := rand.Read(b); err != nil {
return "", err
}
return hex.EncodeToString(b), nil
}