跳转至

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.tsx 3 处 TODO(Mock → 真实 fetch)


全局约束(听码必须遵守)

  1. 0 前端 breaking change:后端接口返回的字段结构,必须严格与本规格 DTO 一致;前端代码仅需替换 Mock 常量/函数(最多 ±10 行/处),禁止重写 V2 组件结构或新增 npm 依赖。
  2. ppl_id Header 兼容:MFR 门户请求 ppl_id 可选(对齐 L-20260823-05 hotfix),接口路由声明 ppl_id: str | None = Header(default=None)
  3. CORS 头:沿用现有 FastAPI CORSMiddleware(已在 app.main.py 启用),无需额外处理。
  4. DTO 命名规范:对齐现有后端风格 — app/application/{qsv|cpt|mds}/dtos.py 内新增 Request/Response Pydantic 类(按「服务职责」归属:BPL → MDS;L1 → CPT;PPL → MDS)。
  5. 测试要求:每个接口 ≥ 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_SAMPLESname + 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.pyprefix="/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)