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:
jackyu66git
2026-08-03 11:37:53 +08:00
co-authored by Cursor
parent 15a9db374a
commit bd22d9dddd
248 changed files with 26309 additions and 842 deletions
+245
View File
@@ -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)先回应用户当下问题(23 句)
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
}
+53 -1
View File
@@ -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)
+223
View File
@@ -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
}
+39 -25
View File
@@ -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(),
"我的沟通方式:",
"这是你当前沟通偏好的探索结果,可用于自我了解与关系理解,不是固定标签。"
}
}