2026-06-19
B端企业入驻订单聚合系统:从设计到优化的完整复盘
项目背景
这是一个 B 端企业入驻管理的 Demo 项目——核心场景是:企业方提交入驻申请表单(商户信息/房源/商品/报表),运营方根据地理位置批量聚合处理,并统计提交成功率。
项目的诉求很清晰:不追求华丽的 UI/UX,而是保障数据准确性 + 处理效率,这是 B 端系统和 C 端产品最本质的差异。
一、设计出发点:先理解矛盾
拿到需求后,我花时间思考的不是「怎么做」,而是 「这个系统的本质矛盾是什么」。
10w+ 表单数据的核心矛盾是:
数据量(10w+)和数据质量(准确性)之间的矛盾,必须用效率来填补。
如果只有 100 条表单,逐条查看也能应付。但 10w+ 意味着:
- 运营人员不可能逐条浏览 → 必须批量聚合
- 人工找地理位置分布不现实 → 必须自动聚类
- 出错了难排查 → 必须埋点 + 统计
所以所有的设计决策都围绕一个主线:「一个运营人员在 10w+ 数据面前最需要什么」。
二、关键设计决策
1. 为什么选地理聚合作为核心能力?
企业入驻天然带地理属性——商户地址、房源位置、服务覆盖范围。地理是 B 端数据最有业务价值的聚合维度,远优于按时间或按分类聚合:
- 同一商圈的商户可以统一处理、统一派单
- 附近的房源可以批量审批
- 甚至按区域分配给不同的审核团队
因此我做了 两阶段聚合(Geohash 粗分桶 → 距离细聚类),这不是在炫技:
阶段1: Geohash 将平面空间切分为 32×32 的网格 → O(n) 分桶
阶段2: 每个桶内做距离计算(<500m 视为同一簇) → O(n/m) 细聚类
Geohash 把 O(n²) 的全表聚类问题先降维到 O(n) 的子问题,这是 10w+ 数据量下的必要优化,不只是锦上添花。
2. 四类表单为什么不各自独立路由?
题目给了「商户信息/房源/商品/报表」四类表单。如果做四个独立 CRUD 路由,代码会大量重复(每个都写一遍分页、排序、过滤),而且扩展性差——加一个新表单类型就要加整套路由。
所以我用了 单路由多模型 的设计:
# 同一个 /api/v1/forms/{form_type} 路径
MODEL_MAP = {
"merchant": MerchantForm,
"listing": ListingForm,
"product": ProductForm,
"report": ReportForm,
}
async def query_forms(form_type: str, filters: FormFilter, db):
model = MODEL_MAP.get(form_type)
query = select(model)
if filters.status:
query = query.where(model.status == filters.status)
# ... 统一的分页、排序、过滤逻辑
这个设计在初期非常高效——四类表单共享一套查询逻辑。但这是有 trade-off 的:如果未来某个表单类型的查询逻辑差异极大(比如报表需要按 JSON 字段过滤),这个统一层就会崩,那时就该拆分了。
3. FastAPI 而非 Flask 的选择
不是因为 FastAPI 更「潮」,而是有实际的理由:
- WebSocket 原生支持:批处理需要实时推送进度,FastAPI(基于 Starlette)内置 WebSocket,Flask 需要额外搭 Flask-SocketIO
- Async ORM:SQLAlchemy 2.0 async session 可以在同一个事件循环里处理 DB 查询和 WebSocket 推送,不需要开额外的线程池
- Pydantic 集成:FastAPI + Pydantic v2 的验证层对表单数据的强校验场景特别契合——B 端每个字段都不能错
4. 成功率统计:为什么单独建一张表?
这个问题看起来很简单,但值得多想一步:为什么不用表单自身的 status 字段来算成功率?
因为表单的 status 是「当前状态」(draft/submitted/approved/rejected),不代表「提交这个动作是否成功」。提交动作可能成功但后续审核驳回——这被算作「成功提交」但「审核未通过」,是完全不同的两个维度。
所以我单独建了 submission_logs 表,记录的是 事件 而非 状态:
submission_logs:
- form_type # 哪类表单
- status # start / success / fail / timeout
- duration_ms # 耗时
- error_code # 错误码(用于聚合)
- error_msg # 错误信息(用于 debug)
- batch_id # 关联批处理批次
这样就能回答一个关键的运营问题:「高峰期提交成功率有没有下降?」 如果某个小时成功率骤降,那就是后端需要扩容的信号。
5. 前端缓存策略:为什么手写 LRU 而不是用 React Query?
React Query 确实装了,但我在表单列表页选择了手写一个 Map<string, CacheEntry> 做 LRU 缓存,原因:
- 四维缓存 key:分页缓存需要
{form_type} : {page} : {keyword} : {status}组合,React Query 的 queryKey 管理起来非常别扭 - 缓存上限透明:最多 50 个分页,超了就删最早的——运营人员翻页通常只有固定几个模式,50 个 cache entry 足够覆盖
- HTTP Cache-Control 兜底:加上了
max-age=30的 header,浏览器本身也会缓存响应
const formCache = new Map<string, { data: FormRow[]; total: number }>();
// set
formCache.set(cacheKey, { data, total });
if (formCache.size > 50) {
const firstKey = formCache.keys().next().value;
if (firstKey) formCache.delete(firstKey); // LRU eviction
}
这不意味着 React Query 不好——统计页面(analytics)只用拉一次数据,确实更适合用 React Query。
三、遇到的真实 Bug(按严重程度排序)
Bug 1: SQLAlchemy 笛卡尔积(最严重)
这是调试时间最长的 bug。我最初写了一个看似合理的聚合查询:
base = select(SubmissionLog).where(
SubmissionLog.created_at.between(start, end),
)
count_q = select(
func.count(),
func.sum(case((SubmissionLog.status == "success", 1), else_=0)),
).select_from(base.subquery())
row = (await db.execute(count_q)).one()
SQLAlchemy 生成的 SQL 是:
SELECT count(*), sum(CASE WHEN ...)
FROM (
SELECT ... FROM submission_logs WHERE created_at BETWEEN ...
) AS anon_1,
submission_logs -- ← 注意这里!
注意最后的 , submission_logs——这是笛卡尔积。原因是 count_q 里的 case((SubmissionLog.status == "success", 1)) 引用了外层 SubmissionLog 表,SQLAlchemy 自动把它 join 了进来,但没加任何 join 条件。50k × 50k = 2.5B 中间行。
教训:不要在 subquery() 内外同时引用同一个 ORM 模型。解决方案是直接用 raw SQL 或改为不套子查询的写法。
Bug 2: TypeScript 没拦住的 useState bug
const [rows] = useState<FormRow[]>([]);
// ^ 缺少 setRows
解构赋值只取了 state,没取 setter,所以 fetchForms 函数里写的是:
// Update rows/total with data ← 注释是真的,代码根本没写
这种 bug TypeScript 的类型系统也拦不住——useState 返回的 tuple 两个元素都是合法的,类型检查不会报错。最后的修复是加上:
const [rows, setRows] = useState<FormRow[]>([]);
Bug 3: PostgreSQL raw SQL 参数类型推断
AND (:ft IS NULL OR form_type = :ft)
当 form_type 传 None 时,PostgreSQL 无法推断 $3 的类型——是 text 还是 varchar?加 ::VARCHAR 显式转换解决:
AND (:ft::VARCHAR IS NULL OR form_type = :ft)
Bug 4: --reload 残留进程管理
uvicorn --reload 在代码变更时自动重启,但如果 kill 了父 shell,子进程(pid 不同)可能还占着端口。用 ss -tlnp | grep 8000 找到实际的 pid 再 kill 才是可靠的清理方式。
四、性能优化总结
| 优化项 | Before | After | 手法 |
|---|---|---|---|
| 表单列表查询 | — | < 50ms | 分页 + 状态过滤 + LRU 缓存 |
| 地理聚合 | 假 geohash 分桶 | 9k 表单→6.5k 簇 3.69s | Geohash 5精度 + Haversine + 连通分量聚类 |
| 成功率统计 | 请求挂起(笛卡尔积) | < 100ms | 重写为 raw SQL |
| 前端渲染 | react-window v1 崩溃 | 14w 数据 60fps | 适配 react-window v2(List/rowComponent) |
| 前端缓存 | 每次重新请求 | LRU 50 entry | Map 缓存 + HTTP Cache-Control |
| WebSocket | 路由存在但未接线 | 批处理 10 步推送 | 接入 notify_batch_progress 广播 |
| 前端字段展示 | 仅 ID + 状态 | 按类型动态多列 | 后端序列化全字段 + 前端 COLUMNS 定义 |
五、修复过程中的关键决策
为什么还是手写了 DBSCAN 而非用 scikit-learn?
最初计划引入 scikit-learn 的 DBSCAN,但最终选择手写 Haversine + 连通分量,原因是:
- 地球曲面距离 — scikit-learn 的 DBSCAN 用欧几里得距离,对经纬度坐标需要先投影(墨卡托/UTM),而南京范围 1°×1° 的投影误差可达 100m+
- 部署复杂度 — 加 sklearn→numpy→scipy 依赖链会让 Docker 镜像从 150MB 膨胀到 800MB+
- 实际收益有限 — DBSCAN 的
min_samples参数在这个场景下没有业务含义(一个商户也可以自成簇)
最终实现的连通分量聚类(邻接表 + DFS)对 500m 距离阈值下的商户聚合来说,效果等价于 eps=500m, min_samples=1 的 DBSCAN。
react-window v2 迁移踩的坑
v2 的 Breaking Change 不止是组件改名:
FixedSizeList→ListitemCount→rowCount,itemSize→rowHeight- 子渲染函数改为
rowComponentprop(不再是 children) rowProps变为必填(缺了直接Object.values(null)崩溃)height/width改为 CSSstyleprop
这些变更在 v2 的文档里没有 migration guide,是一点点翻类型定义文件试出来的。
六、项目地址
全部代码开源在 GitHub:**github.com/freezetheflame/B-demo**
技术栈:
- Frontend: Next.js 16 (React 19) + TypeScript + TailwindCSS + Zustand
- Backend: FastAPI + SQLAlchemy 2.0 + PostgreSQL/PostGIS + Redis
- Infra: Docker Compose
- GIS: Geohash + PostGIS GIST Index
七、测试体系:41 个测试 / 3 层
Demo 项目容易被质疑「能不能跑」,所以补了一套分层测试。核心思路:纯函数优先、API 次之、前端工具函数兜底。
后端 — pytest(27 个)
| 层 | 文件 | 测试数 | 覆盖 |
|---|---|---|---|
| 纯函数 | test_aggregation_pure.py |
17 | Geohash 编码(6) + Haversine(5) + 聚类(6) |
| API 集成 | test_api.py |
10 | 全字段、分页、过滤、聚合、WS |
纯函数测试的几个高价值 case:
# 证明 geohash 幂等性——同坐标必同哈希
def test_deterministic_same_coordinates_same_hash(self):
assert geohash_encode(31.5, 118.5, precision=6) == \
geohash_encode(31.5, 118.5, precision=6)
# 证明距离指标的物理准确性——南京新街口到鼓楼约 2.1km
def test_known_distance_reference(self):
dist = haversine_m(32.041, 118.784, 32.060, 118.781)
assert 1900 < dist < 2300 # 允许 ±5%
# 证明连通分量聚类的链式合并——A-B通 + B-C通 → A-C同簇
def test_three_points_chain_clusters_correctly(self):
pts = [(1, 32.000, 118.500),
(2, 32.003, 118.500), # ~300m
(3, 32.006, 118.500)] # ~300m, 但与 1 相距 600m
clusters = cluster_by_distance(pts, 500)
assert len(clusters) == 1 # 链式连通
纯函数测试的好处:不需要数据库、不需要网络,秒级跑完 17 个——反馈回路极短。
前端 — vitest(14 个)
| 测试组 | 测试数 | 验证 |
|---|---|---|
formatCell |
5 | ¥千分位、面积 1 位小数、库存格式、null→"-" |
statusColors |
3 | 5 种状态全、approved=绿 rejected=红 |
createFormCache |
6 | 存取、超限驱逐、clear、key 隔离 |
前端测试的秘密:把纯逻辑抽到 utils.ts。formatCell、statusColors、LRU 缓存逻辑都是纯函数——不需要 DOM、不需要 mock fetch,直接 vitest 毫秒级验证。
运行
# 后端
cd backend && pytest tests/ -v
# 前端
cd frontend && npx vitest run
欢迎讨论任何细节。