从开发者调试工具到消费级 App:一个 AI 电商导购项目的 C 端改造与 6 个连环踩坑实录

从开发者调试工具到消费级 App:一个 AI 电商导购项目的 C 端改造与 6 个连环踩坑实录

技术栈:LangGraph + FastAPI + PostgreSQL/Redis/ChromaDB + MCP 工具层 + React/Vite/TS

关键词:AI Agent、MCP、CPS 导购、幂等设计、无状态鉴权、时区 Bug

前言

我手上有一个自己攒的 AI 电商导购项目(代号 shopping-agent):用户用自然语言说"帮我找个 2000 以内的降噪耳机",后端用 LangGraph 编排的 Agent 去搜货、比价、做风险分析,工具层通过 MCP 协议对接商品数据源。

功能一直是能跑的,但一直有个尴尬:它是个"开发者调试工具",不是"能拿给普通用户用的产品"。

具体来说,前端只有两个页面------一个对话页、一个"失败任务排查页",后者还把死信队列的内容直接怼到用户脸上。搜索结果是一堆纯文本商品描述,点不动;没有商品详情;没有个人主页;下单流程压根没实现。

这一天的工作,就是把它从"能用"改到"敢给用户用"。过程里踩了 6 个坑,有几个挺典型,写下来复盘一下。

一、先把路线定清楚:CPS 导购模式

改造前最关键的一个决策不是技术,是产品形态:这个 App 到底要不要在站内收款?

结论是不要 。我们没支付资质,硬做站内收款就是给自己找麻烦。所以定的是经典的 CPS 导购模式:

  • 站内只记一笔"意向单"(用户想买哪个商品、哪个平台、什么价);
  • 点击"去 XX 购买"跳到第三方平台,真正的交易在那边发生;
  • 我们赚的是推广佣金。

这个决策直接决定了后端要设计什么表、前端要做什么交互,后面很多坑都跟它有关。

二、主线:C 端产品化改造 P0--P3

按依赖顺序拆了四个阶段,一天内全部落地。

P0:路由 + 拆页 + 把"失败任务"请出 C 端

原来 App.tsx 是一个巨型组件,现在引入 react-router-dom,拆成七个独立路由:

路由 页面 说明
/ 首页 hero + 分类入口 + AI 导购入口
/search 搜索结果 卡片网格
/product/:id 商品详情 大图 + 多平台比价表
/chat AI 导购 SSE 流式对话 + Agent 推理可视化
/orders 我的订单 意向单列表
/me 个人主页 画像/订单/盯价聚合
/admin/deadletter 死信管理 仅开发者可见,不进 C 端导航

关键一点:死信队列(失败任务)绝不能出现在 C 端导航里 。它是给开发者排查用的,普通用户看到"失败任务"只会一脸问号。所以单独挪到 /admin/deadletter 并且加了"开发者管理端"横幅。

同时 App.tsx 改成 NavBar + <Routes> 结构,桌面用 topnav、移动端用 tabbar,nginx.conf 补了 SPA 的 try_files 兜底(否则刷新子路由 404)。

P1:让搜索结果变成"能点的商品卡片"

这一步暴露了后端的一个缺口:原来的 MCP 工具只有 product_search / price_query / review_risk 三个,返回的都是结构化数字,没有图片、没有描述、没有跳转链接。

解决办法不是硬造,而是给 MCP 加第四个工具 product_detail:

python 复制代码
# mcp_server/main.py(契约头注释节选)
# product_detail {product_ids} -> {"products": [{product_id, name, category, brand,
#   tags, description, image, negative_rate, shop_score,
#   platforms: {平台: {price, url, url_kind}}}]}

后端再套一层读接口 catalog.py,前端只连后端、不直连 MCP:

python 复制代码
# backend/app/api/catalog.py
READ_TIMEOUT = 4  # 秒:读接口宁可快失败降级,也不让页面干等

async def _try_tool(name: str, args: dict) -> dict | None:
    """调 MCP 读接口,失败静默返回 None(调用方兜空)。"""
    try:
        return await mcp_client.call_tool(name, args, timeout=READ_TIMEOUT)
    except Exception as e:
        logger.warning("商品读接口降级 tool=%s err=%s", name, e)
        return None

前端于是有了 ProductCard.tsx(商品图 + 名称 + 平台标签 + 最低价 + 店铺分/差评率)和 PlatformPriceList.tsx(多平台比价表,按价升序、最低价高亮)。

P2:下单链路------"意向单"表

CPS 模式下订单表可以做得非常薄,只存最精简的字段,商品名/图/链接这些会在 API 层用 MCP 现拉:

