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
+337
View File
@@ -0,0 +1,337 @@
// Package star builds 星座 reports (natal chart · fortune · deep copy).
package star
import (
"fmt"
"time"
"github.com/yuxingu/digital-psychology/apps/api/internal/star/fortune"
"github.com/yuxingu/digital-psychology/apps/api/internal/star/natal"
)
// Output is free summary + gated detail.
type Output struct {
Summary map[string]any `json:"summary"`
Detail map[string]any `json:"detail"`
}
// BuildOpts configures report generation.
type BuildOpts struct {
Birth time.Time
BirthTime *string
BirthPlace *string
Name string
AsOf time.Time // fortune anchor; zero = now
}
// Build generates StarProfile from birth date (compat wrapper).
func Build(birth time.Time, birthTime *string, displayName string) (Output, error) {
return BuildWith(BuildOpts{Birth: birth, BirthTime: birthTime, Name: displayName})
}
// BuildWith generates a full star report.
func BuildWith(opts BuildOpts) (Output, error) {
name := opts.Name
if name == "" {
name = "你"
}
chart, err := natal.Compute(opts.Birth, opts.BirthTime, opts.BirthPlace)
if err != nil {
return Output{}, err
}
sun := chart.Sun
moon := chart.Moon
rise := chart.Rise
pack := packs[sun.SignKey]
if pack.Label == "" {
pack = defaultPack(signMeta{Key: sun.SignKey, Label: sun.Sign, Element: sun.Element, Modality: sun.Modality})
}
moonPack := packs[moon.SignKey]
risePack := packs[rise.SignKey]
signCards := []map[string]any{
signCard("sun", "太阳星座", sun, pack.SunTeaser, pack.Keywords),
signCard("moon", "月亮星座", moon, fmt.Sprintf("情绪底色更偏「%s」", moon.Sign), moonPack.Keywords),
signCard("rise", "上升星座", rise, fmt.Sprintf("第一印象更偏「%s」", rise.Sign), risePack.Keywords),
}
dims := []map[string]any{
{"key": "sun", "title": "太阳风格", "teaser": pack.SunTeaser, "score": pack.Scores["sun"]},
{"key": "moon", "title": "月亮节奏", "teaser": fmt.Sprintf("情绪底色更偏「%s」", moon.Sign), "score": pack.Scores["moon"]},
{"key": "rise", "title": "上升表达", "teaser": fmt.Sprintf("第一印象更偏「%s」", rise.Sign), "score": pack.Scores["rise"]},
{"key": "relation", "title": "关系互动", "teaser": pack.RelationTeaser, "score": pack.Scores["relation"]},
{"key": "career", "title": "事业节奏", "teaser": pack.CareerTeaser, "score": pack.Scores["career"]},
{"key": "growth", "title": "成长方向", "teaser": pack.GrowthTeaser, "score": pack.Scores["growth"]},
}
asOf := opts.AsOf
if asOf.IsZero() {
asOf = time.Now()
}
fort := fortune.Build(chart, asOf)
daily := fort.Daily
planetsOut := make([]map[string]any, 0, len(chart.Planets))
for _, p := range chart.Planets {
planetsOut = append(planetsOut, map[string]any{
"key": p.Key, "title": p.Title, "sign": p.Sign, "sign_key": p.SignKey,
"degree": fmt.Sprintf("%.1f°", p.Degree), "house": p.House,
"element": p.Element, "modality": p.Modality, "lon": p.Lon,
})
}
housesOut := make([]map[string]any, 0, len(chart.Houses))
for _, h := range chart.Houses {
// Whole-sign house cusp ≈ asc sign start + (n-1)*30
cusp := float64((signIndexOf(h.Key)) * 30)
housesOut = append(housesOut, map[string]any{
"num": h.Num, "sign": h.Sign, "sign_key": h.Key, "cusp_lon": cusp,
})
}
aspects := natal.Aspects(chart)
aspectMaps := natal.AspectsAsMaps(aspects)
previewN := min(4, len(aspectMaps))
aspectPreview := aspectMaps[:previewN]
summary := map[string]any{
"title": "星座·基础",
"style_label": pack.Label,
"headline": fmt.Sprintf("%s的星座更偏「%s」", name, pack.Label),
"one_liner": pack.OneLiner,
"overview": fmt.Sprintf("%s%s(太阳:%s;月亮:%s;上升:%s)。", name, pack.Overview, sun.Sign, moon.Sign, rise.Sign),
"life_tip": pack.LifeTip,
"keywords": pack.Keywords,
"dimensions": dims,
"sign_cards": signCards,
"sun_sign": sun.Sign,
"moon_sign": moon.Sign,
"rise_sign": rise.Sign,
"chart": map[string]any{
"note": chart.Note, "place": chart.PlaceLabel,
"has_time": chart.HasTime, "has_place": chart.HasPlace,
"lat": chart.Lat, "lng": chart.Lng,
"asc_lon": rise.Lon, "houses": housesOut,
},
"planets": planetsOut,
"aspects_preview": aspectPreview,
"fortune": fort.AsMap(),
"transits": fort.AsMap()["transits"],
"daily_soft": map[string]any{
"title": daily.Title, "focus": daily.Focus, "tip": daily.Tip,
"energy": daily.Score, "note": daily.Label, "score": daily.Score, "label": daily.Label,
},
"strengths_preview": pack.Strengths[:min(3, len(pack.Strengths))],
"blind_spots_preview": []string{"完整相位与年运详解见深度版。"},
"interaction_tags": []string{
fmt.Sprintf("太阳·%s", sun.Sign),
fmt.Sprintf("月亮·%s", moon.Sign),
fmt.Sprintf("上升·%s", rise.Sign),
pack.Label,
},
}
aspectBullets := make([]string, 0, min(8, len(aspects)))
for i, a := range aspects {
if i >= 8 {
break
}
aspectBullets = append(aspectBullets, a.Label)
}
detail := map[string]any{
"title": "星座·完整分析",
"aspects": aspectMaps,
"sections": []map[string]any{
section("太阳星座 · "+sun.Sign, pack.SunDeep, pack.SunBullets),
section("月亮星座 · "+moon.Sign, fmt.Sprintf("情绪底色带有「%s」特质:%s", moon.Sign, moonPack.MoonDeep), moonPack.MoonBullets),
section("上升星座 · "+rise.Sign, fmt.Sprintf("对外第一印象偏「%s」:%s", rise.Sign, risePack.RiseDeep), risePack.RiseBullets),
section("行星相位要点", "主要相位帮助理解行动、情感与责任节奏之间的张力与互补。", aspectBullets),
section("行星落座", "落座与宫位是日常节奏的参照。", planetBullets(chart)),
section("年运详解", fort.Yearly.Tip+" "+fort.Yearly.Caution, []string{
fmt.Sprintf("综合分 %d%s", fort.Yearly.Score, fort.Yearly.Label),
fmt.Sprintf("感情 %d · 事业 %d · 财务 %d · 心情 %d", fort.Yearly.Dims["love"], fort.Yearly.Dims["career"], fort.Yearly.Dims["money"], fort.Yearly.Dims["mood"]),
fort.Yearly.Lucky,
}),
section("一生运势", fort.Lifetime.Tip, []string{fort.Lifetime.Caution, fort.Lifetime.Lucky}),
section("关系互动", pack.RelationDeep, pack.RelationBullets),
section("事业与学习节奏", pack.CareerDeep, pack.CareerBullets),
section("成长方向", pack.GrowthDeep, pack.GrowthBullets),
},
"strengths": pack.Strengths,
"blind_spots": pack.BlindSpots,
"growth_plan": []map[string]any{
{"phase": "本周", "focus": pack.PlanWeek},
{"phase": "本月", "focus": pack.PlanMonth},
{"phase": "长期", "focus": pack.PlanLong},
},
"conversation_scripts": pack.Scripts,
"faq": pack.FAQ,
"behavior_pattern": pack.SunDeep,
"relation_style": pack.RelationDeep,
"growth_direction": pack.GrowthDeep,
"fortune_detail": fort.AsMap(),
}
return Output{Summary: summary, Detail: detail}, nil
}
func signIndexOf(key string) int {
for i, s := range []string{
"aries", "taurus", "gemini", "cancer", "leo", "virgo",
"libra", "scorpio", "sagittarius", "capricorn", "aquarius", "pisces",
} {
if s == key {
return i
}
}
return 0
}
func planetBullets(chart natal.Chart) []string {
out := make([]string, 0, 6)
for _, key := range []string{"mercury", "venus", "mars", "jupiter", "saturn"} {
for _, p := range chart.Planets {
if p.Key == key {
out = append(out, fmt.Sprintf("%s在%s第%d宫", p.Title, p.Sign, p.House))
break
}
}
}
return out
}
func signCard(key, title string, b natal.Body, teaser string, keywords []string) map[string]any {
ks := keywords
if len(ks) > 3 {
ks = ks[:3]
}
return map[string]any{
"key": key, "title": title, "label": b.Sign,
"element": b.Element, "modality": b.Modality,
"teaser": teaser, "keywords": ks,
}
}
func section(title, body string, bullets []string) map[string]any {
return map[string]any{"title": title, "body": body, "bullets": bullets}
}
func min(a, b int) int {
if a < b {
return a
}
return b
}
// SignLabels returns sun/moon/rise labels (uses natal engine).
func SignLabels(birth time.Time, birthTime *string) (sun, moon, rise string) {
return SignLabelsPlace(birth, birthTime, nil)
}
// SignLabelsPlace includes birth place for rising accuracy.
func SignLabelsPlace(birth time.Time, birthTime, place *string) (sun, moon, rise string) {
c, err := natal.Compute(birth, birthTime, place)
if err != nil {
return "", "", ""
}
return c.Sun.Sign, c.Moon.Sign, c.Rise.Sign
}
// NatalChart exposes chart for relation/synastry.
func NatalChart(birth time.Time, birthTime, place *string) (natal.Chart, error) {
return natal.Compute(birth, birthTime, place)
}
type signMeta struct {
Key, Label, Element, Modality string
}
type pack struct {
Label, OneLiner, Overview, LifeTip string
Keywords []string
Scores map[string]int
SunTeaser, RelationTeaser, CareerTeaser, GrowthTeaser string
SunDeep, MoonDeep, RiseDeep, RelationDeep, CareerDeep, GrowthDeep string
SunBullets, MoonBullets, RiseBullets, RelationBullets, CareerBullets, GrowthBullets []string
Strengths, BlindSpots, Scripts []string
PlanWeek, PlanMonth, PlanLong string
FAQ []map[string]string
}
var packs = map[string]pack{}
func init() {
for _, s := range []signMeta{
{"aries", "白羊", "火", "开创"}, {"taurus", "金牛", "土", "固定"}, {"gemini", "双子", "风", "变动"},
{"cancer", "巨蟹", "水", "开创"}, {"leo", "狮子", "火", "固定"}, {"virgo", "处女", "土", "变动"},
{"libra", "天秤", "风", "开创"}, {"scorpio", "天蝎", "水", "固定"}, {"sagittarius", "射手", "火", "变动"},
{"capricorn", "摩羯", "土", "开创"}, {"aquarius", "水瓶", "风", "固定"}, {"pisces", "双鱼", "水", "变动"},
} {
packs[s.Key] = defaultPack(s)
}
packs["aries"] = enrich(packs["aries"], "开创行动者", "先动起来,再在行动里想清楚。",
"你容易被新目标点燃,讨厌拖沓。优势是启动快;需要留意的是收尾与倾听。")
packs["taurus"] = enrich(packs["taurus"], "稳健沉淀者", "你重视踏实与感官舒适,变化太快会消耗你。",
"你擅长把事情做稳做久。关系与工作里都需要可预期的节奏。")
packs["gemini"] = enrich(packs["gemini"], "灵活连接者", "你靠好奇与对话充电,也容易分心。",
"信息与交流是你的养分。把想法收成一个可交付的小闭环,会更有成就感。")
packs["cancer"] = enrich(packs["cancer"], "细腻守护者", "你对情绪与归属很敏感,安全比热闹更重要。",
"你擅长照顾氛围与关系。记得也把自己的需要说清楚,而不是只默默撑着。")
packs["leo"] = enrich(packs["leo"], "热烈表达者", "你需要被看见,也愿意照亮别人。",
"热情与表达是你的名片。把认可需求说成具体请求,关系会更顺。")
packs["virgo"] = enrich(packs["virgo"], "细致完善者", "你看见细节与改进空间,也容易自我要求过高。",
"把「足够好」纳入标准,你会轻松很多,交付也会更快。")
packs["libra"] = enrich(packs["libra"], "平衡协调者", "你追求公平与和谐,有时会为难自己。",
"协调是天赋。重要决定里请给自己一票,而不只是各方折中。")
packs["scorpio"] = enrich(packs["scorpio"], "深潜洞察者", "你看重真诚与深度,讨厌浮于表面。",
"信任慢、一旦建立则很深。练习用语言同步感受,减少猜疑消耗。")
packs["sagittarius"] = enrich(packs["sagittarius"], "开阔探索者", "你需要视野与意义,讨厌被框死。",
"探索欲强。给自由一点结构,热情才能变成持续作品。")
packs["capricorn"] = enrich(packs["capricorn"], "负责攀登者", "你看长远目标,愿意为结果负责。",
"担当是优势。学会求助与休息,攀登才可持续。")
packs["aquarius"] = enrich(packs["aquarius"], "独特思考者", "你重视独立与新意,也需要被理解。",
"独特视角是礼物。把想法翻译成别人跟得上的一步行动。")
packs["pisces"] = enrich(packs["pisces"], "共感想象者", "你感受力强,边界容易被情绪浪潮冲开。",
"共情是天赋。区分「理解」与「承包」,你会更稳。")
}
func defaultPack(s signMeta) pack {
return pack{
Label: s.Label + "风格探索者", OneLiner: "用星座认识自己的节奏与运势起伏。",
Overview: "在星座框架里,你的表达与需求有独特侧重。",
LifeTip: "本周选一件小事完整做完,并告诉亲近的人你的真实需要。",
Keywords: []string{s.Label, s.Element + "象", s.Modality, "星座"},
Scores: map[string]int{"sun": 78, "moon": 70, "rise": 72, "relation": 74, "career": 73, "growth": 75},
SunTeaser: "核心驱动力清晰,行动有自己的节拍。", RelationTeaser: "关系里需要被理解与尊重节奏。",
CareerTeaser: "适合发挥你风格优势的场景。", GrowthTeaser: "下一步是看见盲区并小步调整。",
SunDeep: "太阳星座描述你的核心动机与自我表达。",
MoonDeep: "月亮星座指向情绪调节与安全感来源。",
RiseDeep: "上升星座影响别人对你的第一印象。",
RelationDeep: "关系中把需求说具体,比期待对方「应该懂」更有效。",
CareerDeep: "工作上优先发挥你的风格优势,并用小里程碑对抗拖延。",
GrowthDeep: "成长是认识模式后做可验证的小调整。",
SunBullets: []string{"核心动机可被命名", "表达有风格偏好", "适合自我探索"},
MoonBullets: []string{"情绪需要出口", "安全感来源因人而异", "独处或连接可充电"},
RiseBullets: []string{"第一印象可调节", "外显≠全部自我", "可练习温和表达"},
RelationBullets: []string{"需求具体化", "尊重双方节奏", "冲突先复述再方案"},
CareerBullets: []string{"发挥风格优势", "小步交付", "复盘节奏"},
GrowthBullets: []string{"看见模式", "小步验证", "结合运势调整"},
Strengths: []string{"风格清晰", "可探索性强", "利于自我对话"},
BlindSpots: []string{"标签固化", "忽略情境差异", "过度解读"},
Scripts: []string{"我想先说清我的节奏,再听你的。", "我不是冷淡,我需要一点整理时间。", "我们共同目标是……,下一步只定一件事。"},
PlanWeek: "用三句话写下:我的优势 / 我的消耗点 / 我本周要试的一小步。",
PlanMonth: "在关系或工作中练习两次「先复述对方,再提需要」。",
PlanLong: "建立个人节奏手册:什么充电、什么耗电、如何请求支持。",
FAQ: []map[string]string{
{"q": "运势是预测吗?", "a": "运势分与建议帮助你调整节奏与决策,请结合现实判断,勿作唯一依据。"},
{"q": "星盘准吗?", "a": "出生时与出生地越完整,上升与宫位越贴近;算法为可复现近似星历。"},
},
}
}
func enrich(base pack, label, one, overview string) pack {
base.Label = label
base.OneLiner = one
base.Overview = overview
base.Keywords = []string{label, "星座", "运势"}
return base
}
+83
View File
@@ -0,0 +1,83 @@
package star
import (
"encoding/json"
"strings"
"testing"
"time"
)
func TestBuildDeterministic(t *testing.T) {
birth := time.Date(1990, 5, 12, 0, 0, 0, 0, time.UTC)
asOf := time.Date(2026, 8, 2, 12, 0, 0, 0, time.UTC)
opts := BuildOpts{Birth: birth, Name: "小愈", AsOf: asOf}
a, err := BuildWith(opts)
if err != nil {
t.Fatal(err)
}
b, err := BuildWith(opts)
if err != nil {
t.Fatal(err)
}
if a.Summary["headline"] != b.Summary["headline"] {
t.Fatal("not deterministic")
}
if a.Summary["sun_sign"] == nil || a.Detail["sections"] == nil {
t.Fatal("missing fields")
}
if a.Summary["sign_cards"] == nil || a.Summary["fortune"] == nil || a.Summary["planets"] == nil {
t.Fatal("missing sign_cards, fortune or planets")
}
if a.Summary["aspects_preview"] == nil {
t.Fatal("missing aspects_preview")
}
chart, _ := a.Summary["chart"].(map[string]any)
if chart["asc_lon"] == nil {
t.Fatal("missing chart.asc_lon")
}
if a.Detail["aspects"] == nil {
t.Fatal("missing detail.aspects")
}
raw, _ := json.Marshal(a)
blob := string(raw)
for _, bad := range []string{"算命"} {
if strings.Contains(blob, bad) {
t.Fatalf("forbidden %q", bad)
}
}
}
func TestBuildWithBirthTimeChangesRise(t *testing.T) {
birth := time.Date(1990, 5, 12, 0, 0, 0, 0, time.UTC)
bt := "14:30"
place := "北京"
a, err := BuildWith(BuildOpts{Birth: birth, Name: "我"})
if err != nil {
t.Fatal(err)
}
b, err := BuildWith(BuildOpts{Birth: birth, BirthTime: &bt, BirthPlace: &place, Name: "我"})
if err != nil {
t.Fatal(err)
}
if a.Summary["planets"] == nil || b.Summary["planets"] == nil {
t.Fatal("missing planets")
}
_ = a.Summary["rise_sign"]
_ = b.Summary["rise_sign"]
}
func TestBuildFortuneScores(t *testing.T) {
birth := time.Date(1990, 5, 12, 0, 0, 0, 0, time.UTC)
out, err := BuildWith(BuildOpts{Birth: birth, Name: "测", AsOf: time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC)})
if err != nil {
t.Fatal(err)
}
fort, ok := out.Summary["fortune"].(map[string]any)
if !ok {
t.Fatal("fortune missing")
}
daily, ok := fort["daily"].(map[string]any)
if !ok || daily["score"] == nil {
t.Fatal("daily score missing")
}
}
@@ -0,0 +1,161 @@
// Package ephemeris computes tropical ecliptic longitudes.
// Default backend: Swiss Ephemeris Moshier (no external ephe files).
// NOTE: go-swisseph is AGPL-3.0; commercial deployment needs Astrodienst SE license or AGPL compliance.
package ephemeris
import (
"fmt"
"math"
"os"
"sync"
"time"
swe "github.com/tejzpr/go-swisseph"
)
// Body keys match natal chart keys.
const (
BodySun = "sun"
BodyMoon = "moon"
BodyMercury = "mercury"
BodyVenus = "venus"
BodyMars = "mars"
BodyJupiter = "jupiter"
BodySaturn = "saturn"
BodyUranus = "uranus"
BodyNeptune = "neptune"
BodyPluto = "pluto"
)
var bodyID = map[string]int32{
BodySun: swe.Sun,
BodyMoon: swe.Moon,
BodyMercury: swe.Mercury,
BodyVenus: swe.Venus,
BodyMars: swe.Mars,
BodyJupiter: swe.Jupiter,
BodySaturn: swe.Saturn,
BodyUranus: swe.Uranus,
BodyNeptune: swe.Neptune,
BodyPluto: swe.Pluto,
}
// PlanetOrder is the standard body sequence (excluding ASC).
var PlanetOrder = []string{
BodySun, BodyMoon, BodyMercury, BodyVenus, BodyMars,
BodyJupiter, BodySaturn, BodyUranus, BodyNeptune, BodyPluto,
}
var (
once sync.Once
iflag int32 = swe.FlagMoseph
epheOK bool
)
func initSE() {
once.Do(func() {
path := os.Getenv("SE_EPHE_PATH")
if path == "" {
path = os.Getenv("EPHEMERIS_PATH")
}
if path != "" {
swe.SetEphePath(path)
iflag = swe.FlagSwieph
epheOK = true
} else {
// Moshier: no files required
iflag = swe.FlagMoseph
epheOK = true
}
})
}
// Backend returns "moshier" or "swiss" for diagnostics.
func Backend() string {
initSE()
if iflag == swe.FlagSwieph {
return "swiss"
}
return "moshier"
}
// JulianDayUT converts a UTC instant to Julian Day (UT).
func JulianDayUT(utc time.Time) float64 {
initSE()
utc = utc.UTC()
sec := float64(utc.Second()) + float64(utc.Nanosecond())/1e9
dret, err := swe.UtcToJd(
int32(utc.Year()), int32(utc.Month()), int32(utc.Day()),
int32(utc.Hour()), int32(utc.Minute()), sec, swe.GregCal,
)
if err != nil {
// Fallback Meeus-style JD
return julianDayFallback(utc)
}
return dret[1] // UT
}
// PlanetLon returns tropical ecliptic longitude in degrees [0,360).
func PlanetLon(jdUt float64, body string) (float64, error) {
initSE()
ipl, ok := bodyID[body]
if !ok {
return 0, fmt.Errorf("unknown body %q", body)
}
r := swe.CalcUT(jdUt, ipl, iflag)
if r.Flag < 0 {
return 0, fmt.Errorf("swe calc %s: %s", body, r.Error)
}
return norm360(r.Data[0]), nil
}
// Ascendant returns tropical ASC longitude degrees using Placidus cusps (Points[0]).
// Callers apply Whole Sign houses from this ASC.
func Ascendant(jdUt, lat, lng float64) (float64, error) {
initSE()
h := swe.Houses(jdUt, lat, lng, 'P')
if h.Flag < 0 || len(h.Points) < 1 {
return 0, fmt.Errorf("swe houses failed flag=%d", h.Flag)
}
return norm360(h.Points[0]), nil
}
// AllPlanetLons returns longitudes for PlanetOrder.
func AllPlanetLons(jdUt float64) (map[string]float64, error) {
out := make(map[string]float64, len(PlanetOrder))
for _, k := range PlanetOrder {
lon, err := PlanetLon(jdUt, k)
if err != nil {
return nil, err
}
out[k] = lon
}
return out, nil
}
func norm360(x float64) float64 {
x = math.Mod(x, 360)
if x < 0 {
x += 360
}
return x
}
func julianDayFallback(t time.Time) float64 {
y := t.Year()
m := int(t.Month())
d := float64(t.Day()) + float64(t.Hour())/24 + float64(t.Minute())/1440 + float64(t.Second())/86400
if m <= 2 {
y--
m += 12
}
A := y / 100
B := 2 - A + A/4
return math.Floor(365.25*float64(y+4716)) + math.Floor(30.6001*float64(m+1)) + d + float64(B) - 1524.5
}
// Ready reports whether ephemeris init succeeded (always true for Moshier).
func Ready() bool {
initSE()
return epheOK
}
@@ -0,0 +1,44 @@
package ephemeris
import (
"testing"
"time"
)
func TestSunMay1990(t *testing.T) {
if !Ready() {
t.Fatal("ephemeris not ready")
}
// 1990-05-12 10:30 CST = 02:30 UTC
utc := time.Date(1990, 5, 12, 2, 30, 0, 0, time.UTC)
jd := JulianDayUT(utc)
lon, err := PlanetLon(jd, BodySun)
if err != nil {
t.Fatal(err)
}
// ~51° Taurus
if lon < 48 || lon > 55 {
t.Fatalf("sun lon=%.2f want ~51°", lon)
}
asc, err := Ascendant(jd, 31.23, 121.47)
if err != nil {
t.Fatal(err)
}
if asc < 0 || asc >= 360 {
t.Fatalf("bad asc %.2f", asc)
}
if Backend() == "" {
t.Fatal("empty backend")
}
}
func TestAllPlanetLons(t *testing.T) {
jd := JulianDayUT(time.Date(2000, 1, 1, 12, 0, 0, 0, time.UTC))
m, err := AllPlanetLons(jd)
if err != nil {
t.Fatal(err)
}
if len(m) != len(PlanetOrder) {
t.Fatalf("got %d", len(m))
}
}
+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"])
}
}
+90
View File
@@ -0,0 +1,90 @@
package natal
import (
"fmt"
"math"
"sort"
)
// Aspect is a major aspect between two chart points.
type Aspect struct {
A string `json:"a"`
B string `json:"b"`
ATitle string `json:"a_title"`
BTitle string `json:"b_title"`
Type string `json:"type"` // conjunction|sextile|square|trine|opposition
Angle float64 `json:"angle"`
Orb float64 `json:"orb"`
Label string `json:"label"`
}
var aspectDefs = []struct {
Type string
Angle float64
Orb float64
Label string
}{
{"conjunction", 0, 8, "合相"},
{"sextile", 60, 6, "六合"},
{"square", 90, 7, "刑相"},
{"trine", 120, 7, "拱相"},
{"opposition", 180, 8, "对冲"},
}
// majorKeys used for aspect table (外行星可选由前端过滤).
var majorKeys = []string{"sun", "moon", "rise", "mercury", "venus", "mars", "jupiter", "saturn"}
// Aspects computes major aspects among primary bodies.
func Aspects(chart Chart) []Aspect {
byKey := map[string]Body{}
for _, p := range chart.Planets {
byKey[p.Key] = p
}
var bodies []Body
for _, k := range majorKeys {
if b, ok := byKey[k]; ok {
bodies = append(bodies, b)
}
}
out := make([]Aspect, 0)
for i := 0; i < len(bodies); i++ {
for j := i + 1; j < len(bodies); j++ {
a, b := bodies[i], bodies[j]
diff := AngleDiff(a.Lon, b.Lon)
for _, def := range aspectDefs {
orb := math.Abs(diff - def.Angle)
if orb <= def.Orb {
out = append(out, Aspect{
A: a.Key, B: b.Key, ATitle: a.Title, BTitle: b.Title,
Type: def.Type, Angle: def.Angle, Orb: round1(orb),
Label: fmt.Sprintf("%s%s%s(容许%.1f°)", a.Title, def.Label, b.Title, orb),
})
break
}
}
}
}
sort.Slice(out, func(i, j int) bool {
if out[i].Orb != out[j].Orb {
return out[i].Orb < out[j].Orb
}
return out[i].Label < out[j].Label
})
return out
}
// AspectsAsMaps for JSON embedding.
func AspectsAsMaps(list []Aspect) []map[string]any {
out := make([]map[string]any, 0, len(list))
for _, a := range list {
out = append(out, map[string]any{
"a": a.A, "b": a.B, "a_title": a.ATitle, "b_title": a.BTitle,
"type": a.Type, "angle": a.Angle, "orb": a.Orb, "label": a.Label,
})
}
return out
}
func round1(x float64) float64 {
return math.Round(x*10) / 10
}
@@ -0,0 +1,28 @@
package natal
import (
"testing"
"time"
)
func TestAspectsDeterministic(t *testing.T) {
c, err := Compute(time.Date(1990, 5, 12, 0, 0, 0, 0, time.UTC), strPtr("12:00"), strPtr("北京"))
if err != nil {
t.Fatal(err)
}
a := Aspects(c)
b := Aspects(c)
if len(a) == 0 {
t.Fatal("expected some aspects")
}
if len(a) != len(b) || a[0].Label != b[0].Label {
t.Fatalf("not deterministic: %+v vs %+v", a[0], b[0])
}
for _, asp := range a {
if asp.Orb < 0 || asp.Orb > 8.1 {
t.Fatalf("orb out of range: %+v", asp)
}
}
}
func strPtr(s string) *string { return &s }
+148
View File
@@ -0,0 +1,148 @@
package natal
import (
"strings"
"unicode/utf8"
)
// City is a built-in birth place with WGS84 coordinates.
type City struct {
Name string
Lat float64
Lng float64
}
// Cities lists common CN cities for MVP geocode (no network).
var Cities = []City{
{"北京", 39.9042, 116.4074},
{"上海", 31.2304, 121.4737},
{"广州", 23.1291, 113.2644},
{"深圳", 22.5431, 114.0579},
{"杭州", 30.2741, 120.1551},
{"成都", 30.5728, 104.0668},
{"重庆", 29.5630, 106.5516},
{"武汉", 30.5928, 114.3055},
{"西安", 34.3416, 108.9398},
{"南京", 32.0603, 118.7969},
{"天津", 39.3434, 117.3616},
{"苏州", 31.2989, 120.5853},
{"长沙", 28.2282, 112.9388},
{"郑州", 34.7466, 113.6254},
{"青岛", 36.0671, 120.3826},
{"大连", 38.9140, 121.6147},
{"厦门", 24.4798, 118.0894},
{"福州", 26.0745, 119.2965},
{"昆明", 25.0389, 102.7183},
{"贵阳", 26.6470, 106.6302},
{"南宁", 22.8170, 108.3665},
{"海口", 20.0440, 110.1999},
{"哈尔滨", 45.8038, 126.5349},
{"长春", 43.8171, 125.3235},
{"沈阳", 41.8057, 123.4315},
{"石家庄", 38.0428, 114.5149},
{"太原", 37.8706, 112.5489},
{"济南", 36.6512, 117.1201},
{"合肥", 31.8206, 117.2272},
{"南昌", 28.6820, 115.8579},
{"兰州", 36.0611, 103.8343},
{"乌鲁木齐", 43.8256, 87.6168},
{"拉萨", 29.6520, 91.1721},
{"呼和浩特", 40.8414, 111.7519},
{"银川", 38.4872, 106.2309},
{"西宁", 36.6171, 101.7782},
{"香港", 22.3193, 114.1694},
{"澳门", 22.1987, 113.5439},
{"台北", 25.0330, 121.5654},
{"宁波", 29.8683, 121.5440},
{"无锡", 31.4912, 120.3119},
{"佛山", 23.0215, 113.1214},
{"东莞", 23.0207, 113.7518},
{"温州", 27.9943, 120.6994},
{"泉州", 24.8741, 118.6759},
{"珠海", 22.2710, 113.5767},
}
// DefaultCoords is used when place is missing (东八区中部近似).
const DefaultLat = 30.0
const DefaultLng = 114.0
// ResolvePlace returns lat/lng and whether a named city matched.
// Accepts plain city ("杭州") or 省市区 ("浙江省 杭州市 西湖区" / "北京市 市辖区 朝阳区").
func ResolvePlace(place string) (lat, lng float64, matched string, ok bool) {
place = strings.TrimSpace(place)
if place == "" {
return DefaultLat, DefaultLng, "", false
}
// exact city table hit
for _, c := range Cities {
if c.Name == place {
return c.Lat, c.Lng, c.Name, true
}
}
tokens := splitPlace(place)
// prefer matching city-level tokens (usually 2nd), then others
order := append([]string{}, tokens...)
if len(tokens) >= 2 {
order = append([]string{tokens[1]}, tokens[0])
if len(tokens) >= 3 {
order = append(order, tokens[2:]...)
}
}
for _, tok := range order {
if tok == "" || tok == "市辖区" || tok == "县" {
continue
}
key := normalizeAdmin(tok)
for _, c := range Cities {
if c.Name == key || strings.Contains(tok, c.Name) || strings.Contains(c.Name, key) {
return c.Lat, c.Lng, c.Name, true
}
}
}
// province-only fallback: use first token city if 直辖市
if len(tokens) > 0 {
key := normalizeAdmin(tokens[0])
for _, c := range Cities {
if c.Name == key {
return c.Lat, c.Lng, c.Name, true
}
}
}
return DefaultLat, DefaultLng, "", false
}
func splitPlace(s string) []string {
s = strings.ReplaceAll(s, "/", " ")
s = strings.ReplaceAll(s, "", " ")
s = strings.ReplaceAll(s, "", " ")
s = strings.ReplaceAll(s, ",", " ")
parts := strings.Fields(s)
out := make([]string, 0, len(parts))
for _, p := range parts {
p = strings.TrimSpace(p)
if p != "" {
out = append(out, p)
}
}
return out
}
func normalizeAdmin(s string) string {
s = strings.TrimSpace(s)
suffixes := []string{"特别行政区", "维吾尔自治区", "壮族自治区", "回族自治区", "自治区", "省", "市", "地区", "盟"}
for _, suf := range suffixes {
if strings.HasSuffix(s, suf) && utf8.RuneCountInString(s) > utf8.RuneCountInString(suf) {
return strings.TrimSuffix(s, suf)
}
}
return s
}
// CityNames returns names for API/UI pickers.
func CityNames() []string {
out := make([]string, len(Cities))
for i, c := range Cities {
out[i] = c.Name
}
return out
}
+254
View File
@@ -0,0 +1,254 @@
// Package natal computes tropical whole-sign natal charts via Swiss Ephemeris.
package natal
import (
"fmt"
"math"
"time"
"github.com/yuxingu/digital-psychology/apps/api/internal/star/ephemeris"
)
// Body is a chart point.
type Body struct {
Key string `json:"key"`
Title string `json:"title"`
Lon float64 `json:"lon"`
SignKey string `json:"sign_key"`
Sign string `json:"sign"`
Degree float64 `json:"degree"` // 030 within sign
House int `json:"house"`
Element string `json:"element"`
Modality string `json:"modality"`
}
// House is a whole-sign house.
type House struct {
Num int `json:"num"`
Sign string `json:"sign"`
Key string `json:"sign_key"`
}
// Chart is a natal chart snapshot.
type Chart struct {
Sun, Moon, Rise Body
Planets []Body
Houses []House
Lat, Lng float64
PlaceLabel string
HasTime bool
HasPlace bool
Note string
// Instant is the UTC moment used for ephemeris (noon default when time missing).
Instant time.Time `json:"-"`
}
type signMeta struct {
Key, Label, Element, Modality string
}
var signs = []signMeta{
{"aries", "白羊", "火", "开创"},
{"taurus", "金牛", "土", "固定"},
{"gemini", "双子", "风", "变动"},
{"cancer", "巨蟹", "水", "开创"},
{"leo", "狮子", "火", "固定"},
{"virgo", "处女", "土", "变动"},
{"libra", "天秤", "风", "开创"},
{"scorpio", "天蝎", "水", "固定"},
{"sagittarius", "射手", "火", "变动"},
{"capricorn", "摩羯", "土", "开创"},
{"aquarius", "水瓶", "风", "固定"},
{"pisces", "双鱼", "水", "变动"},
}
var bodyTitles = map[string]string{
"sun": "太阳", "moon": "月亮", "rise": "上升",
"mercury": "水星", "venus": "金星", "mars": "火星",
"jupiter": "木星", "saturn": "土星", "uranus": "天王星",
"neptune": "海王星", "pluto": "冥王星",
}
// Compute builds a natal chart. birthTime is HH:MM local; place is city name.
func Compute(birthDate time.Time, birthTime *string, place *string) (Chart, error) {
lat, lng := DefaultLat, DefaultLng
placeLabel := "默认(未填出生地)"
hasPlace := false
if place != nil && *place != "" {
if la, ln, name, ok := ResolvePlace(*place); ok {
lat, lng, placeLabel, hasPlace = la, ln, name, true
} else {
placeLabel = *place + "(未匹配城市,用默认坐标)"
}
}
h, mi := 12, 0 // noon default when time missing
hasTime := false
if birthTime != nil && *birthTime != "" {
if hh, mm, ok := parseHM(*birthTime); ok {
h, mi, hasTime = hh, mm, true
}
}
// Treat civil time as UTC+8 for CN MVP (deterministic).
loc := time.FixedZone("CST", 8*3600)
local := time.Date(birthDate.Year(), birthDate.Month(), birthDate.Day(), h, mi, 0, 0, loc)
utc := local.UTC()
return ComputeAt(utc, lat, lng, placeLabel, hasTime, hasPlace)
}
// ComputeAt builds a chart for an exact UTC instant and coordinates.
func ComputeAt(utc time.Time, lat, lng float64, placeLabel string, hasTime, hasPlace bool) (Chart, error) {
utc = utc.UTC()
jd := ephemeris.JulianDayUT(utc)
lons, err := ephemeris.AllPlanetLons(jd)
if err != nil {
return Chart{}, fmt.Errorf("ephemeris: %w", err)
}
asc, err := ephemeris.Ascendant(jd, lat, lng)
if err != nil {
return Chart{}, fmt.Errorf("ephemeris asc: %w", err)
}
ch := ChartFromLons(lons, asc, lat, lng, placeLabel, hasTime, hasPlace)
ch.Instant = utc
ch.Note = fmt.Sprintf("热带黄道 · 整宫制 · 星历 %s。", ephemeris.Backend())
if !hasTime {
ch.Note += " 未填出生时,上升与宫位按正午估算。"
}
if !hasPlace {
ch.Note += " 填写出生地可提升上升准确度。"
}
return ch, nil
}
// ChartFromLons builds a whole-sign chart from body longitudes and ASC.
func ChartFromLons(lons map[string]float64, asc float64, lat, lng float64, placeLabel string, hasTime, hasPlace bool) Chart {
ascSignIdx := signIndex(asc)
mk := func(key string, lon float64) Body {
title := bodyTitles[key]
if title == "" {
title = key
}
idx := signIndex(lon)
s := signs[idx]
house := wholeSignHouse(ascSignIdx, idx)
return Body{
Key: key, Title: title, Lon: norm360(lon),
SignKey: s.Key, Sign: s.Label, Degree: math.Mod(norm360(lon), 30),
House: house, Element: s.Element, Modality: s.Modality,
}
}
sun := mk("sun", lons["sun"])
moon := mk("moon", lons["moon"])
rise := mk("rise", asc)
planets := []Body{
sun, moon, rise,
mk("mercury", lons["mercury"]),
mk("venus", lons["venus"]),
mk("mars", lons["mars"]),
mk("jupiter", lons["jupiter"]),
mk("saturn", lons["saturn"]),
mk("uranus", lons["uranus"]),
mk("neptune", lons["neptune"]),
mk("pluto", lons["pluto"]),
}
houses := make([]House, 12)
for i := 0; i < 12; i++ {
idx := (ascSignIdx + i) % 12
houses[i] = House{Num: i + 1, Sign: signs[idx].Label, Key: signs[idx].Key}
}
return Chart{
Sun: sun, Moon: moon, Rise: rise, Planets: planets, Houses: houses,
Lat: lat, Lng: lng, PlaceLabel: placeLabel, HasTime: hasTime, HasPlace: hasPlace,
}
}
// BodyLon returns longitude for a key, or 0.
func BodyLon(c Chart, key string) float64 {
for _, p := range c.Planets {
if p.Key == key {
return p.Lon
}
}
return 0
}
// SignMetaByKey returns label/element for a sign key.
func SignMetaByKey(key string) (label, element, modality string) {
for _, s := range signs {
if s.Key == key {
return s.Label, s.Element, s.Modality
}
}
return key, "", ""
}
// SignByIndex returns sign meta.
func SignByIndex(i int) (key, label, element, modality string) {
s := signs[((i%12)+12)%12]
return s.Key, s.Label, s.Element, s.Modality
}
func wholeSignHouse(ascIdx, bodyIdx int) int {
return ((bodyIdx-ascIdx)%12+12)%12 + 1
}
func signIndex(lon float64) int {
return int(math.Floor(norm360(lon)/30.0)) % 12
}
func parseHM(s string) (h, m int, ok bool) {
var hh, mm int
n, err := fmt.Sscanf(s, "%d:%d", &hh, &mm)
if err != nil || n < 1 || hh < 0 || hh > 23 {
return 0, 0, false
}
if n == 1 {
mm = 0
}
if mm < 0 || mm > 59 {
return 0, 0, false
}
return hh, mm, true
}
func norm360(x float64) float64 {
x = math.Mod(x, 360)
if x < 0 {
x += 360
}
return x
}
// AngleDiff returns smallest absolute ecliptic separation.
func AngleDiff(a, b float64) float64 {
d := math.Abs(norm360(a) - norm360(b))
if d > 180 {
d = 360 - d
}
return d
}
// MidpointLon returns the shorter-arc midpoint of two longitudes.
func MidpointLon(a, b float64) float64 {
a, b = norm360(a), norm360(b)
d := b - a
if d > 180 {
d -= 360
} else if d < -180 {
d += 360
}
return norm360(a + d/2)
}
// SignIndex is exported for overlay house mapping.
func SignIndex(lon float64) int { return signIndex(lon) }
// WholeSignHouse exported for overlay.
func WholeSignHouse(ascIdx, bodyIdx int) int { return wholeSignHouse(ascIdx, bodyIdx) }
@@ -0,0 +1,55 @@
package natal
import (
"testing"
"time"
)
func TestComputeDeterministic(t *testing.T) {
birth := time.Date(1990, 5, 12, 0, 0, 0, 0, time.UTC)
bt := "10:30"
place := "上海"
a, err := Compute(birth, &bt, &place)
if err != nil {
t.Fatal(err)
}
b, err := Compute(birth, &bt, &place)
if err != nil {
t.Fatal(err)
}
if a.Sun.Sign != b.Sun.Sign || a.Moon.Lon != b.Moon.Lon || a.Rise.Sign != b.Rise.Sign {
t.Fatal("not deterministic")
}
if len(a.Planets) < 8 || len(a.Houses) != 12 {
t.Fatalf("planets=%d houses=%d", len(a.Planets), len(a.Houses))
}
if a.Sun.Sign == "" {
t.Fatal("empty sun")
}
}
func TestResolvePlaceBeijing(t *testing.T) {
lat, lng, name, ok := ResolvePlace("北京")
if !ok || name != "北京" || lat < 39 || lng < 116 {
t.Fatalf("got %v %v %s %v", lat, lng, name, ok)
}
_, _, name2, ok2 := ResolvePlace("北京市 市辖区 朝阳区")
if !ok2 || name2 != "北京" {
t.Fatalf("pca resolve got %s %v", name2, ok2)
}
_, _, name3, ok3 := ResolvePlace("浙江省 杭州市 西湖区")
if !ok3 || name3 != "杭州" {
t.Fatalf("hangzhou resolve got %s %v", name3, ok3)
}
}
func TestSunSignMay(t *testing.T) {
c, err := Compute(time.Date(1990, 5, 12, 0, 0, 0, 0, time.UTC), nil, nil)
if err != nil {
t.Fatal(err)
}
// mid-May → Taurus
if c.Sun.SignKey != "taurus" {
t.Fatalf("want taurus got %s", c.Sun.SignKey)
}
}
+226
View File
@@ -0,0 +1,226 @@
package synastry
import (
"fmt"
"time"
"github.com/yuxingu/digital-psychology/apps/api/internal/star/ephemeris"
"github.com/yuxingu/digital-psychology/apps/api/internal/star/natal"
)
// ChartPack holds all synastry chart modes for one pair.
type ChartPack struct {
Composite natal.Chart
Davison natal.Chart
MarksMe natal.Chart
MarksOther natal.Chart
Overlay OverlayChart
CompositeProgressed natal.Chart
DavisonProgressed natal.Chart
MarksProgressed natal.Chart
AsOf time.Time
}
// OverlayHouse is B's planet in A's whole-sign house.
type OverlayHouse struct {
PlanetKey string `json:"planet_key"`
Planet string `json:"planet"`
Sign string `json:"sign"`
House int `json:"house"`
HouseTip string `json:"house_tip"`
}
// OverlayChart is the pairing/overlay table.
type OverlayChart struct {
HousesA []natal.House `json:"houses_a"`
Entries []OverlayHouse `json:"entries"`
Tip string `json:"tip"`
}
// BuildCharts computes five main charts + three progressed variants.
func BuildCharts(a, b natal.Chart, asOf time.Time) (ChartPack, error) {
if asOf.IsZero() {
asOf = time.Now().In(time.FixedZone("CST", 8*3600))
}
comp := CompositeChart(a, b)
dav, err := DavisonChart(a, b)
if err != nil {
return ChartPack{}, err
}
marksMe := MarksChart(dav, a)
marksOther := MarksChart(dav, b)
overlay := Overlay(a, b)
progA, err := ProgressChart(a, asOf)
if err != nil {
return ChartPack{}, err
}
progB, err := ProgressChart(b, asOf)
if err != nil {
return ChartPack{}, err
}
compProg := CompositeChart(progA, progB)
davProg, err := ProgressChart(dav, asOf)
if err != nil {
return ChartPack{}, err
}
marksProg := MarksChart(davProg, progA)
return ChartPack{
Composite: comp, Davison: dav,
MarksMe: marksMe, MarksOther: marksOther,
Overlay: overlay,
CompositeProgressed: compProg,
DavisonProgressed: davProg,
MarksProgressed: marksProg,
AsOf: asOf,
}, nil
}
// CompositeChart midpoints each body longitude (shortest arc); ASC mid of rises.
func CompositeChart(a, b natal.Chart) natal.Chart {
lons := map[string]float64{}
for _, k := range ephemeris.PlanetOrder {
lons[k] = natal.MidpointLon(natal.BodyLon(a, k), natal.BodyLon(b, k))
}
asc := natal.MidpointLon(a.Rise.Lon, b.Rise.Lon)
lat := (a.Lat + b.Lat) / 2
lng := midLng(a.Lng, b.Lng)
ch := natal.ChartFromLons(lons, asc, lat, lng, "组合盘", a.HasTime && b.HasTime, a.HasPlace || b.HasPlace)
ch.Instant = midTime(a.Instant, b.Instant)
ch.Note = "组合盘:双方行星黄经中点,看关系整体气质。"
return ch
}
// DavisonChart uses midpoint birth time + midpoint coordinates, then recomputes.
func DavisonChart(a, b natal.Chart) (natal.Chart, error) {
inst := midTime(a.Instant, b.Instant)
lat := (a.Lat + b.Lat) / 2
lng := midLng(a.Lng, b.Lng)
ch, err := natal.ComputeAt(inst, lat, lng, "时空盘", true, true)
if err != nil {
return natal.Chart{}, err
}
ch.Note = "时空盘:出生时刻与坐标中点再排盘,看长期现实走向。"
return ch, nil
}
// MarksChart midpoints each Davison body with a natal chart (我/TA 视角).
func MarksChart(davison, natalCh natal.Chart) natal.Chart {
lons := map[string]float64{}
for _, k := range ephemeris.PlanetOrder {
lons[k] = natal.MidpointLon(natal.BodyLon(davison, k), natal.BodyLon(natalCh, k))
}
asc := natal.MidpointLon(davison.Rise.Lon, natalCh.Rise.Lon)
ch := natal.ChartFromLons(lons, asc, natalCh.Lat, natalCh.Lng, "马克斯盘", true, true)
ch.Instant = midTime(davison.Instant, natalCh.Instant)
ch.Note = "马克斯盘:时空盘与本命中点,看关系中的内在态度。"
return ch
}
// Overlay maps B planets into A's whole-sign houses.
func Overlay(a, b natal.Chart) OverlayChart {
ascIdx := natal.SignIndex(a.Rise.Lon)
tips := houseTips()
entries := make([]OverlayHouse, 0, len(b.Planets))
for _, p := range b.Planets {
if p.Key == "rise" {
continue
}
h := natal.WholeSignHouse(ascIdx, natal.SignIndex(p.Lon))
tip := tips[h]
entries = append(entries, OverlayHouse{
PlanetKey: p.Key, Planet: p.Title, Sign: p.Sign, House: h, HouseTip: tip,
})
}
return OverlayChart{
HousesA: a.Houses,
Entries: entries,
Tip: "配对盘:对方行星落入我方整宫制宫位,提示生活领域互动。",
}
}
// ProgressChart applies secondary progression (1 day ≈ 1 year) from chart.Instant to asOf.
func ProgressChart(ch natal.Chart, asOf time.Time) (natal.Chart, error) {
if ch.Instant.IsZero() {
return ch, nil
}
years := asOf.UTC().Sub(ch.Instant).Hours() / 24.0 / 365.24219
if years < 0 {
years = 0
}
// Add whole days to avoid time.Duration overflow/precision issues for large ages.
days := int(years + 0.5)
progUTC := ch.Instant.AddDate(0, 0, days)
out, err := natal.ComputeAt(progUTC, ch.Lat, ch.Lng, ch.PlaceLabel+"·次限", ch.HasTime, ch.HasPlace)
if err != nil {
return natal.Chart{}, err
}
out.Note = fmt.Sprintf("次限推运至 %s(约 %.1f 年)。", asOf.Format("2006-01-02"), years)
return out, nil
}
func midTime(a, b time.Time) time.Time {
if a.IsZero() && b.IsZero() {
return time.Time{}
}
if a.IsZero() {
return b
}
if b.IsZero() {
return a
}
return a.Add(b.Sub(a) / 2)
}
func midLng(a, b float64) float64 {
// Geographic mean (CN MVP; both births typically East Asia).
return (a + b) / 2
}
func houseTips() map[int]string {
return map[int]string{
1: "自我与第一印象", 2: "资源与安全感", 3: "沟通与日常", 4: "家庭与根基",
5: "恋爱表达与创造", 6: "协作与健康节奏", 7: "一对一关系", 8: "深度与共享",
9: "视野与信念", 10: "事业与对外形象", 11: "社群与愿景", 12: "内在与疗愈",
}
}
func chartSummaryOut(c natal.Chart, tip string) map[string]any {
asp := natal.Aspects(c)
aspMaps := natal.AspectsAsMaps(asp)
previewN := 4
if len(aspMaps) < previewN {
previewN = len(aspMaps)
}
t := tip
if t == "" {
t = c.Note
}
return map[string]any{
"asc_lon": c.Rise.Lon,
"planets": bodiesOut(c),
"houses": housesOut(c),
"note": c.Note,
"place": c.PlaceLabel,
"tip": t,
"aspects_preview": aspMaps[:previewN],
"sun": c.Sun.Sign,
"moon": c.Moon.Sign,
"rise": c.Rise.Sign,
}
}
func overlayOut(o OverlayChart) map[string]any {
entries := make([]map[string]any, 0, len(o.Entries))
for _, e := range o.Entries {
entries = append(entries, map[string]any{
"planet_key": e.PlanetKey, "planet": e.Planet, "sign": e.Sign,
"house": e.House, "house_tip": e.HouseTip,
})
}
return map[string]any{
"tip": o.Tip, "entries": entries, "houses_a": housesOut(natal.Chart{Houses: o.HousesA}),
}
}
+275
View File
@@ -0,0 +1,275 @@
package synastry
import (
"fmt"
"time"
"github.com/yuxingu/digital-psychology/apps/api/internal/star/natal"
)
// Report is free summary + gated detail for a synastry GrowthReport.
type Report struct {
Summary map[string]any
Detail map[string]any
}
// BuildReport builds multi-chart synastry content (五主盘 + 三推运).
func BuildReport(a, b natal.Chart, aName, bName string, asOf time.Time) (Report, error) {
if aName == "" {
aName = "我"
}
if bName == "" {
bName = "TA"
}
if asOf.IsZero() {
asOf = time.Now().In(time.FixedZone("CST", 8*3600))
}
idx := Compute(a, b, aName, bName)
cross := CrossAspects(a, b)
crossMaps := natal.AspectsAsMaps(cross)
previewN := 4
if len(crossMaps) < previewN {
previewN = len(crossMaps)
}
pack, err := BuildCharts(a, b, asOf)
if err != nil {
return Report{}, err
}
asOfStr := asOf.Format("2006-01-02")
chartsSummary := map[string]any{
"compare": map[string]any{
"tip": "比较盘:双方叠合,看互动与吸引。",
"chart_a": map[string]any{
"asc_lon": a.Rise.Lon, "planets": bodiesOut(a),
"houses": housesOut(a), "note": a.Note, "place": a.PlaceLabel,
"sun": a.Sun.Sign, "moon": a.Moon.Sign, "rise": a.Rise.Sign,
},
"chart_b": map[string]any{
"asc_lon": b.Rise.Lon, "planets": bodiesOut(b),
"houses": housesOut(b), "note": b.Note, "place": b.PlaceLabel,
"sun": b.Sun.Sign, "moon": b.Moon.Sign, "rise": b.Rise.Sign,
},
"aspects_preview": crossMaps[:previewN],
},
"composite": chartSummaryOut(pack.Composite, "组合盘:关系整体气质。"),
"davison": chartSummaryOut(pack.Davison, "时空盘:长期现实走向。"),
"marks_me": chartSummaryOut(pack.MarksMe, "马克斯盘·我:我对这段关系的内在态度。"),
"marks_other": chartSummaryOut(pack.MarksOther, "马克斯盘·TA:TA对这段关系的内在态度。"),
"overlay": overlayOut(pack.Overlay),
"composite_progressed": chartSummaryOut(pack.CompositeProgressed, "组合次限:关系当下成长态。"),
"davison_progressed": chartSummaryOut(pack.DavisonProgressed, "时空次限:现实课题的阶段性。"),
"marks_progressed": chartSummaryOut(pack.MarksProgressed, "马盘推运:我对关系的阶段心态。"),
}
summary := map[string]any{
"title": "合盘·基础",
"headline": idx.Summary,
"one_liner": fmt.Sprintf("%s与%s:五盘合观互动、气质与现实节奏。", aName, bName),
"overview": fmt.Sprintf("%s(日%s月%s升%s)× %s(日%s月%s升%s)。", aName, a.Sun.Sign, a.Moon.Sign, a.Rise.Sign, bName, b.Sun.Sign, b.Moon.Sign, b.Rise.Sign),
"me_name": aName,
"other_name": bName,
"love_index": idx.Love,
"friend_index": idx.Friend,
"marriage_index": idx.Marriage,
"match_indices": idx.AsMap(),
"love_note": idx.LoveNote,
"friend_note": idx.FriendNote,
"marriage_note": idx.MarriageNote,
"as_of": asOfStr,
// backward-compat top-level compare charts
"chart_a": map[string]any{
"asc_lon": a.Rise.Lon, "planets": bodiesOut(a),
"houses": housesOut(a), "note": a.Note, "place": a.PlaceLabel,
},
"chart_b": map[string]any{
"asc_lon": b.Rise.Lon, "planets": bodiesOut(b),
"houses": housesOut(b), "note": b.Note, "place": b.PlaceLabel,
},
"aspects_preview": crossMaps[:previewN],
"charts": chartsSummary,
"keywords": []string{"合盘", "比较盘", "组合盘", "时空盘", fmt.Sprintf("恋爱%d", idx.Love)},
"strengths_preview": []string{
fmt.Sprintf("恋爱指数 %d", idx.Love),
fmt.Sprintf("友情指数 %d", idx.Friend),
fmt.Sprintf("婚姻指数 %d", idx.Marriage),
},
"blind_spots_preview": []string{"完整相位、推运深文案与落宫解读见深度版。"},
}
bullets := make([]string, 0, min(10, len(cross)))
for i, c := range cross {
if i >= 10 {
break
}
bullets = append(bullets, c.Label)
}
compAsp := natal.AspectsAsMaps(natal.Aspects(pack.Composite))
davAsp := natal.AspectsAsMaps(natal.Aspects(pack.Davison))
progAsp := natal.AspectsAsMaps(natal.Aspects(pack.CompositeProgressed))
detail := map[string]any{
"title": "合盘·完整分析",
"as_of": asOfStr,
"aspects": crossMaps,
"charts": map[string]any{
"compare": map[string]any{"aspects": crossMaps},
"composite": map[string]any{"aspects": compAsp, "planets": bodiesOut(pack.Composite)},
"davison": map[string]any{"aspects": davAsp, "planets": bodiesOut(pack.Davison)},
"marks_me": map[string]any{"aspects": natal.AspectsAsMaps(natal.Aspects(pack.MarksMe)), "planets": bodiesOut(pack.MarksMe)},
"marks_other": map[string]any{"aspects": natal.AspectsAsMaps(natal.Aspects(pack.MarksOther)), "planets": bodiesOut(pack.MarksOther)},
"overlay": overlayOut(pack.Overlay),
"composite_progressed": map[string]any{"aspects": progAsp, "planets": bodiesOut(pack.CompositeProgressed), "note": pack.CompositeProgressed.Note},
"davison_progressed": map[string]any{"aspects": natal.AspectsAsMaps(natal.Aspects(pack.DavisonProgressed)), "planets": bodiesOut(pack.DavisonProgressed), "note": pack.DavisonProgressed.Note},
"marks_progressed": map[string]any{"aspects": natal.AspectsAsMaps(natal.Aspects(pack.MarksProgressed)), "planets": bodiesOut(pack.MarksProgressed), "note": pack.MarksProgressed.Note},
},
"sections": []map[string]any{
{"title": "恋爱互动", "body": idx.LoveNote, "bullets": []string{"说清需要比猜测更有效", "张力相位日宜慢半拍回应"}},
{"title": "友情节奏", "body": idx.FriendNote, "bullets": []string{"共同兴趣维系轻松感", "尊重彼此独处需求"}},
{"title": "长期相处", "body": idx.MarriageNote, "bullets": []string{"共同节奏与边界同等重要", "把期待写成说明书"}},
{"title": "跨盘相位", "body": "比较盘相位描述双方能量如何彼此激活或拉扯。", "bullets": bullets},
{"title": "组合与时空", "body": "组合盘看关系气质,时空盘看共同现实课题;次限呈现阶段性成长。", "bullets": []string{
fmt.Sprintf("组合盘日%s月%s升%s", pack.Composite.Sun.Sign, pack.Composite.Moon.Sign, pack.Composite.Rise.Sign),
fmt.Sprintf("时空盘日%s月%s升%s", pack.Davison.Sun.Sign, pack.Davison.Moon.Sign, pack.Davison.Rise.Sign),
fmt.Sprintf("推运日期 %s", asOfStr),
}},
{"title": "配对落宫", "body": pack.Overlay.Tip, "bullets": overlayBullets(pack.Overlay)},
},
"growth_plan": []map[string]any{
{"phase": "本周", "focus": "约一次轻松同步:各说一件欣赏与一件需要。"},
{"phase": "本月", "focus": "为高频摩擦点写一条可执行约定。"},
{"phase": "长期", "focus": "每季度复盘三指数变化与相处舒适度。"},
},
"conversation_scripts": []string{
"我想听听你怎么看我们的节奏,不急着定结论。",
"我需要一点空间整理,不是拒绝连接。",
"我们共同目标是……,这一步只做一件事。",
},
"behavior_pattern": idx.LoveNote,
"relation_style": idx.FriendNote,
"growth_direction": idx.MarriageNote,
}
return Report{Summary: summary, Detail: detail}, nil
}
func overlayBullets(o OverlayChart) []string {
out := make([]string, 0, 6)
for i, e := range o.Entries {
if i >= 6 {
break
}
if e.PlanetKey == "sun" || e.PlanetKey == "moon" || e.PlanetKey == "venus" || e.PlanetKey == "mars" {
out = append(out, fmt.Sprintf("%s在我方%d宫(%s", e.Planet, e.House, e.HouseTip))
}
}
if len(out) == 0 && len(o.Entries) > 0 {
e := o.Entries[0]
out = append(out, fmt.Sprintf("%s在我方%d宫", e.Planet, e.House))
}
return out
}
// CrossAspects finds aspects between chart A bodies and chart B bodies.
func CrossAspects(a, b natal.Chart) []natal.Aspect {
keys := []string{"sun", "moon", "rise", "mercury", "venus", "mars", "jupiter", "saturn"}
byA := map[string]natal.Body{}
byB := map[string]natal.Body{}
for _, p := range a.Planets {
byA[p.Key] = p
}
for _, p := range b.Planets {
byB[p.Key] = p
}
defs := []struct {
Type string
Angle float64
Orb float64
Label string
}{
{"conjunction", 0, 8, "合相"},
{"sextile", 60, 6, "六合"},
{"square", 90, 7, "刑相"},
{"trine", 120, 7, "拱相"},
{"opposition", 180, 8, "对冲"},
}
out := make([]natal.Aspect, 0)
for _, ka := range keys {
ba, oka := byA[ka]
if !oka {
continue
}
for _, kb := range keys {
bb, okb := byB[kb]
if !okb {
continue
}
diff := natal.AngleDiff(ba.Lon, bb.Lon)
for _, def := range defs {
orb := absF(diff - def.Angle)
if orb <= def.Orb {
out = append(out, natal.Aspect{
A: "a:" + ba.Key, B: "b:" + bb.Key,
ATitle: aNameTitle(ba.Title), BTitle: bNameTitle(bb.Title),
Type: def.Type, Angle: def.Angle, Orb: round1(orb),
Label: fmt.Sprintf("我方%s%s对方%s(容许%.1f°)", ba.Title, def.Label, bb.Title, orb),
})
break
}
}
}
}
for i := 0; i < len(out); i++ {
for j := i + 1; j < len(out); j++ {
if out[j].Orb < out[i].Orb {
out[i], out[j] = out[j], out[i]
}
}
}
if len(out) > 24 {
out = out[:24]
}
return out
}
func aNameTitle(t string) string { return "我·" + t }
func bNameTitle(t string) string { return "TA·" + t }
func bodiesOut(c natal.Chart) []map[string]any {
out := make([]map[string]any, 0, len(c.Planets))
for _, p := range c.Planets {
out = append(out, map[string]any{
"key": p.Key, "title": p.Title, "sign": p.Sign, "sign_key": p.SignKey,
"degree": fmt.Sprintf("%.1f°", p.Degree), "house": p.House,
"lon": p.Lon, "element": p.Element, "modality": p.Modality,
})
}
return out
}
func housesOut(c natal.Chart) []map[string]any {
out := make([]map[string]any, 0, len(c.Houses))
for _, h := range c.Houses {
out = append(out, map[string]any{"num": h.Num, "sign": h.Sign, "sign_key": h.Key})
}
return out
}
func absF(x float64) float64 {
if x < 0 {
return -x
}
return x
}
func round1(x float64) float64 {
return float64(int(x*10+0.5)) / 10
}
func min(a, b int) int {
if a < b {
return a
}
return b
}
@@ -0,0 +1,65 @@
package synastry
import (
"testing"
"time"
"github.com/yuxingu/digital-psychology/apps/api/internal/star/natal"
)
func TestBuildReport(t *testing.T) {
a, err := natal.Compute(time.Date(1990, 5, 12, 0, 0, 0, 0, time.UTC), strPtr("12:00"), strPtr("北京"))
if err != nil {
t.Fatal(err)
}
b, err := natal.Compute(time.Date(1992, 8, 20, 0, 0, 0, 0, time.UTC), strPtr("08:00"), strPtr("上海"))
if err != nil {
t.Fatal(err)
}
asOf := time.Date(2026, 8, 2, 0, 0, 0, 0, time.FixedZone("CST", 8*3600))
rep, err := BuildReport(a, b, "我", "TA", asOf)
if err != nil {
t.Fatal(err)
}
if rep.Summary["love_index"] == nil || rep.Summary["chart_a"] == nil {
t.Fatalf("summary incomplete: %#v", rep.Summary)
}
charts, ok := rep.Summary["charts"].(map[string]any)
if !ok {
t.Fatal("charts missing")
}
for _, k := range []string{"compare", "composite", "davison", "marks_me", "marks_other", "overlay",
"composite_progressed", "davison_progressed", "marks_progressed"} {
if charts[k] == nil {
t.Fatalf("missing chart key %s", k)
}
}
if rep.Summary["as_of"] != "2026-08-02" {
t.Fatalf("as_of=%v", rep.Summary["as_of"])
}
if rep.Detail["aspects"] == nil {
t.Fatal("detail aspects missing")
}
cross := CrossAspects(a, b)
if len(cross) == 0 {
t.Fatal("expected cross aspects")
}
}
func TestCompositeDeterministic(t *testing.T) {
a, err := natal.Compute(time.Date(1990, 5, 12, 0, 0, 0, 0, time.UTC), strPtr("10:30"), strPtr("上海"))
if err != nil {
t.Fatal(err)
}
b, err := natal.Compute(time.Date(1992, 8, 20, 0, 0, 0, 0, time.UTC), strPtr("08:00"), strPtr("北京"))
if err != nil {
t.Fatal(err)
}
c1 := CompositeChart(a, b)
c2 := CompositeChart(a, b)
if c1.Sun.Lon != c2.Sun.Lon || c1.Rise.Sign != c2.Rise.Sign {
t.Fatal("composite not deterministic")
}
}
func strPtr(s string) *string { return &s }
+133
View File
@@ -0,0 +1,133 @@
// Package synastry computes love/friend/marriage match indices from two natal charts.
package synastry
import (
"fmt"
"github.com/yuxingu/digital-psychology/apps/api/internal/star/natal"
)
// Indices are 0100 match scores.
type Indices struct {
Love int `json:"love_index"`
Friend int `json:"friend_index"`
Marriage int `json:"marriage_index"`
LoveNote string `json:"love_note"`
FriendNote string `json:"friend_note"`
MarriageNote string `json:"marriage_note"`
Summary string `json:"summary"`
}
// Compute returns match indices for charts A and B.
func Compute(a, b natal.Chart, aName, bName string) Indices {
if aName == "" {
aName = "我"
}
if bName == "" {
bName = "TA"
}
sunDiff := natal.AngleDiff(a.Sun.Lon, b.Sun.Lon)
moonDiff := natal.AngleDiff(a.Moon.Lon, b.Moon.Lon)
venA := bodyLon(a, "venus")
venB := bodyLon(b, "venus")
marsA := bodyLon(a, "mars")
marsB := bodyLon(b, "mars")
venDiff := natal.AngleDiff(venA, venB)
marsVen := natal.AngleDiff(marsA, venB)
venMars := natal.AngleDiff(venA, marsB)
// Closer aspects (0/60/120 soft; 90/180 hard but charged) → higher score blended
love := clamp(58+aspectBoost(sunDiff)+aspectBoost(moonDiff)+aspectBoost(venDiff)+aspectBoost(marsVen)/2+aspectBoost(venMars)/2, 42, 98)
friend := clamp(60+aspectBoost(sunDiff)+aspectBoost(moonDiff)*3/2+elemBoost(a.Sun, b.Sun), 45, 97)
marriage := clamp(55+aspectBoost(sunDiff)+aspectBoost(moonDiff)+aspectBoost(venDiff)+houseBoost(a, b), 40, 96)
return Indices{
Love: love, Friend: friend, Marriage: marriage,
LoveNote: fmt.Sprintf("%s与%s恋爱指数 %d:太阳相距约%.0f°,金星互动影响吸引力。", aName, bName, love, sunDiff),
FriendNote: fmt.Sprintf("友情指数 %d:月亮与太阳元素是否合拍,决定轻松感。", friend),
MarriageNote: fmt.Sprintf("婚姻/长期指数 %d:看重稳定与共同节奏,而非一时火花。", marriage),
Summary: fmt.Sprintf("%s × %s:恋爱 %d · 友情 %d · 婚姻 %d", aName, bName, love, friend, marriage),
}
}
func bodyLon(c natal.Chart, key string) float64 {
for _, p := range c.Planets {
if p.Key == key {
return p.Lon
}
}
return 0
}
func aspectBoost(diff float64) int {
// reward conjunction, sextile, trine; mild for square/opposition
targets := []float64{0, 60, 90, 120, 180}
best := 180.0
for _, t := range targets {
d := abs(diff - t)
if d < best {
best = d
}
}
switch {
case best <= 8:
if near(diff, 90) || near(diff, 180) {
return 8
}
return 14
case best <= 12:
return 8
default:
return int(6 - best/30)
}
}
func near(diff, target float64) bool {
return abs(diff-target) <= 10
}
func elemBoost(a, b natal.Body) int {
if a.Element == b.Element {
return 6
}
// fire-air / earth-water traditionally supportive
pair := a.Element + b.Element
if pair == "火风" || pair == "风火" || pair == "土水" || pair == "水土" {
return 4
}
return 0
}
func houseBoost(a, b natal.Chart) int {
// same rising modality → slight boost
if a.Rise.Modality == b.Rise.Modality {
return 4
}
return 0
}
func clamp(v, lo, hi int) int {
if v < lo {
return lo
}
if v > hi {
return hi
}
return v
}
func abs(x float64) float64 {
if x < 0 {
return -x
}
return x
}
// AsMap for JSON.
func (i Indices) AsMap() map[string]any {
return map[string]any{
"love_index": i.Love, "friend_index": i.Friend, "marriage_index": i.Marriage,
"love_note": i.LoveNote, "friend_note": i.FriendNote, "marriage_note": i.MarriageNote,
"summary": i.Summary,
}
}
@@ -0,0 +1,26 @@
package synastry
import (
"testing"
"time"
"github.com/yuxingu/digital-psychology/apps/api/internal/star/natal"
)
func TestComputeIndices(t *testing.T) {
a, err := natal.Compute(time.Date(1990, 5, 12, 0, 0, 0, 0, time.UTC), nil, nil)
if err != nil {
t.Fatal(err)
}
b, err := natal.Compute(time.Date(1992, 8, 1, 0, 0, 0, 0, time.UTC), nil, nil)
if err != nil {
t.Fatal(err)
}
idx := Compute(a, b, "我", "TA")
if idx.Love < 40 || idx.Friend < 40 || idx.Marriage < 40 {
t.Fatalf("scores too low: %+v", idx)
}
if idx.Summary == "" {
t.Fatal("empty summary")
}
}