买家门户与交易模块 迭代2 - xiaochu-dev/online-shopping-group5 GitHub Wiki

买家门户与交易模块(迭代 2)

2026-10-21商品接口交接:PR #1以正式CategoryQueryService / ProductDetailService / StockService接入门户,移除商品结构探针与无限库存降级,并通过ProductQueueAdapter提供库存归零的队列失败处理。详见商品中心模块-迭代2。PR已合入iter2-java;本页正文已同步正式接入后的行为。

负责人:周到(产品经理) 对应需求:挑战需求 A.1.3(一件一单与库存扣减)× A.1.4(售罄与队列归零) × A.2.1(下单必须登录)× A.2.2(同买家同商品限一条)× A.2.5(商家确认交易与客户历史) 代码包:portal/ 独占数据表:intent(新增 5 列) 依据:《工作任务表 v4.0》第五节「周到 —— 买家门户与交易(15 点)」


一、模块边界

本模块独占 portal/ 包与 intent 表,其他模块不得直接 import 本模块的 Entity / Mapper, 也不得写 intent 表的 SQL。客户历史对外出口如下(另有库存队列适配,见下表):

出口 使用方 作用
IntentHistoryProvider(实现 admin.port.IntentHistoryPort) 卖家后台客户详情页 按买家 id 返回已结束订单,供货主查看客户购买历史

方向是「倒过来」的:intent 表归本模块,所以由消费方(账号与客户模块)定义接口、本模块实现, 后台只认接口,不碰 intent 的表结构与实体。

当前跨模块调用如下:

接口 正式提供方 当前行为
BuyerContext.current() 账号与客户模块 提供登录买家,未登录跳买家登录页
ProductPort product.ProductDetailService 图片、库存、冻结、释放与扣减调用正式商品服务
CategoryPort product.CategoryQueryService 启用的两级分类树,一级筛选包含二级子类
IntentQueuePort portal.ProductQueueAdapter 向StockService提供pending批量失败及hasTrading查询

原商品结构探针与无限库存降级已移除。portal.ContractDefaults只保留卖家Session上下文适配;部分门户查询仍直接使用商品Entity/Mapper,不把这些旧查询描述成已完全解耦。


二、路由一览

买家侧

方法 路径 权限 说明
GET / 公开 商品列表;page / size / categoryId / keyword,默认 12 条/页,上限 100
GET /products/{id} 公开 商品详情;含图片、库存、当前排队人数、在售/交易中状态
POST /products/{id}/intents 需买家登录 提交意向;未登录 302 → /buyer/login?redirect=/products/{id}
POST /intents/cancel 公开(凭口令码) 基线兜底,买家页面已无入口
GET /my/orders 需买家登录 我的订单,默认 10 条/页

