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:
@@ -4,18 +4,123 @@ 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.
|
||||
@@ -34,6 +139,83 @@ func (s *Service) CreatePortrait(ctx context.Context, userID, profileID uuid.UUI
|
||||
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)
|
||||
@@ -43,6 +225,23 @@ func (s *Service) Get(ctx context.Context, userID, reportID uuid.UUID) (*model.G
|
||||
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 {
|
||||
@@ -85,3 +284,27 @@ func (s *Service) CreateOrder(ctx context.Context, userID uuid.UUID, in CreateOr
|
||||
func (s *Service) PayMock(ctx context.Context, userID, orderID uuid.UUID) error {
|
||||
return s.Reports.PayMock(ctx, userID, orderID)
|
||||
}
|
||||
|
||||
// MembershipMe is the public membership snapshot.
|
||||
type MembershipMe struct {
|
||||
Active bool `json:"active"`
|
||||
Plan string `json:"plan,omitempty"`
|
||||
Status string `json:"status"`
|
||||
ExpiresAt *time.Time `json:"expires_at,omitempty"`
|
||||
AskQuotaLeft int `json:"ask_quota_left,omitempty"`
|
||||
}
|
||||
|
||||
// GetMembership returns current growth membership for the user.
|
||||
func (s *Service) GetMembership(ctx context.Context, userID uuid.UUID) (*MembershipMe, error) {
|
||||
row, err := s.Reports.GetMembership(ctx, userID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &MembershipMe{
|
||||
Active: row.Active,
|
||||
Plan: row.Plan,
|
||||
Status: row.Status,
|
||||
ExpiresAt: row.ExpiresAt,
|
||||
AskQuotaLeft: row.AskQuotaLeft,
|
||||
}, nil
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user