落地埋点 ingest/数据看板、首页宫格 CMS 与测评上下架;含账号引导、问答流式与免责声明去重,以及 review P1 审计同事务修复。 Co-authored-by: Cursor <cursoragent@cursor.com>
270 lines
8.7 KiB
Go
270 lines
8.7 KiB
Go
package report
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"time"
|
|
|
|
"github.com/google/uuid"
|
|
|
|
"github.com/yuxingu/digital-psychology/apps/api/internal/model"
|
|
"github.com/yuxingu/digital-psychology/apps/api/internal/portrait"
|
|
"github.com/yuxingu/digital-psychology/apps/api/internal/repository"
|
|
"github.com/yuxingu/digital-psychology/apps/api/internal/rhythm"
|
|
"github.com/yuxingu/digital-psychology/apps/api/internal/star"
|
|
"github.com/yuxingu/digital-psychology/apps/api/internal/star/synastry"
|
|
)
|
|
|
|
// Service creates and reads growth reports with entitlement trimming.
|
|
type Service struct {
|
|
Profiles *repository.ProfileRepo
|
|
Reports *repository.ReportRepo
|
|
Invites *repository.SynastryInviteRepo
|
|
}
|
|
|
|
// Nearby lists geo-visible self profiles of other users within radius.
|
|
func (s *Service) Nearby(ctx context.Context, userID uuid.UUID, lat, lng, radiusKm float64) ([]repository.NearbyItem, error) {
|
|
if s.Profiles == nil {
|
|
return nil, errors.New("profiles unavailable")
|
|
}
|
|
if radiusKm <= 0 {
|
|
radiusKm = 50
|
|
}
|
|
if radiusKm > 200 {
|
|
radiusKm = 200
|
|
}
|
|
return s.Profiles.ListNearby(ctx, userID, lat, lng, radiusKm, 20)
|
|
}
|
|
|
|
// CreateInvite creates a shareable synastry invite for host profile.
|
|
func (s *Service) CreateInvite(ctx context.Context, userID, hostProfileID uuid.UUID) (*model.SynastryInvite, error) {
|
|
if s.Invites == nil {
|
|
return nil, errors.New("invites unavailable")
|
|
}
|
|
if _, err := s.Profiles.GetForUser(ctx, userID, hostProfileID); err != nil {
|
|
return nil, errors.New("profile not found")
|
|
}
|
|
return s.Invites.Create(ctx, userID, hostProfileID)
|
|
}
|
|
|
|
// GetInvite returns invite by token (public metadata for landing page).
|
|
func (s *Service) GetInvite(ctx context.Context, token string) (map[string]any, error) {
|
|
if s.Invites == nil {
|
|
return nil, errors.New("invites unavailable")
|
|
}
|
|
inv, err := s.Invites.GetByToken(ctx, token)
|
|
if err != nil {
|
|
return nil, errors.New("invite not found")
|
|
}
|
|
if time.Now().After(inv.ExpiresAt) {
|
|
return nil, errors.New("invite expired")
|
|
}
|
|
host, err := s.Profiles.GetByID(ctx, inv.HostProfileID)
|
|
if err != nil {
|
|
return nil, errors.New("host profile missing")
|
|
}
|
|
return map[string]any{
|
|
"token": inv.Token,
|
|
"expires_at": inv.ExpiresAt,
|
|
"host_name": host.DisplayName,
|
|
"already_accepted": inv.ReportID != nil,
|
|
}, nil
|
|
}
|
|
|
|
// AcceptInvite creates guest other-profile for guest user, builds synastry, marks invite (atomic).
|
|
func (s *Service) AcceptInvite(ctx context.Context, guestUser uuid.UUID, token, displayName, birthDate string, birthTime, birthPlace *string) (*model.GrowthReport, error) {
|
|
if s.Invites == nil {
|
|
return nil, errors.New("invites unavailable")
|
|
}
|
|
inv, err := s.Invites.GetByToken(ctx, token)
|
|
if err != nil {
|
|
return nil, errors.New("invite not found")
|
|
}
|
|
if time.Now().After(inv.ExpiresAt) {
|
|
return nil, errors.New("invite expired")
|
|
}
|
|
if inv.ReportID != nil {
|
|
return nil, errors.New("invite already used")
|
|
}
|
|
if inv.HostUserID == guestUser {
|
|
return nil, errors.New("不能接受自己的邀请")
|
|
}
|
|
birth, err := time.Parse("2006-01-02", birthDate)
|
|
if err != nil {
|
|
return nil, errors.New("birth_date must be YYYY-MM-DD")
|
|
}
|
|
name := displayName
|
|
if name == "" {
|
|
name = "TA"
|
|
}
|
|
host, err := s.Profiles.GetByID(ctx, inv.HostProfileID)
|
|
if err != nil {
|
|
return nil, errors.New("host profile missing")
|
|
}
|
|
ca, err := star.NatalChart(host.BirthDate, host.BirthTime, host.BirthPlace)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
cb, err := star.NatalChart(birth, birthTime, birthPlace)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
out, err := synastry.BuildReport(ca, cb, host.DisplayName, name, time.Now())
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
sum, _ := json.Marshal(out.Summary)
|
|
det, _ := json.Marshal(out.Detail)
|
|
rep, err := s.Invites.AcceptAtomic(ctx, inv.ID, guestUser, name, birth, birthTime, birthPlace, sum, det)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return s.applyEntitlement(ctx, guestUser, rep)
|
|
}
|
|
|
|
// CreatePortrait builds and stores a portrait report.
|
|
func (s *Service) CreatePortrait(ctx context.Context, userID, profileID uuid.UUID) (*model.GrowthReport, error) {
|
|
p, err := s.Profiles.GetForUser(ctx, userID, profileID)
|
|
if err != nil {
|
|
return nil, errors.New("profile not found")
|
|
}
|
|
out := portrait.Build(p.BirthDate, p.DisplayName)
|
|
sum, _ := json.Marshal(out.Summary)
|
|
det, _ := json.Marshal(out.Detail)
|
|
rep, err := s.Reports.UpsertWithPeer(ctx, userID, profileID, nil, "portrait", sum, det)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return s.applyEntitlement(ctx, userID, rep)
|
|
}
|
|
|
|
// CreateStar builds and stores a 星象性格 report.
|
|
func (s *Service) CreateStar(ctx context.Context, userID, profileID uuid.UUID) (*model.GrowthReport, error) {
|
|
p, err := s.Profiles.GetForUser(ctx, userID, profileID)
|
|
if err != nil {
|
|
return nil, errors.New("profile not found")
|
|
}
|
|
out, err := star.BuildWith(star.BuildOpts{
|
|
Birth: p.BirthDate, BirthTime: p.BirthTime, BirthPlace: p.BirthPlace, Name: p.DisplayName,
|
|
})
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
sum, _ := json.Marshal(out.Summary)
|
|
det, _ := json.Marshal(out.Detail)
|
|
rep, err := s.Reports.UpsertWithPeer(ctx, userID, profileID, nil, "star", sum, det)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return s.applyEntitlement(ctx, userID, rep)
|
|
}
|
|
|
|
// CreateSynastry builds a multi-chart synastry report for two profiles.
|
|
// asOf is the secondary-progression date (defaults to today CST when nil/zero).
|
|
func (s *Service) CreateSynastry(ctx context.Context, userID, profileAID, profileBID uuid.UUID, asOf *time.Time) (*model.GrowthReport, error) {
|
|
pa, err := s.Profiles.GetForUser(ctx, userID, profileAID)
|
|
if err != nil {
|
|
return nil, errors.New("profile a not found")
|
|
}
|
|
pb, err := s.Profiles.GetForUser(ctx, userID, profileBID)
|
|
if err != nil {
|
|
// Allow geo-visible self profiles of other users (附近的人).
|
|
pb, err = s.Profiles.GetByID(ctx, profileBID)
|
|
if err != nil || !pb.GeoVisible || pb.Relation != "self" || pb.UserID == userID {
|
|
return nil, errors.New("profile b not found")
|
|
}
|
|
}
|
|
ca, err := star.NatalChart(pa.BirthDate, pa.BirthTime, pa.BirthPlace)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
cb, err := star.NatalChart(pb.BirthDate, pb.BirthTime, pb.BirthPlace)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
when := time.Now().In(time.FixedZone("CST", 8*3600))
|
|
if asOf != nil && !asOf.IsZero() {
|
|
when = *asOf
|
|
}
|
|
out, err := synastry.BuildReport(ca, cb, pa.DisplayName, pb.DisplayName, when)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
sum, _ := json.Marshal(out.Summary)
|
|
det, _ := json.Marshal(out.Detail)
|
|
peer := profileBID
|
|
rep, err := s.Reports.UpsertWithPeer(ctx, userID, profileAID, &peer, "synastry", sum, det)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return s.applyEntitlement(ctx, userID, rep)
|
|
}
|
|
|
|
// CreateRhythm builds and stores a 身心节律 report.
|
|
func (s *Service) CreateRhythm(ctx context.Context, userID, profileID uuid.UUID) (*model.GrowthReport, error) {
|
|
p, err := s.Profiles.GetForUser(ctx, userID, profileID)
|
|
if err != nil {
|
|
return nil, errors.New("profile not found")
|
|
}
|
|
out := rhythm.Build(p.BirthDate, p.DisplayName)
|
|
sum, _ := json.Marshal(out.Summary)
|
|
det, _ := json.Marshal(out.Detail)
|
|
rep, err := s.Reports.UpsertWithPeer(ctx, userID, profileID, nil, "rhythm", sum, det)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return s.applyEntitlement(ctx, userID, rep)
|
|
}
|
|
|
|
// GetLatest returns cached report by profile+type(+peer).
|
|
func (s *Service) GetLatest(ctx context.Context, userID, profileID uuid.UUID, typ string, peer *uuid.UUID) (*model.GrowthReport, error) {
|
|
rep, err := s.Reports.GetLatest(ctx, userID, profileID, typ, peer)
|
|
if err != nil {
|
|
return nil, errors.New("report not found")
|
|
}
|
|
return s.applyEntitlement(ctx, userID, rep)
|
|
}
|
|
|
|
// Get returns a report with detail gated.
|
|
func (s *Service) Get(ctx context.Context, userID, reportID uuid.UUID) (*model.GrowthReport, error) {
|
|
rep, err := s.Reports.GetForUser(ctx, userID, reportID)
|
|
if err != nil {
|
|
return nil, errors.New("report not found")
|
|
}
|
|
return s.applyEntitlement(ctx, userID, rep)
|
|
}
|
|
|
|
// List returns recent reports with entitlement trimming.
|
|
func (s *Service) List(ctx context.Context, userID uuid.UUID) ([]*model.GrowthReport, error) {
|
|
items, err := s.Reports.ListForUser(ctx, userID, 50)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
out := make([]*model.GrowthReport, 0, len(items))
|
|
for i := range items {
|
|
rep, err := s.applyEntitlement(ctx, userID, &items[i])
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
out = append(out, rep)
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
func (s *Service) applyEntitlement(ctx context.Context, userID uuid.UUID, rep *model.GrowthReport) (*model.GrowthReport, error) {
|
|
deep, err := s.Reports.HasDeepAccess(ctx, userID, rep.ID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
vip, err := s.Reports.HasActiveMembership(ctx, userID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
rep.HasDeep = deep || vip
|
|
if !rep.HasDeep {
|
|
rep.Detail = nil
|
|
}
|
|
return rep, nil
|
|
}
|