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:
@@ -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
|
||||
}
|
||||
@@ -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("今日免费次数已用完")
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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, "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
|
||||
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user