feat: P1 合盘/星座/问答与测测完整设计包及模拟器取证工具
落地 synastry/star/ask API 与 H5 页面,补齐 cece-frontend-re complete-design 证据文档,并加入 Android 模拟器截图抓取脚本。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -0,0 +1,261 @@
|
||||
// Package fortune synthesizes daily/weekly/monthly/yearly/lifetime scores and transits.
|
||||
package fortune
|
||||
|
||||
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 // 55–95
|
||||
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,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
package fortune
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/yuxingu/digital-psychology/apps/api/internal/star/natal"
|
||||
)
|
||||
|
||||
func TestBuildPeriods(t *testing.T) {
|
||||
chart, err := natal.Compute(time.Date(1990, 5, 12, 0, 0, 0, 0, time.UTC), nil, nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
asOf := time.Date(2026, 8, 2, 0, 0, 0, 0, time.UTC)
|
||||
a := Build(chart, asOf)
|
||||
b := Build(chart, asOf)
|
||||
if a.Daily.Score != b.Daily.Score || a.Yearly.Tip != b.Yearly.Tip {
|
||||
t.Fatal("not deterministic")
|
||||
}
|
||||
if a.Daily.Score < 50 || a.Weekly.Dims["love"] == 0 {
|
||||
t.Fatalf("bad scores %+v", a.Daily)
|
||||
}
|
||||
m := a.AsMap()
|
||||
if m["monthly"] == nil || m["lifetime"] == nil {
|
||||
t.Fatal("missing monthly or lifetime")
|
||||
}
|
||||
tr, ok := m["transits"].([]map[string]any)
|
||||
if !ok || len(tr) == 0 {
|
||||
t.Fatalf("transits missing: %#v", m["transits"])
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user