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:
@@ -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"),
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user