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/search:q: 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 -q → 284 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。