x-iztro 0.2.0

Zi Wei Dou Shu (Purple Star Astrology) chart engine, field-for-field identical to iztro
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
//! JS iztro 兼容的序列化 DTO。
//!
//! 核心模型(`Astrolabe`/`HoroscopeData`)以枚举承载数据,供 Rust 调用方做
//! 类型安全查询;跨语言绑定(FFI/PyO3)输出的 JSON 则来自本模块的 DTO:
//! 键为 camelCase、值为按排盘语言翻译的字符串,结构与 JS iztro 的
//! `JSON.stringify` 输出一致(不含其 `plugins`/`copyright` 及运限对象内嵌的
//! `astrolabe` 等实现细节字段)。
//!
//! 在 JS 字段集之外附加两类扩展:
//! - 排盘上下文(`genderKey`/`timeIndex`/`fixLeap`/`language`/`config`),
//!   使消费方能以纯参数(无状态)方式发起运限计算;
//! - 语言无关标识(星/宫/干支/四化/亮度的 `*key`/`*Key(s)` 字段,取值为
//!   iztro i18n key),供强类型绑定做跨语言的身份判断与枚举映射;宫位另带
//!   `mutagenStarKeys`(本宫天干化出的禄权科忌四星),绑定层据此做飞星判断。
//!
//! 出错时三条绑定出口(FFI/wasm/PyO3)返回同一形状的错误 JSON
//! `{"error": "<描述>", "code": "<分类>"}`,分类取值见 [`crate::error::BridgeError`]。

use serde::{Deserialize, Serialize};

use crate::data::stars::{MUTAGEN, StarKey};
use crate::data::types::*;
use crate::error::BridgeError;
use crate::i18n::{
    translate_brightness, translate_earthly_branch, translate_five_elements_class,
    translate_gender, translate_heavenly_stem, translate_mutagen, translate_palace, translate_star,
};
use crate::models::astrolabe::Astrolabe;
use crate::models::horoscope::{HoroscopeData, HoroscopeItem};
use crate::models::palace::PalaceData;
use crate::models::star::Star;

/// 星耀 DTO。
/// 主星/辅星的 `brightness` 恒存在(无亮度为空串);无四化时省略 `mutagen` 键;
/// 杂耀与运限流耀省略 `brightness` 与 `mutagen`。
/// `key`/`brightnessKey`/`mutagenKey` 为语言无关标识(x-iztro 扩展),
/// 供强类型绑定做跨语言的身份判断。
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct StarDto {
    /// 语言无关星耀标识(iztro i18n key,如 "ziweiMaj")
    pub key: String,
    /// 名称(按排盘语言翻译)
    pub name: String,
    /// 星耀类型(major/soft/tough/adjective/flower/helper/lucun/tianma)
    #[serde(rename = "type")]
    pub star_type: String,
    /// 作用范围(origin/decadal/yearly/monthly/daily/hourly)
    pub scope: String,
    /// 亮度显示文本;主星辅星恒有该键(无亮度为空串),杂耀与流耀省略
    #[serde(skip_serializing_if = "Option::is_none")]
    pub brightness: Option<String>,
    /// 语言无关亮度标识("miao" 等),无亮度时省略
    #[serde(skip_serializing_if = "Option::is_none")]
    pub brightness_key: Option<String>,
    /// 四化显示文本;四化候选星恒有该键(无四化为空串),其余省略
    #[serde(skip_serializing_if = "Option::is_none")]
    pub mutagen: Option<String>,
    /// 语言无关四化标识("sihuaLu" 等),无四化时省略
    #[serde(skip_serializing_if = "Option::is_none")]
    pub mutagen_key: Option<String>,
}

/// 大限区间与干支 DTO。
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct DecadalDto {
    /// 大限起止虚岁(起始、截止,含两端)
    pub range: [u32; 2],
    /// 天干(按排盘语言翻译)
    pub heavenly_stem: String,
    /// 语言无关天干标识("jiaHeavenly" 等)
    pub heavenly_stem_key: String,
    /// 地支(按排盘语言翻译)
    pub earthly_branch: String,
    /// 语言无关地支标识("ziEarthly" 等)
    pub earthly_branch_key: String,
}

