Files
digital-psychology/apps/api/internal/repository/synastry_invite_repo.go
T
jackyu66gitandCursor bd22d9dddd feat: P1 合盘/星座/问答与测测完整设计包及模拟器取证工具
落地 synastry/star/ask API 与 H5 页面,补齐 cece-frontend-re complete-design 证据文档,并加入 Android 模拟器截图抓取脚本。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-03 11:37:53 +08:00

126 lines
3.7 KiB
Go

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
}