跳转至

L-20260824-02 MFR V2 B1/B2/B3 后端接口实现 + 前端 Mock 真实对接

任务编号:L-20260824-02 / 执行人:听码 WDE / 日期:2026-08-24 任务来源:用户指令「听写已经推进,请听码继续」 依据规格dip1/docs/DIP1-MFR-V2-B1B2B3-接口对接规格.md(DTL L-20260824-05 §4.3 移交) 范围dip1/ 本地代码(后端 MDS/CPT 3 接口 + 前端 mfr-portal 3 处 Mock 替换 + 测试) 约束:只改 dip1/;不 git push/pull;0 新增 npm 依赖;前端组件结构不重写


1. 任务起止与输入产物

  • 开始/完成:2026-08-24
  • 输入产物:B1/B2/B3 接口对接规格(含 DTO 定义、路由签名、权限策略、11 项测试验收矩阵、前端替换点代码片段)
  • 输出产物
  • 后端:3 接口 + 2 DTO 文件改动 + 1 新 DTO 文件 + 1 新种子文件/数据 + 1 新测试文件(12 用例)
  • 前端:page.tsx 3 处 Mock → 真实 fetch
  • 本日志

2. 实现明细

B1:GET /api/v2/mds/bpl/search(BPL 楼宇搜索)

文件 改动
DTO app/application/mds/dtos.py(新建) BplSearchOut(字段与前端 TS BplSample 1:1 snake_case,含 HK 经纬度/楼宇类型/emoji 长度校验)+ PplBadgeOut(B3 用)
仓储 infrastructure/db/repositories/mds.py 新增 search_bpl(keyword, limit):ACTIVE 态 + 楼宇名/business_key/extra.keywords 命中(SQLite 全表 Python 过滤,MVP;注释标注生产可切 PG trigram)
服务 application/mds/services.py 新增 bpl_search():extra 扩展字段(has_parking/stairs_width_cm/keywords/btype_icon)取值 + 楼宇类型兜底图标映射
路由 interfaces/api/mds_router.py GET /bpl/searchq: str = Query(min_length=1)(空 q → 422)· limit 默认 5 最大 20 · 匿名可用(新增 get_optional_principal,见下)
种子 data/bpl_seed.json(新建,28 条)+ seed/bpl_seed.py(新建)+ seed/__init__.py + session.py 28 条样本由前端 BPL_SAMPLES 原样提取(node 脚本转换,保证前后端同源);幂等 INSERT(business_key 唯一);仅 SQLite 开发模式自动跑

B2:POST /api/v2/cpt/lead/from-mfr-quote(报价 → L1 线索)

文件 改动
领域 domain/cpt/enums.py LeadSource 新增 MFR_OPEN_QUOTE(RQD-B §10.2 闭环 1 来源编码)
异常 core/exceptions.py 新增 ValidationFailedError(HTTP 400:值合法但业务无效,如金额 ≤ 0;与 Pydantic 422 区分)
DTO application/cpt/dtos.py MfrQuoteToL1In(10 字段:quote_id/mfr_ppl_id/brand_tier/final_amount/bpl_id/地址三项/scl_code/initial_demand_text)+ MfrQuoteToL1Out(project_id/lead_status/redirect_hint)
服务 application/cpt/services.py 新增 create_lead_from_mfr_quote():① final_amount ≤ 0 → 400 ② quote_id 经 QuoteRepository 反查不存在 → 404 ③ cst_id 占位 待定_MFR_{quote_id}(L2 OL 补全)④ MFR 角色或带 ppl_id → L1_PENDING,纯匿名 → NEEDS_OL_REVIEW ⑤ 聚合挂 quote_ref(报价闭环)+ ppl_id ⑥ L1 采集物写入 quote_id/final_amount/brand_tier/needs_ol_review
路由 interfaces/api/cpt_router.py POST /lead/from-mfr-quote(字面量路径,201):get_optional_principal 匿名可用;已登录但非 MFR 角色(如 OL)→ 403 权限边界

B3:GET /api/v2/mds/ppl/{ppl_id}/badge(PPL 信任徽章)

  • 路由(mds_router.py):require_roles("MFR-ADM","MFR-USR") + 越权校验(JWT claim ppl_id ≠ 路径参数 → 403)
  • 服务 ppl_badge():CPT L8 NPS 反哺链路未接入前按 brand_tier 推导(STANDARD→BRONZE 30 分 / PREMIUM→SILVER 65 分 / SUPER_PREMIUM→GOLD 90 分封顶),trust_badges 暂空(TODO 标注切换点)
  • PPL 不存在 → 404(前端静默降级 bronze)

鉴权公共能力

  • core/security.py 新增 get_optional_principal():无 Bearer → None(匿名放行);有 token 正常解析(非法 token 仍 401)。B1/B2 共用。