/// 宫位 DTO。
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct PalaceDto {
    /// 宫位索引(0-11,寅宫为 0)
    pub index: usize,
    /// 名称(按排盘语言翻译)
    pub name: String,
    /// 语言无关宫位标识("soulPalace" 等)
    pub name_key: String,
    /// 是否身宫
    pub is_body_palace: bool,
    /// 是否来因宫
    pub is_original_palace: bool,
    /// 天干(按排盘语言翻译)
    pub heavenly_stem: String,
    /// 语言无关天干标识
    pub heavenly_stem_key: String,
    /// 地支(按排盘语言翻译)
    pub earthly_branch: String,
    /// 语言无关地支标识
    pub earthly_branch_key: String,
    /// 主星列表(按安放顺序)
    pub major_stars: Vec<StarDto>,
    /// 辅星列表(按安放顺序)
    pub minor_stars: Vec<StarDto>,
    /// 杂耀列表(按安放顺序)
    pub adjective_stars: Vec<StarDto>,
    /// 长生十二神(按排盘语言翻译)
    pub changsheng12: String,
    /// 长生十二神的语言无关标识
    pub changsheng12_key: String,
    /// 博士十二神(按排盘语言翻译)
    pub boshi12: String,
    /// 博士十二神的语言无关标识
    pub boshi12_key: String,
    /// 将前十二神(按排盘语言翻译)
    pub jiangqian12: String,
    /// 将前十二神的语言无关标识
    pub jiangqian12_key: String,
    /// 岁前十二神(按排盘语言翻译)
    pub suiqian12: String,
    /// 岁前十二神的语言无关标识
    pub suiqian12_key: String,
    /// 大限信息
    pub decadal: DecadalDto,
    /// 小限经过的虚岁列表
    pub ages: Vec<u32>,
    /// x-iztro 扩展:本宫天干化出的四颗星的语言无关标识,顺序为禄、权、科、忌。
    /// 取自排盘时生效的四化表(含 `Config` 的自定义覆盖),
    /// 绑定层据此做飞星判断,无须各自再抄一份四化表。
    pub mutagen_star_keys: [String; 4],
}

/// 数字化农历生日 DTO。
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct RawLunarDateDto {
    /// 农历年
    pub lunar_year: i64,
    /// 农历月(1-12,闰月与否见 is_leap)
    pub lunar_month: u32,
    /// 农历日(1-30)
    pub lunar_day: u32,
    /// 是否闰月
    pub is_leap: bool,
}

/// 四柱干支 DTO(每柱为 [天干, 地支] 两元素数组)。
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct RawChineseDateDto {
    /// 年柱(天干、地支,干支原文)
    pub yearly: [String; 2],
    /// 月柱(天干、地支,干支原文)
    pub monthly: [String; 2],
    /// 日柱(天干、地支,干支原文)
    pub daily: [String; 2],
    /// 时柱(天干、地支,干支原文)
    pub hourly: [String; 2],
    /// 年柱的语言无关标识(天干、地支)
    pub yearly_keys: [String; 2],
    /// 月柱的语言无关标识
    pub monthly_keys: [String; 2],
    /// 日柱的语言无关标识
    pub daily_keys: [String; 2],
    /// 时柱的语言无关标识
    pub hourly_keys: [String; 2],
}

/// 结构化出生日期 DTO。
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct RawDatesDto {
    /// 数字化农历生日
    pub lunar_date: RawLunarDateDto,
    /// 四柱干支
    pub chinese_date: RawChineseDateDto,
}

