Files
digital-psychology/apps/api/internal/service/report/service.go
T
jackyu66gitandCursor 19d3cd5945 refactor(ECR-001): 接入 ESS 并完成结构对齐 Phase A–E
绑定 ESS 双轨治理,拆分超大 H5 页与 Go 引擎,抽出 membership 服务,
并将 star/fortune 重命名为 outlook(JSON 双写兼容);同时修复 /psy API 代理与首页 + 菜单层级。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-05 17:51:40 +08:00

260 lines
8.2 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.Create(ctx, userID, profileID, "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.Create(ctx, userID, profileID, "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)
rep, err := s.Reports.Create(ctx, userID, profileAID, "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.Create(ctx, userID, profileID, "rhythm", sum, det)
if err != nil {
return nil, err
}
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
}