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
+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) }