/// 排盘配置 DTO(字符串取值,与 JS iztro 的 config() 取值一致)。
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ConfigDto {
    /// "normal" | "exact"
    pub year_divide: String,
    /// "normal" | "exact"
    pub horoscope_divide: String,
    /// "normal" | "birthday"
    pub age_divide: String,
    /// "forward" | "current"
    pub day_divide: String,
    /// "default" | "zhongzhou"
    pub algorithm: String,
    /// "heaven" | "earth" | "human"
    pub astro_type: String,
}

/// 星盘 DTO。
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct AstrolabeDto {
    /// 性别(按排盘语言翻译)
    pub gender: String,
    /// 阳历日期("YYYY-M-D")
    pub solar_date: String,
    /// 农历日期中文表示
    pub lunar_date: String,
    /// 干支纪日四柱展示串
    pub chinese_date: String,
    /// 结构化的出生日期信息
    pub raw_dates: RawDatesDto,
    /// 时辰名称(按排盘语言)
    pub time: String,
    /// 时辰对应的时间段
    pub time_range: String,
    /// 星座(按排盘语言)
    pub sign: String,
    /// 生肖(按排盘语言)
    pub zodiac: String,
    /// 命宫地支(按排盘语言翻译)
    pub earthly_branch_of_soul_palace: String,
    /// 命宫地支的语言无关标识
    pub earthly_branch_of_soul_palace_key: String,
    /// 身宫地支(按排盘语言翻译)
    pub earthly_branch_of_body_palace: String,
    /// 身宫地支的语言无关标识
    pub earthly_branch_of_body_palace_key: String,
    /// 命主星(按排盘语言翻译)
    pub soul: String,
    /// 命主星的语言无关标识
    pub soul_key: String,
    /// 身主星(按排盘语言翻译)
    pub body: String,
    /// 身主星的语言无关标识
    pub body_key: String,
    /// 五行局(按排盘语言翻译)
    pub five_elements_class: String,
    /// 五行局的语言无关标识("water2nd" 等)
    pub five_elements_class_key: String,
    /// 十二宫数据
    pub palaces: Vec<PalaceDto>,
    /// x-iztro 扩展:机器可读性别("male"/"female",无状态运限所需)
    pub gender_key: String,
    /// x-iztro 扩展:出生时辰索引(无状态运限所需)
    pub time_index: u8,
    /// x-iztro 扩展:是否修正闰月
    pub fix_leap: bool,
    /// x-iztro 扩展:排盘语言("zh-CN" 等)
    pub language: String,
    /// x-iztro 扩展:排盘配置
    pub config: ConfigDto,
}

/// 运限单层级 DTO(大限/流年/流月/流日/流时)。
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct HoroscopeScopeDto {
    /// 宫位索引(0-11,寅宫为 0)
    pub index: usize,
    /// 名称(按排盘语言翻译)
    pub name: String,
    /// 天干(按排盘语言翻译)
    pub heavenly_stem: String,
    /// 语言无关天干标识
    pub heavenly_stem_key: String,
    /// 地支(按排盘语言翻译)
    pub earthly_branch: String,
    /// 语言无关地支标识
    pub earthly_branch_key: String,
    /// 该运限的十二宫名(按宫位索引排列,翻译文本)
    pub palace_names: Vec<String>,
    /// 十二宫名的语言无关标识
    pub palace_name_keys: Vec<String>,
    /// 四化星名(禄、权、科、忌,翻译文本)
    pub mutagen: Vec<String>,
    /// 四化星(禄、权、科、忌)的语言无关标识
    pub mutagen_keys: Vec<String>,
    /// 流耀在十二宫的分布;无流耀的层级省略该键
    #[serde(skip_serializing_if = "Option::is_none")]
    pub stars: Option<Vec<Vec<StarDto>>>,
}

/// 小限 DTO。
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct AgeDto {
    /// 通用运限字段
    #[serde(flatten)]
    pub base: HoroscopeScopeDto,
    /// 虚岁
    pub nominal_age: u32,
}