卖家侧(LoginInterceptor 拦 /admin/**)

方法 路径 说明
GET /admin/intents 交易与意向总览:我的商品 + 每个商品的未结束意向数
GET /admin/intents/{productId} 单商品队列:交易中 1 条 + 排队若干,按提交时间正序
POST /admin/intents/{intentId}/confirm 确认交易(只允许队首)
POST /admin/intents/{intentId}/success 交易成功:扣 1 件库存
POST /admin/intents/{intentId}/fail 交易失败:requeue=true 塞回队尾,否则作废;reason 可空

三个动作都走 Post/Redirect/Get,执行完返回redirect视图,当前默认302回队列页,F5 不会重复成交。


三、数据模型:intent

本迭代对 intent 表做了 5 处改动(脚本 schema/03-intent.sql):

字段 类型 作用 为什么必须有
buyer_id INTEGER NOT NULL DEFAULT 0 下单人;0 = 迭代 1 遗留的匿名单 A.2.1 下单必须登录;历史只查 buyer_id = ?,匿名单永远不会串到任何客户账下
trade_address TEXT 下单时的交易地点快照 买家之后改默认地点,历史订单要显示当时的约定地点
finished_at INTEGER 交易时间 「我的订单」按这一单的时间排序
trade_result TEXT success / failed 与 status 分离:status 是流程位置,trade_result 是对外结果
fail_reason TEXT 失败原因 A.2.2 要求历史里说明为什么失败

同时:token_code 改为可空(主路径不再生成,存量数据仍可凭码撤销),没有 quantity 字段—— 一次意向对应1件;同一买家要买3件,须在前一笔结束后再提交下一笔,不能同时挂3笔未结束意向。buyer_id是逻辑关联,DDL没有buyer外键。

索引与查询路径一一对应:

索引 服务于
idx_intent_queue (product_id, status, created_at) 队列页
idx_intent_buyer (buyer_id, status, finished_at) 我的订单
idx_intent_buyer_id (buyer_id, created_at) 后台客户购买历史
uq_intent_buyer_active (buyer_id, product_id) WHERE status IN ('pending','trading') AND buyer_id > 0 库层兜底「一人一商品一条未结束」,防并发穿透

队列位次不落库:不存 position 字段。有人结束或撤销,后面的人靠行号天然前移, 不需要批量 UPDATE,也不会出现「位次卡在 3 不动」的脏数据。


四、状态机与「冻结」语义

pending ──确认交易──→ trading ──交易成功──→ success   (扣 1 件库存)
   ↑                     │
   └──塞回队尾───────────┴──交易失败──→ failed        (库存不动,可选塞回)

当前卖家作废动作置failed(带原因);cancelled仍仅保留给旧口令码撤销。

三条容易写错的地方,也是本模块的三个判断:

判断一:整件冻结不需要 locked 字段。 A.1.3 是「一件一单」,库存 N 件时同一商品会在不同时间成交 N 次,任意时刻只有一笔在谈。 所以「交易中」是商品级互斥的状态位,用 product.status = frozen 就能表达,再开一个 locked 只会多一份需要同步的真相。冻结发生在「确认交易」,交易结束调用release;只有仍为frozen才会恢复,手动下架保持下架。

判断二:扣库存的时机是「交易成功」,不是「提交意向」。 提交意向时扣,失败一次就要回滚一次,还要处理并发超卖;放到成功那一刻, StockService按商品编号、库存大于0及允许成交的状态进行原子条件更新, 「扣不动就置不了成功」由数据库保证,事务里直接抛异常回滚。

判断三:售罄要主动清理队列。 库存扣到 0 以后,后面排队的人永远不会被确认。让他们继续挂着,等于给了一句「你先等着」的空承诺。 所以 markSuccess 扣完库存会回查剩余量,为 0 就把该商品所有 pending 批量置 failed, StockService经ProductQueueAdapter在同一事务内结束pending,门户还保留零库存兜底;原因写「商品库存已售罄,本次排队自动结束」,买家在「我的订单」里能直接看到。


五、挑战需求 A 的落地对照

口径(澄清问答记录) 落地位置 可验证的行为
A.2.1 下单必须登录,浏览可不登录 PortalController#submitIntent 未登录点「登录后购买」跳登录页并带回跳地址
A.1.3 一次意向 = 1 件,不设数量 intent 结构 + submitOrder 页面无数量输入,每笔成交扣1,提交不预扣或预占库存
A.2.2 同一买家同一商品只能有 1 条未结束 countActiveByBuyer + 部分唯一索引 第二次提交被拒绝并给出提示;并发下由索引拦住
A.1.3 确认交易即冻结商品 confirmTrade → freeze 商品从列表消失,详情页显示「交易中」
A.1.4 库存归零后队列批量置失败 markSuccess → failPendingOfProduct 最后一笔成交后,剩余排队单全部变失败并带原因
A.2.2 历史只显示 success / failed OrderHistoryService + selectFinishedByBuyer 排队中、交易中的单不进「我的订单」
A.2.5 商家按队列顺序处理 confirmTrade 校验队首 确认非队首被拒绝
A.2.5 失败可由卖家决定作废或重新排队 markFailed(requeue, reason) 塞回后该买家回到队尾,created_at 刷新

六、提示语口径

统一「说到位、不吓人」:所有提示都通过 ?msg= / ?error= 走 PRG,不弹独立错误页。

场景 文案
提交成功 购买意向已提交,等待卖家确认。可在「我的订单」查看已有结果的订单。
重复提交 你已对该商品提交过意向,请在卖家处理后再次购买
商品售罄 该商品已售罄,暂时无法提交意向
商品下架 商品不存在或已下架
确认交易 已确认交易,商品进入「交易中」,其他买家暂时无法下单
交易成功 已标记交易成功,库存扣减 1 件
交易失败 已标记交易失败并作废该买家的意向 / 已标记交易失败,该买家已塞回队尾重新排队
塞回冲突 该买家已另有一条排队中的意向,塞回会重复占位;请改选「作废」

页面上还有两处刻意的解释性文案:详情页写明「本系统按提交时间先到先得,越早提交排得越前」, 未登录时写明「浏览商品无需登录,提交购买意向时必须登录身份」——这两句是为了让验收人一眼看懂规则, 不用翻文档。


七、验收证据

测试(门户相关26项,包含在已记录的全量83项中)

逐条用例与实测结果见 买家门户与交易测试记录-迭代2。

测试类 用例 覆盖
PortalFlowTest 14 列表筛选/搜索/分页、详情四种状态(在售/交易中/已下架/不存在)、未登录跳登录带回跳、下单成功与买家快照、重复提交拦截、交易地点默认值与缺失校验
MyOrdersFlowTest 3 未登录跳登录、只显示已有结果的订单且按交易时间倒序、换号看不到他人订单
SellerTradeFlowTest 9 未登录拦截、待处理数、队首校验、冻结时库存不动、不能开第二笔、成交扣 1 件并解冻、失败不扣库存、失败塞回队尾、队列只显未结束

既有门户截图(原模块记录:本地8080)

# 截图 说明
1 images/zhou-dao/review2/product-list.png 商品列表(免登录可浏览)
2 images/zhou-dao/review2/keyword-search.png 关键词搜索命中名称与描述
3 images/zhou-dao/review2/category-filter.png 分类筛选
4 images/zhou-dao/review2/detail-onsale.png 详情:在售,显示排队人数
5 images/zhou-dao/review2/login-redirect.png 未登录下单 → 跳登录并带回跳地址
6 images/zhou-dao/review2/detail-form.png 已登录的下单表单
7 images/zhou-dao/review2/order-submitted.png 提交成功提示
8 images/zhou-dao/review2/detail-frozen.png 交易中的商品无法下单
9 images/zhou-dao/review2/my-orders.png 我的订单:只显示已有结果的单
10 images/zhou-dao/review2/seller-intents.png 卖家:商品与未结束意向数
11 images/zhou-dao/review2/seller-queue.png 卖家:队列与三个动作

八、接入状态与后续核对

项 当前状态
库存、扣减、自动下架 已接入StockService,零库存经队列适配批量失败
商品多图 product_image已建表,按主图优先、sort_no与id展示;无上传使用占位图
两级分类 parent_id与CategoryQueryService已交付,无缺列文本推导路径
卖家商品管理 /admin/products负责库存与上下架;门户/admin/intents负责交易,不提供商品编辑
旧口令码撤销 接口保留,买家页面无入口;未承诺下一迭代删除
验证边界 已记录全量83项;团队Java17环境、外站视频播放和录屏待补

交易失败不会自动把下一位改为trading,需要卖家再次确认队首。发布和补货为0时自动下架;有交易时不允许直接将库存调为0;手动下架后的已有交易仍可完成,release不会将其恢复在售。


相关文档:挑战需求A-澄清问答记录(口径来源)、第2次阶段性评审报告(任务与进度)