sql 复制代码
CREATE TABLE IF NOT EXISTS orders (
    id BIGSERIAL PRIMARY KEY,
    user_id TEXT NOT NULL,
    session_id TEXT,
    product_id TEXT NOT NULL,
    platform TEXT,
    price NUMERIC,
    status TEXT NOT NULL DEFAULT 'intent',
    created_at TIMESTAMPTZ DEFAULT now()
);

为什么商品快照不落库?因为价格会变、图会变、链接会变,存进去就是一堆过期数据。订单表只记"意向",展示时现查现拼。

交互上,原来的 ApprovalModal(HITL 人工审批弹窗)语义从"审批"改成了"下单前确认",确认后跳 /orders。

P3:个人主页聚合

个人主页要展示"画像偏好 + 订单数 + 盯价订阅 + 历史对话",于是加了 /api/profile 做四源聚合,每个数据源各自降级、查不到给空但不抛 500。同时新建 monitor_subscriptions 表记录盯价订阅。

到这里,P0--P3 主体完工。但真正的故事从这一步才开始。

三、6 个连环坑

坑 1:下单弹窗显示的是商品 id,不是商品名

P0--P3 是先在接口层 + 单测里验的,看着都过。等到浏览器手点走查时才发现 bug:下单确认弹窗上"商品"一栏显示的是 xiaomi-buds-4-pro 这种原始 id。

根因是接口字段和前端读取字段对不上:

  • 后端 prepare_order 挂上去的是比价表里的一行,字段名是 name;
  • 前端 ApprovalModal.tsx 只读 product.title,取不到就一路兜底到了 product_id。

修法是读取顺序改成 name → title → product_id:

tsx 复制代码
// 后端 prepare_order 挂上来的是比价表那一行:字段叫 name(不是 title)
const productName = product?.name ?? product?.title ?? product?.product_id ?? '未选出具体商品'

教训很直白:接口层 + 单测全绿,不代表功能是对的。 前后端字段约定这种事,只有真点一遍才靠得住。

坑 2:意向单不幂等,审批被重复点就落多单

HITL 弹窗有个天然风险:用户手抖点两次"确认购买",或者前端网络重试,就会调两次 POST /api/orders,落两笔一模一样的意向单。

一开始想在前端做防抖,但前端防抖挡不住重试和并发,得在后端做真正的幂等。用 PG 的部分唯一索引:

sql 复制代码
-- 同一会话同一商品只记一笔:只约束有 session_id 的行,手动建单不受影响
CREATE UNIQUE INDEX IF NOT EXISTS uq_orders_session_product
    ON orders (session_id, product_id) WHERE session_id IS NOT NULL;

写入时用 ON CONFLICT DO NOTHING,撞键就把原单捞回来返回:

python 复制代码
cur.execute(
    "INSERT INTO orders (user_id, session_id, product_id, platform, price, status)"
    " VALUES (%s, %s, %s, %s, %s, %s)"
    " ON CONFLICT (session_id, product_id) WHERE session_id IS NOT NULL DO NOTHING"
    f" RETURNING {_COLUMNS}",
    (user_id, session_id, product_id, platform, price, status),
)
row = cur.fetchone()
if row is None:  # 撞唯一键:这轮对话已经下过这一单,捞回原单
    cur.execute(
        f"SELECT {_COLUMNS} FROM orders WHERE session_id = %s AND product_id = %s LIMIT 1",
        (session_id, product_id),
    )
    row = cur.fetchone()

顺带把商品校验也补了:把"数据源降级"和"真没这个货"分开,降级返回 503(可重试),查无返回 404,两种情况都不写空单(之前不存在的商品也会写一条 platform 为 NULL 的脏数据)。

坑 3:盯价状态"撒谎"

个人主页有"我的盯价"列表,展示哪些商品在监控中。原来的 active 字段恒为 true------因为它直接读的库里的值。

问题是:盯价任务是进程内 的(APScheduler 的内存 job),服务一重启内存里啥都没了,但数据库里那条订阅记录还在。结果就是明明没在监控,页面还显示"监控中"。

修法是:展示前统一用调度器的真实状态覆盖:

python 复制代码
def is_registered(session_id: str) -> bool:
    """这个会话的盯价任务当前是否真在跑。
    调度任务是进程内的,服务重启后内存里啥都没了------这时候返回 False,
    订阅记录就不能再显示「监控中」(active 要照实反映,不能撒谎)。
    """
    return bool(_scheduler and _scheduler.get_job(f"monitor:{session_id}"))

同时补了 DELETE /api/monitor/{id},取消盯价时同时摘掉内存任务和订阅记录。前端每条盯价加了"取消"按钮。