/// 流年十二神 DTO。
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct YearlyDecStarDto {
    /// 岁前十二神(按宫位索引排列,翻译文本)
    pub suiqian12: Vec<String>,
    /// 岁前十二神的语言无关标识
    #[serde(rename = "suiqian12Keys")]
    pub suiqian12_keys: Vec<String>,
    /// 将前十二神(按宫位索引排列,翻译文本)
    pub jiangqian12: Vec<String>,
    /// 将前十二神的语言无关标识
    #[serde(rename = "jiangqian12Keys")]
    pub jiangqian12_keys: Vec<String>,
}

/// 流年 DTO。
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct YearlyDto {
    /// 通用运限字段
    #[serde(flatten)]
    pub base: HoroscopeScopeDto,
    /// 流年十二神
    pub yearly_dec_star: YearlyDecStarDto,
}

/// 运限 DTO。
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct HoroscopeDto {
    /// 农历日期中文表示
    pub lunar_date: String,
    /// 阳历日期("YYYY-M-D")
    pub solar_date: String,
    /// 大限(未起运时为童限)
    pub decadal: HoroscopeScopeDto,
    /// 小限
    pub age: AgeDto,
    /// 流年
    pub yearly: YearlyDto,
    /// 流月
    pub monthly: HoroscopeScopeDto,
    /// 流日
    pub daily: HoroscopeScopeDto,
    /// 流时
    pub hourly: HoroscopeScopeDto,
}

// ============================================================
// 枚举 → 字符串
// ============================================================

/// 绑定层出口的错误 JSON:`{"error": "<message>", "code": "<code>"}`。
///
/// 用结构体而非 map 序列化,键序固定为 error 先、code 后;消息经 serde
/// 转义,引号、反斜杠与控制字符一律安全。
pub(crate) fn error_json(err: &BridgeError) -> String {
    #[derive(Serialize)]
    struct ErrorJson<'a> {
        error: &'a str,
        code: &'a str,
    }
    serde_json::to_string(&ErrorJson {
        error: &err.message,
        code: err.code,
    })
    .expect("错误 JSON 只含两个字符串字段,序列化不会失败")
}

/// 从 panic 载荷提取人类可读消息(downcast String/&str,兜底固定文案)。
/// 供各绑定层把核心计算的 panic 转为对外错误。
pub(crate) fn panic_message(panic: &(dyn std::any::Any + Send)) -> &str {
    panic
        .downcast_ref::<String>()
        .map(String::as_str)
        .or_else(|| panic.downcast_ref::<&str>().copied())
        .unwrap_or("computation panicked")
}

/// config JSON 的部分键补丁:缺省键取默认值。
#[derive(Debug, Default, Deserialize)]
#[serde(rename_all = "camelCase", default)]
struct ConfigPatch {
    year_divide: Option<String>,
    horoscope_divide: Option<String>,
    age_divide: Option<String>,
    day_divide: Option<String>,
    algorithm: Option<String>,
    astro_type: Option<String>,
    /// 天干标识 → 四化星标识数组(禄权科忌四项)
    mutagens: Option<std::collections::HashMap<String, Vec<String>>>,
    /// 星耀标识 → 十二宫亮度标识数组(十二项,空串表示该宫无亮度)
    brightness: Option<std::collections::HashMap<String, Vec<String>>>,
}

/// 解析绑定层的 config JSON(如 `{"algorithm":"zhongzhou"}`)。
/// `None` 或空串返回默认配置;未出现的键取默认值;非法取值报错。
///
/// # Errors
/// JSON 语法错误或任一键取值不在允许集合内时返回 `invalid_argument`。
pub fn parse_config_json(json: Option<&str>) -> Result<Config, BridgeError> {
    let json = match json {
        None => return Ok(Config::default()),
        Some(s) if s.trim().is_empty() => return Ok(Config::default()),
        Some(s) => s,
    };
    let patch: ConfigPatch = serde_json::from_str(json)
        .map_err(|e| BridgeError::invalid_argument(format!("invalid config JSON: {e}")))?;
    config_from_patch(patch)
}

