feat(ECR-012–016): 合规、题库、时辰刷新、头像、MBTI OEJTS 与埋点
落地输入合规、探索题库、报告日/时辰刷新、账号头像、OEJTS 量表,并补齐 H5 埋点与 Admin 漏斗;同步 ESS 工件、切至自建 Git、清理 GitHub Actions。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -46,6 +46,15 @@ var allowedNames = map[string]struct{}{
|
||||
"synastry_invite_created": {}, "synastry_invite_accepted": {}, "synastry_nearby_opened": {},
|
||||
"star_wheel_viewed": {}, "companion_viewed": {}, "mood_saved": {},
|
||||
"cards_scene_selected": {}, "cards_drawn": {}, "cards_quota_exhausted": {},
|
||||
"star_completed": {}, "rhythm_completed": {},
|
||||
"auth_register": {}, "auth_login": {}, "auth_logout": {},
|
||||
"avatar_sheet_opened": {}, "avatar_upload_succeeded": {}, "avatar_upload_failed": {},
|
||||
"nickname_updated": {},
|
||||
"scale_list_viewed": {}, "scale_bank_category_viewed": {},
|
||||
"scale_started": {}, "scale_completed": {}, "scale_result_reopened": {},
|
||||
"scale_retake_clicked": {}, "scale_locked_viewed": {}, "scale_share_clicked": {},
|
||||
"scale_cta_relation": {}, "scale_cta_ask": {},
|
||||
"growth_plan_viewed": {}, "growth_plan_created": {}, "growth_plan_checkin": {},
|
||||
}
|
||||
|
||||
var allowedPropKeys = map[string]struct{}{
|
||||
@@ -53,9 +62,11 @@ var allowedPropKeys = map[string]struct{}{
|
||||
"element_id": {}, "exit_page": {}, "duration_ms": {}, "cold": {}, "app_ver": {},
|
||||
"source": {}, "kind": {}, "surface": {}, "label": {}, "plan": {}, "count": {},
|
||||
"depth": {}, "scene": {}, "score": {}, "planet": {}, "report_id": {},
|
||||
"slug": {}, "category": {}, "reason": {}, "access": {},
|
||||
}
|
||||
|
||||
var funnelDefault = []string{
|
||||
"scale_list_viewed", "scale_started", "scale_completed",
|
||||
"portrait_completed", "deep_access_clicked", "purchase_completed",
|
||||
}
|
||||
|
||||
|
||||
@@ -15,6 +15,7 @@ import (
|
||||
"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"
|
||||
"github.com/yuxingu/digital-psychology/apps/api/internal/textsafe"
|
||||
)
|
||||
|
||||
// FreeQuota is the number of assistant replies allowed without membership.
|
||||
@@ -42,7 +43,11 @@ func (s *Service) CreateThread(ctx context.Context, userID uuid.UUID, in CreateT
|
||||
return nil, errors.New("profile not found")
|
||||
}
|
||||
var scene *string
|
||||
if sc := strings.TrimSpace(in.Scene); sc != "" {
|
||||
if strings.TrimSpace(in.Scene) != "" {
|
||||
sc, err := textsafe.Check(textsafe.Scene, in.Scene)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
scene = &sc
|
||||
}
|
||||
return s.Ask.CreateThread(ctx, userID, in.ProfileID, scene)
|
||||
@@ -137,12 +142,10 @@ type SendResult struct {
|
||||
|
||||
// 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")
|
||||
var err error
|
||||
content, err = textsafe.Check(textsafe.AskContent, content)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
thread, err := s.Ask.GetThreadForUser(ctx, userID, threadID)
|
||||
@@ -281,12 +284,10 @@ func (s *Service) StreamMessage(ctx context.Context, userID, threadID uuid.UUID,
|
||||
if emit == nil {
|
||||
return errors.New("emit required")
|
||||
}
|
||||
content = strings.TrimSpace(content)
|
||||
if content == "" {
|
||||
return errors.New("content required")
|
||||
}
|
||||
if len([]rune(content)) > 2000 {
|
||||
return errors.New("content too long")
|
||||
var err error
|
||||
content, err = textsafe.Check(textsafe.AskContent, content)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
thread, err := s.Ask.GetThreadForUser(ctx, userID, threadID)
|
||||
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"mime/multipart"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
@@ -12,21 +13,25 @@ import (
|
||||
"github.com/jackc/pgx/v5"
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
|
||||
"github.com/yuxingu/digital-psychology/apps/api/internal/avatar"
|
||||
nickgen "github.com/yuxingu/digital-psychology/apps/api/internal/nickname"
|
||||
"github.com/yuxingu/digital-psychology/apps/api/internal/repository"
|
||||
"github.com/yuxingu/digital-psychology/apps/api/internal/textsafe"
|
||||
)
|
||||
|
||||
// Service handles register/login sessions.
|
||||
// Temporary open mode: any non-empty phone+password can enter; missing accounts are created.
|
||||
type Service struct {
|
||||
Repo *repository.AuthRepo
|
||||
Repo *repository.AuthRepo
|
||||
AvatarDir string
|
||||
}
|
||||
|
||||
// Me is the public account payload.
|
||||
type Me struct {
|
||||
ID string `json:"id"`
|
||||
Phone string `json:"phone"`
|
||||
Nickname string `json:"nickname"`
|
||||
ID string `json:"id"`
|
||||
Phone string `json:"phone"`
|
||||
Nickname string `json:"nickname"`
|
||||
AvatarURL string `json:"avatar_url,omitempty"`
|
||||
}
|
||||
|
||||
// SessionResult is returned after register/login.
|
||||
@@ -34,6 +39,7 @@ type SessionResult struct {
|
||||
Token string `json:"token"`
|
||||
ExpiresAt time.Time `json:"expires_at"`
|
||||
User Me `json:"user"`
|
||||
IsNew bool `json:"is_new"`
|
||||
}
|
||||
|
||||
// Register upgrades or opens an account (same open rules as Login).
|
||||
@@ -52,7 +58,14 @@ func (s *Service) OpenLogin(ctx context.Context, deviceUserID uuid.UUID, deviceK
|
||||
if phone == "" {
|
||||
return nil, errors.New("请填写手机号")
|
||||
}
|
||||
nickIn = nickgen.Normalize(nickIn)
|
||||
nickIn = strings.TrimSpace(nickIn)
|
||||
if nickIn != "" {
|
||||
n, err := textsafe.Check(textsafe.Nickname, nickIn)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
nickIn = n
|
||||
}
|
||||
hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -73,7 +86,7 @@ func (s *Service) OpenLogin(ctx context.Context, deviceUserID uuid.UUID, deviceK
|
||||
nick = nickgen.Random()
|
||||
_ = s.Repo.UpdateNickname(ctx, acc.ID, nick)
|
||||
}
|
||||
return s.issue(ctx, acc.ID, acc.Phone, nick)
|
||||
return s.issue(ctx, acc.ID, acc.Phone, nick, false)
|
||||
}
|
||||
if !errors.Is(err, pgx.ErrNoRows) {
|
||||
return nil, err
|
||||
@@ -99,18 +112,23 @@ func (s *Service) OpenLogin(ctx context.Context, deviceUserID uuid.UUID, deviceK
|
||||
if deviceKey != "" {
|
||||
_ = s.Repo.BindDevice(ctx, deviceKey, uid)
|
||||
}
|
||||
return s.issue(ctx, uid, phone, nick)
|
||||
return s.issue(ctx, uid, phone, nick, true)
|
||||
}
|
||||
|
||||
// UpdateNickname changes account display nickname (1–16 chars).
|
||||
func (s *Service) UpdateNickname(ctx context.Context, userID uuid.UUID, nick string) (*Me, error) {
|
||||
nick = nickgen.Normalize(nick)
|
||||
if nick == "" {
|
||||
return nil, errors.New("请填写昵称")
|
||||
nick, err := textsafe.Check(textsafe.Nickname, nick)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := s.Repo.UpdateNickname(ctx, userID, nick); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return s.Me(ctx, userID)
|
||||
}
|
||||
|
||||
// UpdateAvatar stores profile photo and returns updated me.
|
||||
func (s *Service) UpdateAvatar(ctx context.Context, userID uuid.UUID, fh *multipart.FileHeader) (*Me, error) {
|
||||
acc, err := s.Repo.GetAccount(ctx, userID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -118,7 +136,18 @@ func (s *Service) UpdateNickname(ctx context.Context, userID uuid.UUID, nick str
|
||||
if acc.Phone == "" {
|
||||
return nil, errors.New("未登录")
|
||||
}
|
||||
return &Me{ID: acc.ID.String(), Phone: maskPhone(acc.Phone), Nickname: acc.Nickname}, nil
|
||||
dir := s.AvatarDir
|
||||
if dir == "" {
|
||||
dir = "data/avatars"
|
||||
}
|
||||
path, err := avatar.Store(dir, userID, fh)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := s.Repo.UpdateAvatarURL(ctx, userID, path); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return s.Me(ctx, userID)
|
||||
}
|
||||
|
||||
// Logout revokes the current bearer session and unbinds the device from the account
|
||||
@@ -141,7 +170,7 @@ func (s *Service) Me(ctx context.Context, userID uuid.UUID) (*Me, error) {
|
||||
if acc.Phone == "" {
|
||||
return nil, errors.New("未登录")
|
||||
}
|
||||
return &Me{ID: acc.ID.String(), Phone: maskPhone(acc.Phone), Nickname: acc.Nickname}, nil
|
||||
return toMe(acc), nil
|
||||
}
|
||||
|
||||
// ResolveSessionUser returns user id for a live token.
|
||||
@@ -154,18 +183,32 @@ func (s *Service) IsRegistered(ctx context.Context, userID uuid.UUID) (bool, err
|
||||
return s.Repo.IsRegistered(ctx, userID)
|
||||
}
|
||||
|
||||
func (s *Service) issue(ctx context.Context, userID uuid.UUID, phone, nickname string) (*SessionResult, error) {
|
||||
func (s *Service) issue(ctx context.Context, userID uuid.UUID, phone, nickname string, isNew bool) (*SessionResult, error) {
|
||||
tok := "usr_" + randomHex(24)
|
||||
exp := time.Now().Add(30 * 24 * time.Hour)
|
||||
if err := s.Repo.CreateSession(ctx, userID, tok, exp); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
me := &Me{ID: userID.String(), Phone: maskPhone(phone), Nickname: nickname}
|
||||
if acc, err := s.Repo.GetAccount(ctx, userID); err == nil {
|
||||
me = toMe(acc)
|
||||
}
|
||||
return &SessionResult{
|
||||
Token: tok, ExpiresAt: exp,
|
||||
User: Me{ID: userID.String(), Phone: maskPhone(phone), Nickname: nickname},
|
||||
User: *me,
|
||||
IsNew: isNew,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func toMe(acc *repository.AccountRow) *Me {
|
||||
return &Me{
|
||||
ID: acc.ID.String(),
|
||||
Phone: maskPhone(acc.Phone),
|
||||
Nickname: acc.Nickname,
|
||||
AvatarURL: acc.AvatarURL,
|
||||
}
|
||||
}
|
||||
|
||||
func maskPhone(p string) string {
|
||||
if len(p) != 11 {
|
||||
return p
|
||||
|
||||
@@ -67,8 +67,10 @@ func (s *Service) genSolo(ctx context.Context, userID uuid.UUID, p *model.Profil
|
||||
log.Printf("bootstrap portrait: %v", err)
|
||||
}
|
||||
|
||||
now := time.Now().In(time.FixedZone("CST", 8*3600))
|
||||
outS, err := star.BuildWith(star.BuildOpts{
|
||||
Birth: p.BirthDate, BirthTime: p.BirthTime, BirthPlace: p.BirthPlace, Name: p.DisplayName,
|
||||
AsOf: now,
|
||||
})
|
||||
if err != nil {
|
||||
log.Printf("bootstrap star: %v", err)
|
||||
@@ -80,7 +82,7 @@ func (s *Service) genSolo(ctx context.Context, userID uuid.UUID, p *model.Profil
|
||||
}
|
||||
}
|
||||
|
||||
outR := rhythm.Build(p.BirthDate, p.DisplayName)
|
||||
outR := rhythm.BuildWith(p.BirthDate, p.DisplayName, now)
|
||||
sum, _ = json.Marshal(outR.Summary)
|
||||
det, _ = json.Marshal(outR.Detail)
|
||||
if _, err := s.Reports.UpsertWithPeer(ctx, userID, p.ID, nil, "rhythm", sum, det); err != nil {
|
||||
|
||||
@@ -3,7 +3,6 @@ package companion
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
@@ -12,6 +11,7 @@ import (
|
||||
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"
|
||||
"github.com/yuxingu/digital-psychology/apps/api/internal/textsafe"
|
||||
)
|
||||
|
||||
// Service exposes solar terms and moods.
|
||||
@@ -37,11 +37,15 @@ func (s *Service) SaveMood(ctx context.Context, userID uuid.UUID, in SaveMoodInp
|
||||
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")
|
||||
n, err := textsafe.Check(textsafe.Note, *in.Note)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if n == "" {
|
||||
in.Note = nil
|
||||
} else {
|
||||
in.Note = &n
|
||||
}
|
||||
in.Note = &n
|
||||
}
|
||||
day := time.Now()
|
||||
if in.Day != nil {
|
||||
|
||||
@@ -14,6 +14,7 @@ import (
|
||||
|
||||
"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"
|
||||
"github.com/yuxingu/digital-psychology/apps/api/internal/yijing"
|
||||
)
|
||||
|
||||
@@ -33,58 +34,131 @@ type DailyTips struct {
|
||||
Source string `json:"source"` // llm | fallback
|
||||
GuaName string `json:"gua_name,omitempty"`
|
||||
DayPart string `json:"day_part,omitempty"`
|
||||
AsOf string `json:"as_of"` // YYYY-MM-DD
|
||||
Shichen int `json:"shichen"`
|
||||
ShichenName string `json:"shichen_name"`
|
||||
ValidUntil string `json:"valid_until"` // RFC3339 next 时辰 start
|
||||
NeedBirth bool `json:"need_birth"`
|
||||
}
|
||||
|
||||
type tipsCacheEntry struct {
|
||||
type tipsMemEntry struct {
|
||||
tips DailyTips
|
||||
exp time.Time
|
||||
}
|
||||
|
||||
var tipsCache sync.Map // key string → tipsCacheEntry
|
||||
var (
|
||||
tipsMem sync.Map // cacheKey → tipsMemEntry
|
||||
tipsInflight sync.Map // cacheKey → *sync.Mutex generating
|
||||
)
|
||||
|
||||
// DailyTips returns personalized tips; falls back when LLM / profile unavailable.
|
||||
func tipsCacheKey(userID uuid.UUID, start time.Time) string {
|
||||
return userID.String() + ":" + start.UTC().Format(time.RFC3339)
|
||||
}
|
||||
|
||||
// DailyTips returns tips for the current 时辰; prefers cache, never blocks on LLM.
|
||||
func (s *Service) DailyTips(ctx context.Context, userID uuid.UUID) (*DailyTips, error) {
|
||||
now := time.Now()
|
||||
loc, err := time.LoadLocation("Asia/Shanghai")
|
||||
if err != nil {
|
||||
loc = time.FixedZone("CST", 8*3600)
|
||||
}
|
||||
now = now.In(loc)
|
||||
win := CurrentShichen(now)
|
||||
asOf := now.Format("2006-01-02")
|
||||
|
||||
self, ok := s.findSelf(ctx, userID)
|
||||
if !ok {
|
||||
fb := fallbackTips(now, "", nil)
|
||||
fb.NeedBirth = true
|
||||
if !ok || userID == uuid.Nil {
|
||||
fb := decorateTips(fallbackTips(now, "", nil), win, asOf, true)
|
||||
return fb, nil
|
||||
}
|
||||
|
||||
birth := self.BirthDate.Format("2006-01-02")
|
||||
yc := yijing.Seed(birth, self.BirthTime, now)
|
||||
cacheKey := fmt.Sprintf("%s:%s:%s:%d", userID, birth, now.Format("2006-01-02"), now.Hour()/3)
|
||||
if v, hit := tipsCache.Load(cacheKey); hit {
|
||||
e := v.(tipsCacheEntry)
|
||||
if time.Now().Before(e.exp) {
|
||||
out := e.tips
|
||||
return &out, nil
|
||||
key := tipsCacheKey(userID, win.Start)
|
||||
|
||||
if v, hit := tipsMem.Load(key); hit {
|
||||
out := v.(tipsMemEntry).tips
|
||||
return decorateTips(&out, win, asOf, false), nil
|
||||
}
|
||||
if s.Tips != nil {
|
||||
if raw, source, err := s.Tips.GetTips(ctx, userID, win.Start); err == nil {
|
||||
var t DailyTips
|
||||
if json.Unmarshal(raw, &t) == nil {
|
||||
t.Source = source
|
||||
tipsMem.Store(key, tipsMemEntry{tips: t})
|
||||
return decorateTips(&t, win, asOf, false), nil
|
||||
}
|
||||
} else if err != repository.ErrNoTips {
|
||||
log.Printf("home daily-tips db: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
if s.LLM == nil || !s.LLM.Enabled() {
|
||||
fb := fallbackTips(now, birth, self.BirthTime)
|
||||
fb.GuaName = yc.GuaName
|
||||
fb.DayPart = yc.DayPart
|
||||
return fb, nil
|
||||
}
|
||||
// Instant path: deterministic fallback, warm LLM in background.
|
||||
fb := fallbackTips(now, birth, self.BirthTime)
|
||||
out := decorateTips(fb, win, asOf, false)
|
||||
tipsMem.Store(key, tipsMemEntry{tips: *out})
|
||||
s.persistTips(userID, win, out)
|
||||
s.warmLLMAsync(userID, birth, self.BirthTime, win, asOf, key)
|
||||
return out, nil
|
||||
}
|
||||
|
||||
tips, err := s.generateLLMTips(ctx, yc)
|
||||
if err != nil {
|
||||
log.Printf("home daily-tips llm: %v", err)
|
||||
fb := fallbackTips(now, birth, self.BirthTime)
|
||||
fb.GuaName = yc.GuaName
|
||||
fb.DayPart = yc.DayPart
|
||||
return fb, nil
|
||||
func decorateTips(t *DailyTips, win ShichenWindow, asOf string, needBirth bool) *DailyTips {
|
||||
cp := *t
|
||||
cp.AsOf = asOf
|
||||
cp.Shichen = win.Index
|
||||
cp.ShichenName = win.Name + "时"
|
||||
cp.ValidUntil = win.End.Format(time.RFC3339)
|
||||
cp.NeedBirth = needBirth
|
||||
return &cp
|
||||
}
|
||||
|
||||
func (s *Service) persistTips(userID uuid.UUID, win ShichenWindow, tips *DailyTips) {
|
||||
if s.Tips == nil || tips == nil {
|
||||
return
|
||||
}
|
||||
tips.Source = "llm"
|
||||
tips.GuaName = yc.GuaName
|
||||
tips.DayPart = yc.DayPart
|
||||
tips.NeedBirth = false
|
||||
tipsCache.Store(cacheKey, tipsCacheEntry{tips: *tips, exp: now.Add(45 * time.Minute)})
|
||||
return tips, nil
|
||||
raw, err := json.Marshal(tips)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
|
||||
defer cancel()
|
||||
if err := s.Tips.UpsertTips(ctx, userID, win.Start, win.Index, raw, tips.Source); err != nil {
|
||||
log.Printf("home daily-tips upsert: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Service) warmLLMAsync(userID uuid.UUID, birth string, birthTime *string, win ShichenWindow, asOf, key string) {
|
||||
if s.LLM == nil || !s.LLM.Enabled() {
|
||||
return
|
||||
}
|
||||
if _, loaded := tipsInflight.LoadOrStore(key, true); loaded {
|
||||
return
|
||||
}
|
||||
go func() {
|
||||
defer tipsInflight.Delete(key)
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 25*time.Second)
|
||||
defer cancel()
|
||||
now := win.Start.Add(30 * time.Minute) // representative instant inside 时辰
|
||||
if now.After(win.End) {
|
||||
now = win.Start
|
||||
}
|
||||
yc := yijing.Seed(birth, birthTime, now)
|
||||
tips, err := s.generateLLMTips(ctx, yc, now, win)
|
||||
if err != nil {
|
||||
log.Printf("home daily-tips llm warm: %v", err)
|
||||
return
|
||||
}
|
||||
tips.Source = "llm"
|
||||
tips.ClothingIndex = clothingIndexFromSeed(yc, now, win.Index)
|
||||
tips.GuaName = yc.GuaName
|
||||
tips.DayPart = yc.DayPart
|
||||
out := decorateTips(tips, win, asOf, false)
|
||||
tipsMem.Store(key, tipsMemEntry{tips: *out})
|
||||
s.persistTips(userID, win, out)
|
||||
}()
|
||||
}
|
||||
|
||||
func clothingIndexFromSeed(yc yijing.Context, now time.Time, shichen int) int {
|
||||
n := yc.GuaIndex*11 + yc.Line*5 + now.YearDay()*3 + shichen*17
|
||||
return 55 + n%41
|
||||
}
|
||||
|
||||
func (s *Service) findSelf(ctx context.Context, userID uuid.UUID) (*model.Profile, bool) {
|
||||
@@ -103,17 +177,22 @@ func (s *Service) findSelf(ctx context.Context, userID uuid.UUID) (*model.Profil
|
||||
return nil, false
|
||||
}
|
||||
|
||||
func (s *Service) generateLLMTips(ctx context.Context, yc yijing.Context) (*DailyTips, error) {
|
||||
sys := `你是愈心谷的生活节律助手。根据用户出生信息与当前时刻的易经卦象种子,给出温和的生活建议。
|
||||
func (s *Service) generateLLMTips(ctx context.Context, yc yijing.Context, now time.Time, win ShichenWindow) (*DailyTips, error) {
|
||||
weekday := []string{"日", "一", "二", "三", "四", "五", "六"}[now.Weekday()]
|
||||
sys := `你是愈心谷的生活节律助手。根据用户出生信息与当前时辰的易经卦象种子,给出温和的生活建议。
|
||||
硬性要求:
|
||||
- 禁止占卜/算命/改命恐吓/吉凶祸福话术
|
||||
- 禁止医疗疗效承诺
|
||||
- 用探索向、身心节律语气;可参考卦象意象,不要说「必有」类断言
|
||||
- 文案必须贴合「此刻时辰」与日期,体现与上一时辰不同的节律侧重
|
||||
- 只输出 JSON,不要 markdown:
|
||||
{"clothing_index":70,"clothing":"一句穿衣建议","color_note":"一句颜色说明","palette":[{"name":"色名","hex":"#RRGGBB"},{"name":"色名","hex":"#RRGGBB"}],"wellness":"一句养生建议"}
|
||||
- clothing_index 为 55–95 整数;palette 恰好 2 项;文案各不超过 40 字`
|
||||
|
||||
user := yc.PromptLine() + "\n请生成今日穿衣指数、颜色搭配与养生推荐。"
|
||||
user := fmt.Sprintf(
|
||||
"%s\n今天是 %s(星期%s),当前%s时。请生成贴合本时辰的穿衣指数、颜色搭配与养生推荐,勿与通用套话雷同。",
|
||||
yc.PromptLine(), now.Format("2006-01-02"), weekday, win.Name,
|
||||
)
|
||||
raw, err := s.LLM.Chat(ctx, []deepseek.Message{
|
||||
{Role: "system", Content: sys},
|
||||
{Role: "user", Content: user},
|
||||
@@ -181,12 +260,20 @@ func fallbackTips(now time.Time, birth string, birthTime *string) *DailyTips {
|
||||
if birth == "" {
|
||||
yc = yijing.Seed(now.Format("2006-01-02"), nil, now)
|
||||
}
|
||||
win := CurrentShichen(now)
|
||||
palettes := [][]ColorChip{
|
||||
{{Name: "米白", Hex: "#F5F0E8"}, {Name: "雾霾蓝", Hex: "#A8C4D8"}},
|
||||
{{Name: "燕麦色", Hex: "#E8DCC8"}, {Name: "浅杏", Hex: "#F0C9A8"}},
|
||||
{{Name: "浅灰", Hex: "#D8D6D4"}, {Name: "雾粉", Hex: "#E8B8C4"}},
|
||||
{{Name: "象牙白", Hex: "#F7F4EC"}, {Name: "鼠尾草", Hex: "#A8C4B0"}},
|
||||
{{Name: "浅卡其", Hex: "#DCC8A8"}, {Name: "浅蓝", Hex: "#B0D0E8"}},
|
||||
{{Name: "天青", Hex: "#9BB8D4"}, {Name: "陶土", Hex: "#C4A484"}},
|
||||
{{Name: "豆沙", Hex: "#C9A0A0"}, {Name: "雾绿", Hex: "#B5C9B8"}},
|
||||
{{Name: "奶油", Hex: "#F3EADF"}, {Name: "烟灰", Hex: "#B8B4B0"}},
|
||||
{{Name: "浅咖", Hex: "#C8B09A"}, {Name: "靛蓝", Hex: "#6B8CAE"}},
|
||||
{{Name: "杏白", Hex: "#F6EBDD"}, {Name: "藕荷", Hex: "#D4B8C4"}},
|
||||
{{Name: "竹青", Hex: "#A8BFA8"}, {Name: "沙色", Hex: "#E0D0B8"}},
|
||||
{{Name: "月白", Hex: "#EEF2F5"}, {Name: "焦糖", Hex: "#C4A070"}},
|
||||
}
|
||||
clothing := []string{
|
||||
"轻薄透气更舒服,外套可备一件薄衫应付温差。",
|
||||
@@ -194,18 +281,41 @@ func fallbackTips(now time.Time, birth string, birthTime *string) *DailyTips {
|
||||
"上下同色系更省心,再加一件小配饰点睛即可。",
|
||||
"宽松剪裁更自在,适合慢节奏出门与散步。",
|
||||
"内里干净简约,外搭一件有质感的外套就够。",
|
||||
"今日宜层搭:薄内搭+透气外衫,方便随时增减。",
|
||||
"选垂感面料更显松弛,走路也会轻一点。",
|
||||
"少用厚重配饰,让肩颈更轻松。",
|
||||
"浅色上衣更提气色,下装选舒适脚感即可。",
|
||||
"风大时加一件防风薄外套,比厚羽绒服更灵活。",
|
||||
"本时辰适合干净线条,少印花更省心。",
|
||||
"运动鞋或软底鞋更友好,站久也不累。",
|
||||
}
|
||||
notes := []string{
|
||||
"清爽干净,不抢戏。", "温柔耐看,适合日常。", "柔和提气色,不显沉。",
|
||||
"安静又有呼吸感。", "干净利落,好搭配。", "此刻偏清透,层次更轻。",
|
||||
"暖调一点点,气色更稳。", "冷暖各一,对比柔和。", "低饱和更耐看。",
|
||||
"偏自然色,贴近户外光线。", "雾感配色,不刺眼。", "柔对比,适合慢节奏。",
|
||||
}
|
||||
notes := []string{"清爽干净,不抢戏。", "温柔耐看,适合日常。", "柔和提气色,不显沉。", "安静又有呼吸感。", "干净利落,好搭配。"}
|
||||
well := []string{
|
||||
"午后泡一杯温茶,给身心一点缓冲。",
|
||||
"今晚早点放下屏幕,让眼睛歇一会儿。",
|
||||
"走路时把肩膀放松,呼吸会顺很多。",
|
||||
"饭后慢走十分钟,比猛坐着更舒服。",
|
||||
"喝水提醒自己小口多次,别等口渴再灌。",
|
||||
"此刻做三次深呼吸,拉长呼气更易放松。",
|
||||
"站起来伸个懒腰,活动一下髋与肩。",
|
||||
"晚饭少一点刺激,给肠胃留空档。",
|
||||
"睡前把明天三件小事写下来,心会静些。",
|
||||
"听一首慢歌,跟着节奏把呼吸放慢。",
|
||||
"把窗帘开一条缝,让自然光进一点。",
|
||||
"洗手洗脸用温水,给皮肤一点温柔。",
|
||||
}
|
||||
n := len(palettes)
|
||||
i := (yc.GuaIndex*13 + yc.Line*7 + now.YearDay()*3 + win.Index*19) % n
|
||||
if i < 0 {
|
||||
i = -i
|
||||
}
|
||||
i := (yc.GuaIndex + yc.Line + now.Hour()) % 5
|
||||
return &DailyTips{
|
||||
ClothingIndex: 62 + (yc.GuaIndex % 31),
|
||||
ClothingIndex: clothingIndexFromSeed(yc, now, win.Index),
|
||||
Clothing: clothing[i],
|
||||
ColorNote: notes[i],
|
||||
Palette: palettes[i],
|
||||
@@ -213,5 +323,6 @@ func fallbackTips(now time.Time, birth string, birthTime *string) *DailyTips {
|
||||
Source: "fallback",
|
||||
GuaName: yc.GuaName,
|
||||
DayPart: yc.DayPart,
|
||||
AsOf: now.Format("2006-01-02"),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -29,6 +29,7 @@ var allowedIcons = map[string]struct{}{
|
||||
// Service serves homepage tool catalog and daily tips.
|
||||
type Service struct {
|
||||
Repo *repository.HomeToolsRepo
|
||||
Tips *repository.HomeDailyTipsRepo
|
||||
Profiles *repository.ProfileRepo
|
||||
LLM *deepseek.Client
|
||||
}
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
package home
|
||||
|
||||
import "testing"
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestNormalizeToolOK(t *testing.T) {
|
||||
b := "热"
|
||||
@@ -28,3 +31,53 @@ func TestNormalizeToolRejects(t *testing.T) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestFallbackTipsChangeByDay(t *testing.T) {
|
||||
loc := time.FixedZone("CST", 8*3600)
|
||||
d1 := time.Date(2026, 8, 11, 19, 0, 0, 0, loc)
|
||||
d2 := time.Date(2026, 8, 12, 19, 0, 0, 0, loc)
|
||||
a := fallbackTips(d1, "1990-05-12", nil)
|
||||
b := fallbackTips(d2, "1990-05-12", nil)
|
||||
if a.ClothingIndex == b.ClothingIndex && a.Clothing == b.Clothing && a.Wellness == b.Wellness {
|
||||
t.Fatalf("expected day-varying tips, got same %+v", a)
|
||||
}
|
||||
if a.AsOf == b.AsOf {
|
||||
t.Fatalf("as_of should differ: %s", a.AsOf)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCurrentShichenBoundaries(t *testing.T) {
|
||||
loc := time.FixedZone("CST", 8*3600)
|
||||
cases := []struct {
|
||||
h, wantIdx int
|
||||
wantName string
|
||||
}{
|
||||
{23, 0, "子"},
|
||||
{0, 0, "子"},
|
||||
{9, 5, "巳"},
|
||||
{10, 5, "巳"},
|
||||
{11, 6, "午"},
|
||||
}
|
||||
for _, c := range cases {
|
||||
now := time.Date(2026, 8, 12, c.h, 15, 0, 0, loc)
|
||||
w := CurrentShichen(now)
|
||||
if w.Index != c.wantIdx || w.Name != c.wantName {
|
||||
t.Fatalf("h=%d got %d %s want %d %s", c.h, w.Index, w.Name, c.wantIdx, c.wantName)
|
||||
}
|
||||
if !w.Start.Before(now) && !w.Start.Equal(now) {
|
||||
t.Fatalf("start should be <= now: %v %v", w.Start, now)
|
||||
}
|
||||
if !w.End.After(now) {
|
||||
t.Fatalf("end should be > now")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestFallbackTipsChangeByShichen(t *testing.T) {
|
||||
loc := time.FixedZone("CST", 8*3600)
|
||||
a := fallbackTips(time.Date(2026, 8, 12, 9, 0, 0, 0, loc), "1990-05-12", nil)
|
||||
b := fallbackTips(time.Date(2026, 8, 12, 11, 0, 0, 0, loc), "1990-05-12", nil)
|
||||
if a.Clothing == b.Clothing && a.Wellness == b.Wellness && a.ClothingIndex == b.ClothingIndex {
|
||||
t.Fatalf("expected shichen-varying tips")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
package home
|
||||
|
||||
import (
|
||||
"time"
|
||||
)
|
||||
|
||||
var shichenNames = [12]string{"子", "丑", "寅", "卯", "辰", "巳", "午", "未", "申", "酉", "戌", "亥"}
|
||||
|
||||
// ShichenWindow is the current Chinese double-hour window.
|
||||
type ShichenWindow struct {
|
||||
Index int
|
||||
Name string
|
||||
Start time.Time
|
||||
End time.Time
|
||||
}
|
||||
|
||||
// CurrentShichen returns the 十二时辰 window for now (子时 23:00–01:00 …).
|
||||
func CurrentShichen(now time.Time) ShichenWindow {
|
||||
h := now.Hour()
|
||||
idx := ((h + 1) % 24) / 2
|
||||
startHour := (idx*2 + 23) % 24
|
||||
start := time.Date(now.Year(), now.Month(), now.Day(), startHour, 0, 0, 0, now.Location())
|
||||
if start.After(now) {
|
||||
start = start.Add(-24 * time.Hour)
|
||||
}
|
||||
return ShichenWindow{
|
||||
Index: idx,
|
||||
Name: shichenNames[idx],
|
||||
Start: start,
|
||||
End: start.Add(2 * time.Hour),
|
||||
}
|
||||
}
|
||||
@@ -76,3 +76,12 @@ func (s *Service) Get(ctx context.Context, userID uuid.UUID) (*Me, error) {
|
||||
AskQuotaLeft: row.AskQuotaLeft,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// IsActive reports whether the user has an active成长会员.
|
||||
func (s *Service) IsActive(ctx context.Context, userID uuid.UUID) (bool, error) {
|
||||
me, err := s.Get(ctx, userID)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
return me.Active, nil
|
||||
}
|
||||
|
||||
@@ -11,6 +11,7 @@ import (
|
||||
"github.com/yuxingu/digital-psychology/apps/api/internal/model"
|
||||
"github.com/yuxingu/digital-psychology/apps/api/internal/repository"
|
||||
"github.com/yuxingu/digital-psychology/apps/api/internal/service/bootstrap"
|
||||
"github.com/yuxingu/digital-psychology/apps/api/internal/textsafe"
|
||||
)
|
||||
|
||||
// Service manages personal archives.
|
||||
@@ -58,6 +59,10 @@ func (s *Service) Create(ctx context.Context, userID uuid.UUID, in CreateInput)
|
||||
name = "TA"
|
||||
}
|
||||
}
|
||||
name, err := textsafe.Check(textsafe.DisplayName, name)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
p, err := s.Repo.Create(ctx, userID, in.Relation, name, in.BirthDate, in.RelationType, in.BirthTime, in.BirthPlace)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -94,6 +99,12 @@ func (s *Service) Update(ctx context.Context, userID, profileID uuid.UUID, in Up
|
||||
name := strings.TrimSpace(in.DisplayName)
|
||||
if name == "" {
|
||||
name = cur.DisplayName
|
||||
} else {
|
||||
var err error
|
||||
name, err = textsafe.Check(textsafe.DisplayName, name)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
birth := in.BirthDate
|
||||
if birth.IsZero() {
|
||||
|
||||
@@ -14,6 +14,7 @@ import (
|
||||
"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"
|
||||
"github.com/yuxingu/digital-psychology/apps/api/internal/textsafe"
|
||||
)
|
||||
|
||||
// Service creates and reads growth reports with entitlement trimming.
|
||||
@@ -98,6 +99,10 @@ func (s *Service) AcceptInvite(ctx context.Context, guestUser uuid.UUID, token,
|
||||
if name == "" {
|
||||
name = "TA"
|
||||
}
|
||||
name, err = textsafe.Check(textsafe.DisplayName, name)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
host, err := s.Profiles.GetByID(ctx, inv.HostProfileID)
|
||||
if err != nil {
|
||||
return nil, errors.New("host profile missing")
|
||||
@@ -147,6 +152,7 @@ func (s *Service) CreateStar(ctx context.Context, userID, profileID uuid.UUID) (
|
||||
}
|
||||
out, err := star.BuildWith(star.BuildOpts{
|
||||
Birth: p.BirthDate, BirthTime: p.BirthTime, BirthPlace: p.BirthPlace, Name: p.DisplayName,
|
||||
AsOf: time.Now().In(time.FixedZone("CST", 8*3600)),
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -212,7 +218,7 @@ func (s *Service) CreateRhythm(ctx context.Context, userID, profileID uuid.UUID)
|
||||
if err != nil {
|
||||
return nil, errors.New("profile not found")
|
||||
}
|
||||
out := rhythm.Build(p.BirthDate, p.DisplayName)
|
||||
out := rhythm.BuildWith(p.BirthDate, p.DisplayName, time.Now().In(time.FixedZone("CST", 8*3600)))
|
||||
sum, _ := json.Marshal(out.Summary)
|
||||
det, _ := json.Marshal(out.Detail)
|
||||
rep, err := s.Reports.UpsertWithPeer(ctx, userID, profileID, nil, "rhythm", sum, det)
|
||||
@@ -222,21 +228,23 @@ func (s *Service) CreateRhythm(ctx context.Context, userID, profileID uuid.UUID)
|
||||
return s.applyEntitlement(ctx, userID, rep)
|
||||
}
|
||||
|
||||
// GetLatest returns cached report by profile+type(+peer).
|
||||
// GetLatest returns cached report by profile+type(+peer); refreshes star/rhythm day tips.
|
||||
func (s *Service) GetLatest(ctx context.Context, userID, profileID uuid.UUID, typ string, peer *uuid.UUID) (*model.GrowthReport, error) {
|
||||
rep, err := s.Reports.GetLatest(ctx, userID, profileID, typ, peer)
|
||||
if err != nil {
|
||||
return nil, errors.New("report not found")
|
||||
}
|
||||
rep, _ = s.refreshTemporalIfNeeded(ctx, userID, rep)
|
||||
return s.applyEntitlement(ctx, userID, rep)
|
||||
}
|
||||
|
||||
// Get returns a report with detail gated.
|
||||
// Get returns a report with detail gated; refreshes star/rhythm day tips.
|
||||
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")
|
||||
}
|
||||
rep, _ = s.refreshTemporalIfNeeded(ctx, userID, rep)
|
||||
return s.applyEntitlement(ctx, userID, rep)
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,127 @@
|
||||
package report
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
|
||||
"github.com/yuxingu/digital-psychology/apps/api/internal/model"
|
||||
"github.com/yuxingu/digital-psychology/apps/api/internal/rhythm"
|
||||
"github.com/yuxingu/digital-psychology/apps/api/internal/star"
|
||||
)
|
||||
|
||||
func cstLoc() *time.Location {
|
||||
return time.FixedZone("CST", 8*3600)
|
||||
}
|
||||
|
||||
func dayKey(t time.Time) string {
|
||||
return t.In(cstLoc()).Format("2006-01-02")
|
||||
}
|
||||
|
||||
func summaryDay(raw json.RawMessage) string {
|
||||
var m map[string]any
|
||||
if err := json.Unmarshal(raw, &m); err != nil {
|
||||
return ""
|
||||
}
|
||||
s, _ := m["as_of"].(string)
|
||||
return s
|
||||
}
|
||||
|
||||
func hasWuxingBars(raw json.RawMessage) bool {
|
||||
var m map[string]any
|
||||
if err := json.Unmarshal(raw, &m); err != nil {
|
||||
return false
|
||||
}
|
||||
wx, _ := m["wuxing"].(map[string]any)
|
||||
if wx == nil {
|
||||
return false
|
||||
}
|
||||
bars, ok := wx["bars"].([]any)
|
||||
return ok && len(bars) > 0
|
||||
}
|
||||
|
||||
func rhythmNeedsContentBackfill(raw json.RawMessage) bool {
|
||||
if !hasWuxingBars(raw) {
|
||||
return true
|
||||
}
|
||||
var m map[string]any
|
||||
if err := json.Unmarshal(raw, &m); err != nil {
|
||||
return true
|
||||
}
|
||||
prev, _ := m["blind_spots_preview"].([]any)
|
||||
if len(prev) == 0 {
|
||||
return true
|
||||
}
|
||||
for _, item := range prev {
|
||||
s, _ := item.(string)
|
||||
if s == "完整习惯方案见深度版。" || strings.Contains(s, "见深度版") {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// refreshTemporalIfNeeded updates star/rhythm day-scoped tips in place when as_of is stale.
|
||||
func (s *Service) refreshTemporalIfNeeded(ctx context.Context, userID uuid.UUID, rep *model.GrowthReport) (*model.GrowthReport, error) {
|
||||
if rep == nil || (rep.Type != "star" && rep.Type != "rhythm") {
|
||||
return rep, nil
|
||||
}
|
||||
now := time.Now().In(cstLoc())
|
||||
today := dayKey(now)
|
||||
staleDay := summaryDay(rep.Summary) != today
|
||||
needBackfill := rep.Type == "rhythm" && rhythmNeedsContentBackfill(rep.Summary)
|
||||
if !staleDay && !needBackfill {
|
||||
return stampValidUntil(rep, now), nil
|
||||
}
|
||||
p, err := s.Profiles.GetForUser(ctx, userID, rep.ProfileID)
|
||||
if err != nil {
|
||||
return rep, nil
|
||||
}
|
||||
var sum, det json.RawMessage
|
||||
switch rep.Type {
|
||||
case "star":
|
||||
out, err := star.BuildWith(star.BuildOpts{
|
||||
Birth: p.BirthDate, BirthTime: p.BirthTime, BirthPlace: p.BirthPlace,
|
||||
Name: p.DisplayName, AsOf: now,
|
||||
})
|
||||
if err != nil {
|
||||
return rep, nil
|
||||
}
|
||||
sum, _ = json.Marshal(out.Summary)
|
||||
det, _ = json.Marshal(out.Detail)
|
||||
case "rhythm":
|
||||
out := rhythm.BuildWith(p.BirthDate, p.DisplayName, now)
|
||||
sum, _ = json.Marshal(out.Summary)
|
||||
det, _ = json.Marshal(out.Detail)
|
||||
default:
|
||||
return rep, nil
|
||||
}
|
||||
if err := s.Reports.UpdateContent(ctx, userID, rep.ID, sum, det); err != nil {
|
||||
return rep, nil
|
||||
}
|
||||
rep.Summary = sum
|
||||
rep.Detail = det
|
||||
return rep, nil
|
||||
}
|
||||
|
||||
func stampValidUntil(rep *model.GrowthReport, now time.Time) *model.GrowthReport {
|
||||
var m map[string]any
|
||||
if err := json.Unmarshal(rep.Summary, &m); err != nil {
|
||||
return rep
|
||||
}
|
||||
if _, ok := m["valid_until"].(string); ok && m["as_of"] == dayKey(now) {
|
||||
return rep
|
||||
}
|
||||
m["as_of"] = dayKey(now)
|
||||
next := time.Date(now.Year(), now.Month(), now.Day()+1, 0, 0, 0, 0, now.Location())
|
||||
m["valid_until"] = next.Format(time.RFC3339)
|
||||
raw, err := json.Marshal(m)
|
||||
if err != nil {
|
||||
return rep
|
||||
}
|
||||
rep.Summary = raw
|
||||
return rep
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
package report
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/yuxingu/digital-psychology/apps/api/internal/rhythm"
|
||||
"github.com/yuxingu/digital-psychology/apps/api/internal/star"
|
||||
)
|
||||
|
||||
func TestDayKeyCST(t *testing.T) {
|
||||
// 2026-08-12 23:30 UTC = 2026-08-13 07:30 CST
|
||||
utc := time.Date(2026, 8, 12, 23, 30, 0, 0, time.UTC)
|
||||
if got := dayKey(utc); got != "2026-08-13" {
|
||||
t.Fatalf("dayKey=%s", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStarOutlookChangesByDay(t *testing.T) {
|
||||
birth := time.Date(1990, 5, 15, 0, 0, 0, 0, time.UTC)
|
||||
d1 := time.Date(2026, 8, 12, 10, 0, 0, 0, cstLoc())
|
||||
d2 := time.Date(2026, 8, 13, 10, 0, 0, 0, cstLoc())
|
||||
a, err := star.BuildWith(star.BuildOpts{Birth: birth, Name: "测", AsOf: d1})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
b, err := star.BuildWith(star.BuildOpts{Birth: birth, Name: "测", AsOf: d2})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if a.Summary["as_of"] != "2026-08-12" || b.Summary["as_of"] != "2026-08-13" {
|
||||
t.Fatalf("as_of a=%v b=%v", a.Summary["as_of"], b.Summary["as_of"])
|
||||
}
|
||||
oa, _ := a.Summary["outlook"].(map[string]any)
|
||||
ob, _ := b.Summary["outlook"].(map[string]any)
|
||||
da, _ := oa["daily"].(map[string]any)
|
||||
db, _ := ob["daily"].(map[string]any)
|
||||
if da["score"] == db["score"] && da["tip"] == db["tip"] {
|
||||
t.Fatalf("expected daily outlook to differ across days")
|
||||
}
|
||||
if a.Summary["valid_until"] == nil || b.Summary["valid_until"] == nil {
|
||||
t.Fatal("missing valid_until")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRhythmTipsChangeByDay(t *testing.T) {
|
||||
birth := time.Date(1992, 6, 8, 0, 0, 0, 0, time.UTC)
|
||||
d1 := time.Date(2026, 8, 10, 12, 0, 0, 0, cstLoc()) // Mon
|
||||
d2 := time.Date(2026, 8, 11, 12, 0, 0, 0, cstLoc()) // Tue
|
||||
a := rhythm.BuildWith(birth, "测", d1)
|
||||
b := rhythm.BuildWith(birth, "测", d2)
|
||||
if a.Summary["as_of"] != "2026-08-10" || b.Summary["as_of"] != "2026-08-11" {
|
||||
t.Fatalf("as_of a=%v b=%v", a.Summary["as_of"], b.Summary["as_of"])
|
||||
}
|
||||
if a.Summary["week_focus"] == b.Summary["week_focus"] && a.Summary["today_tip"] == b.Summary["today_tip"] {
|
||||
t.Fatalf("expected tips to differ")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSummaryDay(t *testing.T) {
|
||||
raw, _ := json.Marshal(map[string]any{"as_of": "2026-08-12"})
|
||||
if summaryDay(raw) != "2026-08-12" {
|
||||
t.Fatal(summaryDay(raw))
|
||||
}
|
||||
if summaryDay(json.RawMessage(`{}`)) != "" {
|
||||
t.Fatal("empty")
|
||||
}
|
||||
}
|
||||
@@ -8,13 +8,23 @@ import (
|
||||
"github.com/google/uuid"
|
||||
|
||||
sc "github.com/yuxingu/digital-psychology/apps/api/internal/scale"
|
||||
"github.com/yuxingu/digital-psychology/apps/api/internal/scalebank"
|
||||
"github.com/yuxingu/digital-psychology/apps/api/internal/repository"
|
||||
)
|
||||
|
||||
// ErrMembershipRequired when full MBTI requires growth membership.
|
||||
var ErrMembershipRequired = errors.New("membership required")
|
||||
|
||||
// MembershipChecker reports whether成长会员 is active.
|
||||
type MembershipChecker interface {
|
||||
IsActive(ctx context.Context, userID uuid.UUID) (bool, error)
|
||||
}
|
||||
|
||||
// Service serves 探索测试.
|
||||
type Service struct {
|
||||
Repo *repository.ScaleRepo
|
||||
Profiles *repository.ProfileRepo
|
||||
Repo *repository.ScaleRepo
|
||||
Profiles *repository.ProfileRepo
|
||||
Membership MembershipChecker
|
||||
}
|
||||
|
||||
// List returns published scales.
|
||||
@@ -22,12 +32,42 @@ func (s *Service) List(ctx context.Context) ([]repository.ScaleListItem, error)
|
||||
return s.Repo.ListPublished(ctx)
|
||||
}
|
||||
|
||||
// Get returns scale detail.
|
||||
func (s *Service) Get(ctx context.Context, slug string) (*repository.ScaleDetail, error) {
|
||||
// Get returns scale detail; bank slugs served from curated embed; mbti-full may be locked.
|
||||
func (s *Service) Get(ctx context.Context, userID uuid.UUID, slug string) (*repository.ScaleDetail, error) {
|
||||
if bank, err := scalebank.Get(slug); err == nil {
|
||||
if _, err := s.Repo.EnsurePublished(ctx, bank.Slug, bank.Title, bank.Description); err != nil {
|
||||
return nil, errors.New("scale not found")
|
||||
}
|
||||
d := &repository.ScaleDetail{
|
||||
Slug: bank.Slug, Title: bank.Title, Description: bank.Description, Access: "free",
|
||||
}
|
||||
for i, q := range bank.Questions {
|
||||
body, _ := json.Marshal(map[string]any{
|
||||
"prompt": q.Prompt, "options": q.Options,
|
||||
})
|
||||
d.Questions = append(d.Questions, repository.ScaleQuestion{
|
||||
ID: scalebank.QuestionID(slug, i), Sort: i + 1, Body: body,
|
||||
})
|
||||
}
|
||||
return d, nil
|
||||
}
|
||||
d, err := s.Repo.GetBySlug(ctx, slug)
|
||||
if err != nil {
|
||||
return nil, errors.New("scale not found")
|
||||
}
|
||||
if slug == "mbti-full" {
|
||||
d.Access = "membership"
|
||||
ok := false
|
||||
if s.Membership != nil && userID != uuid.Nil {
|
||||
ok, _ = s.Membership.IsActive(ctx, userID)
|
||||
}
|
||||
if !ok {
|
||||
d.Locked = true
|
||||
d.Questions = nil
|
||||
}
|
||||
} else {
|
||||
d.Access = "free"
|
||||
}
|
||||
return d, nil
|
||||
}
|
||||
|
||||
@@ -43,19 +83,58 @@ type SubmitResult struct {
|
||||
Result map[string]interface{} `json:"result"`
|
||||
}
|
||||
|
||||
// Submit scores a simple majority style.
|
||||
// Submit scores answers and stores result.
|
||||
func (s *Service) Submit(ctx context.Context, userID uuid.UUID, slug string, in SubmitInput) (*SubmitResult, error) {
|
||||
if _, err := s.Profiles.GetForUser(ctx, userID, in.ProfileID); err != nil {
|
||||
return nil, errors.New("profile not found")
|
||||
}
|
||||
scaleID, err := s.Repo.ScaleIDBySlug(ctx, slug)
|
||||
if err != nil {
|
||||
return nil, errors.New("scale not found")
|
||||
if slug == "mbti-full" {
|
||||
ok := false
|
||||
if s.Membership != nil {
|
||||
ok, _ = s.Membership.IsActive(ctx, userID)
|
||||
}
|
||||
if !ok {
|
||||
return nil, ErrMembershipRequired
|
||||
}
|
||||
}
|
||||
|
||||
labels, _, _ := labelsForSlug(slug)
|
||||
key, label := sc.ScoreMajority(in.Answers, labels, "平衡探索型")
|
||||
result := sc.BuildResult(slug, key, label)
|
||||
var result map[string]interface{}
|
||||
var scaleID uuid.UUID
|
||||
|
||||
if bank, err := scalebank.Get(slug); err == nil {
|
||||
scaleID, err = s.Repo.EnsurePublished(ctx, bank.Slug, bank.Title, bank.Description)
|
||||
if err != nil {
|
||||
return nil, errors.New("scale not found")
|
||||
}
|
||||
key, label, _, _ := scalebank.ScoreSum(in.Answers, bank.QuestionCount)
|
||||
result = sc.BuildResult(slug, key, label)
|
||||
result["disclaimer"] = bank.Disclaimer
|
||||
} else {
|
||||
detail, err := s.Repo.GetBySlug(ctx, slug)
|
||||
if err != nil {
|
||||
return nil, errors.New("scale not found")
|
||||
}
|
||||
scaleID, err = s.Repo.ScaleIDBySlug(ctx, slug)
|
||||
if err != nil {
|
||||
return nil, errors.New("scale not found")
|
||||
}
|
||||
if slug == "mbti-lite" || slug == "mbti-full" {
|
||||
meta := map[string]sc.JungianQuestionMeta{}
|
||||
for _, q := range detail.Questions {
|
||||
meta[q.ID.String()] = sc.ParseJungianMeta(q.Body)
|
||||
}
|
||||
perDim := 8
|
||||
if slug == "mbti-full" {
|
||||
perDim = 15
|
||||
}
|
||||
code, _, pct := sc.ScoreJungian(in.Answers, meta, perDim)
|
||||
result = sc.BuildJungianResult(code, pct)
|
||||
} else {
|
||||
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)
|
||||
@@ -65,15 +144,36 @@ func (s *Service) Submit(ctx context.Context, userID uuid.UUID, slug string, in
|
||||
return &SubmitResult{ID: id, Result: result}, nil
|
||||
}
|
||||
|
||||
// LatestResult returns the newest stored result for this user + slug.
|
||||
func (s *Service) LatestResult(ctx context.Context, userID uuid.UUID, slug string) (*SubmitResult, error) {
|
||||
if scalebank.Has(slug) {
|
||||
bank, err := scalebank.Get(slug)
|
||||
if err != nil {
|
||||
return nil, errors.New("scale not found")
|
||||
}
|
||||
if _, err := s.Repo.EnsurePublished(ctx, bank.Slug, bank.Title, bank.Description); err != nil {
|
||||
return nil, errors.New("scale not found")
|
||||
}
|
||||
} else if _, err := s.Repo.ScaleIDBySlug(ctx, slug); err != nil {
|
||||
return nil, errors.New("scale not found")
|
||||
}
|
||||
row, err := s.Repo.LatestResult(ctx, userID, slug)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var result map[string]interface{}
|
||||
if err := json.Unmarshal(row.Result, &result); err != nil {
|
||||
return nil, errors.New("invalid result")
|
||||
}
|
||||
return &SubmitResult{ID: row.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": "独立驱动型"},
|
||||
"我的动机模式:", "内在动机轻量探索,用于自我觉察。"
|
||||
@@ -98,3 +198,13 @@ func labelsForSlug(slug string) (labels map[string]string, sharePrefix, summary
|
||||
"这是你当前沟通偏好的探索结果,可用于自我了解与关系理解,不是固定标签。"
|
||||
}
|
||||
}
|
||||
|
||||
// BankCatalog returns curated exploration bank tiles + categories.
|
||||
func (s *Service) BankCatalog() (*scalebank.CatalogOut, error) {
|
||||
return scalebank.Catalog()
|
||||
}
|
||||
|
||||
// BankCategory lists scales in a curated category.
|
||||
func (s *Service) BankCategory(key string) (*scalebank.CategoryCard, []scalebank.ScaleListItem, error) {
|
||||
return scalebank.CategoryScales(key)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user