Pico-CRM 是一个家政行业的 SaaS 系统,Rust 全栈(Axum + Leptos + PostgreSQL)。它的业务不复杂:家政公司接客户电话 → 建需求 → 转订单 → 派阿姨 → 完工。但"不复杂"不代表"要一口气做完"。
做独立开发最怕一件事:功能做完了,没人用。
所以开工前我画了一条线------核心链路深度实现,非核心链路给最简单的能跑版本。系统现在还没上线,但我想把画这条线的判断逻辑写清楚:不是拍脑袋,是看业务特征。
一、核心链路:四条,全部事件溯源
家政 CRM 最核心的业务流就四个节点:
客户来电 → 建需求 → 转订单 → 排班派单 → 完工
对应到代码里是三个聚合根,全部上了事件溯源:
| 聚合根 | 代码量 | 状态机 | 说明 |
|---|---|---|---|
ServiceRequest |
142 行 | New → Confirmed → Converted / Cancelled | 客户需求,确认后可转订单 |
Order |
367 行 | Pending → Confirmed → Dispatching → InService → Completed / Cancelled | 核心工单,6 状态互转 |
Schedule |
202 行 | Planned → InService → Done / Cancelled | 排班分配,子聚合 |
为什么这三样要上事件溯源?
因为它们是纠纷高发区。哪个客服把"待确认"点成了"已派单"?哪个阿姨的排班被改过?订单金额什么时候从 299 改成了 399?------这些问题在事件流里精确到秒就能回答,不需要额外记 changelog。
事件溯源不是银弹,但它恰好打中了家政行业最痛的点:服务纠纷需要完整时间线。
而且这三个聚合各有一个投影监听器(order_projection、schedule_projection、service_request_projection),后台 tokio 任务持续消费事件写入读模型,前端查的是读模型,写走的是事件存储。
二、非核心链路:直接写库,没有状态机
画线之后,哪些模块被划到了"非核心"?
2.1 售后服务:status 是 String,不是枚举
rust
// backend/src/domain/crm/after_sales/model.rs
pub struct AfterSalesCase {
pub uuid: String,
pub order_uuid: String,
pub case_type: String,
pub description: String,
pub status: String, // ← 不是枚举,是 String
pub refund_amount_cents: Option<i64>,
// ...
}
pub fn validate_after_sales_status(value: &str) -> Result<(), String> {
match value {
"open" | "processing" | "resolved" | "closed" => Ok(()),
_ => Err(format!("invalid after sales status: {}", value)),
}
}
对比一下核心链路的 OrderStatus:
rust
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum OrderStatus {
Pending, Confirmed, Dispatching, InService, Completed, Cancelled,
}
一个是 Rust 枚举,编译期穷尽匹配;一个是 String + 运行时校验函数。差距不是代码风格问题,是我故意不给它上状态机。因为 MVP 阶段,售后流程的真实形态我还没摸清楚:客户投诉到底是"退款→重做→回访"三步还是"直接赔钱"一步?不知道的时候用 String 反而灵活,等跑通了再收敛成枚举。
2.2 售后返工调度:整个模块 64 行
rust
// backend/src/domain/crm/after_sales_rework/model.rs (完整文件)
pub struct AfterSalesRework {
pub uuid: String,
pub case_uuid: String,
pub assigned_user_uuid: String,
pub assigned_user_name: Option<String>,
pub scheduled_start_at: DateTime<Utc>,
pub scheduled_end_at: DateTime<Utc>,
pub note: Option<String>,
pub status: String, // ← 没有状态枚举
pub created_at: DateTime<Utc>,
}
pub struct CreateAfterSalesRework {
pub case_uuid: String,
pub assigned_user_uuid: String,
pub scheduled_start_at: DateTime<Utc>,
pub scheduled_end_at: DateTime<Utc>,
pub note: Option<String>,
}
pub trait AfterSalesReworkRepository: Send + Sync {
fn create_rework(
&self,
rework: CreateAfterSalesRework,
) -> impl std::future::Future<Output = Result<AfterSalesRework, String>> + Send;
}
就这些。整个文件 64 行,一个 create_rework 方法,连 update 都没有,连状态枚举都没定义。对比核心链路的 Schedule(202 行、完整状态机、事件溯源、投影监听器),这就是"最简可跑版本"的极限。
我当时想的是:如果客户投诉后真的要派人返工,先能录进去再说。更新、取消、完成这些操作------等真实跑出几个 case 再补。
2.3 客户管理:所有字段都可空
rust
pub struct Contact {
pub uuid: String,
pub name: String,
pub phone: String,
pub address: Option<String>, // ← 可空
pub community: Option<String>, // ← 可空
pub building: Option<String>, // ← 可空
pub house_area_sqm: Option<i32>, // ← 可空
pub service_need: Option<String>, // ← 可空
pub tags: Vec<String>,
// ...
}
姓名和电话是必填的------接单必须要这两样。地址、小区、楼栋、房屋面积、服务需求全部 Option。因为我不知道家政公司实际会用哪些字段:有人只记电话+地址,有人要记户型面积方便报价。先全放开,用一段时间看哪些字段填充率最高,再把高频字段改成必填。
客户管理也没有事件溯源,直接写库。客户信息改了就改了,不需要审计------跟订单金额变更完全是两种安全等级。
2.4 服务目录:最简单 CRUD
ServiceCatalog 121 行,纯 CRUD,连状态字段都没有。就是一个定价表------"深度保洁 299 元/次""擦窗 199 元/次"。不涉及流程、不涉及审计,不值得上任何架构复杂度。
三、最极致的取舍:需求来源只有一个值
这是整个项目里我最喜欢的一个设计决策:
rust
// backend/src/domain/crm/service_request/model.rs
pub enum ServiceRequestSource {
SalesManual, // ← 就这一个值
}
没有在线预约、没有小程序下单、没有 API 接入、没有客服系统对接。为什么?因为第一个商户用的是电话接单------客户打电话来,客服手动录入系统。
我完全可以"提前设计"一个 OnlineBooking、WechatMiniProgram、ApiIntegration......然后三个月用不上。但我选择不写。
等哪天真的有商户说"我要一个小程序让客户自己下单",我再加一个 WechatMiniProgram 变体,编译器会告诉我所有 match 需要补的分支------改起来五分钟的事。但提前设计,就是 0 用户验证的过度工程。
四、取舍原则
总结下来,我在 Pico-CRM 里的取舍就一条原则:
今天真实有人用的链路 → 深度实现(事件溯源 + 状态机 + 投影)。还没验证的需求 → 给最简单的能跑版本(直接写库 + String 状态 + 最少方法)。
具体落地成三条规则:
- 有纠纷追溯需求的 → 事件溯源。订单、排班、需求变更必须能回溯到秒级。客户信息和定价不需要。
- 有状态流转的 → Rust 枚举 + 状态机 。订单 6 状态互转需要
can_transition兜底。售后流程还没定型,用 String 先跑。 - 不确定会不会用的 → 不写 。
ServiceRequestSource只有一个值,AfterSalesRework只有一个方法。真需要了再加,编译器帮你找漏。
五、还没上线,凭什么敢这么砍?
说实话,这系统现在还没上线。那我凭什么判断哪些链路"高频"、哪些"低频"?
靠的不是数据,是业务特征。
家政行业有一条很明确的主路径:客户来电 → 客服录入需求 → 确认后生成订单 → 排班派阿姨 → 完工。这条路径上每一步都必然发生,没有任何分支可以跳过。它就是整个系统的"心跳"。
而售后、返工、在线预约这些东西有一个共同特征:它们都是"如果"。
- 如果客户投诉了,才走售后流程
- 如果投诉后需要重做,才走返工调度
- 如果商户想开小程序,才需要在线预约
"必然发生"和"如果发生",实现策略不应该一样。
打个比方:修一条主干道和修一条消防通道,施工标准不同。主干道每天有车流,要铺沥青、画标线、设路灯。消防通道可能十年用不上一次,但要保证"能通车"------压实碎石就够,不需要铺沥青。
代码里也是一样的逻辑:
- 核心链路铺沥青:事件溯源保证审计能力,状态机保证流转正确,投影监听保证查询性能
- 扩展链路压实碎石:直接写库能跑就行,等哪天真有车流了再升级
这个决策的风险我也清楚:万一售后模块上线第一周就爆了,现在的 64 行实现肯定扛不住。但"扛不住"比"白写了"好修------从 String 升级到枚举、从直接写库升级到事件溯源,代码量不大,而且是在真实需求的驱动下改,不是闭门造车。
反过来,如果我一上来就把售后做成事件溯源 + 完整状态机,上线后发现三个月没人投诉------那删也不是、留也不是,每次改核心链路还得带着它。没验证过的功能,提前做的越多,沉没成本越大。
六、总结
做独立开发者的 SaaS 产品,最大的成本不是写代码,是维护没人用的代码。
我的建议很简单:
- 先画一条线:哪些是业务必然发生的?哪些是"如果"才发生的?
- 必然发生的深度做:事件溯源、状态机、完整测试------该上就上
- "如果"的给最简版本:能跑就行,别提前设计------真需要的时候再加,改起来比删起来容易
你现在做的项目里,有哪些模块是"不确定用不用但先写出来了"的?评论区聊聊,一起反思。