/// 由已反序列化的补丁构造配置,供 `parse_config_json` 与 `serde_json::Value`
/// 入参共用——后者不必先序列化回字符串再解析一遍。
///
/// # Errors
/// 任一键取值不在允许集合内时返回 `invalid_argument`。
pub fn parse_config_value(value: &serde_json::Value) -> Result<Config, BridgeError> {
    let patch: ConfigPatch = serde_json::from_value(value.clone())
        .map_err(|e| BridgeError::invalid_argument(format!("invalid config: {e}")))?;
    config_from_patch(patch)
}

/// 开关键取值非法的统一报错:`invalid <field> '<value>': expected <expected>`
fn bad_switch(field: &str, value: &str, expected: &str) -> BridgeError {
    BridgeError::invalid_argument(format!("invalid {field} '{value}': expected {expected}"))
}

fn config_from_patch(patch: ConfigPatch) -> Result<Config, BridgeError> {
    let mut config = Config::default();
    if let Some(v) = patch.year_divide {
        config.year_divide = YearDivide::from_key(&v)
            .ok_or_else(|| bad_switch("yearDivide", &v, "'normal' or 'exact'"))?;
    }
    if let Some(v) = patch.horoscope_divide {
        config.horoscope_divide = HoroscopeDivide::from_key(&v)
            .ok_or_else(|| bad_switch("horoscopeDivide", &v, "'normal' or 'exact'"))?;
    }
    if let Some(v) = patch.age_divide {
        config.age_divide = AgeDivide::from_key(&v)
            .ok_or_else(|| bad_switch("ageDivide", &v, "'normal' or 'birthday'"))?;
    }
    if let Some(v) = patch.day_divide {
        config.day_divide = DayDivide::from_key(&v)
            .ok_or_else(|| bad_switch("dayDivide", &v, "'forward' or 'current'"))?;
    }
    if let Some(v) = patch.algorithm {
        config.algorithm = Algorithm::from_key(&v)
            .ok_or_else(|| bad_switch("algorithm", &v, "'default' or 'zhongzhou'"))?;
    }
    if let Some(v) = patch.astro_type {
        config.astro_type = AstroType::from_key(&v)
            .ok_or_else(|| bad_switch("astroType", &v, "'heaven', 'earth' or 'human'"))?;
    }

    // 自定义四化与亮度表:键与值都是语言无关标识
    if let Some(map) = patch.mutagens {
        for (stem_key, star_keys) in map {
            let stem = HeavenlyStem::from_key(&stem_key).ok_or_else(|| {
                BridgeError::invalid_argument(format!(
                    "invalid mutagens key '{stem_key}': unknown heavenly stem"
                ))
            })?;
            if star_keys.len() != 4 {
                return Err(BridgeError::invalid_argument(format!(
                    "invalid mutagens for '{stem_key}': expected 4 stars (lu, quan, ke, ji), got {}",
                    star_keys.len()
                )));
            }
            let mut stars = [StarKey::ZiweiMaj; 4];
            for (i, key) in star_keys.iter().enumerate() {
                stars[i] = StarKey::from_key(key).ok_or_else(|| {
                    BridgeError::invalid_argument(format!(
                        "invalid mutagens for '{stem_key}': unknown star '{key}'"
                    ))
                })?;
            }
            config = config.with_mutagens(stem, stars);
        }
    }
    if let Some(map) = patch.brightness {
        for (star_key, brightness_keys) in map {
            let star = StarKey::from_key(&star_key).ok_or_else(|| {
                BridgeError::invalid_argument(format!(
                    "invalid brightness key '{star_key}': unknown star"
                ))
            })?;
            if brightness_keys.len() != 12 {
                return Err(BridgeError::invalid_argument(format!(
                    "invalid brightness for '{star_key}': expected 12 entries, got {}",
                    brightness_keys.len()
                )));
            }
            let mut table = [None; 12];
            for (i, key) in brightness_keys.iter().enumerate() {
                // 空串表示该宫位无亮度
                if key.is_empty() {
                    continue;
                }
                table[i] = Some(Brightness::from_key(key).ok_or_else(|| {
                    BridgeError::invalid_argument(format!(
                        "invalid brightness for '{star_key}': unknown brightness '{key}'"
                    ))
                })?);
            }
            config = config.with_brightness(star, table);
        }
    }

    Ok(config)
}

