8月5号新增筛选条件
商户预警筛选功能设计:
一个「看起来简单」的筛选功能背后,隐藏了几个值得深挖的架构判断。
一、需求回顾
这个功能的起点是一句话:
连续3天未交易的通过审核商户,记录下来,方便查哪天有哪些人不交易。
随着需求的展开,它演变成了一个带有多维筛选的预警系统:
按公司信息搜索(公司名/法人/注册号)
按审核状态过滤(已通过/待审核/已驳回)
按指定上级手机号查其所有下级的流失数据
每个筛选维度背后,都有一个值得说说的架构决策。
二、筛选维度一:公司信息搜索
数据来源
公司信息(法人、注册号、公司名)本属于 cn_user_company,但我们选择了反规范化——在每日快照写入时,把这些字段冗余到 cn_user_churn_warning_record。
为什么不联表查
最直接的方案是查询时 JOIN cn_user_company:
但我没有这么做。原因:
历史快照语义:快照记录的是「统计那一天用户的状态」。如果用户之后修改了公司信息,JOIN 拿到的是现在的公司信息,而不是快照时间点的。冗余字段保证了历史快照的完整性。
查询性能:cn_user_company 有近万行,高频查询时 JOIN 每次都要关联,冗余后直接走快照表索引。
筛选简洁:搜索直接 LIKE business_name 或 LIKE register_number,不需要跨表。
代价:冗余字段在公司信息变更后不自动同步,但快照本来就是历史记录,这个代价可以接受。
三、筛选维度二:审核状态过滤
设计决策
// 前端默认传 companyStatus = 1,后端 SQL 加过滤
<if test="companyStatus != null">
and r.company_status = #{companyStatus}
</if>
默认值的选择是这里最重要的决策:默认 status=1(已通过),而不是「全部」。
理由:运营看这个页面的目的是「找需要跟进的活跃商户」,未审核或被驳回的商户在这个场景下几乎没有运营价值。所以默认过滤掉,降低认知负担。
但允许选「全部/待审核/已驳回」,给运营留了灵活空间——比如想看「即使是待审核用户也3天没交易」的情况。
四、筛选维度三:指定上级手机号
这是最有技术含量的一个维度,也是我做过几次方案选择的地方。
数据结构
系统的邀请关系通过两个字段记录:
inviter_id: 直接上级
inviter_path: /10/25/30/ 完整邀请链路径
inviter_path 是一种树形结构的路径压缩存储,用 LIKE 可以高效查任意层级的下级:
-- 查 user_id=25 的所有下级(直接+间接)
WHERE inviter_path LIKE '%/25/%'
方案选择:Java层 vs SQL层
我考虑了两种实现方式:
方案A(SQL子查询):
AND w.user_id IN (
SELECT user_id FROM cn_user WHERE inviter_path LIKE '%/X/%'
)
方案B(Java层处理):
// 先查下级列表
List<Long> subordinates = cnUserMapper.getInviterIds2(targetUserId);
// 再传给Mapper做IN过滤
query.setUserIdList(subordinates);
选择方案B,理由:
报错友好:手机号不存在时可以提前返回「该用户不存在」,而不是静默返回空数据
代码清晰:查下级和过滤预警是两个独立逻辑,放在一起的子查询难以维护
符合项目风格:getInviterIds2 在项目中已有多处复用,不新造轮子
扩展灵活:下级 ID 列表可以在 Service 层做更多处理(如限量、排重)
但方案B有个隐患:下级列表可能非常大(间接下级可能数千甚至上万)。IN 列表过长会导致 SQL 性能下降。当前数据规模下没有问题,但上规模后需要考虑分页或改用关联查询。
五、一个踩坑记录:XML <> 不转义
在实现「下级过滤」时,我在 CnUserMapper.xml 里写了:
and u.user_id <> #{getUserId}
< 在 XML 里是标签起始符,必须写成 <。这导致 MyBatis 解析 XML 失败,整个应用启动退出码1。
正确写法:
and u.user_id <> #{getUserId}
这是 XML 开发中最经典的低级错误之一。MyBatis Mapper 文件是 XML 格式,SQL 里涉及比较运算符时必须转义:
原符号 XML 转义
< <
> >(严格来说 > 不强制,但建议转义)
<> <>
<= <=
更好的防护方式是用 <![CDATA[...]]> 包裹含有特殊字符的 SQL 片段:
<![CDATA[ and u.user_id <> #{getUserId} ]]>
CDATA 块内的内容不会被 XML 解析器处理,可以写原始 SQL 字符,更直观。
六、数据快照的语义边界
筛选功能的背后,其实是「快照表」和「实时联表查询」之间的权衡。
快照表的核心价值是历史可追溯 + 查询性能,但它有一个固有的语义问题:快照是某一时刻的状态,不随时间自动更新。
这对筛选有直接影响:
筛选字段 数据时效 可能的语义问题
business_name 快照时间点 用户改了公司名后,快照里是旧名字
legal_person 快照时间点 同上
company_status 快照时间点 状态变更后快照不反映
last_trade_time 快照时间点 每天更新,较准确
当前处理方式:每次手动统计或定时任务跑时,ON DUPLICATE KEY UPDATE 会刷新当天快照,公司信息随之更新。所以实际上每天的快照是当天最新状态,只有历史快照(如昨天的)才可能有偏差。
对于运营来说,看最新一次快照(默认行为)时,数据是准确的。
七、架构总结
这个筛选功能的设计,体现了几条可以复用的原则:
原则 体现
反规范化换查询效率 冗余公司字段进快照表,避免 JOIN
默认值决定用户心智 默认 status=1,降低认知负担
Java层做报错,SQL层做过滤 下级查询在 Service 层控制边界,SQL 层做 IN 过滤
XML 特殊字符必须转义 <> → <>,或用 CDATA 块
快照语义需明确边界 快照是时间点状态,每日更新保证最新快照准确性
筛选功能不难,但把它做对——既高效又语义清晰——需要在每个维度上都做出合理的判断。这大概是架构工作的本质:不是找最复杂的解,而是找在当前约束下最合适的解。先读一下现有的 Mapper 和 Service 代码,再写这篇分析。先读一下现有的 Mapper 和 Service 代码,再写这篇分析。
智能体
读取商户预警相关代码
思考
▼