前端 3 处 Mock 替换(apps/mfr-portal/app/quote/page.tsx

Mock 替换 说明
BPL_SAMPLES 28 条常量 + 本地 filter 删除整段常量;防抖 useEffect 改 fetch GET /mds/bpl/search(try/catch 失败置空不阻塞报价) 类型 BplSample 保留作契约
onConvertL1() localStorage 队列 改 async fetch POST /cpt/lead/from-mfr-quote,body 按规格 10 字段组装(含 V2 表单结构化参数拼接 initial_demand_text);Toast 用后端 redirect_hint React 允许 async onClick
hashPplCent() 哈希徽章 新增 pplBadgeRemote state + useEffect 拉 B3 接口;pplBadgeLevel 优先远程值,404/网络失败降级哈希兜底(函数保留);品牌卡追加徽章分/trust_badges chip 行 0 新增依赖

3. 验证结果(对齐规格 §测试验收矩阵)

后端(ruff 0.16.3 + pytest,SQLite 内存库)

编号 用例 结果
T-B1-01 q=尖沙咀 命中,字段结构与 BplSearchOut 一致 ✅ 200(另加英文关键词 q=nathan 命中 keywords 用例)
T-B1-02 q= 空 → 422
T-B1-03 无匹配 → 200 []
T-B2-01 MFR token + ppl_id → 201 L1_PENDING + project_id
T-B2-02 匿名 → 201 NEEDS_OL_REVIEW
T-B2-03 OL token → 403
T-B2-04 quote_id 不存在 → 404;final_amount=0 → 400
T-B3-01 MFR 查本主体 → 200(PREMIUM→SILVER 65 分)
T-B3-02 查别家 ppl_id → 403
T-B3-03 ppl_id 不存在 → 404
  • 新增测试文件tests/api/test_mfr_b1b2b3.py(12 用例,含 1 个补充英文关键词用例)
  • 全量回归pytest tests -q284 passed / 0 failed(272 + 新增 12)· 84s;ruff check app tests 全过
  • 端到端冒烟(带种子 SQLite 文件库):init_db 自动种 28 BPL + 3 PPL → q=尖沙咀 命中 BPL-KLN-0001、q=IFC 命中 BPL-HKI-0001、B3 徽章 200 SILVER ✅
  • 排障记录:B2 测试首跑 422,根因是测试报价单 project_id MFRQ-TEST-0001 不符 QSV DTO 的 MFRQ-yyyyMMdd-NNNN 格式校验(非接口缺陷),改为 MFRQ-20260824-0001 后全过

前端(pnpm + tsc + next 14.2.5)

  • pnpm --filter @dip1/mfr-portal run typecheck → ✅ 0 TS 错误
  • pnpm --filter @dip1/mfr-portal build → ✅ 4 页面静态生成;/quote 12.8 kB(First Load 110 kB,删除 28 条 Mock 常量后 -1.6 kB)

4. 与规格的偏差声明(均已验证不破坏契约)

# 规格 实际 理由
1 final_amount ≤ 0 的状态码 400 400(新增 ValidationFailedError,而非依赖 Pydantic gt=0 的 422) 严格对齐矩阵 T-B2-04
2 B1 权限 "ANY_MFR_OR_ANONYMOUS" get_optional_principal(匿名 + 任何登录角色均可) 规格括号已允许该简化;BPL 样本无敏感字段(无 L3/L4)
3 B2 身份识别 MFR 或匿名两态 MFR token 或 ppl_id Header 均视为已识别 → L1_PENDING ppl_id Header 即 MFR 门户身份凭证(对齐 L-20260823-05 热修复语义)
4 DTO 落点 规格建议 mds/dtos.py 采纳(新建该文件);cpt 沿用现有 dtos.py 与规格一致

5. 遗留问题与移交

# 移交方 说明
1 MFR V2 + B1/B2/B3 生产部署 听云 OCM 后端需 pip install -e .(reportlab)+ restart;前端 standalone build + restart;种子数据随 init_db 自动入 SQLite(生产 PG 由 OCM 经 MDM 导入真实楼宇库)
2 B3 trust_badges 真实化 听码(后续迭代) 待 CPT L8 NPS → MDS PPL badge_level 反哺链路(RQD-B §10.2 闭环 3),当前按 brand_tier 推导
3 B1 搜索性能 听码(规模化后) SQLite 全表过滤 → 生产切 PG trigram 索引(仓储层已注释标注)
4 浏览器端到端 8 项人工回归 听写 DT + 人工 T-INTEG-01 的人工部分(本地 dev 双端联调)
5 git 提交 + hk2026 主仓同步 听写 DT 听码不执行 push;关联 DTL L-20260824-05
6 build_api_router() 裸 except 排障优化 听码(下一迭代) 沿用 WDL L-20260824-01 §5 #1 建议

6. 修订记录

版本 修订内容
V1.0 2026-08-24 听码 WDE 初稿:B1/B2/B3 三接口 + 前端 3 Mock 真实对接 + 12 用例全过 + 全量 284 回归绿

本日志登记 B1/B2/B3 接口实现;上游规格 DTL L-20260824-05,上游前端验证 WDL L-20260824-01。