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,119 @@
|
||||
package imagecard
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
|
||||
"github.com/yuxingu/digital-psychology/apps/api/internal/imagecard"
|
||||
"github.com/yuxingu/digital-psychology/apps/api/internal/model"
|
||||
"github.com/yuxingu/digital-psychology/apps/api/internal/repository"
|
||||
)
|
||||
|
||||
// Service draws image cards with quota + entitlement.
|
||||
type Service struct {
|
||||
Profiles *repository.ProfileRepo
|
||||
Reports *repository.ReportRepo
|
||||
Quotas *repository.ImageCardRepo
|
||||
}
|
||||
|
||||
// DrawInput is POST /image-cards/draw body.
|
||||
type DrawInput struct {
|
||||
Scene string
|
||||
ProfileID uuid.UUID
|
||||
Depth bool
|
||||
}
|
||||
|
||||
// DrawResult wraps report + cards.
|
||||
type DrawResult struct {
|
||||
Report *model.GrowthReport `json:"report"`
|
||||
Scene string `json:"scene"`
|
||||
Cards []imagecard.Card `json:"cards"`
|
||||
QuotaLeft int `json:"quota_left"`
|
||||
}
|
||||
|
||||
// Scenes returns reflection scenes.
|
||||
func (s *Service) Scenes() []map[string]string {
|
||||
return imagecard.Scenes()
|
||||
}
|
||||
|
||||
// Quota returns remaining draws today.
|
||||
func (s *Service) Quota(ctx context.Context, userID uuid.UUID) (map[string]any, error) {
|
||||
vip, err := s.Reports.HasActiveMembership(ctx, userID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
left, err := s.Quotas.RemainingToday(ctx, userID, time.Now(), vip)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return map[string]any{
|
||||
"remaining": left,
|
||||
"daily_free": repository.DailyFreeLimit(),
|
||||
"unlimited": vip,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Draw consumes quota (unless membership), builds cards, stores report.
|
||||
func (s *Service) Draw(ctx context.Context, userID uuid.UUID, in DrawInput) (*DrawResult, error) {
|
||||
p, err := s.Profiles.GetForUser(ctx, userID, in.ProfileID)
|
||||
if err != nil {
|
||||
return nil, errors.New("profile not found")
|
||||
}
|
||||
vip, err := s.Reports.HasActiveMembership(ctx, userID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
now := time.Now()
|
||||
var left int
|
||||
if vip {
|
||||
left, _ = s.Quotas.RemainingToday(ctx, userID, now, true)
|
||||
} else {
|
||||
left, err = s.Quotas.TryConsume(ctx, userID, now)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
wantDeep := in.Depth && vip
|
||||
out := imagecard.Draw(userID.String(), in.Scene, wantDeep || in.Depth, now)
|
||||
// Persist full detail when depth requested; entitlement strips on read.
|
||||
sum, _ := json.Marshal(out.Summary)
|
||||
detPayload := out.Detail
|
||||
if in.Depth && detPayload == nil {
|
||||
// force 3-card detail generation for unlock-after-pay path
|
||||
full := imagecard.Draw(userID.String(), in.Scene, true, now)
|
||||
detPayload = full.Detail
|
||||
out.Cards = full.Cards
|
||||
sum, _ = json.Marshal(full.Summary)
|
||||
}
|
||||
det, _ := json.Marshal(detPayload)
|
||||
if detPayload == nil {
|
||||
det = []byte("{}")
|
||||
}
|
||||
rep, err := s.Reports.Create(ctx, userID, p.ID, "image_card", sum, det)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
deep, _ := s.Reports.HasDeepAccess(ctx, userID, rep.ID)
|
||||
rep.HasDeep = deep || vip
|
||||
if !rep.HasDeep {
|
||||
rep.Detail = nil
|
||||
if !wantDeep {
|
||||
// free single-card: already summary-only
|
||||
}
|
||||
}
|
||||
cards := out.Cards
|
||||
if !rep.HasDeep && len(cards) > 1 {
|
||||
cards = cards[:1]
|
||||
}
|
||||
return &DrawResult{
|
||||
Report: rep,
|
||||
Scene: out.Scene,
|
||||
Cards: cards,
|
||||
QuotaLeft: left,
|
||||
}, nil
|
||||
}
|
||||
Reference in New Issue
Block a user