做 CRM 的联系人列表时,有一个需求看起来特别普通:
按姓名查
按手机号查
按地址关键字查
按标签查
按跟进状态查
按姓名排序
分页展示
导出 Excel
这类需求很常见,所以也很容易被低估。
一开始你可能会觉得,不就是几个 if let Some(...) 吗?接口收到参数以后,哪里要用就在哪里判断一下,最后拼到 ORM 查询里就行。
但后台系统写久了就会发现,筛选条件真正麻烦的地方不是“怎么拼 SQL”,而是:
谁来定义允许怎么查,谁来校验参数是否合法,谁来保证列表和导出用的是同一套规则。
所以我在 Pico-CRM 里没有把联系人查询参数一路裸传到底,而是用了一个很朴素的规约对象:
ContactSpecification
它不是为了显得架构很复杂,而是为了把“查询意图”从一堆散参数里收回来。
一、查询条件为什么会越写越散
Pico-CRM 是一个家政服务 CRM,联系人就是客户池。
联系人 DTO 在 shared/src/contact.rs 里,前后端共用:
#[derive(Debug, Deserialize, Serialize, Clone, Default)]
pub struct ContactFilters {
pub user_name: Option<String>,
pub phone_number: Option<String>,
pub address_keyword: Option<String>,
pub tag: Option<String>,
pub follow_up_status: Option<String>,
}
#[derive(Debug, Deserialize, Serialize, Clone, Default)]
pub struct ContactQuery {
pub page: u64,
pub page_size: u64,
pub sort: Option<Vec<SortOption>>,
pub filters: Option<ContactFilters>,
}
这个结构非常适合作为接口参数。
但它不适合直接当领域查询规则用。
原因很简单:shared 里的 DTO 主要解决序列化和前后端传输问题,它只说明“请求里可能有哪些字段”,并不说明这些字段在业务上是否合法。
举个例子:
- 手机号能不能随便传字符串?
- 地址关键字最长允许多少?
- 标签为空字符串时算不算有效筛选?
- 跟进状态能不能传一个系统里不存在的值?
- 排序字段重复传两次怎么办?
如果这些判断散在接口层、应用层、查询层里,代码短期看着方便,长期一定会漂。
最常见的结果是:列表接口加了一条校验,导出接口忘了加;页面查出来是 20 条,导出的 Excel 又是另一套条件。
二、规约不是参数袋,而是合法查询意图
项目里的规约定义在:
backend/src/domain/crm/contact/specification.rs
核心结构很简单:
#[derive(Debug)]
pub struct ContactSpecification {
pub filters: ContactFilters,
pub sort: Vec<SortOption>,
}
impl ContactSpecification {
pub fn new(
filters: Option<ContactFilters>,
sort: Option<Vec<SortOption>>,
) -> Result<Self, ValidationError> {
let filters = filters.unwrap_or_default();
let sort = sort.unwrap_or_default();
Self::validate_filters(&filters)?;
Self::validate_sort(&sort)?;
Ok(Self { filters, sort })
}
}
这段代码的关键不是 struct,而是 new()。
它表达了一个原则:
只要你拿到了 ContactSpecification,它就应该已经是合法的查询意图。
所以校验会在构造时完成。
手机号校验放在这里:
fn is_valid_phone(phone: &str) -> bool {
let trimmed = phone.trim();
let is_cn_mobile = trimmed.len() == 11
&& trimmed.starts_with('1')
&& trimmed.chars().all(|c| c.is_ascii_digit());
let is_international = trimmed.starts_with('+')
&& trimmed.len() >= 8
&& trimmed[1..].chars().all(|c| c.is_ascii_digit());
is_cn_mobile || is_international
}
这里做了一个实际业务取舍:既支持 11 位中国手机号,也支持 + 开头的国际号码。
跟进状态也不是随便传字符串:
if let Some(follow_up_status) = &filters.follow_up_status {
if !matches!(
follow_up_status.as_str(),
"pending" | "contacted" | "quoted" | "scheduled" | "completed"
) {
return Err(ValidationError::business_rule(
"follow_up_status",
"跟进状态必须是预定义值",
));
}
}
这类校验如果放在 SQL 拼接前才做,也能跑。
但我更倾向于让规约对象自己保证合法性,因为后面的查询实现就不用一边拼条件,一边担心参数是不是脏的。
三、应用层只做翻译,不拼查询
联系人查询的应用服务在:
backend/src/application/queries/crm/contact_service.rs
分页列表的入口是 fetch_contacts():
pub async fn fetch_contacts(
&self,
params: ContactQuery,
) -> Result<ListResult<Contact>, String> {
let sort_options: Vec<SortOption> = params
.sort
.unwrap_or_default()
.into_iter()
.map(|item| item.into())
.collect();
let pagination =
Pagination::new(params.page, params.page_size).map_err(|e| e.to_string())?;
let filters: ContactFilters = params.filters.map(|f| f.into()).unwrap_or_default();
let spec = ContactSpecification::new(Some(filters), Some(sort_options))
.map_err(|e| e.to_string())?;
let (contacts, total) = self.contact_query.contacts(spec, pagination).await?;
Ok(ListResult {
items: contacts,
total,
})
}
这段代码里应用层做了三件事:
shared DTO -> domain filters/sort
分页参数校验
构造 ContactSpecification
注意,它没有拼 SeaORM 条件,也没有知道 contacts 表到底有哪些列。
这就是我比较喜欢的边界:
- 接口层负责拿参数和租户上下文
- 应用层负责把请求翻译成领域查询意图
- 领域层负责定义什么查询意图是合法的
- 基础设施层负责把查询意图翻译成数据库查询
很多项目写到后面难维护,不是因为没有分层,而是因为每一层都偷偷知道了太多别的层的事情。
四、基础设施层把规约翻译成 SeaORM 查询
真正拼查询的代码在:
backend/src/infrastructure/queries/crm/contact_query_impl.rs
查询入口会先把租户条件固定住:
let query = apply_contact_sorting(
apply_contact_filters(
Entity::find().filter(Column::MerchantId.eq(merchant_uuid)),
filters,
),
sort,
);
这里有一个很重要的项目约束:Pico-CRM 是单库多租户,业务表按 merchant_id 隔离。
所以联系人查询永远先带上:
Column::MerchantId.eq(merchant_uuid)
筛选条件则由 apply_contact_filters() 动态累加:
fn apply_contact_filters(query: Select<Entity>, filters: ContactFilters) -> Select<Entity> {
let mut condition = Condition::all();
if let Some(name) = filters.name {
condition = condition.add(Column::UserName.contains(name));
}
if let Some(phone) = filters.phone {
condition = condition.add(Column::PhoneNumber.eq(phone));
}
if let Some(keyword) = filters.address_keyword {
condition = condition.add(
Condition::any()
.add(Column::Address.contains(keyword.clone()))
.add(Column::Community.contains(keyword.clone()))
.add(Column::Building.contains(keyword)),
);
}
query.filter(condition)
}
这里用了 SeaORM 的两个条件组合:
Condition::all() 表示 AND
Condition::any() 表示 OR
所以地址关键字这一段翻译成人话就是:
在当前商户下
并且
address 包含关键字
或者 community 包含关键字
或者 building 包含关键字
代码上则是:
Condition::any()
.add(Column::Address.contains(keyword.clone()))
.add(Column::Community.contains(keyword.clone()))
.add(Column::Building.contains(keyword))
这个实现比在字符串里手拼 SQL 稳很多,也能保持 SeaORM 查询表达式的类型约束。
五、标签筛选是一个现实妥协
联系人有 tags: Vec<String>,在读模型里是 JSON 字段。
标签筛选目前的实现是:
if let Some(tag) = filters.tag {
let pattern = format!("%\"{}\"%", tag.trim());
condition = condition.add(Expr::col(Column::Tags).cast_as("text").like(pattern));
}
也就是把 JSON 转成 text,再用类似这样的模式匹配:
%"VIP"%
这不是最“数据库洁癖”的方案。
如果标签查询未来变成高频核心能力,更好的做法可能是拆一张联系人标签关联表,或者用 PostgreSQL JSONB 操作符和合适索引。
但在 Pico-CRM 当前阶段,联系人标签主要用于后台筛选,数据量和访问频率都还在 MVP 范围内。这里先用简单方案,换来开发速度和可读性。
我觉得这也是写业务系统时很重要的一点:
架构不是每个点都一步到位,而是要知道哪里是临时妥协,哪里必须收口。
ContactSpecification 收口的是规则;标签的 SQL 实现,则可以随着数据规模再升级。
六、排序也属于规约的一部分
很多人会把筛选条件当成规约,但把排序当成普通参数。
我一开始也差点这么写。
但排序其实也会影响查询语义,所以 Pico-CRM 里把它一起放进了 ContactSpecification:
#[derive(Debug, Clone)]
pub enum SortOption {
ByName(SortDirection),
}
#[derive(Debug, Clone, PartialEq)]
pub enum SortDirection {
Asc,
Desc,
}
现在只支持按姓名排序:
fn apply_contact_sorting(mut query: Select<Entity>, sort: Vec<SortOption>) -> Select<Entity> {
if sort.is_empty() {
return query.order_by_desc(Column::InsertedAt);
}
for sort in sort {
query = match sort {
SortOption::ByName(direction) => match direction {
SortDirection::Asc => query.order_by_asc(Column::UserName),
SortDirection::Desc => query.order_by_desc(Column::UserName),
},
};
}
query
}
如果没传排序,就默认:
inserted_at desc
也就是新联系人排前面。
规约构造时还会检查重复排序字段:
fn validate_sort(sort: &[SortOption]) -> Result<(), ValidationError> {
let mut fields = std::collections::HashSet::new();
for opt in sort {
let field = match opt {
SortOption::ByName(_) => "name",
};
if !fields.insert(field) {
return Err(ValidationError::business_rule(
"sort",
&format!("重复的排序字段: {}", field),
));
}
}
Ok(())
}
当前只有一个排序字段,这段看起来有点“提前设计”。
但我保留它的原因是,排序字段通常会继续增加,比如最近服务时间、更新时间、售后次数。提前把“同一字段不能重复排序”的规则放到规约里,后面扩展时不会再到处补判断。
七、列表和导出复用同一份规约
ContactSpecification 最实用的地方,是它同时被分页列表和 Excel 导出使用。
分页列表走:
let (contacts, total) = self.contact_query.contacts(spec, pagination).await?;
导出走:
let contacts = self.contact_query.all_contacts(spec).await?;
领域查询 trait 也把这两个入口放在一起:
pub trait ContactQuery: Send + Sync {
type Result: Debug + Send + Sync;
fn contacts(
&self,
spec: ContactSpecification,
pagination: Pagination,
) -> impl Future<Output = Result<(Vec<Self::Result>, u64), String>> + Send;
fn all_contacts(
&self,
spec: ContactSpecification,
) -> impl Future<Output = Result<Vec<Self::Result>, String>> + Send;
}
区别只在于:
contacts 用 spec + pagination,返回当前页和 total
all_contacts 只用 spec,返回全部匹配数据,给 Excel 导出
筛选规则是同一份。
这点在后台系统里很关键。
用户在列表页筛了“跟进状态 = 已预约”,然后点导出,他的直觉一定是:导出的就是当前筛选条件下的全部数据。
如果列表和导出各写一套条件,早晚会出现“页面看到的和导出的对不上”。
这类问题通常不是技术难题,但很伤用户信任。
八、规约模式的边界
我不是建议所有查询都抽一个 Specification。
如果只是按主键查详情:
get_contact(uuid)
那就没必要为了模式而模式。
Pico-CRM 里 get_contact() 还是直接按 merchant_id + contact_uuid 查:
let contact = Entity::find()
.filter(Column::MerchantId.eq(merchant_uuid))
.filter(Column::ContactUuid.eq(uuid))
.one(txn)
.await?;
规约更适合这些场景:
- 筛选条件超过三四个
- 参数需要业务校验
- 同一套查询规则会被列表、导出、统计复用
- 查询条件会持续扩展
- 想把“允许怎么查”和“怎么翻译成 SQL”分开
不太适合这些场景:
- 简单主键查询
- 一次性报表 SQL
- 强依赖复杂 join/group by 的统计查询
- 查询规则完全由数据库视图或专门读模型承载
我的判断标准很简单:
如果你发现同一个筛选字段在三个地方被判断了,就该考虑收口。
总结
ContactSpecification 在 Pico-CRM 里解决的不是一个抽象的设计模式问题,而是后台查询的工程卫生问题。
它把联系人筛选拆成了四个清晰边界:
shared DTO 负责前后端传输
application 负责 DTO 到领域查询意图的转换
domain spec 负责构造时校验,保证查询意图合法
infrastructure 负责把规约翻译成 SeaORM 查询
这样做以后,联系人列表和 Excel 导出复用同一套筛选规则,手机号、标签、跟进状态、重复排序这些校验也不会散落在各个接口里。
我现在写后台复杂筛选时,会先问自己一个问题:
这堆参数只是参数,还是已经值得变成一个合法的查询对象?
如果答案是后者,那规约模式就很适合上场。
你们项目里的复杂筛选条件,一般是放在 service 里,还是会单独抽成规约对象?评论区聊聊。