Files
digital-psychology/apps/api/internal/service/home/daily_tips.go
T
jackyu66gitandCursor 89756f65b4 feat(ECR-012–016): 合规、题库、时辰刷新、头像、MBTI OEJTS 与埋点
落地输入合规、探索题库、报告日/时辰刷新、账号头像、OEJTS 量表,并补齐 H5 埋点与 Admin 漏斗;同步 ESS 工件、切至自建 Git、清理 GitHub Actions。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-13 01:27:58 +08:00

329 lines
11 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package home
import (
"context"
"encoding/json"
"fmt"
"log"
"strings"
"sync"
"time"
"unicode/utf8"
"github.com/google/uuid"
"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"
)
// ColorChip is one palette swatch.
type ColorChip struct {
Name string `json:"name"`
Hex string `json:"hex"`
}
// DailyTips is homepage self-card lifestyle tips.
type DailyTips struct {
ClothingIndex int `json:"clothing_index"`
Clothing string `json:"clothing"`
ColorNote string `json:"color_note"`
Palette []ColorChip `json:"palette"`
Wellness string `json:"wellness"`
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 tipsMemEntry struct {
tips DailyTips
}
var (
tipsMem sync.Map // cacheKey → tipsMemEntry
tipsInflight sync.Map // cacheKey → *sync.Mutex generating
)
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 || userID == uuid.Nil {
fb := decorateTips(fallbackTips(now, "", nil), win, asOf, true)
return fb, nil
}
birth := self.BirthDate.Format("2006-01-02")
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)
}
}
// 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
}
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
}
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) {
if s.Profiles == nil || userID == uuid.Nil {
return nil, false
}
items, err := s.Profiles.ListByUser(ctx, userID)
if err != nil {
return nil, false
}
for i := range items {
if items[i].Relation == "self" {
return &items[i], true
}
}
return nil, false
}
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 为 5595 整数;palette 恰好 2 项;文案各不超过 40 字`
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},
})
if err != nil {
return nil, err
}
return parseTipsJSON(raw)
}
func parseTipsJSON(raw string) (*DailyTips, error) {
raw = strings.TrimSpace(raw)
if i := strings.Index(raw, "{"); i >= 0 {
raw = raw[i:]
}
if j := strings.LastIndex(raw, "}"); j >= 0 {
raw = raw[:j+1]
}
var t DailyTips
if err := json.Unmarshal([]byte(raw), &t); err != nil {
return nil, err
}
if t.ClothingIndex < 40 || t.ClothingIndex > 100 {
t.ClothingIndex = 72
}
t.Clothing = clampRunes(t.Clothing, 48)
t.ColorNote = clampRunes(t.ColorNote, 40)
t.Wellness = clampRunes(t.Wellness, 48)
if len(t.Palette) != 2 {
return nil, fmt.Errorf("palette len")
}
for i := range t.Palette {
t.Palette[i].Name = clampRunes(t.Palette[i].Name, 8)
if !looksHex(t.Palette[i].Hex) {
t.Palette[i].Hex = "#E8DCC8"
}
}
return &t, nil
}
func clampRunes(s string, max int) string {
s = strings.TrimSpace(s)
if utf8.RuneCountInString(s) <= max {
return s
}
r := []rune(s)
return string(r[:max])
}
func looksHex(h string) bool {
if len(h) != 7 || h[0] != '#' {
return false
}
for i := 1; i < 7; i++ {
c := h[i]
if !((c >= '0' && c <= '9') || (c >= 'a' && c <= 'f') || (c >= 'A' && c <= 'F')) {
return false
}
}
return true
}
func fallbackTips(now time.Time, birth string, birthTime *string) *DailyTips {
yc := yijing.Seed(birth, birthTime, now)
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{
"轻薄透气更舒服,外套可备一件薄衫应付温差。",
"选柔软面料贴身,活动一整天也不易紧绷。",
"上下同色系更省心,再加一件小配饰点睛即可。",
"宽松剪裁更自在,适合慢节奏出门与散步。",
"内里干净简约,外搭一件有质感的外套就够。",
"今日宜层搭:薄内搭+透气外衫,方便随时增减。",
"选垂感面料更显松弛,走路也会轻一点。",
"少用厚重配饰,让肩颈更轻松。",
"浅色上衣更提气色,下装选舒适脚感即可。",
"风大时加一件防风薄外套,比厚羽绒服更灵活。",
"本时辰适合干净线条,少印花更省心。",
"运动鞋或软底鞋更友好,站久也不累。",
}
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
}
return &DailyTips{
ClothingIndex: clothingIndexFromSeed(yc, now, win.Index),
Clothing: clothing[i],
ColorNote: notes[i],
Palette: palettes[i],
Wellness: well[i],
Source: "fallback",
GuaName: yc.GuaName,
DayPart: yc.DayPart,
AsOf: now.Format("2006-01-02"),
}
}