DIP1 · MFR V2 开放报价 B1/B2/B3 后端接口 + 前端 Mock 对接规格¶
文档归属:dip1/ 技术对接规格(移交听码 WDE) 上游设计:DTL L-20260824-03 §六 6.1 B1/B2/B3/B4 阻塞项 上游 WDL:L-20260824-01(V2 核查通过 · BPL Mock 扩充 28 条 · pytest 272 全过) 参考实现:后端 CPT/QSV/DIS 现有 router 风格(Annotated + Depends + Pydantic DTO · 路由前缀 /api/v2/{svc}) 前端对接点:
dip1/frontend/apps/mfr-portal/app/quote/page.tsx3 处 TODO(Mock → 真实 fetch)
全局约束(听码必须遵守)¶
- 0 前端 breaking change:后端接口返回的字段结构,必须严格与本规格 DTO 一致;前端代码仅需替换 Mock 常量/函数(最多 ±10 行/处),禁止重写 V2 组件结构或新增 npm 依赖。
- ppl_id Header 兼容:MFR 门户请求 ppl_id 可选(对齐 L-20260823-05 hotfix),接口路由声明
ppl_id: str | None = Header(default=None)。 - CORS 头:沿用现有 FastAPI CORSMiddleware(已在 app.main.py 启用),无需额外处理。
- DTO 命名规范:对齐现有后端风格 —
app/application/{qsv|cpt|mds}/dtos.py内新增 Request/Response Pydantic 类(按「服务职责」归属:BPL → MDS;L1 → CPT;PPL → MDS)。 - 测试要求:每个接口 ≥ 3 个单元测试(成功路径 / 权限失败 / 非法入参),确保 pytest 全绿不回退。
B1:BPL 楼宇档案智能搜索接口(GET /api/v2/mds/bpl/search)¶
B1 目标:替换前端 BPL_SAMPLES 28 条 JSON Mock(L82-L145 附近 const BPL_SAMPLES: BplSample[])。
现状¶
- 前端:
useEffect+ 500ms debounce,关键词 ≥3 字模糊匹配BPL_SAMPLES的name+keywords - 前端期望的响应结构 =
BplSample[](强类型约束,禁止改字段名):type BplSample = { bpl_id: string; name: string; district: string; building_type: string; has_elevator: boolean; floor_num: number; has_parking: boolean; stairs_width_cm: number; lat: number; lng: number; keywords: string[]; btype_icon: string; };
后端路由规格¶
| 项 | 规格 |
|---|---|
| Router 文件 | app/interfaces/api/mds_router.py(若不存在则新建,对齐 qsv_router.py APIRouter(prefix="/mds", tags=["MDS-Core"])) |
| Method + Path | GET /api/v2/mds/bpl/search |
| Query 参数 | q: str(必填,长度 ≥1,模糊匹配关键词)· limit: int = 5(默认 5,最大 20) |
| 权限 | Depends(require_roles("ANY_MFR_OR_ANONYMOUS"))(对齐 MFR 门户 ppl_id 非必填;如现有权限框架无此细粒度,则 ppl_id: str | None = Header(default=None) 不做强校验 + 允许匿名) |
| Status Code | 200(成功,空结果也返回 [])· 400(q 为空或 <1 字)· 422(Validation) |
DTO 规格(MDS 服务 app/application/mds/dtos.py)¶
from pydantic import BaseModel, Field
class BplSearchOut(BaseModel):
bpl_id: str
name: str
district: str = Field(..., pattern=r"^(HK_ISLAND|KOWLOON|NEW_TERRITORIES|ISLANDS)$")
building_type: str = Field(..., pattern=r"^(PUBLIC_HDB|PRIVATE_RESIDENCE|VILLAGE_HOUSE|OFFICE_BUILDING)$")
has_elevator: bool
floor_num: int
has_parking: bool
stairs_width_cm: int = Field(..., ge=50, le=300)
lat: float = Field(..., ge=22.0, le=22.6) # 香港经纬度范围
lng: float = Field(..., ge=113.8, le=114.5)
keywords: list[str]
btype_icon: str = Field(..., min_length=1, max_length=4) # emoji 1-4 字符
字段顺序与类型严格对齐 TS
BplSample,禁止 snake→camel 转换(TS 端已 snake,前后端一致)。
前端替换点(page.tsx 精确行号定位)¶
/* ===== 现有(BPL_SAMPLES Mock,L82-L145 左右)===== */
const BPL_SAMPLES: BplSample[] = [{...}]; // 对接真实接口后 ← 整段删除
/* ===== BPL 搜索防抖 useEffect(L294-L305 现有)需替换 fetch 部分 ===== */
useEffect(() => {
if (addrKeyword.length < 3) { setAddrCandidates([]); return; }
const t = setTimeout(async () => {
/* -- ↓↓↓ TODO: B1 真实接口替换(以下 4 行 Mock)↓↓↓ -- */
const kw = addrKeyword.toLowerCase();
const hits = BPL_SAMPLES.filter(s =>
s.keywords.some(k => k.toLowerCase().includes(kw)) ||
s.name.toLowerCase().includes(kw)
).slice(0, 5);
setAddrCandidates(hits);
/* -- ↑↑↑ 替换为:
const base = process.env.NEXT_PUBLIC_API_BASE_URL ?? "http://localhost:9100";
const res = await fetch(`${base}/api/v2/mds/bpl/search?q=${encodeURIComponent(addrKeyword)}&limit=5`, { headers: { Accept: "application/json" } });
if (!res.ok) throw new Error(`BPL 搜索 HTTP ${res.status}`);
const data: BplSample[] = await res.json();
setAddrCandidates(data);
-- ↑↑↑ */
}, 500);
return () => clearTimeout(t);
}, [addrKeyword]);
注意:fetch 成功/失败处理要套 try/catch,失败时
setAddrCandidates([])+ 不影响主流程(避免因 BPL 接口超时阻塞正常报价)。
B2:MFR 开放报价 → CPT L1 线索转化接口(POST /api/v2/cpt/lead/from-mfr-quote)¶
B2 目标:替换前端 onConvertL1() 中的 localStorage Mock(L388-L404 左右)。
现状阻塞(现有 /project/create-lead 无法直接复用)¶
| # | 现有接口问题 | 新接口如何解决 |
|---|---|---|
| A | require_roles(*_OL_ROLES) 权限要求 OL 角色 → MFR token 403 |
新接口权限:require_roles("MFR") 或允许 ppl_id 任何绑定角色 + 匿名(ppl_id 可选走 "未登录 MFR" → 线索打标记待 OL 审核) |
| B | CreateLeadRequest.cst_id 必填 string → MFR 报价时无客户 ID |
新接口入参不要求 cst_id,改为后端 svc.create_or_link_cst_by_quote() 根据收货地址/联系人信息(MVP 期可先填 "待定_MFR_quote_id" 占位,L2 阶段 OL 跟进时补全) |
| C | CreateLeadRequest.lead_source_code 硬编码 S01-S06 枚举 |
新接口强制 lead_source_code = "MFR_OPEN_QUOTE"(GLY RQD-B §10.2 闭环 1 信息流定义的来源编码) |
| D | 无法关联已生成的报价单 quote_id + final_amount |
新接口 DTO 新增 quote_id / mfr_ppl_id / brand_tier / final_amount 字段 → 进入 CPT L1 线索数据闭环 |
后端路由规格¶
| 项 | 规格 |
|---|---|
| Router 文件 | app/interfaces/api/cpt_router.py(prefix="/cpt" 已有) |
| Method + Path | POST /api/v2/cpt/lead/from-mfr-quote(路径必须是字面量,放在 {project_id} 动态路径上面,避免被吞) |
| Header | ppl_id: str | None = Header(default=None)(MFR 生产商绑定 ppl;匿名时 mfr_ppl_id 存 null 标记待审核) |
| Body DTO | MfrQuoteToL1In(见下) |
| 权限 | Depends(require_roles("MFR")) 或 ppl_id 非空时松校验;匿名允许但标记 needs_ol_review = True |
| Status Code | 201(线索创建成功)· 400(quote_id 不存在或金额≤0)· 403(权限不匹配)· 404(quote_id 查无此单)· 422(Validation) |
| 依赖 CPT 服务 | svc: ProjectAppService = Depends(_svc),在服务层新增方法 create_lead_from_mfr_quote(...)(不要直接用现有 create_lead(),因为 cst_id 缺失) |
DTO 规格(app/application/cpt/dtos.py 追加)¶
from pydantic import BaseModel, Field, DecimalEncoder # DecimalEncoder 如不存在用 float 或 decimal
from decimal import Decimal
class MfrQuoteToL1In(BaseModel):
quote_id: str = Field(..., min_length=8, max_length=32, description="QSV 已生成的 quote_id,用于数据闭环反查")
mfr_ppl_id: str | None = Field(default=None, max_length=64, description="MFR ppl_id(前端 Header ppl_id 同步写一份这里冗余,便于审计)")
brand_tier: str = Field(..., pattern=r"^(STANDARD|PREMIUM|SUPER_PREMIUM)$", description="前端选中的品牌档位")
final_amount: Decimal = Field(..., gt=0, max_digits=12, decimal_places=2, description="报价最终金额 HKD")
# ---- 楼宇/地址(BPL 选中时写 bpl_id,未选中写 delivery_addr 自由文本)----
bpl_id: str | None = Field(default=None, max_length=64)
delivery_addr_text: str | None = Field(default=None, max_length=500)
delivery_addr_gps_lat: float | None = None
delivery_addr_gps_lng: float | None = None
scl_code: str | None = Field(default=None, max_length=32, description="QSV P1 scl_code(品类编码 → CPT 线索分类标签)")
initial_demand_text: str | None = Field(default=None, max_length=1000, description="前端 V2 型号/备注等自由信息拼接,供 OL L2 跟进参考")
class MfrQuoteToL1Out(BaseModel):
project_id: str = Field(..., description="CPT project_id(L1 阶段 → L2 跟进入口)")
lead_status: str = Field(..., pattern=r"^(L1_PENDING|NEEDS_OL_REVIEW)$", description="匿名走 NEEDS_OL_REVIEW")
redirect_hint: str = Field(..., description="前端可展示 Toast 文案,如:'已转为 L1 线索 待 OL 联系 项目号 XYZ'")
前端替换点(page.tsx 精确行号定位)¶
/* ===== 现有 localStorage Mock(L388-L404 左右 onConvertL1 函数内)===== */
const onConvertL1 = () => {
if (!result) return;
try {
/* -- ↓↓↓ TODO: B2 真实接口替换(以下 localStorage 5 行)↓↓↓ -- */
const leads = JSON.parse(localStorage.getItem("mfr_l1_leads") ?? "[]");
leads.push({
quote_id: result.quote_id,
source_type: "MFR_OPEN_QUOTE",
mfr_ppl_id: pplId || null,
brand_tier: brandTier,
final_amount: result.final_amount,
created_at: new Date().toISOString(),
});
localStorage.setItem("mfr_l1_leads", JSON.stringify(leads));
showToastFn(`✅ 已转为 L1 线索(本地暂存 ${leads.length} 条)· 待 OL 跟进审核`);
/* -- ↑↑↑ 替换为真实 fetch:
const base = process.env.NEXT_PUBLIC_API_BASE_URL ?? "http://localhost:9100";
const token = getToken();
const headers: Record<string, string> = {
"Content-Type": "application/json", Accept: "application/json",
};
if (token) headers["Authorization"] = `Bearer ${token}`;
if (pplId) headers["ppl_id"] = pplId;
const body = {
quote_id: result.quote_id, mfr_ppl_id: pplId || null,
brand_tier: brandTier, final_amount: result.final_amount,
bpl_id: selectedBpl?.bpl_id ?? null,
delivery_addr_text: addrFreeText || addrKeyword,
delivery_addr_gps_lat: selectedBpl?.lat ?? null,
delivery_addr_gps_lng: selectedBpl?.lng ?? null,
scl_code: CATEGORIES_8.find(c => c.code === category)?.scl ?? null,
initial_demand_text: `型号:${modelNo || "(未填)"} / 尺寸:${hCm}×${wCm}×${dCm}cm / 重量:${weightKg}kg / 工时:${laborHours}h / 易碎品:${fragileYn ? "是" : "否"}`,
};
const res = await fetch(`${base.replace(/\/?$/,"")}/api/v2/cpt/lead/from-mfr-quote`, {
method: "POST", headers, body: JSON.stringify(body),
});
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const out: { project_id: string; lead_status: string; redirect_hint: string } = await res.json();
showToastFn(out.redirect_hint);
-- ↑↑↑ (记得把 onConvertL1 声明加 async → `const onConvertL1 = async () => {`)*/
} catch (e) { showToastFn(e instanceof Error ? e.message : "转 L1 失败", "err"); }
};
注意:
onConvertL1原先是同步函数,改为 async 后 ResultV2Comp 按钮onClick={onConvertL1}仍支持(React 允许 async 事件处理)。
B3:PPL 生产商信任徽章真实等级接口(GET /api/v2/mds/ppl/{ppl_id}/badge)¶
B3 目标:替换前端 hashPplCent() 字符串哈希 Mock(L125-L129)。
现状¶
- 前端:
hashPplCent(ppl_id)对 ppl_id 字符串做 31 滚动哈希 → 0-100 → 三分位 bronze/silver/gold(纯伪,0 业务含义) - 真实字段来源:MDS PPL 档案
badge_level字段(CPT L8 NPS ≥9 推荐、口碑营销 OPS-07 累计转化 ≥ 阈值 → 升级徽章)
后端路由规格¶
| 项 | 规格 |
|---|---|
| Router 文件 | app/interfaces/api/mds_router.py(B1 同文件) |
| Method + Path | GET /api/v2/mds/ppl/{ppl_id}/badge(路径字面量前缀 + 动态 ppl_id) |
| 路径参数 | ppl_id: str(与 MFR 登录 Header ppl_id 一致) |
| 权限 | Depends(require_roles("MFR")) 且 主体 ppl_id 与路径参数匹配(禁止查别家 PPL badge);无 token 或 ppl_id 不匹配 → 403(前端降级为 bronze 默认) |
| Status Code | 200 / 403 / 404(ppl_id 不存在)· 404 时前端降级为默认 bronze,不报错 |
DTO 规格(app/application/mds/dtos.py,如无此文件则参照 qsv dtos 新建)¶
from pydantic import BaseModel, Field
class PplBadgeOut(BaseModel):
ppl_id: str
badge_level: str = Field(..., pattern=r"^(BRONZE|SILVER|GOLD)$")
score: int = Field(..., ge=0, le=100, description="累计徽章分,前端可用于展示进度条")
next_level_at: int | None = Field(default=None, ge=1, le=100, description="升级到下一档所需最低分,GOLD 时为 null")
trust_badges: list[str] = Field(default_factory=list, description="对齐 MDS-D §5.6 CPL 信任徽章扩展:如 ['TOP_RECOMMENDED_3M','NPS_OVER_9']")
前端替换点(page.tsx 精确行号定位)¶
/* ===== 现有哈希 Mock(L125-L129 hashPplCent 函数 + L177-183 pplBadgeLevel useMemo)===== */
/* 1) 删除或保留 hashPplCent 纯函数(留作 ppl_id 不存在时的 fallback 兜底) */
/* 2) pplBadgeLevel useMemo(L177-183 左右)改为 SWR/fetch(或 useEffect + 1 次请求)
推荐方案:新增 state + useEffect 拉取(不引入 swr,保持 0 依赖约束): */
const [pplBadgeRemote, setPplBadgeRemote] = useState<PplBadgeOut | null>(null);
useEffect(() => {
if (!pplId) { setPplBadgeRemote(null); return; }
const base = process.env.NEXT_PUBLIC_API_BASE_URL ?? "http://localhost:9100";
const token = getToken();
const headers: Record<string, string> = { Accept: "application/json" };
if (token) headers["Authorization"] = `Bearer ${token}`;
fetch(`${base}/api/v2/mds/ppl/${encodeURIComponent(pplId)}/badge`, { headers })
.then(r => r.ok ? r.json() : null)
.then((d: PplBadgeOut | null) => setPplBadgeRemote(d))
.catch(() => setPplBadgeRemote(null)); // 网络失败降级不阻塞报价
}, [pplId]);
/* 3) pplBadgeLevel 三分位判断改为优先远程 badge_level */
const pplBadgeLevel: "BRONZE"|"SILVER"|"GOLD" = useMemo(() => {
if (pplBadgeRemote?.badge_level) return pplBadgeRemote.badge_level; // 优先真实值
if (!pplId) return "BRONZE";
const h = hashPplCent(pplId); // fallback 哈希(接口 404/网络失败)
if (h >= 67) return "GOLD";
if (h >= 34) return "SILVER";
return "BRONZE";
}, [pplBadgeRemote, pplId]);
ResultV2Comp 信任徽章展示补充(V2 现有代码未绘制 score/进度条/trust_badges 小徽章):B3 接口上线后,在 section④ 品牌档位卡下方追加 1 行小 badge chip
<span className="badge-green">NPS 推荐 ≥ 9</span>,用pplBadgeRemote.trust_badges.map()。(如 0 依赖 CSS 无法画 chip,可复用 globals.css 中的badge-green/gold/silver)。
B4(附 · 移交听云):MFR V2 生产部署(非编码,此处略)¶
WDL §5 #2、DTL L-20260824-04 §4.2 B4,移交听云 OCM,路径: 1. 听码完成 B1~B3 接口并 pytest 全过 2. 听写打包代码包 + scp/rsync 推送听云本地暂存区(AGENTS 双轨交付机制 §双轨交付路径) 3. 听云按 L-20260823-05 部署流程:后端 dip1-backend systemd restart + 前端 Next standalone build → mfr-portal systemd restart 4. OCL 写对应日志记录
测试验收矩阵(听码必须全部通过)¶
| 编号 | 用例 | 接口 | 预期结果 |
|---|---|---|---|
| T-B1-01 | GET /mds/bpl/search?q=尖沙咀&limit=5(关键词命中) |
B1 | 200 · 返回 ≥1 条,字段与 BplSearchOut 一致 |
| T-B1-02 | GET /mds/bpl/search?q=(空 q) |
B1 | 400/422 · ValidationError |
| T-B1-03 | GET /mds/bpl/search?q=ZZZZZZZ无匹配 |
B1 | 200 · 返回 [](前端正确渲染下拉为空) |
| T-B2-01 | MFR 角色 token POST /cpt/lead/from-mfr-quote(全字段合法 + ppl_id=有效) |
B2 | 201 · project_id 存在,lead_status=L1_PENDING,quote_id 已落 CPT 库关联 |
| T-B2-02 | 匿名无 token POST(ppl_id 空) | B2 | 201 · lead_status=NEEDS_OL_REVIEW(线索入 OL 审核池) |
| T-B2-03 | OL 角色 token 调此接口(角色不匹配) | B2 | 403 禁止(权限边界) |
| T-B2-04 | quote_id 不存在 / final_amount ≤ 0 | B2 | 400/404 · 不创建无效线索 |
| T-B3-01 | MFR token 查 自己的 ppl_id/badge |
B3 | 200 · badge_level ∈ |
| T-B3-02 | MFR token 查 别人的 ppl_id/badge(越权) |
B3 | 403 Forbidden |
| T-B3-03 | ppl_id 不存在(DB 无该档) | B3 | 404(前端静默降级 bronze,不阻断报价) |
| T-INTEG-01 | 前端 3 处 Mock 替换后,V2 流程全链路:选品类→地址拉 B1→提交报价→L1 调 B2→徽章拉 B3 | 端到端 | typecheck 0 错,build 成功,local dev 手动 8 项回归 |
代码引用(精确锚点)¶
| 模块 | 路径 | 锚点 |
|---|---|---|
| BPL Mock 常量 | page.tsx | L82-L145 const BPL_SAMPLES: BplSample[] |
| BPL 防抖 useEffect | page.tsx | L294-L305 useEffect 搜候选 |
| CPT L1 localStorage Mock | page.tsx | L388-L404 onConvertL1() |
| PPL 哈希 Mock | page.tsx | L125-L129 hashPplCent() + L177-L183 pplBadgeLevel |
| CPT create-lead 参考签名 | cpt_router.py | L50-L63 POST /project/create-lead |
| CPT CreateLeadRequest DTO | dtos.py | L12-L19 class CreateLeadRequest |
| PPL Header 热修复参考 | qsv_router.py | L112 ppl_id: str \| None = Header(default=None) |