impl From<Config> for ConfigDto {
    fn from(c: Config) -> Self {
        ConfigDto {
            year_divide: c.year_divide.as_key().to_string(),
            horoscope_divide: c.horoscope_divide.as_key().to_string(),
            age_divide: c.age_divide.as_key().to_string(),
            day_divide: c.day_divide.as_key().to_string(),
            algorithm: c.algorithm.as_key().to_string(),
            astro_type: c.astro_type.as_key().to_string(),
        }
    }
}

// ============================================================
// 模型 → DTO
// ============================================================

/// 是否属于四化候选星(十四主星与文昌/文曲/左辅/右弼)。
/// 这些星的 DTO 恒带 mutagen 键(无四化为空串),其余星省略该键。
fn is_mutagen_candidate(key: crate::data::stars::StarKey) -> bool {
    use crate::data::stars::StarKey::*;
    matches!(
        key,
        ZiweiMaj
            | TianjiMaj
            | TaiyangMaj
            | WuquMaj
            | TiantongMaj
            | LianzhenMaj
            | TianfuMaj
            | TaiyinMaj
            | TanlangMaj
            | JumenMaj
            | TianxiangMaj
            | TianliangMaj
            | QishaMaj
            | PojunMaj
            | WenchangMin
            | WenquMin
            | ZuofuMin
            | YoubiMin
    )
}

/// 主星/辅星条目:brightness 恒存在(无亮度为空串);
/// 四化候选星恒带 mutagen 键(无四化为空串),其余省略。
fn primary_star_dto(s: &Star, lang: Language) -> StarDto {
    StarDto {
        key: s.key.as_key().to_string(),
        name: s.name.clone(),
        star_type: StarType::as_key(s.star_type).to_string(),
        scope: Scope::as_key(s.scope).to_string(),
        brightness: Some(
            s.brightness
                .map(|b| translate_brightness(b, lang).to_string())
                .unwrap_or_default(),
        ),
        brightness_key: s.brightness.map(|b| b.as_key().to_string()),
        mutagen: if is_mutagen_candidate(s.key) {
            Some(
                s.mutagen
                    .map(|m| translate_mutagen(m, lang).to_string())
                    .unwrap_or_default(),
            )
        } else {
            None
        },
        mutagen_key: s.mutagen.map(|m| m.as_key().to_string()),
    }
}

/// 杂耀/流耀条目:仅 name/type/scope 三键。
fn bare_star_dto(s: &Star) -> StarDto {
    StarDto {
        key: s.key.as_key().to_string(),
        name: s.name.clone(),
        star_type: StarType::as_key(s.star_type).to_string(),
        scope: Scope::as_key(s.scope).to_string(),
        brightness: None,
        brightness_key: None,
        mutagen: None,
        mutagen_key: None,
    }
}

fn pillar(p: (HeavenlyStem, EarthlyBranch), lang: Language) -> [String; 2] {
    [
        translate_heavenly_stem(p.0, lang).to_string(),
        translate_earthly_branch(p.1, lang).to_string(),
    ]
}

/// 四柱的语言无关标识,与 `pillar` 一一对应。
fn pillar_keys(p: (HeavenlyStem, EarthlyBranch)) -> [String; 2] {
    [p.0.as_key().to_string(), p.1.as_key().to_string()]
}

