@@ -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" ) ,
}
}