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:
jackyu66git
2026-08-03 11:37:53 +08:00
co-authored by Cursor
parent 15a9db374a
commit bd22d9dddd
248 changed files with 26309 additions and 842 deletions
+261
View File
@@ -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 // 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,
}
}
@@ -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"])
}
}