Files
digital-psychology/apps/api/internal/star/outlook/outlook.go
T
jackyu66gitandCursor 19d3cd5945 refactor(ECR-001): 接入 ESS 并完成结构对齐 Phase A–E
绑定 ESS 双轨治理,拆分超大 H5 页与 Go 引擎,抽出 membership 服务,
并将 star/fortune 重命名为 outlook(JSON 双写兼容);同时修复 /psy API 代理与首页 + 菜单层级。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-05 17:51:40 +08:00

262 lines
8.8 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 outlook synthesizes daily/weekly/monthly/yearly/lifetime scores and transits.
package outlook
import (
"fmt"
"math"
"time"
"github.com/yuxingu/digital-psychology/apps/api/internal/star/natal"
)
// Period is one fortune block.
type Period struct {
Key string `json:"key"`
Title string `json:"title"`
Score int `json:"score"`
Label string `json:"label"`
Dims map[string]int `json:"dims"`
Tip string `json:"tip"`
Lucky string `json:"lucky"`
Caution string `json:"caution"`
Focus string `json:"focus"`
}
// Transit is a simplified day transit tip relative to natal.
type Transit struct {
Key string `json:"key"`
Title string `json:"title"`
Aspect string `json:"aspect"`
Tip string `json:"tip"`
}
// Bundle holds all periods plus lifetime and transits.
type Bundle struct {
Daily Period `json:"daily"`
Weekly Period `json:"weekly"`
Monthly Period `json:"monthly"`
Yearly Period `json:"yearly"`
Lifetime Period `json:"lifetime"`
Transits []Transit `json:"transits"`
}
// Build returns fortune for natal chart as of asOf (date matters).
func Build(chart natal.Chart, asOf time.Time) Bundle {
if asOf.IsZero() {
asOf = time.Now()
}
sun := chart.Sun.Lon
moon := chart.Moon.Lon
daySeed := asOf.Year()*1000 + asOf.YearDay() + int(sun) + int(moon)
daily := period("daily", "今日运势", daySeed, sun, moon, chart,
[]string{"行动", "沟通", "情绪", "财运", "桃花"},
[]string{
fmt.Sprintf("发挥太阳「%s」的主动性,先完成一件小事。", chart.Sun.Sign),
fmt.Sprintf("照顾月亮「%s」的情绪需要,给自己缓冲。", chart.Moon.Sign),
"适合推进沟通:先复述再提方案。",
"宜整理待办与开销,给节奏一点秩序。",
"轻社交或独处充电均可,按电量选择。",
})
weekSeed := asOf.Year()*100 + isoWeek(asOf) + int(sun)/3
weekly := period("weekly", "本周运势", weekSeed, sun, moon, chart,
[]string{"事业", "关系", "学习", "休息", "决策"},
[]string{
"本周适合定一个可交付的小目标并公开承诺。",
"关系上主动约一次轻松同步,不谈对错。",
"学习/复盘:把灵感写成三条行动。",
"安排半日空档恢复精力。",
"重大决定用「可逆/不可逆」分类后再选速度。",
})
monthSeed := asOf.Year()*12 + int(asOf.Month()) + int(moon)/5
monthly := period("monthly", "本月运势", monthSeed, sun, moon, chart,
[]string{"感情", "事业", "财务", "健康节奏", "人际"},
[]string{
fmt.Sprintf("本月主题贴近「%s」:把热情落成节奏。", chart.Sun.Sign),
"感情上说清需要,比猜测更有效。",
"财务宜做一次月度复盘,砍掉低价值开销。",
"作息与运动选可持续的小剂量。",
"拓展一个人际弱连接,可能带来信息增益。",
})
yearSeed := asOf.Year() + int(sun) + int(chart.Rise.Lon)/10
yearly := period("yearly", "今年运势", yearSeed, sun, moon, chart,
[]string{"成长主线", "关系课题", "事业方向", "财富节奏", "身心"},
[]string{
fmt.Sprintf("今年宜强化太阳「%s」优势,并补月亮「%s」的安全感建设。", chart.Sun.Sign, chart.Moon.Sign),
"关系课题:边界与亲密并重,写成说明书。",
"事业上选择能看到里程碑的路径。",
"财富:先稳现金流,再谈进取配置。",
"身心:季度体检式复盘情绪与睡眠。",
})
life := lifetime(chart)
tr := transits(chart, asOf)
return Bundle{
Daily: daily, Weekly: weekly, Monthly: monthly, Yearly: yearly,
Lifetime: life, Transits: tr,
}
}
func lifetime(chart natal.Chart) Period {
sat := bodyLon(chart, "saturn")
seed := int(chart.Sun.Lon) + int(sat)/2 + int(chart.Moon.Lon)/3
score := 62 + seed%28
stages := []string{
fmt.Sprintf("成长主线贴近太阳「%s」:用可见行动定义自我。", chart.Sun.Sign),
fmt.Sprintf("情感底色来自月亮「%s」:安全感与表达节奏需要长期经营。", chart.Moon.Sign),
fmt.Sprintf("对外姿态偏上升「%s」:第一印象可以主动校准。", chart.Rise.Sign),
}
tip := stages[seed%len(stages)] + " 人生阶段不必一次做完,按十年课题拆解更稳。"
return Period{
Key: "lifetime", Title: "一生运势摘要", Score: score, Label: scoreLabel(score),
Dims: map[string]int{
"love": 50 + seed%40, "career": 52 + (seed*3)%40,
"money": 48 + (seed*5)%42, "mood": 55 + (seed*7)%35,
},
Tip: tip, Focus: "人生阶段",
Lucky: "长期复盘 · 边界清晰",
Caution: "避免把阶段标签当成宿命;可调整节奏与选择。",
}
}
func transits(chart natal.Chart, asOf time.Time) []Transit {
// Approximate "transit" sun/moon using same ephemeris at asOf noon CST.
loc := time.FixedZone("CST", 8*3600)
local := time.Date(asOf.Year(), asOf.Month(), asOf.Day(), 12, 0, 0, 0, loc)
tChart, err := natal.Compute(local, strPtr("12:00"), placePtr(chart.PlaceLabel))
if err != nil {
return nil
}
tSun, tMoon := tChart.Sun.Lon, tChart.Moon.Lon
out := []Transit{}
out = append(out, transitHit("transit_sun_natal_sun", "行运太阳×本命太阳", tSun, chart.Sun.Lon,
"适合主动推进与自我表达相关的事。",
"宜复盘目标,不急着扩张战线。",
"注意沟通语气,先对齐再行动。"))
out = append(out, transitHit("transit_moon_natal_moon", "行运月亮×本命月亮", tMoon, chart.Moon.Lon,
"情绪敏感日,给自己更多缓冲。",
"适合温柔连结与休息充电。",
"避免情绪化决策,先写下来再说。"))
out = append(out, transitHit("transit_sun_natal_moon", "行运太阳×本命月亮", tSun, chart.Moon.Lon,
"外在节奏触动内在需求,适合说清感受。",
"工作与情绪平衡日,留一点空白。",
"别硬撑社交,按电量选择场合。"))
return out
}
func transitHit(key, title string, a, b float64, conj, soft, hard string) Transit {
diff := natal.AngleDiff(a, b)
aspect, tip := "弱互动", "节奏平常,按计划推进即可。"
switch {
case near(diff, 0, 8):
aspect, tip = "合相", conj
case near(diff, 60, 6) || near(diff, 120, 7):
aspect, tip = "和谐相位", soft
case near(diff, 90, 7) || near(diff, 180, 8):
aspect, tip = "张力相位", hard
}
return Transit{Key: key, Title: title, Aspect: aspect, Tip: tip}
}
func near(diff, target, orb float64) bool {
return math.Abs(diff-target) <= orb
}
func bodyLon(c natal.Chart, key string) float64 {
for _, p := range c.Planets {
if p.Key == key {
return p.Lon
}
}
return 0
}
func strPtr(s string) *string { return &s }
func placePtr(s string) *string {
if s == "" || s == "默认(未填出生地)" {
return nil
}
return &s
}
func period(key, title string, seed int, sun, moon float64, chart natal.Chart, focuses, tips []string) Period {
score := 55 + (seed*7+int(sun)+int(moon))%41 // 5595
love := 50 + (seed*3+int(moon))%46
career := 50 + (seed*5+int(sun))%46
money := 48 + (seed*11+int(chart.Rise.Lon))%47
mood := 52 + (seed*13+int(moon)/2)%45
i := seed % len(tips)
return Period{
Key: key, Title: title, Score: score, Label: scoreLabel(score),
Dims: map[string]int{
"love": love, "career": career, "money": money, "mood": mood,
},
Tip: tips[i], Focus: focuses[i%len(focuses)],
Lucky: luckyFrom(seed),
Caution: cautionFrom(seed, chart),
}
}
func scoreLabel(s int) string {
switch {
case s >= 85:
return "大吉"
case s >= 75:
return "吉"
case s >= 65:
return "中平偏吉"
case s >= 55:
return "平稳"
default:
return "需谨慎"
}
}
func luckyFrom(seed int) string {
colors := []string{"红色", "金色", "蓝色", "绿色", "紫色", "白色"}
nums := []string{"3", "6", "7", "8", "9"}
return colors[seed%len(colors)] + " · 数字 " + nums[seed%len(nums)]
}
func cautionFrom(seed int, chart natal.Chart) string {
cautions := []string{
"避免冲动承诺,尤其财务相关。",
fmt.Sprintf("「%s」能量过强时易急躁,先深呼吸再回复。", chart.Sun.Sign),
"少开多线任务,完成比完美重要。",
"情绪波动时先写下来,再找人聊。",
}
return cautions[seed%len(cautions)]
}
func isoWeek(t time.Time) int {
_, w := t.ISOWeek()
return w
}
// AsMap for JSON embedding.
func (b Bundle) AsMap() map[string]any {
tr := make([]map[string]any, 0, len(b.Transits))
for _, t := range b.Transits {
tr = append(tr, map[string]any{
"key": t.Key, "title": t.Title, "aspect": t.Aspect, "tip": t.Tip,
})
}
return map[string]any{
"daily": periodMap(b.Daily), "weekly": periodMap(b.Weekly),
"monthly": periodMap(b.Monthly), "yearly": periodMap(b.Yearly),
"lifetime": periodMap(b.Lifetime), "transits": tr,
}
}
func periodMap(p Period) map[string]any {
return map[string]any{
"key": p.Key, "title": p.Title, "score": p.Score, "label": p.Label,
"dims": p.Dims, "tip": p.Tip, "lucky": p.Lucky, "caution": p.Caution, "focus": p.Focus,
}
}