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,245 @@
|
||||
package ask
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
|
||||
eng "github.com/yuxingu/digital-psychology/apps/api/internal/ask"
|
||||
"github.com/yuxingu/digital-psychology/apps/api/internal/llm/deepseek"
|
||||
"github.com/yuxingu/digital-psychology/apps/api/internal/model"
|
||||
"github.com/yuxingu/digital-psychology/apps/api/internal/repository"
|
||||
)
|
||||
|
||||
// FreeQuota is the number of assistant replies allowed without membership.
|
||||
const FreeQuota = 3
|
||||
|
||||
const historyLimit = 10
|
||||
|
||||
// Service handles ask threads, quota, and replies.
|
||||
type Service struct {
|
||||
Profiles *repository.ProfileRepo
|
||||
Reports *repository.ReportRepo
|
||||
Ask *repository.AskRepo
|
||||
LLM *deepseek.Client // optional; nil or disabled → rule engine
|
||||
}
|
||||
|
||||
// CreateThreadInput for POST /ask/threads.
|
||||
type CreateThreadInput struct {
|
||||
ProfileID uuid.UUID
|
||||
Scene string
|
||||
}
|
||||
|
||||
// CreateThread binds a conversation to an owned profile.
|
||||
func (s *Service) CreateThread(ctx context.Context, userID uuid.UUID, in CreateThreadInput) (*model.AskThread, error) {
|
||||
if _, err := s.Profiles.GetForUser(ctx, userID, in.ProfileID); err != nil {
|
||||
return nil, errors.New("profile not found")
|
||||
}
|
||||
var scene *string
|
||||
if sc := strings.TrimSpace(in.Scene); sc != "" {
|
||||
scene = &sc
|
||||
}
|
||||
return s.Ask.CreateThread(ctx, userID, in.ProfileID, scene)
|
||||
}
|
||||
|
||||
// QuotaStatus describes remaining ask allowance.
|
||||
type QuotaStatus struct {
|
||||
ActiveMembership bool `json:"active_membership"`
|
||||
Remaining int `json:"remaining"`
|
||||
FreeLimit int `json:"free_limit"`
|
||||
Source string `json:"source"` // membership | free
|
||||
}
|
||||
|
||||
// GetQuota returns remaining ask replies.
|
||||
func (s *Service) GetQuota(ctx context.Context, userID uuid.UUID) (*QuotaStatus, error) {
|
||||
vip, err := s.Reports.HasActiveMembership(ctx, userID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if vip {
|
||||
me, err := s.Reports.GetMembership(ctx, userID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &QuotaStatus{
|
||||
ActiveMembership: true,
|
||||
Remaining: me.AskQuotaLeft,
|
||||
FreeLimit: FreeQuota,
|
||||
Source: "membership",
|
||||
}, nil
|
||||
}
|
||||
used, err := s.Ask.CountUserAssistantMessages(ctx, userID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
left := FreeQuota - used
|
||||
if left < 0 {
|
||||
left = 0
|
||||
}
|
||||
return &QuotaStatus{
|
||||
ActiveMembership: false,
|
||||
Remaining: left,
|
||||
FreeLimit: FreeQuota,
|
||||
Source: "free",
|
||||
}, nil
|
||||
}
|
||||
|
||||
// ListMessages returns history for an owned thread.
|
||||
func (s *Service) ListMessages(ctx context.Context, userID, threadID uuid.UUID) ([]model.AskMessage, error) {
|
||||
if _, err := s.Ask.GetThreadForUser(ctx, userID, threadID); err != nil {
|
||||
return nil, errors.New("thread not found")
|
||||
}
|
||||
return s.Ask.ListMessages(ctx, threadID)
|
||||
}
|
||||
|
||||
// SendResult is the user+assistant turn after a send.
|
||||
type SendResult struct {
|
||||
UserMessage *model.AskMessage `json:"user_message"`
|
||||
AssistantMessage *model.AskMessage `json:"assistant_message"`
|
||||
Quota *QuotaStatus `json:"quota"`
|
||||
}
|
||||
|
||||
// SendMessage stores user content, consumes quota, generates assistant reply.
|
||||
func (s *Service) SendMessage(ctx context.Context, userID, threadID uuid.UUID, content string) (*SendResult, error) {
|
||||
content = strings.TrimSpace(content)
|
||||
if content == "" {
|
||||
return nil, errors.New("content required")
|
||||
}
|
||||
if len([]rune(content)) > 2000 {
|
||||
return nil, errors.New("content too long")
|
||||
}
|
||||
|
||||
thread, err := s.Ask.GetThreadForUser(ctx, userID, threadID)
|
||||
if err != nil {
|
||||
return nil, errors.New("thread not found")
|
||||
}
|
||||
profile, err := s.Profiles.GetForUser(ctx, userID, thread.ProfileID)
|
||||
if err != nil {
|
||||
return nil, errors.New("profile not found")
|
||||
}
|
||||
|
||||
quota, err := s.GetQuota(ctx, userID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if quota.Remaining <= 0 {
|
||||
return nil, ErrQuotaExhausted
|
||||
}
|
||||
|
||||
userMsg, err := s.Ask.InsertMessage(ctx, threadID, "user", content)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
scene := ""
|
||||
if thread.Scene != nil {
|
||||
scene = *thread.Scene
|
||||
}
|
||||
|
||||
hist, _ := s.Ask.ListMessages(ctx, threadID)
|
||||
reply := s.generateReply(ctx, profile, scene, content, hist)
|
||||
|
||||
asst, err := s.Ask.InsertMessage(ctx, threadID, "assistant", reply)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if quota.ActiveMembership {
|
||||
if ok, _, err := s.Ask.ConsumeMembershipQuota(ctx, userID); err != nil {
|
||||
return nil, err
|
||||
} else if !ok {
|
||||
// race
|
||||
}
|
||||
}
|
||||
|
||||
q2, err := s.GetQuota(ctx, userID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &SendResult{UserMessage: userMsg, AssistantMessage: asst, Quota: q2}, nil
|
||||
}
|
||||
|
||||
func (s *Service) generateReply(ctx context.Context, profile *model.Profile, scene, userContent string, hist []model.AskMessage) string {
|
||||
fallback := eng.BuildReply(eng.ReplyInput{
|
||||
DisplayName: profile.DisplayName,
|
||||
BirthDate: profile.BirthDate,
|
||||
Relation: profile.Relation,
|
||||
Scene: scene,
|
||||
UserMessage: userContent,
|
||||
})
|
||||
if s.LLM == nil || !s.LLM.Enabled() {
|
||||
return fallback
|
||||
}
|
||||
|
||||
msgs := []deepseek.Message{{Role: "system", Content: systemPrompt(profile, scene)}}
|
||||
// history excluding the just-inserted user message duplicate handling: include prior + current user
|
||||
start := 0
|
||||
if len(hist) > historyLimit*2 {
|
||||
start = len(hist) - historyLimit*2
|
||||
}
|
||||
for _, m := range hist[start:] {
|
||||
role := m.Role
|
||||
if role != "user" && role != "assistant" {
|
||||
continue
|
||||
}
|
||||
msgs = append(msgs, deepseek.Message{Role: role, Content: m.Content})
|
||||
}
|
||||
// hist already contains the new user message from InsertMessage
|
||||
|
||||
out, err := s.LLM.Chat(ctx, msgs)
|
||||
if err != nil {
|
||||
log.Printf("ask: deepseek failed, fallback to rules: %v", err)
|
||||
return fallback
|
||||
}
|
||||
if !strings.Contains(out, "不构成") && !strings.Contains(out, "参考") {
|
||||
out = out + "\n\n以上是自我探索与生活方式参考,不构成医疗或占卜预测。"
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func systemPrompt(profile *model.Profile, scene string) string {
|
||||
name := profile.DisplayName
|
||||
if name == "" {
|
||||
if profile.Relation == "other" {
|
||||
name = "TA"
|
||||
} else {
|
||||
name = "你"
|
||||
}
|
||||
}
|
||||
birth := profile.BirthDate.Format("2006-01-02")
|
||||
rel := "我的档案"
|
||||
if profile.Relation == "other" {
|
||||
rel = "TA 的档案"
|
||||
}
|
||||
sc := scene
|
||||
if sc == "" {
|
||||
sc = "自我探索"
|
||||
}
|
||||
return fmt.Sprintf(`你是「愈心谷」的 AI 成长助手,了解用户的智能伙伴。
|
||||
定位:帮助认识自己、理解关系、整理情绪与生活节奏——像一位细致的成长顾问,而不是算命师。
|
||||
禁止:算命、运势、吉凶、预测未来、改命、合盘/合婚话术、医疗诊断或疗效承诺、恐吓话术。
|
||||
推荐用语:了解、探索、分析、建议、成长方向、生活建议、沟通方式、情绪调节。
|
||||
|
||||
当前解读对象:%s(%s),生日 %s,场景倾向:%s。
|
||||
请用中文做「有结构的详细回复」(约 280–450 字),建议结构:
|
||||
1)先回应用户当下问题(2–3 句)
|
||||
2)结合档案风格做一层分析(性格/沟通/关系/情绪/生活节奏中相关的 1–2 维)
|
||||
3)给出 2–4 条可执行小建议(尽量具体到本周可做)
|
||||
4)如合适,给一句可直接说出口的对话示例
|
||||
语气温暖、具体、不空洞;避免鸡汤套话与玄学预测。
|
||||
结尾提醒:内容为自我探索与生活方式参考,不构成医疗或占卜预测。
|
||||
今天是 %s。`, name, rel, birth, sc, time.Now().Format("2006-01-02"))
|
||||
}
|
||||
|
||||
// ErrQuotaExhausted when free or membership ask quota is 0.
|
||||
var ErrQuotaExhausted = errors.New("ask quota exhausted")
|
||||
|
||||
// IsQuotaExhausted reports ErrQuotaExhausted.
|
||||
func IsQuotaExhausted(err error) bool {
|
||||
return errors.Is(err, ErrQuotaExhausted)
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
package companion
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/jackc/pgx/v5"
|
||||
|
||||
core "github.com/yuxingu/digital-psychology/apps/api/internal/companion"
|
||||
"github.com/yuxingu/digital-psychology/apps/api/internal/model"
|
||||
"github.com/yuxingu/digital-psychology/apps/api/internal/repository"
|
||||
)
|
||||
|
||||
// Service exposes solar terms and moods.
|
||||
type Service struct {
|
||||
Moods *repository.MoodRepo
|
||||
}
|
||||
|
||||
// TodaySolar returns today's tip.
|
||||
func (s *Service) TodaySolar() core.SolarTerm {
|
||||
return core.TodaySolar(time.Now())
|
||||
}
|
||||
|
||||
// SaveMoodInput for POST /moods.
|
||||
type SaveMoodInput struct {
|
||||
Score *int
|
||||
Note *string
|
||||
Day *time.Time
|
||||
}
|
||||
|
||||
// SaveMood upserts mood for a day (default today).
|
||||
func (s *Service) SaveMood(ctx context.Context, userID uuid.UUID, in SaveMoodInput) (*model.Mood, error) {
|
||||
if in.Score != nil && (*in.Score < 1 || *in.Score > 5) {
|
||||
return nil, errors.New("score must be 1-5")
|
||||
}
|
||||
if in.Note != nil {
|
||||
n := strings.TrimSpace(*in.Note)
|
||||
if len([]rune(n)) > 200 {
|
||||
return nil, errors.New("note too long")
|
||||
}
|
||||
in.Note = &n
|
||||
}
|
||||
day := time.Now()
|
||||
if in.Day != nil {
|
||||
day = *in.Day
|
||||
}
|
||||
return s.Moods.UpsertToday(ctx, userID, day, in.Score, in.Note)
|
||||
}
|
||||
|
||||
// GetTodayMood returns today's mood or nil when none.
|
||||
func (s *Service) GetTodayMood(ctx context.Context, userID uuid.UUID) (*model.Mood, error) {
|
||||
m, err := s.Moods.GetToday(ctx, userID, time.Now())
|
||||
if err != nil {
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return nil, nil
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
return m, nil
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -3,6 +3,7 @@ package profile
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
@@ -22,6 +23,8 @@ type CreateInput struct {
|
||||
DisplayName string
|
||||
BirthDate time.Time
|
||||
RelationType *string
|
||||
BirthTime *string
|
||||
BirthPlace *string
|
||||
}
|
||||
|
||||
// Create stores a profile for the user.
|
||||
@@ -40,10 +43,59 @@ func (s *Service) Create(ctx context.Context, userID uuid.UUID, in CreateInput)
|
||||
name = "TA"
|
||||
}
|
||||
}
|
||||
return s.Repo.Create(ctx, userID, in.Relation, name, in.BirthDate, in.RelationType)
|
||||
return s.Repo.Create(ctx, userID, in.Relation, name, in.BirthDate, in.RelationType, in.BirthTime, in.BirthPlace)
|
||||
}
|
||||
|
||||
// List returns user's profiles.
|
||||
func (s *Service) List(ctx context.Context, userID uuid.UUID) ([]model.Profile, error) {
|
||||
return s.Repo.ListByUser(ctx, userID)
|
||||
}
|
||||
|
||||
// UpdateInput for PATCH /profiles/:id.
|
||||
type UpdateInput struct {
|
||||
DisplayName string
|
||||
BirthDate time.Time
|
||||
RelationType *string
|
||||
BirthTime *string
|
||||
BirthPlace *string
|
||||
GeoLat *float64
|
||||
GeoLng *float64
|
||||
GeoVisible *bool
|
||||
}
|
||||
|
||||
// Update patches an owned profile.
|
||||
func (s *Service) Update(ctx context.Context, userID, profileID uuid.UUID, in UpdateInput) (*model.Profile, error) {
|
||||
cur, err := s.Repo.GetForUser(ctx, userID, profileID)
|
||||
if err != nil {
|
||||
return nil, errors.New("profile not found")
|
||||
}
|
||||
name := strings.TrimSpace(in.DisplayName)
|
||||
if name == "" {
|
||||
name = cur.DisplayName
|
||||
}
|
||||
birth := in.BirthDate
|
||||
if birth.IsZero() {
|
||||
birth = cur.BirthDate
|
||||
}
|
||||
rt := in.RelationType
|
||||
if rt == nil {
|
||||
rt = cur.RelationType
|
||||
}
|
||||
bt := in.BirthTime
|
||||
if bt == nil {
|
||||
bt = cur.BirthTime
|
||||
}
|
||||
bp := in.BirthPlace
|
||||
if bp == nil {
|
||||
bp = cur.BirthPlace
|
||||
}
|
||||
return s.Repo.UpdateForUser(ctx, userID, profileID, name, birth, rt, bt, bp, in.GeoLat, in.GeoLng, in.GeoVisible)
|
||||
}
|
||||
|
||||
// Delete soft-deletes an owned profile.
|
||||
func (s *Service) Delete(ctx context.Context, userID, profileID uuid.UUID) error {
|
||||
if _, err := s.Repo.GetForUser(ctx, userID, profileID); err != nil {
|
||||
return errors.New("profile not found")
|
||||
}
|
||||
return s.Repo.SoftDeleteForUser(ctx, userID, profileID)
|
||||
}
|
||||
|
||||
@@ -38,7 +38,11 @@ func (s *Service) Create(ctx context.Context, userID, aID, bID uuid.UUID) (*Crea
|
||||
if err != nil {
|
||||
return nil, errors.New("profile_b not found")
|
||||
}
|
||||
out := releng.Build(pa.BirthDate, pb.BirthDate, pa.DisplayName, pb.DisplayName)
|
||||
relType := ""
|
||||
if pb.RelationType != nil {
|
||||
relType = *pb.RelationType
|
||||
}
|
||||
out := releng.BuildFull(pa.BirthDate, pb.BirthDate, pa.BirthTime, pb.BirthTime, pa.BirthPlace, pb.BirthPlace, pa.DisplayName, pb.DisplayName, relType)
|
||||
sum, _ := json.Marshal(out.Summary)
|
||||
det, _ := json.Marshal(out.Detail)
|
||||
rep, err := s.Reports.Create(ctx, userID, aID, "relation", sum, det)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
|
||||
"github.com/google/uuid"
|
||||
|
||||
sc "github.com/yuxingu/digital-psychology/apps/api/internal/scale"
|
||||
"github.com/yuxingu/digital-psychology/apps/api/internal/repository"
|
||||
)
|
||||
|
||||
@@ -51,31 +52,10 @@ func (s *Service) Submit(ctx context.Context, userID uuid.UUID, slug string, in
|
||||
if err != nil {
|
||||
return nil, errors.New("scale not found")
|
||||
}
|
||||
counts := map[string]int{}
|
||||
for _, v := range in.Answers {
|
||||
counts[v]++
|
||||
}
|
||||
best, bestN := "A", -1
|
||||
for k, n := range counts {
|
||||
if n > bestN {
|
||||
best, bestN = k, n
|
||||
}
|
||||
}
|
||||
label := map[string]string{
|
||||
"A": "理性澄清型",
|
||||
"B": "感受连接型",
|
||||
"C": "节奏尊重型",
|
||||
}[best]
|
||||
if label == "" {
|
||||
label = "平衡探索型"
|
||||
}
|
||||
result := map[string]interface{}{
|
||||
"title": "探索结果",
|
||||
"style_key": best,
|
||||
"label": label,
|
||||
"summary": "这是你当前沟通偏好的探索结果,可用于自我了解与关系理解,不是固定标签。",
|
||||
"share_line": "我的沟通方式:" + label,
|
||||
}
|
||||
|
||||
labels, _, _ := labelsForSlug(slug)
|
||||
key, label := sc.ScoreMajority(in.Answers, labels, "平衡探索型")
|
||||
result := sc.BuildResult(slug, key, label)
|
||||
ansJSON, _ := json.Marshal(in.Answers)
|
||||
resJSON, _ := json.Marshal(result)
|
||||
id, err := s.Repo.SaveResult(ctx, userID, scaleID, in.ProfileID, ansJSON, resJSON)
|
||||
@@ -84,3 +64,37 @@ func (s *Service) Submit(ctx context.Context, userID uuid.UUID, slug string, in
|
||||
}
|
||||
return &SubmitResult{ID: id, Result: result}, nil
|
||||
}
|
||||
|
||||
func labelsForSlug(slug string) (labels map[string]string, sharePrefix, summary string) {
|
||||
switch slug {
|
||||
case "emotion-pattern":
|
||||
return sc.EmotionLabels(),
|
||||
"我的情感模式:",
|
||||
"这是你当前情绪调节偏好的探索结果,可用于自我了解与日常调整,不是固定标签。"
|
||||
case "mbti-lite":
|
||||
return map[string]string{"E": "外向充能型", "I": "内向充能型", "T": "理性决策型", "F": "感受决策型", "J": "结构安排型", "P": "弹性探索型"},
|
||||
"我的人格偏好:", "轻量人格偏好探索,不是固定分类。"
|
||||
case "enneagram-lite":
|
||||
return map[string]string{"A": "尽责驱动型", "B": "联结驱动型", "C": "独立驱动型"},
|
||||
"我的动机模式:", "内在动机轻量探索,用于自我觉察。"
|
||||
case "bigfive-lite":
|
||||
return map[string]string{"O": "开放探索型", "C": "条理稳健型", "E": "主动外展型", "A": "温和协作型", "N": "细腻敏感型"},
|
||||
"我的性格五维:", "稳定特质速览,可随情境变化。"
|
||||
case "love-style":
|
||||
return map[string]string{"A": "稳定陪伴型", "B": "深度共鸣型", "C": "空间尊重型"},
|
||||
"我的亲密互动:", "亲密关系偏好探索,不是关系定论。"
|
||||
case "eq-lite":
|
||||
return map[string]string{"A": "觉察表达型", "B": "行动调节型", "C": "缓慢识别型"},
|
||||
"我的情绪觉察:", "情绪习惯探索,可用于日常调节。"
|
||||
case "stress-index":
|
||||
return map[string]string{"A": "节奏稳定型", "B": "波动调节型", "C": "高负荷需休息型"},
|
||||
"我的压力负荷:", "近期压力与恢复偏好,不是诊断。"
|
||||
case "career-interest":
|
||||
return map[string]string{"A": "创造表达型", "B": "助人支持型", "C": "分析解决型"},
|
||||
"我的职业兴趣:", "工作动力偏好探索,可作方向参考。"
|
||||
default:
|
||||
return sc.CommunicationLabels(),
|
||||
"我的沟通方式:",
|
||||
"这是你当前沟通偏好的探索结果,可用于自我了解与关系理解,不是固定标签。"
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user