这个坑的价值在于它定义了一条原则:UI 显示的状态必须是它真实的状态,不能是"我们希望的"状态。

坑 4:个人接口在裸奔,谁都能查别人

/api/profile、/api/orders、/api/monitor 一开始只认 query 里的 user_id,也就是说:

复制代码
GET /api/profile?user_id=victim   # 直接看到别人的画像和订单

demo 阶段没有注册登录,但也不能就这么裸奔。方案是发一个无状态匿名身份 token:

python 复制代码
def _sign(user_id: str) -> str:
    sig = hmac.new(_secret(), user_id.encode(), hashlib.sha256).hexdigest()[:32]
    return f"{user_id}.{sig}"

def _verify(token: str) -> str | None:
    """验签:过了返回 user_id,伪造/串改返回 None。"""
    user_id, _, sig = token.partition(".")
    if not user_id or not sig:
        return None
    expect = hmac.new(_secret(), user_id.encode(), hashlib.sha256).hexdigest()[:32]
    return user_id if hmac.compare_digest(sig, expect) else None

token 是 user_id + HMAC-SHA256 签名,无状态、不落库 :随机 user_id 猜不到,签名验不过就是假的,Redis 挂了也不影响。前端首次访问 POST /api/auth/anon 领一个存 localStorage,之后所有请求带 X-User-Token。

关键是个人接口一律以 token 里的身份为准,外部再传 ?user_id= 也不认。订单详情和取消盯价还补了归属校验------查别人的单/订阅一律 404,不泄露"这东西存不存在"。

坑 5:数据池"永远不更新"------一个时区 Bug

这个是最精彩的。用户反馈"很多东西搜不到",我先按"数据覆盖不够"去查,结果挖出一个真 bug。

数据源接的是京东联盟的京粉精选频道池,构造请求要用 MD5 签名 + 时间戳。代码里原来是:

python 复制代码
# 改前
"timestamp": time.strftime("%Y-%m-%d %H:%M:%S", time.localtime()),

看起来没问题。但 mcp 容器默认时区是 UTC ,所以 localtime() 出来的是 UTC 时间,而京东网关要求的是 GMT+8。

后果:时间戳差 8 小时,网关判"时间戳参数与服务器时间差异大",全量拒绝。表现就是池子长期只能吃旧缓存、翻页和扩池完全无效------"有 423 条但永远不更新"。

修法是显式按 GMT+8 输出:

python 复制代码
def _now_gmt8() -> str:
    """京东网关要求时间戳按 GMT+8;容器默认时区是 UTC,直接 localtime 会差 8 小时,
    被网关判「时间戳参数与服务器时间差异大」直接拒掉------所以这里显式按 GMT+8 出。"""
    return time.strftime("%Y-%m-%d %H:%M:%S", time.gmtime(time.time() + 8 * 3600))

修完之后,扩池立刻生效:探测范围 1..40 → 1..60、有效频道 8 → 16、每频道翻页 3 → 5,真实商品 423 → 734 条。

这个 bug 值得单独说:一个时区差异,不报错、不崩溃,只是静默地把功能变哑巴------最难查的就是这种。

坑 6:搜索漏召回------单向子串

搜"自行车"搜不到"凤凰自行车坐垫",原因在匹配逻辑是单向子串:要求"关键词 ⊆ 查询词"。

商品类目叫"自行车配件","自行车配件"并不是"自行车"的子串,所以命中不了。改成双向子串,再补几个规则:

python 复制代码
def _substr_hit(term: str, kw: str) -> bool:
    """词与关键词双向子串命中;单字只认完全相等,免得搜「书」命中 tag「图书」。"""
    if not term or not kw:
        return False
    if term == kw:
        return True
    if len(term) < 2 or len(kw) < 2:  # 单字不做子串,噪音太大
        return False
    return term in kw or kw in term

再加上"多词 AND 切词"和"类目层优先、整个空才回落到商品名":

python 复制代码
def tool_product_search(args: dict) -> dict:
    terms = _terms(category)
    base = [p for p in _PRODUCTS
            if all(_constraint_hit(p, c) for c in constraints)
            and (budget is None or _min_price(p) <= budget)]
    if not terms:
        matched = base
    else:
        keyword_layer = [p for p in base if all(_keyword_hit(p, t) for t in terms)]
        if keyword_layer:
            matched = keyword_layer           # 类目/品牌/tag 命中优先
        else:
            matched = [p for p in base if all(_name_hit(p, t) for t in terms)]  # 空了才回落 name
    matched.sort(key=_min_price)
    return {"product_ids": [p["product_id"] for p in matched[:30]]}