fn palace_dto(p: &PalaceData, lang: Language) -> PalaceDto {
    PalaceDto {
        index: p.index,
        name: translate_palace(p.name, lang).to_string(),
        name_key: p.name.as_key().to_string(),
        is_body_palace: p.is_body_palace,
        is_original_palace: p.is_original_palace,
        heavenly_stem: translate_heavenly_stem(p.heavenly_stem, lang).to_string(),
        heavenly_stem_key: p.heavenly_stem.as_key().to_string(),
        earthly_branch: translate_earthly_branch(p.earthly_branch, lang).to_string(),
        earthly_branch_key: p.earthly_branch.as_key().to_string(),
        major_stars: p
            .major_stars
            .iter()
            .map(|s| primary_star_dto(s, lang))
            .collect(),
        minor_stars: p
            .minor_stars
            .iter()
            .map(|s| primary_star_dto(s, lang))
            .collect(),
        adjective_stars: p.adjective_stars.iter().map(bare_star_dto).collect(),
        changsheng12: translate_star(p.changsheng12, lang).to_string(),
        changsheng12_key: p.changsheng12.as_key().to_string(),
        boshi12: translate_star(p.boshi12, lang).to_string(),
        boshi12_key: p.boshi12.as_key().to_string(),
        jiangqian12: translate_star(p.jiangqian12, lang).to_string(),
        jiangqian12_key: p.jiangqian12.as_key().to_string(),
        suiqian12: translate_star(p.suiqian12, lang).to_string(),
        suiqian12_key: p.suiqian12.as_key().to_string(),
        decadal: DecadalDto {
            range: [p.decadal.range.0, p.decadal.range.1],
            heavenly_stem: translate_heavenly_stem(p.decadal.heavenly_stem, lang).to_string(),
            heavenly_stem_key: p.decadal.heavenly_stem.as_key().to_string(),
            earthly_branch: translate_earthly_branch(p.decadal.earthly_branch, lang).to_string(),
            earthly_branch_key: p.decadal.earthly_branch.as_key().to_string(),
        },
        ages: p.ages.clone(),
        mutagen_star_keys: {
            let stars = p.mutagen_stars(&MUTAGEN);
            std::array::from_fn(|i| stars[i].as_key().to_string())
        },
    }
}

impl Astrolabe {
    /// 转为 JS iztro 兼容的序列化 DTO(值按排盘语言翻译)。
    pub fn to_dto(&self) -> AstrolabeDto {
        let lang = self.language;
        AstrolabeDto {
            gender: translate_gender(self.gender, lang).to_string(),
            solar_date: self.solar_date.clone(),
            lunar_date: self.lunar_date.clone(),
            chinese_date: self.chinese_date.clone(),
            raw_dates: RawDatesDto {
                lunar_date: RawLunarDateDto {
                    lunar_year: self.raw_dates.lunar_date.lunar_year,
                    lunar_month: self.raw_dates.lunar_date.lunar_month,
                    lunar_day: self.raw_dates.lunar_date.lunar_day,
                    is_leap: self.raw_dates.lunar_date.is_leap,
                },
                // rawDates 的四柱为未本地化的干支原文(任何输出语言下均为中文)
                chinese_date: RawChineseDateDto {
                    yearly: pillar(self.raw_dates.chinese_date.yearly, Language::ZhCN),
                    monthly: pillar(self.raw_dates.chinese_date.monthly, Language::ZhCN),
                    daily: pillar(self.raw_dates.chinese_date.daily, Language::ZhCN),
                    hourly: pillar(self.raw_dates.chinese_date.hourly, Language::ZhCN),
                    yearly_keys: pillar_keys(self.raw_dates.chinese_date.yearly),
                    monthly_keys: pillar_keys(self.raw_dates.chinese_date.monthly),
                    daily_keys: pillar_keys(self.raw_dates.chinese_date.daily),
                    hourly_keys: pillar_keys(self.raw_dates.chinese_date.hourly),
                },
            },
            time: self.time.clone(),
            time_range: self.time_range.clone(),
            sign: self.sign.clone(),
            zodiac: self.zodiac.clone(),
            earthly_branch_of_soul_palace: translate_earthly_branch(
                self.earthly_branch_of_soul_palace,
                lang,
            )
            .to_string(),
            earthly_branch_of_soul_palace_key: self
                .earthly_branch_of_soul_palace
                .as_key()
                .to_string(),
            earthly_branch_of_body_palace: translate_earthly_branch(
                self.earthly_branch_of_body_palace,
                lang,
            )
            .to_string(),
            earthly_branch_of_body_palace_key: self
                .earthly_branch_of_body_palace
                .as_key()
                .to_string(),
            soul: translate_star(self.soul, lang).to_string(),
            soul_key: self.soul.as_key().to_string(),
            body: translate_star(self.body, lang).to_string(),
            body_key: self.body.as_key().to_string(),
            five_elements_class: translate_five_elements_class(self.five_elements_class, lang)
                .to_string(),
            five_elements_class_key: self.five_elements_class.as_key().to_string(),
            palaces: self.palaces.iter().map(|p| palace_dto(p, lang)).collect(),
            gender_key: match self.gender {
                Gender::Male => "male",
                Gender::Female => "female",
            }
            .to_string(),
            time_index: self.time_index,
            fix_leap: self.fix_leap,
            language: lang.as_code().to_string(),
            config: self.config.clone().into(),
        }
    }
}

