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