Skip to main content

x_iztro/
dto.rs

1//! JS iztro 兼容的序列化 DTO。
2//!
3//! 核心模型(`Astrolabe`/`HoroscopeData`)以枚举承载数据,供 Rust 调用方做
4//! 类型安全查询;跨语言绑定(FFI/PyO3)输出的 JSON 则来自本模块的 DTO:
5//! 键为 camelCase、值为按排盘语言翻译的字符串,结构与 JS iztro 的
6//! `JSON.stringify` 输出一致(不含其 `plugins`/`copyright` 及运限对象内嵌的
7//! `astrolabe` 等实现细节字段)。
8//!
9//! 在 JS 字段集之外附加两类扩展:
10//! - 排盘上下文(`genderKey`/`timeIndex`/`fixLeap`/`language`/`config`),
11//!   使消费方能以纯参数(无状态)方式发起运限计算;
12//! - 语言无关标识(星/宫/干支/四化/亮度的 `*key`/`*Key(s)` 字段,取值为
13//!   iztro i18n key),供强类型绑定做跨语言的身份判断与枚举映射;宫位另带
14//!   `mutagenStarKeys`(本宫天干化出的禄权科忌四星),绑定层据此做飞星判断。
15//!
16//! 出错时三条绑定出口(FFI/wasm/PyO3)返回同一形状的错误 JSON
17//! `{"error": "<描述>", "code": "<分类>"}`,分类取值见 [`crate::error::BridgeError`]。
18
19use serde::{Deserialize, Serialize};
20
21use crate::data::stars::{MUTAGEN, StarKey};
22use crate::data::types::*;
23use crate::error::BridgeError;
24use crate::i18n::{
25    translate_brightness, translate_earthly_branch, translate_five_elements_class,
26    translate_gender, translate_heavenly_stem, translate_mutagen, translate_palace,
27    translate_pattern, translate_star,
28};
29use crate::models::astrolabe::Astrolabe;
30use crate::models::horoscope::{HoroscopeData, HoroscopeItem};
31use crate::models::palace::PalaceData;
32use crate::models::star::Star;
33
34/// 星耀 DTO。
35/// 主星/辅星的 `brightness` 恒存在(无亮度为空串);无四化时省略 `mutagen` 键;
36/// 杂耀与运限流耀省略 `brightness` 与 `mutagen`。
37/// `key`/`brightnessKey`/`mutagenKey` 为语言无关标识(x-iztro 扩展),
38/// 供强类型绑定做跨语言的身份判断。
39#[derive(Debug, Clone, Serialize, Deserialize)]
40#[serde(rename_all = "camelCase")]
41pub struct StarDto {
42    /// 语言无关星耀标识(iztro i18n key,如 "ziweiMaj")
43    pub key: String,
44    /// 名称(按排盘语言翻译)
45    pub name: String,
46    /// 星耀类型(major/soft/tough/adjective/flower/helper/lucun/tianma)
47    #[serde(rename = "type")]
48    pub star_type: String,
49    /// 作用范围(origin/decadal/yearly/monthly/daily/hourly)
50    pub scope: String,
51    /// 亮度显示文本;主星辅星恒有该键(无亮度为空串),杂耀与流耀省略
52    #[serde(skip_serializing_if = "Option::is_none")]
53    pub brightness: Option<String>,
54    /// 语言无关亮度标识("miao" 等),无亮度时省略
55    #[serde(skip_serializing_if = "Option::is_none")]
56    pub brightness_key: Option<String>,
57    /// 四化显示文本;四化候选星恒有该键(无四化为空串),其余省略
58    #[serde(skip_serializing_if = "Option::is_none")]
59    pub mutagen: Option<String>,
60    /// 语言无关四化标识("sihuaLu" 等),无四化时省略
61    #[serde(skip_serializing_if = "Option::is_none")]
62    pub mutagen_key: Option<String>,
63}
64
65/// 大限区间与干支 DTO。
66#[derive(Debug, Clone, Serialize, Deserialize)]
67#[serde(rename_all = "camelCase")]
68pub struct DecadalDto {
69    /// 大限起止虚岁(起始、截止,含两端)
70    pub range: [u32; 2],
71    /// 天干(按排盘语言翻译)
72    pub heavenly_stem: String,
73    /// 语言无关天干标识("jiaHeavenly" 等)
74    pub heavenly_stem_key: String,
75    /// 地支(按排盘语言翻译)
76    pub earthly_branch: String,
77    /// 语言无关地支标识("ziEarthly" 等)
78    pub earthly_branch_key: String,
79}
80
81/// 宫位 DTO。
82#[derive(Debug, Clone, Serialize, Deserialize)]
83#[serde(rename_all = "camelCase")]
84pub struct PalaceDto {
85    /// 宫位索引(0-11,寅宫为 0)
86    pub index: usize,
87    /// 名称(按排盘语言翻译)
88    pub name: String,
89    /// 语言无关宫位标识("soulPalace" 等)
90    pub name_key: String,
91    /// 是否身宫
92    pub is_body_palace: bool,
93    /// 是否来因宫
94    pub is_original_palace: bool,
95    /// 天干(按排盘语言翻译)
96    pub heavenly_stem: String,
97    /// 语言无关天干标识
98    pub heavenly_stem_key: String,
99    /// 地支(按排盘语言翻译)
100    pub earthly_branch: String,
101    /// 语言无关地支标识
102    pub earthly_branch_key: String,
103    /// 主星列表(按安放顺序)
104    pub major_stars: Vec<StarDto>,
105    /// 辅星列表(按安放顺序)
106    pub minor_stars: Vec<StarDto>,
107    /// 杂耀列表(按安放顺序)
108    pub adjective_stars: Vec<StarDto>,
109    /// 长生十二神(按排盘语言翻译)
110    pub changsheng12: String,
111    /// 长生十二神的语言无关标识
112    pub changsheng12_key: String,
113    /// 博士十二神(按排盘语言翻译)
114    pub boshi12: String,
115    /// 博士十二神的语言无关标识
116    pub boshi12_key: String,
117    /// 将前十二神(按排盘语言翻译)
118    pub jiangqian12: String,
119    /// 将前十二神的语言无关标识
120    pub jiangqian12_key: String,
121    /// 岁前十二神(按排盘语言翻译)
122    pub suiqian12: String,
123    /// 岁前十二神的语言无关标识
124    pub suiqian12_key: String,
125    /// 大限信息
126    pub decadal: DecadalDto,
127    /// 小限经过的虚岁列表
128    pub ages: Vec<u32>,
129    /// x-iztro 扩展:本宫天干化出的四颗星的语言无关标识,顺序为禄、权、科、忌。
130    /// 取自排盘时生效的四化表(含 `Config` 的自定义覆盖),
131    /// 绑定层据此做飞星判断,无须各自再抄一份四化表。
132    pub mutagen_star_keys: [String; 4],
133}
134
135/// 数字化农历生日 DTO。
136#[derive(Debug, Clone, Serialize, Deserialize)]
137#[serde(rename_all = "camelCase")]
138pub struct RawLunarDateDto {
139    /// 农历年
140    pub lunar_year: i64,
141    /// 农历月(1-12,闰月与否见 is_leap)
142    pub lunar_month: u32,
143    /// 农历日(1-30)
144    pub lunar_day: u32,
145    /// 是否闰月
146    pub is_leap: bool,
147}
148
149/// 四柱干支 DTO(每柱为 [天干, 地支] 两元素数组)。
150#[derive(Debug, Clone, Serialize, Deserialize)]
151#[serde(rename_all = "camelCase")]
152pub struct RawChineseDateDto {
153    /// 年柱(天干、地支,干支原文)
154    pub yearly: [String; 2],
155    /// 月柱(天干、地支,干支原文)
156    pub monthly: [String; 2],
157    /// 日柱(天干、地支,干支原文)
158    pub daily: [String; 2],
159    /// 时柱(天干、地支,干支原文)
160    pub hourly: [String; 2],
161    /// 年柱的语言无关标识(天干、地支)
162    pub yearly_keys: [String; 2],
163    /// 月柱的语言无关标识
164    pub monthly_keys: [String; 2],
165    /// 日柱的语言无关标识
166    pub daily_keys: [String; 2],
167    /// 时柱的语言无关标识
168    pub hourly_keys: [String; 2],
169}
170
171/// 结构化出生日期 DTO。
172#[derive(Debug, Clone, Serialize, Deserialize)]
173#[serde(rename_all = "camelCase")]
174pub struct RawDatesDto {
175    /// 数字化农历生日
176    pub lunar_date: RawLunarDateDto,
177    /// 四柱干支
178    pub chinese_date: RawChineseDateDto,
179}
180
181/// 排盘配置 DTO(字符串取值,与 JS iztro 的 config() 取值一致)。
182#[derive(Debug, Clone, Serialize, Deserialize)]
183#[serde(rename_all = "camelCase")]
184pub struct ConfigDto {
185    /// "normal" | "exact"
186    pub year_divide: String,
187    /// "normal" | "exact"
188    pub horoscope_divide: String,
189    /// "normal" | "birthday"
190    pub age_divide: String,
191    /// "forward" | "current"
192    pub day_divide: String,
193    /// "default" | "zhongzhou"
194    pub algorithm: String,
195    /// "heaven" | "earth" | "human"
196    pub astro_type: String,
197}
198
199/// 星盘 DTO。
200#[derive(Debug, Clone, Serialize, Deserialize)]
201#[serde(rename_all = "camelCase")]
202pub struct AstrolabeDto {
203    /// 性别(按排盘语言翻译)
204    pub gender: String,
205    /// 阳历日期("YYYY-M-D")
206    pub solar_date: String,
207    /// 农历日期中文表示
208    pub lunar_date: String,
209    /// 干支纪日四柱展示串
210    pub chinese_date: String,
211    /// 结构化的出生日期信息
212    pub raw_dates: RawDatesDto,
213    /// 时辰名称(按排盘语言)
214    pub time: String,
215    /// 时辰对应的时间段
216    pub time_range: String,
217    /// 星座(按排盘语言)
218    pub sign: String,
219    /// 生肖(按排盘语言)
220    pub zodiac: String,
221    /// 命宫地支(按排盘语言翻译)
222    pub earthly_branch_of_soul_palace: String,
223    /// 命宫地支的语言无关标识
224    pub earthly_branch_of_soul_palace_key: String,
225    /// 身宫地支(按排盘语言翻译)
226    pub earthly_branch_of_body_palace: String,
227    /// 身宫地支的语言无关标识
228    pub earthly_branch_of_body_palace_key: String,
229    /// 命主星(按排盘语言翻译)
230    pub soul: String,
231    /// 命主星的语言无关标识
232    pub soul_key: String,
233    /// 身主星(按排盘语言翻译)
234    pub body: String,
235    /// 身主星的语言无关标识
236    pub body_key: String,
237    /// 五行局(按排盘语言翻译)
238    pub five_elements_class: String,
239    /// 五行局的语言无关标识("water2nd" 等)
240    pub five_elements_class_key: String,
241    /// 十二宫数据
242    pub palaces: Vec<PalaceDto>,
243    /// x-iztro 扩展:机器可读性别("male"/"female",无状态运限所需)
244    pub gender_key: String,
245    /// x-iztro 扩展:出生时辰索引(无状态运限所需)
246    pub time_index: u8,
247    /// x-iztro 扩展:是否修正闰月
248    pub fix_leap: bool,
249    /// x-iztro 扩展:排盘语言("zh-CN" 等)
250    pub language: String,
251    /// x-iztro 扩展:排盘配置
252    pub config: ConfigDto,
253}
254
255/// 运限单层级 DTO(大限/流年/流月/流日/流时)。
256#[derive(Debug, Clone, Serialize, Deserialize)]
257#[serde(rename_all = "camelCase")]
258pub struct HoroscopeScopeDto {
259    /// 宫位索引(0-11,寅宫为 0)
260    pub index: usize,
261    /// 名称(按排盘语言翻译)
262    pub name: String,
263    /// 天干(按排盘语言翻译)
264    pub heavenly_stem: String,
265    /// 语言无关天干标识
266    pub heavenly_stem_key: String,
267    /// 地支(按排盘语言翻译)
268    pub earthly_branch: String,
269    /// 语言无关地支标识
270    pub earthly_branch_key: String,
271    /// 该运限的十二宫名(按宫位索引排列,翻译文本)
272    pub palace_names: Vec<String>,
273    /// 十二宫名的语言无关标识
274    pub palace_name_keys: Vec<String>,
275    /// 四化星名(禄、权、科、忌,翻译文本)
276    pub mutagen: Vec<String>,
277    /// 四化星(禄、权、科、忌)的语言无关标识
278    pub mutagen_keys: Vec<String>,
279    /// 流耀在十二宫的分布;无流耀的层级省略该键
280    #[serde(skip_serializing_if = "Option::is_none")]
281    pub stars: Option<Vec<Vec<StarDto>>>,
282}
283
284/// 小限 DTO。
285#[derive(Debug, Clone, Serialize, Deserialize)]
286#[serde(rename_all = "camelCase")]
287pub struct AgeDto {
288    /// 通用运限字段
289    #[serde(flatten)]
290    pub base: HoroscopeScopeDto,
291    /// 虚岁
292    pub nominal_age: u32,
293}
294
295/// 流年十二神 DTO。
296#[derive(Debug, Clone, Serialize, Deserialize)]
297pub struct YearlyDecStarDto {
298    /// 岁前十二神(按宫位索引排列,翻译文本)
299    pub suiqian12: Vec<String>,
300    /// 岁前十二神的语言无关标识
301    #[serde(rename = "suiqian12Keys")]
302    pub suiqian12_keys: Vec<String>,
303    /// 将前十二神(按宫位索引排列,翻译文本)
304    pub jiangqian12: Vec<String>,
305    /// 将前十二神的语言无关标识
306    #[serde(rename = "jiangqian12Keys")]
307    pub jiangqian12_keys: Vec<String>,
308}
309
310/// 流年 DTO。
311#[derive(Debug, Clone, Serialize, Deserialize)]
312#[serde(rename_all = "camelCase")]
313pub struct YearlyDto {
314    /// 通用运限字段
315    #[serde(flatten)]
316    pub base: HoroscopeScopeDto,
317    /// 流年十二神
318    pub yearly_dec_star: YearlyDecStarDto,
319}
320
321/// 运限 DTO。
322#[derive(Debug, Clone, Serialize, Deserialize)]
323#[serde(rename_all = "camelCase")]
324pub struct HoroscopeDto {
325    /// 农历日期中文表示
326    pub lunar_date: String,
327    /// 阳历日期("YYYY-M-D")
328    pub solar_date: String,
329    /// 大限(未起运时为童限)
330    pub decadal: HoroscopeScopeDto,
331    /// 小限
332    pub age: AgeDto,
333    /// 流年
334    pub yearly: YearlyDto,
335    /// 流月
336    pub monthly: HoroscopeScopeDto,
337    /// 流日
338    pub daily: HoroscopeScopeDto,
339    /// 流时
340    pub hourly: HoroscopeScopeDto,
341}
342
343// ============================================================
344// 枚举 → 字符串
345// ============================================================
346
347/// 绑定层出口的错误 JSON:`{"error": "<message>", "code": "<code>"}`。
348///
349/// 用结构体而非 map 序列化,键序固定为 error 先、code 后;消息经 serde
350/// 转义,引号、反斜杠与控制字符一律安全。
351pub(crate) fn error_json(err: &BridgeError) -> String {
352    #[derive(Serialize)]
353    struct ErrorJson<'a> {
354        error: &'a str,
355        code: &'a str,
356    }
357    serde_json::to_string(&ErrorJson {
358        error: &err.message,
359        code: err.code,
360    })
361    .expect("错误 JSON 只含两个字符串字段,序列化不会失败")
362}
363
364/// 从 panic 载荷提取人类可读消息(downcast String/&str,兜底固定文案)。
365/// 供各绑定层把核心计算的 panic 转为对外错误。
366pub(crate) fn panic_message(panic: &(dyn std::any::Any + Send)) -> &str {
367    panic
368        .downcast_ref::<String>()
369        .map(String::as_str)
370        .or_else(|| panic.downcast_ref::<&str>().copied())
371        .unwrap_or("computation panicked")
372}
373
374/// config JSON 的部分键补丁:缺省键取默认值。
375#[derive(Debug, Default, Deserialize)]
376#[serde(rename_all = "camelCase", default)]
377struct ConfigPatch {
378    year_divide: Option<String>,
379    horoscope_divide: Option<String>,
380    age_divide: Option<String>,
381    day_divide: Option<String>,
382    algorithm: Option<String>,
383    astro_type: Option<String>,
384    /// 天干标识 → 四化星标识数组(禄权科忌四项)
385    mutagens: Option<std::collections::HashMap<String, Vec<String>>>,
386    /// 星耀标识 → 十二宫亮度标识数组(十二项,空串表示该宫无亮度)
387    brightness: Option<std::collections::HashMap<String, Vec<String>>>,
388}
389
390/// 解析绑定层的 config JSON(如 `{"algorithm":"zhongzhou"}`)。
391/// `None` 或空串返回默认配置;未出现的键取默认值;非法取值报错。
392///
393/// # Errors
394/// JSON 语法错误或任一键取值不在允许集合内时返回 `invalid_argument`。
395pub fn parse_config_json(json: Option<&str>) -> Result<Config, BridgeError> {
396    let json = match json {
397        None => return Ok(Config::default()),
398        Some(s) if s.trim().is_empty() => return Ok(Config::default()),
399        Some(s) => s,
400    };
401    let patch: ConfigPatch = serde_json::from_str(json)
402        .map_err(|e| BridgeError::invalid_argument(format!("invalid config JSON: {e}")))?;
403    config_from_patch(patch)
404}
405
406/// 由已反序列化的补丁构造配置,供 `parse_config_json` 与 `serde_json::Value`
407/// 入参共用——后者不必先序列化回字符串再解析一遍。
408///
409/// # Errors
410/// 任一键取值不在允许集合内时返回 `invalid_argument`。
411pub fn parse_config_value(value: &serde_json::Value) -> Result<Config, BridgeError> {
412    let patch: ConfigPatch = serde_json::from_value(value.clone())
413        .map_err(|e| BridgeError::invalid_argument(format!("invalid config: {e}")))?;
414    config_from_patch(patch)
415}
416
417/// 开关键取值非法的统一报错:`invalid <field> '<value>': expected <expected>`
418fn bad_switch(field: &str, value: &str, expected: &str) -> BridgeError {
419    BridgeError::invalid_argument(format!("invalid {field} '{value}': expected {expected}"))
420}
421
422fn config_from_patch(patch: ConfigPatch) -> Result<Config, BridgeError> {
423    let mut config = Config::default();
424    if let Some(v) = patch.year_divide {
425        config.year_divide = YearDivide::from_key(&v)
426            .ok_or_else(|| bad_switch("yearDivide", &v, "'normal' or 'exact'"))?;
427    }
428    if let Some(v) = patch.horoscope_divide {
429        config.horoscope_divide = HoroscopeDivide::from_key(&v)
430            .ok_or_else(|| bad_switch("horoscopeDivide", &v, "'normal' or 'exact'"))?;
431    }
432    if let Some(v) = patch.age_divide {
433        config.age_divide = AgeDivide::from_key(&v)
434            .ok_or_else(|| bad_switch("ageDivide", &v, "'normal' or 'birthday'"))?;
435    }
436    if let Some(v) = patch.day_divide {
437        config.day_divide = DayDivide::from_key(&v)
438            .ok_or_else(|| bad_switch("dayDivide", &v, "'forward' or 'current'"))?;
439    }
440    if let Some(v) = patch.algorithm {
441        config.algorithm = Algorithm::from_key(&v)
442            .ok_or_else(|| bad_switch("algorithm", &v, "'default' or 'zhongzhou'"))?;
443    }
444    if let Some(v) = patch.astro_type {
445        config.astro_type = AstroType::from_key(&v)
446            .ok_or_else(|| bad_switch("astroType", &v, "'heaven', 'earth' or 'human'"))?;
447    }
448
449    // 自定义四化与亮度表:键与值都是语言无关标识
450    if let Some(map) = patch.mutagens {
451        for (stem_key, star_keys) in map {
452            let stem = HeavenlyStem::from_key(&stem_key).ok_or_else(|| {
453                BridgeError::invalid_argument(format!(
454                    "invalid mutagens key '{stem_key}': unknown heavenly stem"
455                ))
456            })?;
457            if star_keys.len() != 4 {
458                return Err(BridgeError::invalid_argument(format!(
459                    "invalid mutagens for '{stem_key}': expected 4 stars (lu, quan, ke, ji), got {}",
460                    star_keys.len()
461                )));
462            }
463            let mut stars = [StarKey::ZiweiMaj; 4];
464            for (i, key) in star_keys.iter().enumerate() {
465                stars[i] = StarKey::from_key(key).ok_or_else(|| {
466                    BridgeError::invalid_argument(format!(
467                        "invalid mutagens for '{stem_key}': unknown star '{key}'"
468                    ))
469                })?;
470            }
471            config = config.with_mutagens(stem, stars);
472        }
473    }
474    if let Some(map) = patch.brightness {
475        for (star_key, brightness_keys) in map {
476            let star = StarKey::from_key(&star_key).ok_or_else(|| {
477                BridgeError::invalid_argument(format!(
478                    "invalid brightness key '{star_key}': unknown star"
479                ))
480            })?;
481            if brightness_keys.len() != 12 {
482                return Err(BridgeError::invalid_argument(format!(
483                    "invalid brightness for '{star_key}': expected 12 entries, got {}",
484                    brightness_keys.len()
485                )));
486            }
487            let mut table = [None; 12];
488            for (i, key) in brightness_keys.iter().enumerate() {
489                // 空串表示该宫位无亮度
490                if key.is_empty() {
491                    continue;
492                }
493                table[i] = Some(Brightness::from_key(key).ok_or_else(|| {
494                    BridgeError::invalid_argument(format!(
495                        "invalid brightness for '{star_key}': unknown brightness '{key}'"
496                    ))
497                })?);
498            }
499            config = config.with_brightness(star, table);
500        }
501    }
502
503    Ok(config)
504}
505
506impl From<Config> for ConfigDto {
507    fn from(c: Config) -> Self {
508        ConfigDto {
509            year_divide: c.year_divide.as_key().to_string(),
510            horoscope_divide: c.horoscope_divide.as_key().to_string(),
511            age_divide: c.age_divide.as_key().to_string(),
512            day_divide: c.day_divide.as_key().to_string(),
513            algorithm: c.algorithm.as_key().to_string(),
514            astro_type: c.astro_type.as_key().to_string(),
515        }
516    }
517}
518
519// ============================================================
520// 模型 → DTO
521// ============================================================
522
523/// 是否属于四化候选星(十四主星与文昌/文曲/左辅/右弼)。
524/// 这些星的 DTO 恒带 mutagen 键(无四化为空串),其余星省略该键。
525fn is_mutagen_candidate(key: crate::data::stars::StarKey) -> bool {
526    use crate::data::stars::StarKey::*;
527    matches!(
528        key,
529        ZiweiMaj
530            | TianjiMaj
531            | TaiyangMaj
532            | WuquMaj
533            | TiantongMaj
534            | LianzhenMaj
535            | TianfuMaj
536            | TaiyinMaj
537            | TanlangMaj
538            | JumenMaj
539            | TianxiangMaj
540            | TianliangMaj
541            | QishaMaj
542            | PojunMaj
543            | WenchangMin
544            | WenquMin
545            | ZuofuMin
546            | YoubiMin
547    )
548}
549
550/// 主星/辅星条目:brightness 恒存在(无亮度为空串);
551/// 四化候选星恒带 mutagen 键(无四化为空串),其余省略。
552fn primary_star_dto(s: &Star, lang: Language) -> StarDto {
553    StarDto {
554        key: s.key.as_key().to_string(),
555        name: s.name.clone(),
556        star_type: StarType::as_key(s.star_type).to_string(),
557        scope: Scope::as_key(s.scope).to_string(),
558        brightness: Some(
559            s.brightness
560                .map(|b| translate_brightness(b, lang).to_string())
561                .unwrap_or_default(),
562        ),
563        brightness_key: s.brightness.map(|b| b.as_key().to_string()),
564        mutagen: if is_mutagen_candidate(s.key) {
565            Some(
566                s.mutagen
567                    .map(|m| translate_mutagen(m, lang).to_string())
568                    .unwrap_or_default(),
569            )
570        } else {
571            None
572        },
573        mutagen_key: s.mutagen.map(|m| m.as_key().to_string()),
574    }
575}
576
577/// 杂耀/流耀条目:仅 name/type/scope 三键。
578fn bare_star_dto(s: &Star) -> StarDto {
579    StarDto {
580        key: s.key.as_key().to_string(),
581        name: s.name.clone(),
582        star_type: StarType::as_key(s.star_type).to_string(),
583        scope: Scope::as_key(s.scope).to_string(),
584        brightness: None,
585        brightness_key: None,
586        mutagen: None,
587        mutagen_key: None,
588    }
589}
590
591fn pillar(p: (HeavenlyStem, EarthlyBranch), lang: Language) -> [String; 2] {
592    [
593        translate_heavenly_stem(p.0, lang).to_string(),
594        translate_earthly_branch(p.1, lang).to_string(),
595    ]
596}
597
598/// 四柱的语言无关标识,与 `pillar` 一一对应。
599fn pillar_keys(p: (HeavenlyStem, EarthlyBranch)) -> [String; 2] {
600    [p.0.as_key().to_string(), p.1.as_key().to_string()]
601}
602
603fn palace_dto(p: &PalaceData, lang: Language) -> PalaceDto {
604    PalaceDto {
605        index: p.index,
606        name: translate_palace(p.name, lang).to_string(),
607        name_key: p.name.as_key().to_string(),
608        is_body_palace: p.is_body_palace,
609        is_original_palace: p.is_original_palace,
610        heavenly_stem: translate_heavenly_stem(p.heavenly_stem, lang).to_string(),
611        heavenly_stem_key: p.heavenly_stem.as_key().to_string(),
612        earthly_branch: translate_earthly_branch(p.earthly_branch, lang).to_string(),
613        earthly_branch_key: p.earthly_branch.as_key().to_string(),
614        major_stars: p
615            .major_stars
616            .iter()
617            .map(|s| primary_star_dto(s, lang))
618            .collect(),
619        minor_stars: p
620            .minor_stars
621            .iter()
622            .map(|s| primary_star_dto(s, lang))
623            .collect(),
624        adjective_stars: p.adjective_stars.iter().map(bare_star_dto).collect(),
625        changsheng12: translate_star(p.changsheng12, lang).to_string(),
626        changsheng12_key: p.changsheng12.as_key().to_string(),
627        boshi12: translate_star(p.boshi12, lang).to_string(),
628        boshi12_key: p.boshi12.as_key().to_string(),
629        jiangqian12: translate_star(p.jiangqian12, lang).to_string(),
630        jiangqian12_key: p.jiangqian12.as_key().to_string(),
631        suiqian12: translate_star(p.suiqian12, lang).to_string(),
632        suiqian12_key: p.suiqian12.as_key().to_string(),
633        decadal: DecadalDto {
634            range: [p.decadal.range.0, p.decadal.range.1],
635            heavenly_stem: translate_heavenly_stem(p.decadal.heavenly_stem, lang).to_string(),
636            heavenly_stem_key: p.decadal.heavenly_stem.as_key().to_string(),
637            earthly_branch: translate_earthly_branch(p.decadal.earthly_branch, lang).to_string(),
638            earthly_branch_key: p.decadal.earthly_branch.as_key().to_string(),
639        },
640        ages: p.ages.clone(),
641        mutagen_star_keys: {
642            let stars = p.mutagen_stars(&MUTAGEN);
643            std::array::from_fn(|i| stars[i].as_key().to_string())
644        },
645    }
646}
647
648impl Astrolabe {
649    /// 转为 JS iztro 兼容的序列化 DTO(值按排盘语言翻译)。
650    pub fn to_dto(&self) -> AstrolabeDto {
651        let lang = self.language;
652        AstrolabeDto {
653            gender: translate_gender(self.gender, lang).to_string(),
654            solar_date: self.solar_date.clone(),
655            lunar_date: self.lunar_date.clone(),
656            chinese_date: self.chinese_date.clone(),
657            raw_dates: RawDatesDto {
658                lunar_date: RawLunarDateDto {
659                    lunar_year: self.raw_dates.lunar_date.lunar_year,
660                    lunar_month: self.raw_dates.lunar_date.lunar_month,
661                    lunar_day: self.raw_dates.lunar_date.lunar_day,
662                    is_leap: self.raw_dates.lunar_date.is_leap,
663                },
664                // rawDates 的四柱为未本地化的干支原文(任何输出语言下均为中文)
665                chinese_date: RawChineseDateDto {
666                    yearly: pillar(self.raw_dates.chinese_date.yearly, Language::ZhCN),
667                    monthly: pillar(self.raw_dates.chinese_date.monthly, Language::ZhCN),
668                    daily: pillar(self.raw_dates.chinese_date.daily, Language::ZhCN),
669                    hourly: pillar(self.raw_dates.chinese_date.hourly, Language::ZhCN),
670                    yearly_keys: pillar_keys(self.raw_dates.chinese_date.yearly),
671                    monthly_keys: pillar_keys(self.raw_dates.chinese_date.monthly),
672                    daily_keys: pillar_keys(self.raw_dates.chinese_date.daily),
673                    hourly_keys: pillar_keys(self.raw_dates.chinese_date.hourly),
674                },
675            },
676            time: self.time.clone(),
677            time_range: self.time_range.clone(),
678            sign: self.sign.clone(),
679            zodiac: self.zodiac.clone(),
680            earthly_branch_of_soul_palace: translate_earthly_branch(
681                self.earthly_branch_of_soul_palace,
682                lang,
683            )
684            .to_string(),
685            earthly_branch_of_soul_palace_key: self
686                .earthly_branch_of_soul_palace
687                .as_key()
688                .to_string(),
689            earthly_branch_of_body_palace: translate_earthly_branch(
690                self.earthly_branch_of_body_palace,
691                lang,
692            )
693            .to_string(),
694            earthly_branch_of_body_palace_key: self
695                .earthly_branch_of_body_palace
696                .as_key()
697                .to_string(),
698            soul: translate_star(self.soul, lang).to_string(),
699            soul_key: self.soul.as_key().to_string(),
700            body: translate_star(self.body, lang).to_string(),
701            body_key: self.body.as_key().to_string(),
702            five_elements_class: translate_five_elements_class(self.five_elements_class, lang)
703                .to_string(),
704            five_elements_class_key: self.five_elements_class.as_key().to_string(),
705            palaces: self.palaces.iter().map(|p| palace_dto(p, lang)).collect(),
706            gender_key: match self.gender {
707                Gender::Male => "male",
708                Gender::Female => "female",
709            }
710            .to_string(),
711            time_index: self.time_index,
712            fix_leap: self.fix_leap,
713            language: lang.as_code().to_string(),
714            config: self.config.clone().into(),
715        }
716    }
717}
718
719fn scope_dto(item: &HoroscopeItem, lang: Language) -> HoroscopeScopeDto {
720    HoroscopeScopeDto {
721        index: item.index,
722        name: item.name.clone(),
723        heavenly_stem: translate_heavenly_stem(item.heavenly_stem, lang).to_string(),
724        heavenly_stem_key: item.heavenly_stem.as_key().to_string(),
725        earthly_branch: translate_earthly_branch(item.earthly_branch, lang).to_string(),
726        earthly_branch_key: item.earthly_branch.as_key().to_string(),
727        palace_names: item
728            .palace_names
729            .iter()
730            .map(|p| translate_palace(*p, lang).to_string())
731            .collect(),
732        palace_name_keys: item
733            .palace_names
734            .iter()
735            .map(|p| p.as_key().to_string())
736            .collect(),
737        mutagen: item
738            .mutagen
739            .iter()
740            .map(|k| translate_star(*k, lang).to_string())
741            .collect(),
742        mutagen_keys: item
743            .mutagen
744            .iter()
745            .map(|k| k.as_key().to_string())
746            .collect(),
747        stars: item.stars.as_ref().map(|groups| {
748            groups
749                .iter()
750                .map(|g| g.iter().map(bare_star_dto).collect())
751                .collect()
752        }),
753    }
754}
755
756impl HoroscopeData {
757    /// 转为 JS iztro 兼容的序列化 DTO(值按给定语言翻译)。
758    pub fn to_dto(&self, lang: Language) -> HoroscopeDto {
759        HoroscopeDto {
760            lunar_date: self.lunar_date.clone(),
761            solar_date: self.solar_date.clone(),
762            decadal: scope_dto(&self.decadal, lang),
763            age: AgeDto {
764                base: scope_dto(&self.age.base, lang),
765                nominal_age: self.age.nominal_age,
766            },
767            yearly: YearlyDto {
768                base: scope_dto(&self.yearly.base, lang),
769                yearly_dec_star: YearlyDecStarDto {
770                    suiqian12: self
771                        .yearly
772                        .yearly_dec_star
773                        .suiqian12
774                        .iter()
775                        .map(|k| translate_star(*k, lang).to_string())
776                        .collect(),
777                    suiqian12_keys: self
778                        .yearly
779                        .yearly_dec_star
780                        .suiqian12
781                        .iter()
782                        .map(|k| k.as_key().to_string())
783                        .collect(),
784                    jiangqian12: self
785                        .yearly
786                        .yearly_dec_star
787                        .jiangqian12
788                        .iter()
789                        .map(|k| translate_star(*k, lang).to_string())
790                        .collect(),
791                    jiangqian12_keys: self
792                        .yearly
793                        .yearly_dec_star
794                        .jiangqian12
795                        .iter()
796                        .map(|k| k.as_key().to_string())
797                        .collect(),
798                },
799            },
800            monthly: scope_dto(&self.monthly, lang),
801            daily: scope_dto(&self.daily, lang),
802            hourly: scope_dto(&self.hourly, lang),
803        }
804    }
805}
806
807// ============================================================
808// 格局
809// ============================================================
810
811/// 参与成格的一颗星 DTO。
812#[derive(Debug, Clone, Serialize, Deserialize)]
813#[serde(rename_all = "camelCase")]
814pub struct PatternStarDto {
815    /// 语言无关星耀标识
816    pub key: String,
817    /// 名称(按排盘语言翻译)
818    pub name: String,
819    /// 落宫索引(0-11,寅宫为 0)
820    pub palace_index: usize,
821    /// 亮度显示文本,无亮度时省略
822    #[serde(skip_serializing_if = "Option::is_none")]
823    pub brightness: Option<String>,
824    /// 语言无关亮度标识,无亮度时省略
825    #[serde(skip_serializing_if = "Option::is_none")]
826    pub brightness_key: Option<String>,
827    /// 判定视角下的四化显示文本,无四化时省略
828    #[serde(skip_serializing_if = "Option::is_none")]
829    pub mutagen: Option<String>,
830    /// 语言无关四化标识,无四化时省略
831    #[serde(skip_serializing_if = "Option::is_none")]
832    pub mutagen_key: Option<String>,
833}
834
835/// 一次格局命中 DTO。
836#[derive(Debug, Clone, Serialize, Deserialize)]
837#[serde(rename_all = "camelCase")]
838pub struct PatternHitDto {
839    /// 语言无关格局标识(如 "zi_fu_tong_gong")
840    pub key: String,
841    /// 格局名称(按排盘语言翻译)
842    pub name: String,
843    /// 判定视角(origin/decadal/yearly/…)。
844    ///
845    /// 命名是双轨约定(翻译文本用本名、语言无关标识带 `Key` 后缀)的例外:
846    /// 视角没有翻译文本形态,本字段直接装语言无关标识而不叫 `scopeKey`,
847    /// 与运限流耀条目的 `scope` 键同形。
848    pub scope: String,
849    /// 成格所在宫位索引(0-11,寅宫为 0)
850    pub palace_index: usize,
851    /// 成格所在宫位在该视角下的宫名(按排盘语言翻译)
852    pub palace_name: String,
853    /// 语言无关宫名标识
854    pub palace_name_key: String,
855    /// 多口径格局命中的口径,单口径省略
856    #[serde(skip_serializing_if = "Option::is_none")]
857    pub variant: Option<String>,
858    /// 页面称「破格 / 加杀平常」的条件是否触发
859    pub broken: bool,
860    /// 参与成格的星与落宫
861    pub stars: Vec<PatternStarDto>,
862}
863
864impl crate::pattern::PatternHit {
865    /// 转为 DTO:`palace_names` 是该视角下按宫位索引排列的十二宫名
866    /// (本命为 `Astrolabe.palaces[i].name`,运限为 `HoroscopeItem.palace_names`)。
867    pub fn to_dto(&self, palace_names: &[Palace], lang: Language) -> PatternHitDto {
868        let palace_name = palace_names
869            .get(self.palace)
870            .copied()
871            .unwrap_or(Palace::Soul);
872        PatternHitDto {
873            key: self.key.as_key().to_string(),
874            name: translate_pattern(self.key, lang).to_string(),
875            scope: self.scope.as_key().to_string(),
876            palace_index: self.palace,
877            palace_name: translate_palace(palace_name, lang).to_string(),
878            palace_name_key: palace_name.as_key().to_string(),
879            variant: self.variant.map(str::to_string),
880            broken: self.broken,
881            stars: self
882                .stars
883                .iter()
884                .map(|s| PatternStarDto {
885                    key: s.star.as_key().to_string(),
886                    name: translate_star(s.star, lang).to_string(),
887                    palace_index: s.palace,
888                    brightness: s
889                        .brightness
890                        .map(|b| translate_brightness(b, lang).to_string()),
891                    brightness_key: s.brightness.map(|b| b.as_key().to_string()),
892                    mutagen: s.mutagen.map(|m| translate_mutagen(m, lang).to_string()),
893                    mutagen_key: s.mutagen.map(|m| m.as_key().to_string()),
894                })
895                .collect(),
896        }
897    }
898}
899
900impl Astrolabe {
901    /// 本命格局命中的 DTO 列表。
902    pub fn patterns_dto(&self, config: &crate::pattern::PatternConfig) -> Vec<PatternHitDto> {
903        let names: Vec<Palace> = self.palaces.iter().map(|p| p.name).collect();
904        self.patterns_with(config)
905            .iter()
906            .map(|h| h.to_dto(&names, self.language))
907            .collect()
908    }
909}
910
911impl HoroscopeData {
912    /// 某运限层视角格局命中的 DTO 列表;`Scope::Origin` 等同本命。
913    pub fn patterns_dto(
914        &self,
915        astrolabe: &Astrolabe,
916        scope: Scope,
917        config: &crate::pattern::PatternConfig,
918    ) -> Vec<PatternHitDto> {
919        let names: Vec<Palace> = match self.scope_item(scope) {
920            Some(item) => item.palace_names.clone(),
921            None => astrolabe.palaces.iter().map(|p| p.name).collect(),
922        };
923        crate::pattern::patterns_at(astrolabe, self, scope, config)
924            .iter()
925            .map(|h| h.to_dto(&names, astrolabe.language))
926            .collect()
927    }
928}