资讯详情

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

📅 2026/10/14 2:06:53 | 华诺云谱 👁 阅读
从开发者调试工具到消费级 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商品详情大图 多平台比价表/chatAI 导购SSE 流式对话 Agent 推理可视化/orders我的订单意向单列表/me个人主页画像/订单/盯价聚合/admin/deadletter死信管理仅开发者可见不进 C 端导航关键一点死信队列失败任务绝不能出现在 C 端导航里。它是给开发者排查用的普通用户看到失败任务只会一脸问号。所以单独挪到/admin/deadletter并且加了开发者管理端横幅。同时App.tsx改成NavBar Routes结构桌面用 topnav、移动端用 tabbarnginx.conf补了 SPA 的try_files兜底否则刷新子路由 404。P1让搜索结果变成能点的商品卡片这一步暴露了后端的一个缺口原来的 MCP 工具只有product_search / price_query / review_risk三个返回的都是结构化数字没有图片、没有描述、没有跳转链接。解决办法不是硬造而是给 MCP 加第四个工具product_detail# 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# backend/app/api/catalog.pyREAD_TIMEOUT4# 秒读接口宁可快失败降级也不让页面干等asyncdef_try_tool(name:str,args:dict)-dict|None:调 MCP 读接口失败静默返回 None调用方兜空。try:returnawaitmcp_client.call_tool(name,args,timeoutREAD_TIMEOUT)exceptExceptionase:logger.warning(商品读接口降级 tool%s err%s,name,e)returnNone前端于是有了ProductCard.tsx商品图 名称 平台标签 最低价 店铺分/差评率和PlatformPriceList.tsx多平台比价表按价升序、最低价高亮。P2下单链路——意向单表CPS 模式下订单表可以做得非常薄只存最精简的字段商品名/图/链接这些会在 API 层用 MCP 现拉CREATETABLEIFNOTEXISTSorders(id BIGSERIALPRIMARYKEY,user_idTEXTNOTNULL,session_idTEXT,product_idTEXTNOTNULL,platformTEXT,priceNUMERIC,statusTEXTNOTNULLDEFAULTintent,created_at TIMESTAMPTZDEFAULTnow());为什么商品快照不落库因为价格会变、图会变、链接会变存进去就是一堆过期数据。订单表只记意向展示时现查现拼。交互上原来的ApprovalModalHITL 人工审批弹窗语义从审批改成了下单前确认确认后跳/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// 后端 prepare_order 挂上来的是比价表那一行字段叫 name不是 title const productName product?.name ?? product?.title ?? product?.product_id ?? 未选出具体商品教训很直白接口层 单测全绿不代表功能是对的。前后端字段约定这种事只有真点一遍才靠得住。坑 2意向单不幂等审批被重复点就落多单HITL 弹窗有个天然风险用户手抖点两次确认购买或者前端网络重试就会调两次POST /api/orders落两笔一模一样的意向单。一开始想在前端做防抖但前端防抖挡不住重试和并发得在后端做真正的幂等。用 PG 的部分唯一索引-- 同一会话同一商品只记一笔只约束有 session_id 的行手动建单不受影响CREATEUNIQUEINDEXIFNOTEXISTSuq_orders_session_productONorders(session_id,product_id)WHEREsession_idISNOTNULL;写入时用ON CONFLICT DO NOTHING撞键就把原单捞回来返回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 NOTHINGf RETURNING{_COLUMNS},(user_id,session_id,product_id,platform,price,status),)rowcur.fetchone()ifrowisNone:# 撞唯一键这轮对话已经下过这一单捞回原单cur.execute(fSELECT{_COLUMNS}FROM orders WHERE session_id %s AND product_id %s LIMIT 1,(session_id,product_id),)rowcur.fetchone()顺带把商品校验也补了把数据源降级和真没这个货分开降级返回 503可重试查无返回 404两种情况都不写空单之前不存在的商品也会写一条 platform 为 NULL 的脏数据。坑 3盯价状态撒谎个人主页有我的盯价列表展示哪些商品在监控中。原来的active字段恒为true——因为它直接读的库里的值。问题是盯价任务是进程内的APScheduler 的内存 job服务一重启内存里啥都没了但数据库里那条订阅记录还在。结果就是明明没在监控页面还显示监控中。修法是展示前统一用调度器的真实状态覆盖defis_registered(session_id:str)-bool:这个会话的盯价任务当前是否真在跑。 调度任务是进程内的服务重启后内存里啥都没了——这时候返回 False 订阅记录就不能再显示「监控中」active 要照实反映不能撒谎。 returnbool(_schedulerand_scheduler.get_job(fmonitor:{session_id}))同时补了DELETE /api/monitor/{id}取消盯价时同时摘掉内存任务和订阅记录。前端每条盯价加了取消按钮。这个坑的价值在于它定义了一条原则UI 显示的状态必须是它真实的状态不能是我们希望的状态。坑 4个人接口在裸奔谁都能查别人/api/profile、/api/orders、/api/monitor一开始只认 query 里的user_id也就是说GET /api/profile?user_idvictim # 直接看到别人的画像和订单demo 阶段没有注册登录但也不能就这么裸奔。方案是发一个无状态匿名身份 tokendef_sign(user_id:str)-str:sighmac.new(_secret(),user_id.encode(),hashlib.sha256).hexdigest()[:32]returnf{user_id}.{sig}def_verify(token:str)-str|None:验签过了返回 user_id伪造/串改返回 None。user_id,_,sigtoken.partition(.)ifnotuser_idornotsig:returnNoneexpecthmac.new(_secret(),user_id.encode(),hashlib.sha256).hexdigest()[:32]returnuser_idifhmac.compare_digest(sig,expect)elseNonetoken 是user_id HMAC-SHA256 签名无状态、不落库随机 user_id 猜不到签名验不过就是假的Redis 挂了也不影响。前端首次访问POST /api/auth/anon领一个存 localStorage之后所有请求带X-User-Token。关键是个人接口一律以 token 里的身份为准外部再传?user_id也不认。订单详情和取消盯价还补了归属校验——查别人的单/订阅一律 404不泄露这东西存不存在。坑 5数据池永远不更新——一个时区 Bug这个是最精彩的。用户反馈很多东西搜不到我先按数据覆盖不够去查结果挖出一个真 bug。数据源接的是京东联盟的京粉精选频道池构造请求要用 MD5 签名 时间戳。代码里原来是# 改前timestamp:time.strftime(%Y-%m-%d %H:%M:%S,time.localtime()),看起来没问题。但mcp 容器默认时区是 UTC所以localtime()出来的是 UTC 时间而京东网关要求的是GMT8。后果时间戳差 8 小时网关判时间戳参数与服务器时间差异大全量拒绝。表现就是池子长期只能吃旧缓存、翻页和扩池完全无效——“有 423 条但永远不更新”。修法是显式按 GMT8 输出def_now_gmt8()-str:京东网关要求时间戳按 GMT8容器默认时区是 UTC直接 localtime 会差 8 小时 被网关判「时间戳参数与服务器时间差异大」直接拒掉——所以这里显式按 GMT8 出。returntime.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搜索漏召回——单向子串搜自行车搜不到凤凰自行车坐垫原因在匹配逻辑是单向子串要求关键词 ⊆ 查询词。商品类目叫自行车配件自行车配件并不是自行车的子串所以命中不了。改成双向子串再补几个规则def_substr_hit(term:str,kw:str)-bool:词与关键词双向子串命中单字只认完全相等免得搜「书」命中 tag「图书」。ifnottermornotkw:returnFalseiftermkw:returnTrueiflen(term)2orlen(kw)2:# 单字不做子串噪音太大returnFalsereturnterminkworkwinterm再加上多词 AND 切词和类目层优先、整个空才回落到商品名deftool_product_search(args:dict)-dict:terms_terms(category)base[pforpin_PRODUCTSifall(_constraint_hit(p,c)forcinconstraints)and(budgetisNoneor_min_price(p)budget)]ifnotterms:matchedbaseelse:keyword_layer[pforpinbaseifall(_keyword_hit(p,t)fortinterms)]ifkeyword_layer:matchedkeyword_layer# 类目/品牌/tag 命中优先else:matched[pforpinbaseifall(_name_hit(p,t)fortinterms)]# 空了才回落 namematched.sort(key_min_price)return{product_ids:[p[product_id]forpinmatched[:30]]}改前 → 改后面膜 10→12、零食 6→18、耳机 5→10、洗面奶 0→1name 回落生效、书 4→0单字噪音消除。附赠一个“诚实标注”——不让按钮撒谎还有一个体验上的坑mock 商品以及任何拿不到联盟精准落地页的商品点去京东购买实际落到的是京东搜索页。按钮文案和落点不符本质是假购买。方案不是硬编码第三方精准链接天猫/拼多多的详情 URL 无稳定规律、改版就 404而是诚实标注给 MCP 契约加一个url_kind字段。def_platform_link(p:dict,platform:str)-tuple[str,str]:返回 (跳转链接, 链接类型)让前端知道点下去落到商品页还是搜索页。 product数据层给的真实落地页京东联盟 materialUrlsearch拼搜索页兜底。real(p.get(urls)or{}).get(platform)ifreal:returnreal,producttpl_PLATFORM_SEARCH_URLS.get(platform)return(tpl.format(kwurllib.parse.quote(p[name]))iftplelse),search前端按钮文案随落点走去{plat} {cell.url_kind search ? 搜索 : 购买}有真实落地页就写去 XX 购买兜底搜索页就写去 XX 搜索。等以后联盟接口返回真实落地页字段会自动切成购买前端代码一行都不用改。四、收尾三条测试基线一天下来收工时三条基线全绿端命令结果后端cd backend python -m pytest tests/ -q154 条全过MCPcd mcp_server python -m pytest -q21 条全过前端cd frontend npm test31 条全过npm run build通过另外数据库迁移alembic0003、0004都在真实 PG 上应用验证过离线 SQL 也核对过。五、总结这一天的三条主线回头看这一天所有的工作其实是同一条主线的三个切面——诚实接口诚实url_kind让按钮不撒谎降级返回 503/404 而不是写空单盯价状态照实反映。状态诚实UI 显示的是真实状态is_registered覆盖不是我们希望的状态。数据诚实一个时区 bug 让功能静默变哑巴修好之后数据才真的是数据。另外几条工程上的收获接口层 单测全绿 ≠ 功能对前后端字段约定、真实交互必须手点一遍才靠得住坑 1。幂等要做在后端前端防抖挡不住重试和并发部分唯一索引 ON CONFLICT才是真幂等坑 2。契约不变是重构的底气给 MCP 加product_detail、加url_kind上游 Agent 节点零改动、测试零网络。工具层和数据层解耦扩展才不牵一发动全身。最难查的 bug 是静默 bug时区差异不报错不崩溃只把功能变哑巴坑 5。这类问题只能靠对数据为什么不变的持续怀疑来发现。最后C 端改造遗留清单到这一天全部清零。接下来的方向是拿到京东联盟的关键词搜索权限或者接第三方聚合把数据覆盖从日百为主扩展到全品类。如果这篇对你有帮助点个赞让更多做 AI Agent 落地的同学看到。有问题欢迎评论区交流。
📝

华诺云谱内容团队

资深建站顾问 · 行业研究员

10年+企业数字化服务经验,专注智能建站、SEO优化与品牌营销,持续输出建站技巧、行业洞察与营销干货,已帮助5000+企业实现数字化增长。

你可能需要的服务

订阅华诺云谱资讯周报

每周一封,精选建站技巧、SEO与营销干货,直达邮箱。已有 8,000+ 企业主订阅,助你少走弯路。

↑