05 数据建模:struct / enum / derive / Option(结合本项目模型)

本章目标

  • 你能用 Rust 的类型系统把“业务不变量”表达清楚(避免到处塞字符串和魔法值)。
  • 你能读懂并扩展 asset-light 的领域模型(Asset / Snapshot / AllocationPlan)。
  • 你能理解“稳定编码(code)”与“展示名(display_name)”分离的工程价值。

1. Rust 里怎么“建模”

1.1 struct:描述“一个东西长什么样”

Rust 的 struct 更像是:

  • TStype Asset = { ... } + 一些 helper function
  • Java:POJO + 静态工厂方法 + getter/setter(但 Rust 更倾向不可变/显式修改)

本项目的 Asset 定义在:src/models/asset.rs

它把“资产条目”拆成多个正交维度:

  • scope: AssetScope:口径(是否纳入再平衡)
  • category: Category:资产大类(风险暴露维度)
  • sub_asset_class: String:子资产类别稳定编码(例如 EQ_BROAD
  • vehicle_type: VehicleType:工具类型(ETF/基金/股票等“持有载体”)

这四个字段非常关键:它们让 UI/分析/DB 都可以围绕同一套“结构化语义”工作。

1.2 enum:描述“一个东西只能是这几种之一”

Rust 的 enum 是“可穷举的联合类型”(Sum Type):

  • TStype Category = "CASH" | "FI" | ...(但 TS 运行时还是字符串)
  • Javaenum Category { ... }

Rust 的强项在于:match 必须覆盖所有分支,使得“漏处理”在编译期就能被抓住。

本项目大量使用 enum:

  • src/models/category.rsCategory
  • src/models/asset_scope.rsAssetScope
  • src/models/vehicle_type.rsVehicleType

1.3 derive:把“样板代码”交给编译器生成

你会在模型上看到非常多的 #[derive(...)]

  • Debug:方便打印/调试(println!("{:?}", x)
  • Clone:允许显式复制(UI 层经常需要)
  • Copy:轻量值语义复制(常用于小枚举,如 Category
  • PartialEq/Eq/Hash:用于比较、作为 HashMap key 等
  • Serialize/Deserialize:与 serde 结合做序列化(未来做导出/导入会很有用)

例子:CategoryCopy 的(Clone, Copy),而 Asset 不是(包含 StringDecimal 等堆/大对象)。


2. 本项目的建模策略:稳定编码 vs 展示名

2.1 为什么需要稳定编码

UI 展示名可能会变(文案、翻译、缩写),但数据库里存的数据应该尽量稳定。
因此本项目通常采用:

  • DB 存:稳定编码(CASH / FI / EQ_BROAD / ETF …)
  • UI 展示:中文名/图标/颜色(display_name() / icon() / color()

示例:Category 同时提供:

  • as_str():稳定编码(持久化)
  • from_str():从编码解析(读库/兼容旧数据)
  • display_name():展示名(UI)

对应实现见:src/models/category.rs

2.2 “子资产类别”为什么在 Asset 里是 String

你会注意到:

  • 子资产类别标准枚举在:src/models/asset_sub_category.rsAssetSubCategory
  • Asset.sub_asset_classString

这是一个典型工程权衡:
String 存储稳定编码让系统更“前向兼容”(未来增加子类别不必立刻升级所有数据结构),同时仍然可以用 AssetSubCategory 提供:

  • 编码全集(all()
  • 按大类枚举(all_for_category()
  • 从 code 解析(from_code()
  • 校验编码属于哪个大类(is_valid_for_category()

你可以把它理解为:“DB 里是稳定字符串协议,代码里用 enum 提供字典与校验”。


3. 结合代码:读懂 Asset / Snapshot / Plan

3.1 Asset(资产条目)

文件:src/models/asset.rs

重点:

  • Asset::new(...) 是“构造器/工厂方法”,统一初始化 id/created_at/updated_at
  • notes: Option<String> 明确表达“可能没有备注”

典型构造方式(见测试代码):

  • 子资产类别:AssetSubCategory::CashMmf.code().to_string()
  • 金额:用 rust_decimal_macros::dec!() 避免浮点误差

3.2 Snapshot(盘点快照)

文件:src/models/snapshot.rs

关键设计:

  • SnapshotItem 会冗余存储 asset_name/scope/category/sub_asset_class/vehicle_type
    这样即使未来资产条目被改名/改分类,历史快照仍然能反映当时状态(可审计)。
  • Snapshot::new(items) 自动计算 total_value

3.3 AllocationPlan(配置方案)

文件:src/models/plan.rs

关键设计:

  • Allocation.category: Category 用 enum 表达“配置只能基于大类”
  • target_percentage: Decimal 表达比例,避免 f64 的误差
  • get_target(&Category) 提供便利查询(服务层/页面层都会用)

你可以把 Plan 理解成“目标权重向量”,而资产是“当前权重向量”,偏离分析就是两者差异。

对应偏离计算见:src/services/analysis_service.rs


4. 你需要掌握的建模技巧(从易到难)

4.1 用 enum 替代字符串魔法值

优先让业务状态成为 enum

  • 更可读
  • 更可 refactor
  • 更少“拼错字符串”的隐患

本项目里 DB 仍然需要字符串编码,但代码内部尽量用 enum 表达语义。

4.2 用 Option 表达“可缺失字段”

不要用空字符串 "" 代表“没有值”。
本项目里:

  • notes: Option<String>
  • AllocationPlan.description: Option<String>

4.3 用 Display / FromStr 把“协议转换”集中管理

一个很实用的工程习惯:

  • 解析/格式化逻辑不要散落在 Repository / UI 里
  • 集中在模型自身(例如 Category::from_strCategory::as_str

当前项目主要使用自定义的 from_str/as_str 方法;进阶阶段你可以把它们升级为实现标准 trait:

  • impl std::fmt::Display for Category
  • impl std::str::FromStr for Category

Category 已经实现了 Display


5. 本章练习(强烈建议做)

练习 A:给 VehicleType 实现 Display

当前 VehicleTypedisplay_name(),但没有实现 Display
你可以在 src/models/vehicle_type.rs 里增加:

  • impl std::fmt::Display for VehicleType { ... }

目标:让你习惯“把展示逻辑集中在类型上”,UI 里直接 {vehicle_type} 更自然。

练习 B:给 Asset 增加一个轻量校验方法 validate()

src/models/asset.rs 增加:

  • 校验 sub_asset_class 是否属于 category(用 AssetSubCategory::is_valid_for_category

这会让你理解:

  • 业务不变量写在模型里,而不是到处 scattered if
  • 校验失败最好用 Result<(), E> 表达(为下一章“错误处理”做铺垫)

练习 C(进阶):把 sub_asset_class: String 包装成 newtype

比如:

  • pub struct SubAssetClassCode(String);

好处:

  • 更明确的语义(避免把任意 String 当成 code)
  • 更容易集中实现校验/显示

代价:

  • 需要更新 DB 读写与序列化/反序列化逻辑

建议你先做 A/B,熟练后再挑战 C。