fn scope_dto(item: &HoroscopeItem, lang: Language) -> HoroscopeScopeDto {
    HoroscopeScopeDto {
        index: item.index,
        name: item.name.clone(),
        heavenly_stem: translate_heavenly_stem(item.heavenly_stem, lang).to_string(),
        heavenly_stem_key: item.heavenly_stem.as_key().to_string(),
        earthly_branch: translate_earthly_branch(item.earthly_branch, lang).to_string(),
        earthly_branch_key: item.earthly_branch.as_key().to_string(),
        palace_names: item
            .palace_names
            .iter()
            .map(|p| translate_palace(*p, lang).to_string())
            .collect(),
        palace_name_keys: item
            .palace_names
            .iter()
            .map(|p| p.as_key().to_string())
            .collect(),
        mutagen: item
            .mutagen
            .iter()
            .map(|k| translate_star(*k, lang).to_string())
            .collect(),
        mutagen_keys: item
            .mutagen
            .iter()
            .map(|k| k.as_key().to_string())
            .collect(),
        stars: item.stars.as_ref().map(|groups| {
            groups
                .iter()
                .map(|g| g.iter().map(bare_star_dto).collect())
                .collect()
        }),
    }
}

impl HoroscopeData {
    /// 转为 JS iztro 兼容的序列化 DTO(值按给定语言翻译)。
    pub fn to_dto(&self, lang: Language) -> HoroscopeDto {
        HoroscopeDto {
            lunar_date: self.lunar_date.clone(),
            solar_date: self.solar_date.clone(),
            decadal: scope_dto(&self.decadal, lang),
            age: AgeDto {
                base: scope_dto(&self.age.base, lang),
                nominal_age: self.age.nominal_age,
            },
            yearly: YearlyDto {
                base: scope_dto(&self.yearly.base, lang),
                yearly_dec_star: YearlyDecStarDto {
                    suiqian12: self
                        .yearly
                        .yearly_dec_star
                        .suiqian12
                        .iter()
                        .map(|k| translate_star(*k, lang).to_string())
                        .collect(),
                    suiqian12_keys: self
                        .yearly
                        .yearly_dec_star
                        .suiqian12
                        .iter()
                        .map(|k| k.as_key().to_string())
                        .collect(),
                    jiangqian12: self
                        .yearly
                        .yearly_dec_star
                        .jiangqian12
                        .iter()
                        .map(|k| translate_star(*k, lang).to_string())
                        .collect(),
                    jiangqian12_keys: self
                        .yearly
                        .yearly_dec_star
                        .jiangqian12
                        .iter()
                        .map(|k| k.as_key().to_string())
                        .collect(),
                },
            },
            monthly: scope_dto(&self.monthly, lang),
            daily: scope_dto(&self.daily, lang),
            hourly: scope_dto(&self.hourly, lang),
        }
    }
}