改前 → 改后:面膜 10→12、零食 6→18、耳机 5→10、洗面奶 0→1(name 回落生效) 、书 4→0(单字噪音消除)。

附赠一个:"诚实标注"------不让按钮撒谎

还有一个体验上的坑:mock 商品(以及任何拿不到联盟精准落地页的商品)点"去京东购买",实际落到的是京东搜索页 。按钮文案和落点不符,本质是假购买。

方案不是硬编码第三方精准链接(天猫/拼多多的详情 URL 无稳定规律、改版就 404),而是诚实标注 :给 MCP 契约加一个 url_kind 字段。

python 复制代码
def _platform_link(p: dict, platform: str) -> tuple[str, str]:
    """返回 (跳转链接, 链接类型),让前端知道点下去落到商品页还是搜索页。
    product=数据层给的真实落地页(京东联盟 materialUrl);search=拼搜索页兜底。"""
    real = (p.get("urls") or {}).get(platform)
    if real:
        return real, "product"
    tpl = _PLATFORM_SEARCH_URLS.get(platform)
    return (tpl.format(kw=urllib.parse.quote(p["name"])) if tpl else ""), "search"

前端按钮文案随落点走:

tsx 复制代码
去{plat}
{cell.url_kind === 'search' ? '搜索' : '购买'}

有真实落地页就写"去 XX 购买",兜底搜索页就写"去 XX 搜索"。等以后联盟接口返回真实落地页字段,会自动切成"购买",前端代码一行都不用改。

四、收尾:三条测试基线

一天下来,收工时三条基线全绿:

端 命令 结果
后端 cd backend && python -m pytest tests/ -q 154 条全过
MCP cd mcp_server && python -m pytest -q 21 条全过
前端 cd frontend && npm test 31 条全过,npm run build 通过

另外数据库迁移(alembic 0003、0004)都在真实 PG 上应用验证过,离线 SQL 也核对过。

五、总结:这一天的三条主线

回头看,这一天所有的工作其实是同一条主线的三个切面------诚实:

  1. 接口诚实 :url_kind 让按钮不撒谎;降级返回 503/404 而不是写空单;盯价状态照实反映。
  2. 状态诚实 :UI 显示的是真实状态(is_registered 覆盖),不是"我们希望的状态"。
  3. 数据诚实:一个时区 bug 让功能静默变哑巴,修好之后数据才真的是数据。

另外几条工程上的收获:

  • 接口层 + 单测全绿 ≠ 功能对:前后端字段约定、真实交互必须手点一遍才靠得住(坑 1)。
  • 幂等要做在后端 :前端防抖挡不住重试和并发,部分唯一索引 + ON CONFLICT 才是真幂等(坑 2)。
  • 契约不变是重构的底气 :给 MCP 加 product_detail、加 url_kind,上游 Agent 节点零改动、测试零网络。工具层和数据层解耦,扩展才不牵一发动全身。
  • 最难查的 bug 是"静默 bug":时区差异不报错不崩溃,只把功能变哑巴(坑 5)。这类问题只能靠对"数据为什么不变"的持续怀疑来发现。

最后,C 端改造遗留清单到这一天全部清零。接下来的方向是拿到京东联盟的关键词搜索权限(或者接第三方聚合),把数据覆盖从"日百为主"扩展到全品类。


如果这篇对你有帮助,点个赞让更多做 AI Agent 落地的同学看到。有问题欢迎评论区交流。

相关推荐
高升说2 小时前
四路环视相机安装工程指南:机位、视角与标定硬件
人工智能·数码相机
外收内放2 小时前
数据分析(DataFrame与series)
python·学习·数据分析
白杨尚青2 小时前
C++入门篇(十七):priority_queue 完整模拟实现——仿函数、区间建堆与指针特化(附完整代码仓库)
开发语言·c++·stl·仿函数
fenglovemu2 小时前
《2026年珠三角企业AI学习,正在发生三个变化》
大数据·人工智能
爬楼的猪2 小时前
一个轻量化的Codex Web UI
人工智能·ui·chatgpt·ai编程
4SAPI2 小时前
多模型API调用实战:一个项目接入GPT、Claude和DeepSeek
linux·人工智能·gpt·php
AI行业应用研究2 小时前
会务系统的查询路径设计:从菜单导航到意图检索,多大规模的活动该配 AI 助理?
人工智能·apache
程序员cxuan2 小时前
Gemini 4 Argon 正式发布!
人工智能·后端·程序员
shujudang2 小时前
智能数据分析平台有哪些?五个主流平台的数据模型与选型对比
大数据·人工智能·数据挖掘·数据分析