练二双十一 · 后端实现文档
双十一活动后端实现文档
读者:后端开发 / 测试 / 运维。编写日期 2026-10-21。 依据:后端仓库
/Users/idiotfan/Downloads/lianer-back当前代码 + 前端 mock 契约mock-server/index.ts「双十一主会场」段(186–377 行)+ 已拍板的产品口径。 本文所有「现状」结论均已读代码确认,标注路径:行号。没读到或需查库的标「待确认」。
路径约定(下文简写)
| 简写 | 实际路径(相对 lianer-back/) |
|---|---|
PAY/ |
lianer-payment/com-lianer-payment-service/src/main/java/com/lianer/payment/service/ |
MAPPER/ |
lianer-payment/com-lianer-payment-service/src/main/resources/mapper/ |
FE/ |
/Users/idiotfan/Downloads/lianer-user-app/(前端仓库) |
口径基线(与本文冲突的旧说法一律作废)
- 活动时间 2026-11-01 10:00:00 — 2026-11-11 23:59:59(+08:00)。
- 私教年卡全国限量 100 张,下单即占名额,付款时效建议 15 分钟(独立于全站 6 小时)。
- 首单「私教60次半年卡」(combo 套餐)支付成功发 1 张 700 元复购券,仅限再购同款,每人 1 张,一次使用。「首单」= 活动开始(2026-11-01 10:00)后该用户首笔支付成功的该商品订单,活动前历史一律不看(5.6)。
- 私教60次半年卡 11-11 后继续售卖(常规商品,不是活动专属);活动后价格转入运营配置的常规活动区块。700 券有效期内仍可按当时售价使用。
- 积分永久改名「二币」(长期积分货币,替换现有积分,继续用于积分商城):按实付每满 100 元得 1 个,1 个二币价值 1 元。是全局长期规则,不是活动比例,活动结束不回落。
- 二币下单抵扣本期不做(后续功能)。本期范围:按实付入账 + 展示 + 积分商城照旧;保留退款扣回问题待定。
- 单位约定:所有金额为「元」,number 类型保留 2 位小数(如
18900.00);所有时间戳为 epoch 毫秒(如nowTs、expiresAt)。 - 预约开抢走微信订阅消息(模板待申请)。
0. 一页总览
0.1 模块与改动量
| # | 模块 | 主要改动 | 估算(人日,含自测) | 依赖 |
|---|---|---|---|---|
| M1 | 活动配置与城市档位 | 新表:活动、门店档位、活动商品映射;Venue 接口 | 3 | 无 |
| M2 | 限量名额 + 15 分钟时效 | 名额池/占位表、订单 expires_at
快照、抢名额接口、专用关单任务、渠道查单、晚回调补偿 |
8 | M1;泰融接口能力确认 |
| M3 | 支付链路加固(通用) | 私教超时关单改条件更新+查单、setPrivateOrderStatus
改条件更新、券锁 CAS、回调失败可追溯 |
3 | 无(建议先于 M2 上线) |
| M4 | 指定商品券 + 700 复购券 | 券模板 scope、用户券快照、列表过滤、下单校验、补时间校验、活动发券资格表 | 5 | M1;60次半年 combo 已建 |
| M5 | 二币入账统一 | 按实付、combo 父单记分、按订单幂等、确认页预计二币 | 3 | 无 |
| M6 | 二币下单抵扣(新能力,后续,本期不做) | 抵扣记录表、确认页/下单/关单/退款联动 | (5,不计入本期) | M5;产品确认上限与退款政策 |
| M7 | 疯狂星期二月卡 | 周二校验 + 每人一单(含待支付) | 1 | M1 |
| M8 | 预约开抢 | 预约表、订阅消息发送(需在 user 服务暴露 dubbo 接口)、定时推送 | 2 | 订阅模板审核通过 |
| M9 | 促销价生效时间加固 | 下单取价路径补时间校验、缓存失效 | 1 | 无 |
本期合计约 26 人日(不含 M6、联调与压测;含 M6 为 31)。产品已确认 M6 为后续功能:本期只做 M5,确认页不出现「抵扣」入口。
0.2 依赖顺序
M3(通用加固) ──┐
M9(促销时间) ──┼─> M1(配置/档位/Venue) ─> M2(名额+时效) ─> 压测
M5(二币入账) ──┘ └─> M4(指定商品券/700券)
└─> M7(星期二月卡)
M5 ─> M6(二币抵扣,后续版本)
M8(预约) 独立,需订阅模板先过审
0.3 上线前置清单
| 类别 | 内容 | 负责 |
|---|---|---|
| DDL | 第 2 章全部 DDL,先于代码发布执行(test2 先行) | 后端 |
| 商品 | 私教年卡 SKU 方案定稿(复用 courseId 91 还是新建,见 1.9);全国参与门店的课程-门店映射 | 产品/运营 + 后端 |
| 商品 | 各参与门店新建「私教60次半年卡」combo(生产目前只有 30天10次 combo;combo 按门店建,见 1.7) | 运营 |
| 价格 | 每个参与门店的促销活动(会员卡/私教时长/私教次课阶梯)、combo 售价、门店档位映射 | 运营 |
| 券 | 700 元指定商品券模板(scope 指向 60次半年卡商品组) | 运营 + 后端 |
| 二币 | 购买类积分比例(lianer_score source=2 的
source_score)全局长期改为每满 100 元得 1
个(已确认,不只活动内);积分展示名改「二币」 |
后端 + 产品 |
| 订阅 | 「开抢提醒」订阅消息模板申请与 templateId | 产品 |
| 渠道 | 泰融是否支持支付有效期参数、是否有关单接口(代码里没有,见 1.3) | 后端对接银行 |
| 任务 | xxl-job
新建:d11HoldReleaseHandle、activityGrantRetryHandle、d11ReserveNotifyHandle |
运维 |
1. 现状与缺口
1.1 订单状态值(全局约定,代码确认)
私教/套餐订单:0 待支付,1 支付中,2 已支付,-1 已关闭,8 待退款,9
已退款(PAY/business/data/ComboOrderBusiness.java:1096-1115;PAY/business/data/BankCallBackBusiness.java:124
注释)。
1.2 私教单课(course)下单链路
| 环节 | 现状 | 位置 |
|---|---|---|
| 下单入口 | /front/privateOrderInfo,方法上
@Transactional,实际逻辑在
OrderCommonBusiness.privateOrderInfo |
PAY/front/login/PrivateBuyImpl.java:104-110 |
| 用户锁 | Redisson 锁 key=userId,lock.lock()
阻塞,方法内 finally 释放 |
PAY/business/service/OrderCommonBusiness.java:802-805、:1093 |
| 缺口 | 锁在方法内释放,但外层
@Transactional(PrivateBuyImpl:106)在锁释放后才提交,存在「锁先于事务提交释放」窗口;双十一「同用户一个有效占位」不能依赖该锁,必须靠
DB 唯一约束 |
同上 |
| 取价 | 先 promotionBusiness.getPromotionForCourse 覆盖
totalPrice/unitPrice;次课再取阶梯价 |
OrderCommonBusiness.java:822、:941、:950-967 |
| 限购 | buyHistoryBusiness.checkBuyLimit(userId, 2, courseId, buyMax) |
OrderCommonBusiness.java:891 |
| 缺口 | 购买历史只在支付成功时写(payPrivateSuccess result>0
才
recordBuyHistory),待支付订单不计入限购,并发下可超买 |
PAY/business/data/PaymentPrivateBusiness.java:242-250;PAY/business/data/BuyHistoryBusiness.java:93-124 |
| 用券 | canUserCoupon(2, ...) 后
lockUserCoupon |
OrderCommonBusiness.java:1018-1024 |
| 建单 | paymentPrivateBusiness.insertPrivateOrder,order_time
为 epoch 毫秒 |
OrderCommonBusiness.java:1067、:1081 |
| 发起支付 | /front/trCreateOrder:校验订单 0/1 →
paymentPrivateBusiness.createOrder 新建流水 P 单号 →
泰融预下单 → setPrivateOrderStatus(...,1) |
PAY/front/login/PrivateBuyImpl.java:154-200;PAY/business/data/PaymentPrivateBusiness.java:550-600 |
| 前端实际走泰融 | 微信环境用 api.trCreateOrder |
FE/src/utils/common.ts:87 |
| 缺口 | setPrivateOrderStatus 是 @Async,SQL
无状态条件 update ... where order_number=?,若回调先把单置
2,异步任务后到会把 2 改回 1 |
PaymentPrivateBusiness.java:213-231;MAPPER/LianerPrivateOrderMapper.xml:1325-1330 |
1.3 支付回调与查单
| 环节 | 现状 | 位置 |
|---|---|---|
| 泰融回调 | /tairongnotify/order →
bankCallBackBusiness.bankPayCallBack,无论业务成败都返回
"success" |
PAY/redback/TairongRedBackController.java:48-107 |
| 回调分发 | 按订单号 Redisson tryLock(0, 1h),拿不到直接
return;catch (Exception) 只打日志 |
PAY/business/data/BankCallBackBusiness.java:254-306 |
| 缺口 | 业务异常被吞且对银行回 success,银行不重推;双十一晚回调补偿必须落库可重试,不能只靠日志 | 同上 :297 |
| 私教成功处理 | updatePay(事务) 置 2 → 插成功表 → 首单标记 → 积分 →
发权益 → 用券 → 外推码 → 短信;整段无外层事务 |
BankCallBackBusiness.java:330-410;PaymentPrivateBusiness.java:507-534 |
| 缺口 | 置 2 先提交,后续发权益失败时重复回调返回 0 行(幂等),权益不会补发 | 同上 |
| 置已支付 | setOrderPaid 允许
0/1/-1 → 2,即已关闭订单可被回调「复活」 |
MAPPER/LianerPrivateOrderMapper.xml:1353-1362 |
| 微信直连/支付宝回调 | 同样走 payPrivateSuccess 并复制一份后处理逻辑 |
PAY/redback/WxRedBackController.java:89-190;PAY/redback/AliPayRedBackController.java:83-180 |
| 查单 | queryTrBankOrderStatusAndUpdate:S 回放回调,F
不处理(注释写「等定时任务自动关闭」),P 不处理 |
BankCallBackBusiness.java:197-240 |
| 查单取流水 | getRealPayOrderNum 优先已支付流水,否则按 ref_num
取「一条」 |
BankCallBackBusiness.java:168-185 |
| 缺口 | 一笔订单可能有多条流水(每次发起支付新建 P
单号,PayBillIdempotentBusiness.checkBeforeCreate
只在本地把旧流水置
-1,泰融侧旧交易仍可支付),释放名额前只查一条不够,必须逐条查 |
PAY/business/data/PayBillIdempotentBusiness.java:40-58 |
| 泰融能力 | TairongPayUtils
只有下单、退款、扫码下单、查单四类方法;下单报文无有效期字段;无关单接口 |
PAY/utils/TairongPayUtils.java:62-115、:205、:364、:427 |
| 缺口 | 「向渠道传支付有效期」「到期关渠道单」当前无法实现,需与泰融确认报文字段(待确认) | 同上 |
| 微信直连下单 | 未设置 time_expire |
PAY/business/service/OrderBusiness.java:393-421 |
| 补偿查单任务 | synBankOrderStatusHandle 每次扫近 6 小时 0/1
状态的私教/会员卡/体测订单逐笔查单 |
PAY/jobSchedule/SynBankOrderStatusHandle.java:25-30;BankCallBackBusiness.java:94-134 |
1.4 关单任务
| 环节 | 现状 | 位置 |
|---|---|---|
| 超时常量 | outTime = 21600L * 1000(6 小时),所有 handler
共用 |
PAY/jobSchedule/OrderJobHandler.java:44-48 |
| 私教 | privateOrderHandle:0/1、order_time <= now-6h、渠道
1/2 → cancelPrivateOrderList |
OrderJobHandler.java:66-79 |
| 私教关单实现 | @Async;按 id 批量 cancelPrivateOrder,SQL
无状态条件;不查银行;批量解锁券 |
PaymentPrivateBusiness.java:434-452;MAPPER/LianerPrivateOrderMapper.xml:1371-1378 |
| 风险(新发现) | select 与 update 之间若回调已置 2,该 update 会把已支付单改成
-1;之后任一次查单/重复回调又能 -1 → 2(setOrderPaid
允许),payPrivateSuccess 返回 1
行,导致重复发权益/重复记积分/重复插成功表 |
同上 + LianerPrivateOrderMapper.xml:1353-1362 |
| 用户取消私教单 | 有未完结流水先查银行,再条件关单
0/1 → -1,成功后解锁券(这条是对的) |
PAY/front/login/OrderImpl.java:153-195;LianerPrivateOrderMapper.xml:1364-1369 |
| 套餐 | comboOrderHandle →
cancelTimeoutUnpaidOrderList 逐单复用
cancelUnpaidOrder(有查银行 + 条件关单 + 解锁券) |
OrderJobHandler.java:86-99;ComboOrderBusiness.java:612-690 |
结论:双十一 15 分钟不能只改常量。现有私教超时关单不查单、不带条件,6 小时窗口下问题被掩盖,缩到 15 分钟会被放大。
1.5 优惠券
| 环节 | 现状 | 位置 |
|---|---|---|
| 母表/用户券表 | 母表 lianer_counpon(历史拼写);用户券
lianer_user_coupon |
MAPPER/LianerCouponMapper.xml:57;MAPPER/LianerUserCouponMapper.xml:168 |
| 字段 | 母表:over_days, coupon_name, apply, coupon_price, discount, type, start_time, end_time, status;用户券:store_id, coupon_id, user_id, coupon_name, coupon_price, discount, apply, start_time, end_time, status, lock, activity_id, promotion_act_id。无任何商品 scope 字段 | LianerCouponMapper.xml:6-15;LianerUserCouponMapper.xml:6-19 |
| 可用判断 | canUserCoupon 只看 apply(1 会员卡/2 私教/3
通用)、status=0、lock!=1、归属;不查时间 |
PAY/business/data/CouponBusiness.java:219-226 |
| 时间校验 | 套餐下单自己补了 endTime
校验;私教、会员卡下单没有 |
PAY/front/login/ComboBuyImpl.java:430-433;OrderCommonBusiness.java:534、:1018 |
| 锁券 | update set lock=1 where id=? and user_id=?,非
CAS,不检查影响行数 |
CouponBusiness.java:380-382;LianerUserCouponMapper.xml:117-121 |
| 风险(新发现) | 套餐下单无用户锁,同一张券可被两笔并发待支付单同时锁定;两单都付款时,第二单
CouponLifecycleBusiness.consume 抛
IllegalStateException,而它在
ComboOrderBusiness.onPaySuccess
的事务内(:285、:444),会让已付款订单整单回滚、权益不发 |
PAY/business/data/CouponLifecycleBusiness.java:19-34 |
| 核销 | consume 带 FOR UPDATE +
使用记录,同订单幂等 |
同上 |
| 列表 | getUserCouponList 只有传 applys
时才加时间过滤 |
PAY/front/login/CouponFrontImpl.java:70-117(:86-88) |
| 发券 | insertCoupon(couponId, times, userId, promotionActId, storeId);实体在循环外创建并复用,insertSelective
带 useGeneratedKeys,times>1
时第二次插入会带上首次生成的 id(疑似主键冲突,现有缺陷,700 券 times=1
不受影响) |
CouponBusiness.java:327-351;LianerUserCouponMapper.xml:166-172 |
| 退款返券 | CouponRefundProcessor.process:整单退款才返还券 |
PAY/business/data/CouponRefundProcessor.java:24-129(:118
返还) |
1.6 促销价(lianer_promotion_*)
| 环节 | 现状 | 位置 |
|---|---|---|
| 支持类型 | card_type:1 会员卡、2 私教时长、3
私教次课(阶梯价);不支持 combo |
PAY/business/data/PromotionBusiness.java:90、:120、:774、:822、:851 |
| 活动读取 | 每门店取 status=1 and shelf_status=1 的一条(LIMIT
1) |
MAPPER/LianerPromotionActivityMapper.xml:249-267 |
| 缓存 | Guava expireAfterWrite(2, MINUTES),按
storeId,进程内缓存 |
PromotionBusiness.java:68-74 |
| 时间校验 | 列表展示路径(会员卡列表 :116、课程列表
:629、:690)校验
start/end;下单取价路径(getPromotionForCourse
:813-838、getPromotionForCard
:841-866、getPromotionForCourseLevel
:767-810)不校验时间,只依赖 status |
同上 |
| status 切换 | xxl-job promotionStatus 调
updateStatus:start_time<now and end_time>now
置 1,end_time<now 置 2 |
PAY/jobSchedule/PromotionJobHandler.java:27-33;PromotionBusiness.java:869-875;LianerPromotionActivityMapper.xml:230-237 |
| 风险(新发现) | 10:00 开抢时价格生效滞后 = job 间隔 + 最长 2 分钟缓存(每个实例各自缓存);23:59:59 结束后下单仍可能拿到活动价直到 job 跑过 + 缓存过期 | 同上 |
| 同店互斥 | 同一门店同一时期只能参加一个主题活动 | PromotionBusiness.java:152-176 |
1.7 套餐(combo)
| 环节 | 现状 | 位置 |
|---|---|---|
| 必须含会员卡+私教 | 后台保存校验 itemType 1 和 2 都要有 |
PAY/back/ComboBackImpl.java:184-191 |
| 支付成功同样校验 | 缺任一即抛异常 | ComboOrderBusiness.java:341-346 |
| 套餐按门店 | lianer_combo_product.store_id;补录校验「套餐不属于当前门店」 |
ComboBackImpl.java:448;MAPPER/LianerComboProductMapper.xml
字段 |
| 定价 | 直接用 combo.getPrice(),不走促销 |
ComboOrderBusiness.java:182 |
| 支付成功 | 0/1/-1 → 2(允许复活)→ 拆会员卡/私教子单 → 发双权益 →
购买历史 → 核销券;整段 @Transactional |
ComboOrderBusiness.java:285-454 |
| 积分 | 整个类无任何积分调用(grep -ci score = 0),combo
不赠分 |
ComboOrderBusiness.java 全文 |
| 用券 | 只接受 apply=2/3;券面额不得 ≥ 套餐价 | ComboBuyImpl.java:410-449 |
对 700 券的影响:「60次半年卡」是 combo,每个门店一个 comboId,「再购同款」不能用单个 comboId 做 scope,需要「商品组」概念(见 2.6)。
1.8 积分(二币)
| 环节 | 现状 | 位置 |
|---|---|---|
| 计算 | source=2(购买):s = (score * sourceScore) / 100,整数除法;sourceScore
来自 lianer_score 中 source=2 且 status=1 的一行 |
PAY/front/score/ScoreImpl.java:100-177(:142-149);PAY/business/data/ScoreBusiness.java:2085-2093 |
| 异步 | setUserScore 是 @Async |
ScoreImpl.java:102 |
| 会员卡口径 | 传 totalPrice(用券前) |
BankCallBackBusiness.java:436;PAY/front/login/MemberBuyImpl.java:461;OrderCommonBusiness.java:731、:1865 |
| 私教口径 | 传 payPrice(实付) |
BankCallBackBusiness.java:349;WxRedBackController.java:126;AliPayRedBackController.java:117;OrderCommonBusiness.java:1177 |
| 风险(新发现) | 会员卡 0 元单/补录审核路径成功表 orderPrice=0,但仍按
totalPrice 记分 |
OrderCommonBusiness.java:700-731 |
| 流水表 | lianer_user_score 字段仅 user_id, score, time, date,
info,
source;无订单号,无法按订单幂等、无法退款扣回 |
MAPPER/LianerUserScoreMapper.xml |
| 总分 | lianer_user_total_score.total_score = total_score + #{score},无下限保护 |
MAPPER/LianerUserTotalScoreMapper.xml:132-136 |
| 退款 | 退款相关代码中无积分扣回 | PAY/business/data/*Refund*.java 检索无 score |
| 下单抵扣 | 不存在。现有积分只用于积分商城兑换(/front/scoreExchange、/front/scoreExchangeOrder,扣分走
setUserScore(..., -score, ..., 4, ...)) |
PAY/front/login/ScoreInfoImpl.java:155-238、:245-300;前端
FE/src/api/integral.ts 也只有商城接口 |
结论:按实付 1% 返二币可通过 source_score=1 实现(18900
→ 189),但需统一会员卡口径、给 combo
加记分、加订单级幂等;二币抵扣下单是新需求(M6,后续版本)。source_score
是全局配置,产品已确认二币为长期规则,全局改为
1(影响全年所有购买,符合预期)。
1.9 私教年卡商品
- 用户口径:线上没有「私教年卡」产品。
- 生产只读库 2026-10-20 查询(产品同学提供):courseId
91「私教年卡」type=2、duration=360、total_price 24000,仅 store 5 有
config_type=2映射。 - 代码:
config_type=2的课程靠lianer_course_store门店映射决定可售,且要求status=1 and is_sale=1(PAY/business/data/CourseCoachBusiness.java:129-151)。 - 出入:库里有一个只映射到 1 家店的同名课程,可能是历史测试/单店产品,与「线上没有」不一致。
- 建议:新建独立双11 SKU(新
courseId,名称如「私教年卡(双11限定)」),理由:价格/限量/退款规则都是活动专属,复用
91 会把活动规则带到存量订单与报表;91
若有存量订单,权益与结算口径也会混。若产品坚持复用,需先查 91
的历史订单与权益,并在全部参与门店补
lianer_course_store映射。需产品/运营确认。
1.10 城市档位与门店维度
lianer_store有city_id、zone_id、brand_type(lianer-ground/com-lianer-ground-service/src/main/java/com/lianer/ground/service/entities/lianer/LianerStore.java:27-31;对外lianer-ground-api/src/main/java/com/lianer/ground/api/dto/res/StoreBaseRes.java:31-35)。brand_type是品牌(后台 UIbrands: ['Fitmoments', '练二'],/Users/idiotfan/Downloads/lianer-backend-ui/src/views/store/index.vue:538),不能区分综合馆/私教馆。zone_id疑似指向 region 表(lianer-ground/.../mapper/RegionMapper.xml:parent_id, name, level, adcode),能否据此识别新郑/费县/沂水区县需查库(待确认)。- 结论:新增「活动门店档位」配置表(2.3),每店显式配置档位与馆型,不从城市名推断。mock
里按城市名正则推断(
FE/mock-server/index.ts:298-306)只是演示。
1.11 订阅消息
user 服务已有
UserWxBusiness.sendSubscribeMessage(lianer-user/com-lianer-user-service/src/main/java/com/lianer/user/service/business/data/UserWxBusiness.java:279-296),仅被
HaidiBackController.java:315 内部调用,未通过 dubbo
API 暴露给 payment。需在 lianer-user-api
新增接口。
1.12 前端契约(mock)
见
FE/mock-server/index.ts:186-377、FE/src/api/double11.ts、FE/src/pages/activity/activity-261111.vue:276-395。要点:
- Venue 入参
{ cityId, cityName }(mock 另读storeName),出参见 3.1。正式版 Venue / Quota / Lock 三个接口统一带storeId,未定位时带cityId兜底(3.1)。 - 卡片数据没有 productId / storeId,前端跳转只到
/pages/pt/index?cat=(activity-261111.vue:435-438),正式版需补商品引用。 - 抢名额失败时前端读
e.data.quota刷新(activity-261111.vue:346-350),SOLD_OUT 需在 data 里带 quota。 double11MockPay/double11CancelLock注明仅 mock 用(FE/src/api/double11.ts:20-22)。
2. 数据库设计
时间字段风格沿用所在表:lianer_private_order 的时间是
BIGINT 毫秒(order_time
等),lianer_combo_order 是 DATETIME;新表统一
DATETIME(3)。
2.1 活动主表
CREATE TABLE lianer_activity (
id INT AUTO_INCREMENT PRIMARY KEY,
activity_code VARCHAR(32) NOT NULL COMMENT '如 D11_2026',
name VARCHAR(64) NOT NULL,
start_time DATETIME(3) NOT NULL COMMENT '2026-11-01 10:00:00.000',
end_time DATETIME(3) NOT NULL COMMENT '2026-11-11 23:59:59.999',
hold_minutes INT NOT NULL DEFAULT 15 COMMENT '限量商品付款时效',
score_per_amount INT NOT NULL DEFAULT 100 COMMENT '每满多少元',
score_per_points INT NOT NULL DEFAULT 1 COMMENT '得多少二币',
status TINYINT NOT NULL DEFAULT 1,
created_time DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
updated_time DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
UNIQUE KEY uk_code (activity_code)
) COMMENT '营销活动主表';2.2 活动商品映射
CREATE TABLE lianer_activity_item (
id INT AUTO_INCREMENT PRIMARY KEY,
activity_code VARCHAR(32) NOT NULL,
item_key VARCHAR(32) NOT NULL COMMENT 'pt_year/pt_60_half/card_month/... 与前端 key 一致',
tier CHAR(1) NULL COMMENT 'A-E;NULL=不分档',
store_id INT NULL COMMENT '按店覆盖;NULL=该档通用',
product_type TINYINT NOT NULL COMMENT '1会员卡 2私教课程 3套餐',
product_id INT NOT NULL COMMENT 'cardId/courseId/comboId',
product_group VARCHAR(32) NULL COMMENT '商品组,如 PT60_HALF,用于券 scope 跨店同款',
quota_pool_id INT NULL COMMENT '限量池;NULL=不限量',
per_user_limit INT NOT NULL DEFAULT 0 COMMENT '0=不限',
weekday_rule VARCHAR(16) NULL COMMENT '如 TUE;NULL=不限',
sort INT NOT NULL DEFAULT 0,
status TINYINT NOT NULL DEFAULT 1,
UNIQUE KEY uk_item (activity_code, item_key, tier, store_id),
KEY idx_product (product_type, product_id)
) COMMENT '活动商品与真实商品映射;价格不存这里,取价以促销/套餐价为唯一来源';说明:价格只有一个来源(会员卡/课程走
lianer_promotion_card_config,combo 走
lianer_combo_product.price),Venue
展示与下单都读它,避免展示价与成交价不一致。
注意 uk_item 中 tier/store_id 可为
NULL,MySQL 唯一键对 NULL 不去重,需应用层在后台保存时校验;或改为
tier CHAR(1) NOT NULL DEFAULT '-'、store_id INT NOT NULL DEFAULT 0(推荐后者)。
2.3 门店档位
CREATE TABLE lianer_activity_store_tier (
id INT AUTO_INCREMENT PRIMARY KEY,
activity_code VARCHAR(32) NOT NULL,
store_id INT NOT NULL,
tier CHAR(1) NOT NULL COMMENT 'A-E',
venue_type TINYINT NOT NULL DEFAULT 0 COMMENT '0未区分 1综合馆 2私教馆(杭州年卡价不同)',
tier_name VARCHAR(64) NOT NULL COMMENT '展示用,如 杭州 / 郑州·新郑',
UNIQUE KEY uk_store (activity_code, store_id)
) COMMENT '活动门店档位:区县档与馆型必须按店配置';2.4 名额池与占位(防超卖核心)
CREATE TABLE lianer_activity_quota_pool (
id INT AUTO_INCREMENT PRIMARY KEY,
activity_code VARCHAR(32) NOT NULL,
pool_key VARCHAR(32) NOT NULL COMMENT 'pt_year',
total INT NOT NULL COMMENT '100',
held INT NOT NULL DEFAULT 0 COMMENT '待支付占用',
sold INT NOT NULL DEFAULT 0 COMMENT '已售',
updated_time DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
UNIQUE KEY uk_pool (activity_code, pool_key),
CONSTRAINT chk_pool CHECK (held >= 0 AND sold >= 0 AND held + sold <= total)
) COMMENT '全国限量池(单行,条件更新扣减)';
CREATE TABLE lianer_activity_order_hold (
id BIGINT AUTO_INCREMENT PRIMARY KEY,
activity_code VARCHAR(32) NOT NULL,
item_key VARCHAR(32) NOT NULL,
pool_id INT NULL COMMENT 'NULL=不占限量池(如星期二月卡只做每人一单)',
user_id INT NOT NULL,
store_id INT NOT NULL,
order_type VARCHAR(16) NOT NULL COMMENT 'private/member/combo',
order_number VARCHAR(64) NOT NULL,
status VARCHAR(16) NOT NULL COMMENT 'HELD/RELEASING/RELEASED/SOLD/COMPENSATED/REFUND_PENDING/REFUNDED',
expires_at DATETIME(3) NOT NULL COMMENT '与订单 expires_at 一致的快照',
active_flag TINYINT GENERATED ALWAYS AS (IF(status IN ('HELD','RELEASING'), 1, NULL)) STORED,
limit_flag TINYINT NULL COMMENT '每人限购=1 的商品在非释放态置 1,释放/退款时应用置 NULL',
release_attempts INT NOT NULL DEFAULT 0,
release_reason VARCHAR(64) NULL,
released_time DATETIME(3) NULL,
created_time DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
updated_time DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
UNIQUE KEY uk_order (order_type, order_number),
UNIQUE KEY uk_active_hold (activity_code, item_key, user_id, active_flag),
UNIQUE KEY uk_user_limit (activity_code, item_key, user_id, limit_flag),
KEY idx_release (status, expires_at)
) COMMENT '活动订单占位:一单一行,状态机保证名额只释放一次';| 约束 | 防的并发问题 |
|---|---|
uk_pool + chk_pool + 条件更新
held+sold<total |
300 人抢 100 张超卖;扣减与释放计数变负 |
uk_order |
同一订单重复占位(重试、重复点击) |
uk_active_hold(生成列,只有 HELD/RELEASING 为 1,其余
NULL 不参与唯一) |
同一用户同时持有两个有效占位(口径:同用户同时只允许一个有效占位);Redisson 锁失效或「锁先于提交释放」时兜底 |
uk_user_limit |
每人限购 1(年卡若定为每人 1 张、星期二月卡每人一单)包含待支付单,弥补 1.2 所述「购买历史只在支付后写」 |
状态机 CAS(where status='HELD' 等) |
关单任务与支付回调竞态时名额被释放两次 / 已售又被释放 |
2.5 订单快照字段
ALTER TABLE lianer_private_order
ADD COLUMN activity_code VARCHAR(32) NULL COMMENT '活动订单标识',
ADD COLUMN expires_at BIGINT NULL COMMENT '付款截止,epoch 毫秒,创建时快照',
ADD COLUMN activity_snapshot JSON NULL COMMENT '活动价/原价/档位/规则快照',
ADD COLUMN score_rate VARCHAR(16) NULL COMMENT '如 100:1,下单时快照',
ADD COLUMN score_deduct_points INT NOT NULL DEFAULT 0,
ADD COLUMN score_deduct_amount DECIMAL(10,2) NOT NULL DEFAULT 0,
ADD KEY idx_activity_expire (activity_code, order_status, expires_at);
ALTER TABLE lianer_combo_order
ADD COLUMN activity_code VARCHAR(32) NULL,
ADD COLUMN expires_at DATETIME(3) NULL,
ADD COLUMN activity_snapshot JSON NULL,
ADD COLUMN score_rate VARCHAR(16) NULL,
ADD COLUMN score_deduct_points INT NOT NULL DEFAULT 0,
ADD COLUMN score_deduct_amount DECIMAL(10,2) NOT NULL DEFAULT 0;
ALTER TABLE lianer_member_order
ADD COLUMN activity_code VARCHAR(32) NULL,
ADD COLUMN score_rate VARCHAR(16) NULL,
ADD COLUMN score_deduct_points INT NOT NULL DEFAULT 0,
ADD COLUMN score_deduct_amount DECIMAL(10,2) NOT NULL DEFAULT 0;expires_at 防的是:后台修改 hold_minutes
后已创建订单的截止时间被改动;关单任务、支付发起校验、前端倒计时都只认这一个值。
注意 lianer_private_order 已有
activity_id/activity_name(促销活动快照,OrderCommonBusiness.java:1001-1002),新加的
activity_code 是营销活动,两者含义不同,不要复用。
2.6 指定商品券(通用能力)
ALTER TABLE lianer_counpon
ADD COLUMN scope_type TINYINT NOT NULL DEFAULT 0 COMMENT '0不限商品(仍受apply约束) 1套餐 2课程 3会员卡 4商品组',
ADD COLUMN scope_name VARCHAR(64) NULL COMMENT '展示用,如 私教60次半年卡';
CREATE TABLE lianer_counpon_scope_item (
id INT AUTO_INCREMENT PRIMARY KEY,
coupon_id INT NOT NULL,
target_type TINYINT NOT NULL COMMENT '1套餐 2课程 3会员卡 4商品组',
target_ref VARCHAR(64) NOT NULL COMMENT 'comboId/courseId/cardId 或商品组编码 PT60_HALF',
UNIQUE KEY uk_scope (coupon_id, target_type, target_ref)
) COMMENT '券模板适用商品';
ALTER TABLE lianer_user_coupon
ADD COLUMN scope_type TINYINT NOT NULL DEFAULT 0,
ADD COLUMN scope_refs VARCHAR(512) NULL COMMENT '发券时快照,逗号分隔 target_type:target_ref',
ADD COLUMN scope_name VARCHAR(64) NULL,
ADD COLUMN source_activity_code VARCHAR(32) NULL,
ADD COLUMN source_order_number VARCHAR(64) NULL;
ALTER TABLE lianer_combo_product
ADD COLUMN product_group VARCHAR(32) NULL COMMENT '跨门店同款商品组,如 PT60_HALF';- 用户券快照 scope:防止券模板后续改 scope 影响已发的券。
product_group:combo 按门店建(1.7),700 券 scope 用4:PT60_HALF,任何门店的 60次半年卡都能用;是否限同店需产品确认(第 9 章)。- 存量券
scope_type=0,行为不变(仍受 apply、门店、时间、门槛限制)。
2.7 活动发券资格
CREATE TABLE lianer_activity_grant (
id BIGINT AUTO_INCREMENT PRIMARY KEY,
activity_code VARCHAR(32) NOT NULL,
grant_type VARCHAR(32) NOT NULL COMMENT 'PT60_REBUY_700',
user_id INT NOT NULL,
source_order_type VARCHAR(16) NOT NULL,
source_order_number VARCHAR(64) NOT NULL,
user_coupon_id INT NULL,
status VARCHAR(16) NOT NULL COMMENT 'PENDING/GRANTED/FAILED/REVOKED',
attempts INT NOT NULL DEFAULT 0,
last_error VARCHAR(255) NULL,
created_time DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
updated_time DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
UNIQUE KEY uk_user_qual (activity_code, grant_type, user_id),
UNIQUE KEY uk_source (source_order_type, source_order_number, grant_type)
) COMMENT '活动发券资格:活动+用户唯一';| 约束 | 防的问题 |
|---|---|
uk_user_qual |
两笔首单并发支付、重复回调、补偿任务重跑导致一人多券(只用 user+order 唯一挡不住「两笔不同订单」) |
uk_source |
同一订单回调重放重复发券 |
2.8 预约
CREATE TABLE lianer_activity_reserve (
id BIGINT AUTO_INCREMENT PRIMARY KEY,
activity_code VARCHAR(32) NOT NULL,
item_key VARCHAR(32) NOT NULL,
user_id INT NOT NULL,
template_id VARCHAR(64) NULL,
subscribe_accepted TINYINT NOT NULL DEFAULT 0 COMMENT '用户是否在授权弹窗点了允许',
notify_status VARCHAR(16) NOT NULL DEFAULT 'WAIT' COMMENT 'WAIT/SENT/FAILED/SKIPPED',
notify_time DATETIME(3) NULL,
last_error VARCHAR(255) NULL,
created_time DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
UNIQUE KEY uk_reserve (activity_code, item_key, user_id),
KEY idx_notify (activity_code, notify_status)
);uk_reserve 防重复预约导致重复推送。openid
不入此表,发送时按 userId 由 user 服务取。
2.9 二币
ALTER TABLE lianer_user_score
ADD COLUMN order_type VARCHAR(16) NULL,
ADD COLUMN order_number VARCHAR(64) NULL,
ADD COLUMN biz_type VARCHAR(16) NULL COMMENT 'EARN/DEDUCT/RETURN/CLAWBACK',
ADD UNIQUE KEY uk_order_biz (order_type, order_number, biz_type);
CREATE TABLE lianer_score_deduct (
id BIGINT AUTO_INCREMENT PRIMARY KEY,
user_id INT NOT NULL,
order_type VARCHAR(16) NOT NULL,
order_number VARCHAR(64) NOT NULL,
points INT NOT NULL,
amount DECIMAL(10,2) NOT NULL,
status VARCHAR(16) NOT NULL COMMENT 'LOCKED/USED/RETURNED',
created_time DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
updated_time DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
UNIQUE KEY uk_order (order_type, order_number)
) COMMENT '二币下单抵扣(M6,后续版本;本期不建表)';uk_order_biz:存量行 order_number 为 NULL 不受影响;新入账按订单幂等,防回调重放/补偿重复记分;同时让退款扣回可定位。- (M6 后续)抵扣扣减用条件更新
total_score = total_score - #{n} where user_id=? and total_score >= #{n},防并发两单同时扣成负数。
3. 接口设计
统一:网关前缀
/payment/front/...,CommonResponse{code,msg,data},成功
code 沿用现有约定;双十一新错误码使用 mock 已定的字符串码(前端已按 msg
提示,并读取 data.quota)。
本节是双十一后端接口契约的唯一依据(api-spec.md
§1–2 已废弃)。单位:金额一律为元,number 保留 2 位小数;时间戳一律
epoch 毫秒。
门店参数(Venue / Quota / Lock 一致):请求带
storeId(当前门店);未定位到门店时不传
storeId、带
cityId,后端按城市默认档返回起步价,并在响应中标明
priceScope="CITY_FROM",前端显示「以门店价格为准」、不显示具体立省金额。double11Lock
必须有 storeId。
3.1
getDouble11Venue(会场只读)
入参:
| 字段 | 类型 | 说明 |
|---|---|---|
| cityId | int | 前端当前城市(已有) |
| cityName | string | 已有 |
| storeId | int | 新增:当前门店;有则按
lianer_activity_store_tier 定档;无则按 cityId
城市默认档,返回起步价 |
出参(与 mock FE/mock-server/index.ts:307-340
对齐):
{
"nowTs": 1793500000000,
"activityStartTs": 1793498400000,
"activityEndTs": 1794412799999,
"phase": "pre|on|end",
"tier": "B", "tierName": "杭州", "tierMatched": true, "priceScope": "STORE|CITY_FROM",
"hero": { "slogan": "练二一下,好状态", "headline": "健身年卡 999", "subline": "限量私教年卡最高立减 6,100" },
"featured": {
"key": "pt_year", "name": "私教年卡", "badge": "双11首发 · 全国限量 100 张",
"desc": "360 天时长卡", "originPrice": 24000, "price": 18900, "save": 5100, "monthly": 1575,
"lockMinutes": 15,
"quota": { "...同 3.2" },
"productRef": { "productType": 2, "productId": 0, "storeId": 0 }, "buyable": true, "unavailableReason": null
},
"memberCards": [ { "key": "card_year", "name": "健身年卡", "desc": "...", "originPrice": 1599, "price": 999,
"subPrice": { "label": "私教馆", "price": 899 }, "tag": null, "note": null, "limitText": null,
"productRef": { "productType": 1, "productId": 0, "storeId": 0 }, "buyable": true, "unavailableReason": null } ],
"ptCards": [ { "key": "pt_60_half", "cat": "times", "months": 6, "tag": "首单送700券", "...": "...", "productRef": { "productType": 3, "productId": 0, "storeId": 0 }, "buyable": true, "unavailableReason": null } ],
"singleClass": [ { "name": "常规单次课", "origin": "10 节起 · 单节 ¥240", "steps": [ { "label": "20 节", "price": 200 } ] } ],
"points": { "name": "二币", "perAmount": 100, "perPoints": 1, "value": 1 },
"rules": [ "..." ]
}- 每个商品项(featured / memberCards / ptCards)都带
productRef {productType, productId, storeId}(productType:1 会员卡 cardId、2 私教课程 courseId、3 套餐 comboId,与lianer_activity_item.product_type一致)和同级buyable: boolean、unavailableReason。productRef为前端跳真实购买页必需(mock 卡片没有商品 ID)。未配置或当前门店无该商品时buyable=false并给原因,前端置灰,不允许导航到可提交状态。 hero文案按档从配置下发,前端不写死:headline=健身年卡 ${本档年卡价}(A 1699 / B 999 / C 899 / D、E 799,见 mockD11_TIERS),subline默认「限量私教年卡最高立减 6,100」(A 档 25000 → 18900);C/D/E 档私教年卡实际立减 4700,运营可按档覆盖。禁止全国统一写「999」,或写在部分档位不成立的「立减 X 起」类文案。phase由服务端当前时间计算,不进缓存。- 价格读取与下单同源(2.2 说明)。
3.2 getDouble11Quota
入参 { key: "pt_year", storeId, cityId }(storeId
优先,未定位带 cityId;名额是全国池,门店参数用于 myLock
与可售判断)。出参(mock :216-222):
{ "key": "pt_year", "total": 100, "sold": 63, "locked": 5, "remain": 32, "soldOut": false, "availableNow": true,
"reserved": true, "myLock": { "orderNo": "...", "expiresAt": 1793500900000, "status": "HELD" }, "nowTs": 1793500000000 }locked= pool.held;remain = total - sold - held。- 售罄口径(替换 mock 的
soldOut = remain==0 && myLock==null):soldOut = sold >= total,即 100 张全部实际付款才算售罄,前端显示「已抢光」。remain == 0但仍有待支付占位(held > 0)时:soldOut=false、availableNow=false,前端显示「暂无可用名额,可稍后再试」并禁用抢购按钮。availableNow = remain > 0。 - 进度条只用
sold / total,不把 held 计入,避免待支付订单释放后进度倒退。 myLock只返回本人 HELD/RELEASING 的占位,myLock.status:HELD(待支付,前端显示倒计时与「去支付」)|RELEASING(已过截止时间,正在向渠道关单/查单,结果未定;前端显示「支付结果确认中」并禁用抢购)。nowTs(服务端毫秒)必返,前端倒计时纠偏(Venue、Lock 同样返回)。
3.3 double11Reserve
入参 { key, templateId, accepted }(前端先
uni.requestSubscribeMessage,把授权结果上报;页面现有 TODO
见 activity-261111.vue:332)。出参
{ reserved: true }。重复调用幂等(uk_reserve)。accepted=false
也记录预约但不推送。
3.4
double11Lock(正式版 = 创建待支付订单 + 占名额)
入参:
| 字段 | 必填 | 说明 |
|---|---|---|
| key | 是 | pt_year |
| storeId | 是 | 新增:下单门店(年卡在哪个门店履约);未定位不允许抢,前端先引导选店 |
| cityId | 否 | 当前城市,仅用于日志与校验门店归属 |
| setCoachId | 否 | 指定教练;不传为「待分配」,与现有私教下单一致 |
| discountId | 否 | 是否允许叠券待确认(第 9 章),默认不接受 |
出参(保留 mock 字段名
orderNo/expiresAt/quota,追加支付所需):
{
"orderNo": "私教订单号(orderNumberBusiness 生成)",
"expiresAt": 1793500900000,
"nowTs": 1793500000000,
"orderPrice": 18900.00, "totalPrice": 18900.00,
"courseName": "私教年卡(双11限定)", "storeName": "...",
"lockStatus": "HELD",
"quota": { "...同 3.2" },
"pay": { "api": "/payment/front/trCreateOrder", "orderNumber": "同 orderNo" }
}与现有链路衔接:
double11Lock内部复用私教建单核心(从OrderCommonBusiness.privateOrderInfo抽出「校验+组装+insert」方法,见 5.2),写入activity_code/expires_at/activity_snapshot,同事务占名额。- 前端拿
orderNo调现有/front/trCreateOrder(PrivateBuyImpl.java:154-200),后端在该接口内对活动订单加时效校验并向渠道传有效期(5.3)。 - 支付成功由真实回调完成占位 → 已售(5.4),前端轮询
getDouble11Quota或订单详情/front/privateOrderInfoById确认。 - 同一用户已有有效占位:
HELD时直接返回原订单(与 mock 幂等行为一致),不报错;RELEASING时返回D11_RELEASING。 nowTs必返(服务端毫秒),前端据此校正expiresAt倒计时。
3.5
double11MockPay(mock-only,正式版删除)
正式版不提供。由 trCreateOrder + 渠道支付 +
支付回调替代;前端「去支付」按钮改为调起支付(FE/src/utils/common.ts
的通用支付方法),页面里
payLock(activity-261111.vue:369-382)需替换。
3.6
double11CancelLock(正式版 =
用户主动取消待支付活动订单)
入参 { orderNo }。出参:
{ "status": "RELEASED|RELEASING|PAID", "quota": { "...": "..." }, "nowTs": 1793500000000 }- 内部调用与关单任务相同的释放例程(5.5):先查渠道,确认未支付才关单释放。
- 渠道结果未知(P 或查询失败):返回
RELEASING,名额暂不释放,前端提示「支付结果确认中」。 - 确认未支付且渠道已关:返回
RELEASED,名额已归还一次。 - 查到已支付:返回
PAID。 - 也可让前端改调现有
/front/cancelOrder(OrderImpl.java:153-195),但需在其中补活动占位释放;建议保留独立接口,逻辑集中。
3.7 确认页/下单接口扩展(二币 + 券 scope)
| 接口 | 新增入参 | 新增出参 |
|---|---|---|
/front/privateOrderInfo、/front/comboOrderInfo、/front/memberOrderInfo |
本期无(useScorePoints 属 M6 后续版本) |
scoreRate {perAmount, perPoints}、estimatedScore(本期);scoreDeductPoints、scoreDeductAmount(M6
后续) |
/front/getUserCouponList |
targetType、targetId(确认页选券时传当前商品,与
applys 同时传) |
每张券加
scopeType、scopeName、usable、unusableReason |
estimatedScore = floor(orderPrice / perAmount) * perPoints,orderPrice
为扣券后实付(本期无二币抵扣)。
3.7a
getDouble11Flags(购买页氛围条,新增)
会员卡 / 私教 /
套餐购买页用于决定是否显示双11氛围条。合规要求:只有后端返回
inPromotion=true
的商品才显示,未参加活动的商品不得出现「双11特价」等字样;前端不按日期或商品名自行判断。
入参:
{ "storeId": 5, "cityId": 12597, "items": [ { "type": 3, "id": 0 }, { "type": 1, "id": 3 } ] }出参:
{
"phase": "pre|on|end",
"nowTs": 1793500000000,
"items": [
{ "type": 3, "id": 0, "inPromotion": true, "label": "双11价 · 首单送700券", "giftCoupon700Eligible": true },
{ "type": 1, "id": 3, "inPromotion": false, "label": null, "giftCoupon700Eligible": false }
]
}type/id同productRef.productType/productId。inPromotion以生效中的促销配置 +lianer_activity_item映射为准(含时间校验)。giftCoupon700Eligible仅对product_group=PT60_HALF的套餐有意义:当前用户在lianer_activity_grant中尚无PT60_REBUY_700资格记录,且活动发券窗口内为 true;其余商品恒 false。未登录时为 false。- 接口失败或字段缺失:前端不显示氛围条。
3.8 错误码
| code | 场景 | data |
|---|---|---|
D11_NOT_START |
活动未开始(服务端时间) | null |
D11_END |
活动已结束 | null |
SOLD_OUT |
名额已满(含待支付占用) | { quota } |
D11_LOCK_EXPIRED |
发起支付时已超过 expires_at,或剩余不足安全余量 |
{ quota } |
D11_RELEASING |
订单在释放确认中(渠道结果未知),暂不可重新抢 | { quota } |
D11_STORE_NOT_ELIGIBLE |
门店未参加活动或未配置该商品 | null |
D11_USER_LIMIT |
每人限购已达上限(含待支付) | null |
D11_TUESDAY_ONLY |
星期二特价非周二下单 | null |
COUPON_SCOPE_MISMATCH |
券不适用当前商品 | null |
COUPON_EXPIRED |
券不在有效期(补齐时间校验后) | null |
COUPON_LOCKED |
券已被其他待支付订单占用(CAS 失败) | null |
SCORE_NOT_ENOUGH |
二币余额不足(M6 后续) | null |
SCORE_DEDUCT_EXCEED |
超过抵扣上限(M6 后续) | null |
RATE_LIMITED |
触发限流 | null |
前端对上表每个 code 映射独立文案(如
SOLD_OUT「已抢光」、D11_RELEASING「支付结果确认中」、D11_LOCK_EXPIRED「付款时间已过,名额已释放」),不得统一提示「网络异常」或直接透出
msg。新增 code 需同步前端文案表。
4. 核心流程时序
4.1 抢名额 → 支付 → 成功 / 超时 / 晚回调
sequenceDiagram
participant U as 小程序
participant P as payment
participant DB as MySQL
participant TR as 泰融
participant J as 释放任务
U->>P: double11Lock{key,storeId}
P->>P: 时间窗/门店/限流/Redis余量快速失败
P->>DB: BEGIN; 已有HELD占位? 有则返回原单
P->>DB: insert private_order(status0, activity_code, expires_at=now+15m)
P->>DB: insert order_hold(HELD) 唯一约束兜底
P->>DB: update pool set held=held+1 where held+sold<total
alt 影响0行
P->>DB: ROLLBACK
P-->>U: SOLD_OUT + quota
else
P->>DB: COMMIT
P-->>U: orderNo, expiresAt
end
U->>P: trCreateOrder{orderNumber}
P->>P: 校验 now < expires_at - 安全余量, hold=HELD
P->>TR: 预下单(带有效期, 待确认字段)
P-->>U: 支付参数
alt 期限内支付
TR->>P: 回调
P->>DB: setOrderPaid; hold HELD->SOLD; pool held-1 sold+1
P->>DB: 发权益/记二币/...
else 超时
J->>DB: hold HELD->RELEASING (CAS, expires_at+宽限已过)
J->>TR: 逐条查该订单全部流水
alt 任一S
J->>P: 回放支付成功 -> hold SOLD
else 有P或查询失败
J->>DB: 保持RELEASING, attempts+1, 超阈值告警
else 全部F/无流水
J->>TR: 关渠道单(若支持)
J->>DB: BEGIN; 关单0/1->-1; hold RELEASING->RELEASED; pool held-1; COMMIT
end
end
opt 晚到回调(订单已-1, hold已RELEASED)
TR->>P: 回调
P->>DB: setOrderPaid(-1->2)
P->>DB: pool sold+1 where held+sold<total
alt 成功
P->>DB: hold RELEASED->COMPENSATED, 正常履约
else 无余量
P->>DB: hold->REFUND_PENDING, 订单->8, 不发权益
P->>P: 生成全额退款单(走现有退款通道)
end
end
4.2 首单发券 → 再购用券
sequenceDiagram
participant TR as 泰融
participant C as ComboOrderBusiness.onPaySuccess
participant DB as MySQL
TR->>C: 60次半年卡 combo 支付回调
C->>DB: 原有: 置2/拆子单/发权益/核销券
C->>C: combo.product_group=PT60_HALF 且活动期内 且本单未使用700券?
C->>DB: INSERT IGNORE activity_grant(activity+type+user)
alt 插入成功(首单)
C->>DB: insert user_coupon(scope快照, source_order)
C->>DB: grant.user_coupon_id, status=GRANTED
else 已存在
C->>C: 跳过(非首单/重放)
end
Note over C,DB: 同一事务; 发券异常则 grant 置 FAILED 由补偿任务重试, 不回滚履约(见5.6)
participant U as 小程序
U->>DB: getUserCouponList(targetType=4, PT60_HALF)
U->>C: comboOrderInfo{comboId, discountId}
C->>C: canUserCoupon + 时间 + scope命中 + CAS锁券
4.3 二币入账
sequenceDiagram
participant CB as 各支付成功入口
participant S as ScoreAccrualService
participant DB as MySQL
CB->>S: accrue(orderType, orderNumber, userId, payAmount, rate快照)
S->>S: points = floor(payAmount/perAmount)*perPoints; 0则返回
S->>DB: insert user_score(order_type,order_number,biz=EARN) 唯一键冲突=已入账
S->>DB: total_score += points; 等级累加
combo:只在父订单 onPaySuccess 调一次,子单
insertMemberOrderSuccess/insertPrivateOrderSuccess
不调(现状也不调)。
4.4 关单任务
| 任务 | 范围 | 频率 | 逻辑 |
|---|---|---|---|
现有 privateOrderHandle |
普通私教单 | 不变 | 增加 activity_code IS NULL 过滤;改为逐单「查单 +
条件关单」(5.7) |
新 d11HoldReleaseHandle |
order_hold.status in (HELD, RELEASING) and expires_at < now - 宽限 |
30 秒(产品文档对外口径为「每分钟扫描、截止后约 1 分钟内释放」,30 秒满足该口径) | 4.1 释放分支;单次最多处理 200 条 |
现有 synBankOrderStatusHandle |
近 6 小时 0/1 | 不变 | 活动单也被覆盖,作为额外兜底 |
宽限建议 2 分钟(回调延迟、用户在支付页停留);最终值待确认。
5. 改动点详解
5.1 活动配置读取(M1)
新类 PAY/business/data/ActivityConfigBusiness.java:
getActivity(code)、getStoreTier(code, storeId)、listItems(code, tier, storeId);进程内缓存 30 秒(Caffeine 或复用 Guava),后台修改后主动清缓存。- 新类
PAY/front/Double11FrontImpl.java(参照PAY/front/Activity260618FrontImpl.java:29-36的@Service+@ShenyuDubboClient写法),API 接口放lianer-payment-api/src/main/java/com/lianer/payment/api/front/Double11FrontApi.java。
5.2 抢名额
double11Lock(M2)
改动:
- 从
OrderCommonBusiness.privateOrderInfo(:796-1098)抽出buildPrivateOrder(req, ctx):只做校验与组装,不加锁、不插库;原方法改为调用它,行为不变。 - 新
Double11HoldBusiness.lock(userId, key, storeId, setCoachId):
@Transactional(rollbackFor = Exception.class)
public LockResult lock(...) {
Activity act = config.getActivity(D11);
long now = System.currentTimeMillis();
if (now < act.startMs()) throw biz("D11_NOT_START");
if (now > act.endMs()) throw biz("D11_END");
Item item = config.requireItem(D11, "pt_year", storeId); // 门店未配置 -> D11_STORE_NOT_ELIGIBLE
Hold mine = holdMapper.selectActive(D11, "pt_year", userId); // HELD/RELEASING
if (mine != null) {
if ("RELEASING".equals(mine.status)) throw biz("D11_RELEASING");
return LockResult.of(mine); // 幂等
}
PrivateOrderPojo po = orderCommon.buildPrivateOrder(req, ctx); // 价格来自促销配置
po.setActivityCode(D11);
po.setExpiresAt(now + act.holdMinutes() * 60_000L);
po.setActivitySnapshot(json(item, price, tier));
paymentPrivateBusiness.insertPrivateOrder(po);
try {
holdMapper.insert(Hold.held(po, item, limitFlag(item))); // uk_active_hold / uk_user_limit
} catch (DuplicateKeyException e) {
throw new RetryAsIdempotent(); // 外层捕获后查原单返回
}
int n = poolMapper.incHeld(item.poolId()); // 放最后,缩短热点行锁持有时间
if (n == 0) throw biz("SOLD_OUT"); // 抛出即整体回滚
return LockResult.of(po);
}-- incHeld
UPDATE lianer_activity_quota_pool SET held = held + 1
WHERE id = #{id} AND held + sold < total;- 事务边界:订单、占位、名额三者同事务;不在事务内调用任何外部服务(渠道、user 服务)。
- 幂等键:
uk_active_hold(同用户)、uk_order(同订单)。 - 前置快速失败:Redis 读
d11:pool:{id}:remain(由每次 pool 变更后异步刷新,允许短暂不准),为 0 时直接返回 SOLD_OUT,不进 DB。它只做减压,不作为库存依据。 - 用户维度限流:Redis
SET d11:lock:{userId} NX PX 2000,拿不到返回RATE_LIMITED。
5.3 发起支付加时效校验(M2)
改 PAY/front/login/PrivateBuyImpl.java:154-200
trCreateOrder(以及
wxCreateOrder :119-152、alipayTrCreateOrder :249-277,若活动单允许这些通道):
if (po.getActivityCode() != null) {
Hold h = holdMapper.selectByOrder("private", po.getOrderNumber());
long left = po.getExpiresAt() - System.currentTimeMillis();
if (h == null || !"HELD".equals(h.status) || left < SAFE_MARGIN_MS) // SAFE_MARGIN 建议 60s
return fail("D11_LOCK_EXPIRED", quota());
ctx.setPayExpireAt(po.getExpiresAt()); // 传给渠道
}PaymentPrivateBusiness.createOrder(:550-600)→OrderBusiness.trPrivateCreateOrder(:268-291)→TairongPayUtils.createeOrderByConfig(:71-115)需增加有效期参数;泰融报文字段名与格式待与银行确认。若泰融不支持,则只能依赖「到期后查单 + 晚回调补偿」,需在风险中注明。- 微信直连:
WxPayUnifiedOrderRequest.setTimeExpire(yyyyMMddHHmmss)(OrderBusiness.java:393-421)。 setPrivateOrderStatus(...,1)改为同步条件更新(见 5.7)。
5.4 支付成功钩子(M2)
问题:私教成功后处理在三处重复(BankCallBackBusiness.java:330-410、WxRedBackController.java:98-185、AliPayRedBackController.java:100-180)。
改法:抽
PrivatePaySuccessHandler.afterPaid(order, payInfo),三处统一调用;在发权益之前插入活动钩子:
// payPrivateSuccess 返回 >0 之后、insertPrivateRight 之前
if (order.getActivityCode() != null) {
HoldOutcome o = d11Hold.onPaid("private", order.getOrderNumber()); // 独立事务 REQUIRES_NEW
if (o == HoldOutcome.REFUND_REQUIRED) {
markOrderRefundPending(order); // 2 -> 8
refundService.createAutoFullRefund(order, "D11_LATE_PAY_NO_QUOTA");
return; // 不发权益、不记二币
}
}@Transactional(propagation = REQUIRES_NEW)
public HoldOutcome onPaid(String type, String no) {
Hold h = holdMapper.lockByOrder(type, no); // SELECT ... FOR UPDATE
switch (h.status) {
case "HELD": case "RELEASING":
holdMapper.cas(h.id, h.status, "SOLD");
poolMapper.heldToSold(h.poolId); // held=held-1, sold=sold+1 where held>0
return OK;
case "SOLD": case "COMPENSATED":
return OK; // 重放
case "RELEASED":
if (poolMapper.incSold(h.poolId) == 1) { // where held+sold<total
holdMapper.cas(h.id, "RELEASED", "COMPENSATED");
return OK;
}
holdMapper.cas(h.id, "RELEASED", "REFUND_PENDING");
return REFUND_REQUIRED;
default: return OK;
}
}- 幂等键:hold 行状态机 +
FOR UPDATE。 - 退款:复用现有后台退款/泰融退款通道(
TairongPayUtils.refundByConfig :218-265);是否自动退还是进审批流待确认。退款失败需告警并可人工重试。 - 回调吞异常问题:
bankPayCallBack的catch(BankCallBackBusiness.java:297)对活动单写一条失败记录(可复用lianer_activity_grant风格的补偿表或日志表),由补偿任务按订单号重放queryTrBankOrderStatusAndUpdate。
5.5 释放例程(M2)
public ReleaseResult release(Hold h, String reason) {
if (holdMapper.cas(h.id, "HELD", "RELEASING") == 0 && !"RELEASING".equals(h.status)) return SKIP;
List<LianerPayBill> bills = payBillMapper.selectByRefNums(singletonList(h.orderNumber)); // 全部流水
boolean unknown = false;
for (LianerPayBill b : bills) {
TrTransactionStatusPojo r = tairong.queryTransactionStatus(b.getOrderNum(), cfg(h.storeId));
if (r == null || !r.isResult()) { unknown = true; continue; }
if ("S".equals(r.getTranStatus())) {
bankCallBack.bankPayCallBack(TairongPayType.Private, b.getOrderNum(), r.getOriTranSeqNo(), r.getClrgAmt(), now(), new JSONObject());
return PAID; // onPaid 会把 RELEASING -> SOLD
}
if ("P".equals(r.getTranStatus())) unknown = true;
}
if (unknown) { holdMapper.incAttempts(h.id); alertIfOver(h, 20); return PENDING; }
closeChannelIfSupported(bills); // 待确认
return txTemplate.execute(s -> {
if (privateOrderMapper.closeOrder(h.orderNumber, now()) == 0) return CHANGED; // 0/1 -> -1
if (holdMapper.casRelease(h.id, "RELEASING", "RELEASED", reason) == 0) return CHANGED; // 同时 limit_flag=NULL
poolMapper.decHeld(h.poolId); // where held>0
if (order.discountId != null) couponBusiness.unlockUserCoupon(order.userId, order.discountId);
scoreDeduct.returnIfLocked("private", h.orderNumber); // M6 后续版本,本期不加
return RELEASED;
});
}- 释放只发生一次:
casRelease影响 1 行才扣held。 - 查单不确定:保持 RELEASING 占位并重试,绝不释放。
- 注意
queryTrBankOrderStatusAndUpdate内部只查getRealPayOrderNum的一条流水(BankCallBackBusiness.java:208),这里改为逐条查,不复用它。
5.6 700 元复购券(M4)
- 发券:在
ComboOrderBusiness.onPaySuccess末尾(:453的 PAY_SUCCESS_FINISH 日志之前)加:
if (activityConfig.isGrantTarget(D11, combo.getProductGroup(), order.getPayTime())
&& !isPaidWithGrantCoupon(order)) {
activityGrantBusiness.grantOnce(D11, "PT60_REBUY_700", order);
}public void grantOnce(...) {
int n = grantMapper.insertIgnore(code, type, userId, "combo", orderNo, "PENDING"); // 两个唯一键
if (n == 0) return; // 非首单或重放
try {
Integer ucId = couponBusiness.issueScoped(templateId, userId, scopeSnapshot, code, orderNo); // 单张、显式 new 实体
grantMapper.granted(code, type, userId, ucId);
} catch (Exception e) {
grantMapper.failed(code, type, userId, e.getMessage()); // 补偿任务 activityGrantRetryHandle 重试
}
}- 事务:与 onPaySuccess 同事务写 grant 行;
issueScoped是本库插入,失败时被 catch,不回滚履约。 - 「首单」定义(已确认):该用户在活动开始(2026-11-01
10:00:00)之后第一笔支付成功的
60次半年卡订单——订单创建时间与支付时间均不早于活动开始;活动开始前的购买、退款记录一律不看。每人一张由
uk_user_qual(activity+type+user)兜底;并发两单以先拿到 grant 行者为准。发券窗口终点按活动配置(默认活动结束时间);若要求 11-11 后首单仍送券,需产品另行确认。 - 首单退款:不在本节决定,按第 9 章 #8(C1/C2)待定口径;grant 行的
REVOKED与source_order_number已为各方案预留。在政策确定前,首单退款后不删除 grant 行,即默认不再发第二张。 - 60次半年卡 11-11 后继续售卖:
isGrantTarget只控制发券;用券校验(scope + 券有效期)不受活动时间限制,券在有效期内可按商品当时售价使用。 - 不要用现有
insertCoupon(1.5 所述循环复用实体问题),新写issueScoped。
- 用券:
ComboBuyImpl.comboOrderInfo(:410-449)在canUserCoupon后加couponScope.matches(userCoupon, TARGET_COMBO, comboId, combo.getProductGroup()),不匹配返回COUPON_SCOPE_MISMATCH。私教/会员卡下单(OrderCommonBusiness.java:1018、:534)同样加 scope 校验:带 scope 的券在不匹配的商品上一律拒绝。 - 时间校验:
CouponBusiness.canUserCoupon(:219-226)增加startTime <= now <= endTime,三条下单路径统一受益;ComboBuyImpl.java:430-433的局部校验可保留。 - 锁券 CAS:
lockUserCouponSQL 改为where id=? and user_id=? and lock=0 and status=0,返回影响行数;为 0 时返回COUPON_LOCKED。所有调用点(OrderCommonBusiness.java:539、:1024、ComboBuyImpl.java:448等)检查返回值。 - 列表:
CouponFrontImpl.getUserCouponList(:70-117)支持targetType/targetId:计算usable/unusableReason,并下发scopeType/scopeName。 - 退款联动:政策未定,见第 9 章;技术上 grant 表有
REVOKED状态与source_order_number,可支持任一方案。
5.7 通用加固(M3,建议先于活动上线)
| 改动 | 位置 | 改法 |
|---|---|---|
| 私教超时关单 | PaymentPrivateBusiness.java:434-452、MAPPER/LianerPrivateOrderMapper.xml:1371-1378 |
改为逐单:有未完结流水先查单 → 条件关单
closeOrder(:1364-1369)→
关单成功才解锁该单的券;同时 SQL 至少补
and order_status in (0,1);排除
activity_code is not null |
| 支付中状态 | PaymentPrivateBusiness.java:213-231、LianerPrivateOrderMapper.xml:1325-1330 |
去 @Async;SQL 改
set order_status=1 where order_number=? and order_status=0 |
| 锁券 | 见 5.6 第 4 点 | CAS |
| 促销取价时间 | PromotionBusiness.java:767-866 |
三个下单取价方法补 start_time <= now <= end_time
校验(与列表路径 :116
一致);selectListByStore 增加时间条件;活动开始/结束前后 1
分钟由任务调用
clearCache()(:76-79),多实例需广播(或把缓存
TTL 在活动日临时调短到 10 秒) |
| 回调失败可追溯 | BankCallBackBusiness.java:297 |
异常时落补偿记录,任务重放 |
5.8 二币入账统一(M5)
新类 PAY/business/data/ScoreAccrualBusiness.java:
@Transactional(propagation = REQUIRES_NEW)
public void accrue(String orderType, String orderNo, Integer userId, BigDecimal payAmount, String rateSnapshot) {
Rate r = Rate.parse(rateSnapshot != null ? rateSnapshot : globalRate()); // 默认取 lianer_score source=2
int points = payAmount.divide(BigDecimal.valueOf(r.perAmount), 0, RoundingMode.DOWN).intValue() * r.perPoints;
if (points <= 0) return;
try { userScoreMapper.insertEarn(userId, points, orderType, orderNo, "购买获得二币"); }
catch (DuplicateKeyException e) { return; } // 幂等
scoreBusiness.incScore(userId, points);
userScoreLevelApi.addUserScoreAndLevel(userId, points);
}替换调用点:
- 会员卡改按实付:
BankCallBackBusiness.java:436、MemberBuyImpl.java:461、OrderCommonBusiness.java:731、:1865、WxRedBackController.java:223、AliPayRedBackController.java:216。0 元单/补录是否记分需产品确认(现状按 totalPrice 记,见 1.8)。 - 私教:
BankCallBackBusiness.java:349、WxRedBackController.java:126、AliPayRedBackController.java:117、OrderCommonBusiness.java:1177。 - combo:
ComboOrderBusiness.onPaySuccess末尾,用order.getOrderPrice()(父单实付)。 - 算例:18900 → 189;8800 → 88;999 → 9;399 → 3;129 → 1;99 → 0。
- 退款扣回:政策待定。若扣回,按
uk_order_biz写CLAWBACK,余额不足时是否允许负数需产品定。 - 下单时快照
score_rate到订单,避免日后调整比例影响在途订单。 - 二币为长期全局规则(已确认):全局
source_score改为每满 100 元得 1 个,globalRate()即该值;活动结束不回落。积分商城兑换(/front/scoreExchange等)沿用,只改展示名。
5.9 二币下单抵扣(M6,后续版本,本期不做)
产品已确认本期不上线,以下仅为后续版本预案。无现成能力(1.8)。方案:
- 确认页入参
useScorePoints;校验points <= 余额、points <= 上限(上限规则待确认,如不超过实付 X% 或每单最多 N 个)、orderPrice - points >= 0.01(避免 0 元单走 0 元直接成功分支OrderCommonBusiness.java:1056-1061,或明确 0 元单是否允许)。 - 建单事务内:
total_score条件扣减 → 插lianer_score_deduct(LOCKED)→ 订单写score_deduct_points/amount,orderPrice = totalPrice - coupon - points。 - 支付成功:
LOCKED → USED;二币入账按抵扣后实付计算。 - 关单/超时/释放:
LOCKED → RETURNED并加回余额(CAS 保证一次)。 - 退款:政策待定(退回二币 / 不退)。
- 抵扣不应计入等级:扣减不调用
addUserScoreAndLevel(现状ScoreImpl.java:166-172只在 s>0 时加等级)。
5.10 疯狂星期二月卡(M7)
改
OrderCommonBusiness.memberOrderInfo(:421-640),对
activity_item.weekday_rule='TUE' 的卡:
LocalDate.now(ZoneId.of("Asia/Shanghai")).getDayOfWeek() == TUESDAY,否则D11_TUESDAY_ONLY。活动窗口内周二只有 11-03、11-10 两天。- 每人一单:建单同事务插
order_hold(pool_id=NULL, limit_flag=1),靠uk_user_limit防并发;关单时置RELEASED, limit_flag=NULL。现有checkBuyLimit(:462)只数已支付,不够。 - 支付发起是否也限周二(周二下单、周三付款)待确认,建议只校验下单时间。
5.11 预约开抢(M8)
lianer-user-api新增WxSubscribeApi.send(userId, templateId, page, data),实现调用UserWxBusiness.sendSubscribeMessage(UserWxBusiness.java:279-296),内部按 userId 取 openid。- 任务
d11ReserveNotifyHandle:11-01 09:55 起分批(每批 200,间隔 1 秒)发送subscribe_accepted=1 and notify_status='WAIT',结果写回;微信接口限频与失败码(如用户拒收 43101)记FAILED不重试。
6. 性能与并发
6.1 QPS 估算(参数需运营给出真实在线人数,下表为假设)
| 假设 | 值 |
|---|---|
| 10:00 会场同时在线 | 5000 人(待确认) |
| 名额轮询 | 页面 10 秒一次(activity-261111.vue:308-310) |
| 开抢点击集中在前 5 秒,每人平均 2 次 |
| 接口 | 估算峰值 |
|---|---|
getDouble11Quota |
5000/10 = 500
QPS(开抢瞬间页面会立即拉一次,activity-261111.vue:274,叠加约
1000 QPS) |
getDouble11Venue |
进入页面时一次,预热期 100–300 QPS |
double11Lock |
10000 次/5 秒 = 2000 QPS 尖峰;经用户级限流 + Redis 余量快速失败后,真正进 DB 的约等于前几百次 |
6.2 名额扣减方式
| 方案 | 优点 | 缺点 | 结论 |
|---|---|---|---|
| DB 单行条件更新(2.4/5.2) | 强一致、可审计、天然与订单同事务 | 热点行串行,单行约每秒数百到上千次更新 | 采用。总量只有 100,成功事务只有 100 次,其余在 Redis 快速失败层被挡掉 |
| Redis 预扣 + DB 落账 | 吞吐高 | 预扣与落账不一致需对账;锁过期、进程崩溃时「释放一次」无法只靠 Redis 保证 | 只用作「余量为 0 快速失败」与用户限流,不作为库存依据 |
热点行事务内顺序:先插订单与占位,最后更新 pool 行再提交,缩短行锁持有时间。
6.3 Venue 缓存
| 数据 | 策略 |
|---|---|
| 活动配置、档位、商品映射 | 进程内 30 秒;后台改动主动清 |
| 价格 | 与下单同源读取,进程内 30 秒;活动开始/结束前后 1 分钟内不缓存(或 TTL 5 秒)。展示价短暂滞后可接受,成交价以下单接口为准 |
| phase/nowTs | 不缓存,每次按服务器时间算 |
| quota | 不缓存或 Redis 1 秒;myLock 按用户实时查(走
uk_active_hold 索引) |
6.4 限流
- 用户级:
double11Lock每用户 2 秒 1 次(Redis NX)。 - 接口级:网关 ShenYu 限流插件(仓库内
apache-shenyu-2.5.0有 ratelimiter 插件,线上是否启用待确认)对double11Lock设 1000 QPS 上限,超出返回RATE_LIMITED。 - 释放任务、预约推送任务分批执行,避免与开抢峰值重叠(预约推送在 09:55–09:59 完成)。
7. 配置清单(运营后台)
| 项 | 配置位置 | 内容 |
|---|---|---|
| 活动主表 | lianer_activity(需后台页或 SQL 初始化) |
D11_2026,时间窗,hold_minutes=15 |
| 门店档位 | lianer_activity_store_tier |
每个参与门店:A–E、馆型(杭州综合馆/私教馆)、展示名 |
| 促销活动 | 现有促销后台(lianer_promotion_activity/_store/_card_config) |
每门店:会员卡(card_type=1)年卡/季卡/月卡活动价;私教时长(2)月/季/半年/年卡;私教次课(3)10次/30次/单次课阶梯(20/30/50
节)。注意同店同期只能一个主题活动(PromotionBusiness.java:152-176) |
| 私教年卡 SKU | 课程后台 + lianer_course_store |
新建或复用
91(待确认);全部参与门店映射;is_sale=1 |
| 60次半年卡 | 套餐后台,每个参与门店一条 combo | 必须含会员卡明细 + 私教 60
次明细(ComboBackImpl.java:184-191);活动期售价填活动价(如杭州
8800、原价 12200);product_group=PT60_HALF。11-11
后继续售卖,价格由运营改到常规活动区块配置 |
| 活动商品映射 | lianer_activity_item |
item_key → 每档/每店真实 cardId/courseId/comboId;pt_year
关联限量池;card_month 配
weekday_rule=TUE, per_user_limit=1 |
| 名额池 | lianer_activity_quota_pool |
pt_year total=100 |
| 700 券模板 | lianer_counpon +
lianer_counpon_scope_item |
面额 700、apply=2(套餐只收
2/3,ComboBuyImpl.java:410-414)、scope_type=4、target
PT60_HALF、有效期(待确认) |
| 二币比例 | lianer_score source=2 source_score |
全局长期改为 1(每满 100 元得 1 个,已确认) |
| 订阅模板 | 配置中心/常量 | templateId、跳转页 pages/activity/activity-261111 |
8. 测试用例清单
8.1 名额与时效
| # | 用例 | 预期 |
|---|---|---|
| 1 | 300 个不同用户并发 double11Lock,池 total=100 |
恰好 100 个成功;pool.held=100;held+sold<=total
恒成立;其余 SOLD_OUT 且 data.quota 正确 |
| 2 | 同一用户 20 个并发请求 | 只产生 1 个订单 1 个 HELD,其余返回同一 orderNo |
| 3 | 下单后 15 分钟 + 宽限未付,渠道查单 F | 订单 -1,hold RELEASED,held-1,券解锁;只释放一次 |
| 4 | 释放任务两个实例同时处理同一 hold | 只有一个 CAS 成功;held 只减 1 |
| 5 | 到期时渠道返回 P / 查询超时 | hold 保持 RELEASING,名额不释放;attempts 递增;超阈值告警 |
| 6 | 到期查单发现 S | 回放成功,hold SOLD,权益发放 1 次 |
| 7 | 订单有 2 条流水(重复发起支付),旧流水已付 | 逐条查到 S,按已支付处理,不释放 |
| 8 | 晚回调:已 RELEASED,池仍有余量 | 订单 2,hold COMPENSATED,sold+1,正常履约 |
| 9 | 晚回调:已 RELEASED,池已满 | 订单 8,hold REFUND_PENDING,不发权益不记二币,生成全额退款 |
| 10 | 重复回调(同一流水 3 次) | 权益、成功表、二币各 1 次 |
| 11 | expires_at 前 30 秒调 trCreateOrder |
返回 D11_LOCK_EXPIRED(安全余量 60 秒) |
| 12 | 后台把 hold_minutes 改为 30 | 已创建订单 expires_at 不变 |
| 13 | 10:00:00 前 1 秒 / 后 1 秒抢 | D11_NOT_START / 成功(服务端时间) |
| 14 | 11-11 23:59:59 后下单 | D11_END;促销价不再生效 |
| 15 | 普通私教单:回调与 6 小时关单任务竞态(M3) | 已支付单不会被改成 -1;不会重复发权益 |
| 16 | trCreateOrder 后回调先到、置 1 的更新后到(M3) |
订单保持 2 |
8.2 券
| # | 用例 | 预期 |
|---|---|---|
| 17 | 两笔 60次半年卡订单几乎同时支付(同用户) | 只发 1 张 700 券;grant 1 行 |
| 18 | 首单退款后再买 | 按退款联动政策(待定),默认不再发 |
| 18a | 活动前(10-30)已买过 60次半年卡,11-02 再买 | 视为首单,发券 |
| 18b | 11-15 用有效期内的 700 券买 60次半年卡(常规价) | 可用,按当时售价抵扣 |
| 19 | 700 券用于私教月卡 / 其它套餐 | COUPON_SCOPE_MISMATCH |
| 20 | 700 券用于另一门店的 60次半年卡 | 按「是否限同店」口径(待定) |
| 21 | 同一张券并发下两笔套餐单 | 第二笔 COUPON_LOCKED;不会出现付款后回滚 |
| 22 | 过期券用于私教/会员卡下单 | COUPON_EXPIRED(补齐前私教/会员卡可用) |
| 23 | 用 700 券买的第二单支付成功 | 不再发券 |
| 24 | 发券时 DB 异常 | 履约成功;grant=FAILED;补偿任务补发 1 张 |
| 25 | 存量无 scope 的券 | 行为与改造前一致 |
8.3 二币
| # | 用例 | 预期 |
|---|---|---|
| 26 | 私教年卡实付 18900 | +189 |
| 27 | 60次半年卡(combo)实付 8800 | 父单 +88,子单不记 |
| 28 | 会员卡原价 1599 用 100 元券实付 899 | +8(按实付),不是 +15 |
| 29 | 实付 99 | +0,不写流水 |
| 30 | 回调重放 | 只入账 1 次(uk_order_biz) |
| 31 | 抵扣 189 二币买 18900(M6 后续) | 实付 18711,入账 187;余额 -189 |
| 32 | 两单并发各抵扣全部余额(M6 后续) | 只有一单成功,余额不为负 |
| 33 | 抵扣后订单超时关闭(M6 后续) | 二币退回 1 次 |
8.4 其它
| # | 用例 | 预期 |
|---|---|---|
| 34 | 周一下单星期二月卡 | D11_TUESDAY_ONLY |
| 35 | 周二同用户并发下 2 单月卡 | 1 单成功 |
| 36 | 新郑门店进入会场 | D 档价格;杭州私教馆门店年卡显示 899 |
| 37 | 门店未配置档位 | Venue 返回默认档并 tierMatched=false;对应商品
buyable=false |
| 38 | 预约后 09:55 推送 | 每人 1 条;拒收用户记 FAILED 不重试 |
| 39 | 压测:5000 并发轮询 quota + 2000 QPS 抢 | 接口 P99 与 DB CPU 在阈值内,无超卖 |
9. 待产品确认的问题
| # | 问题 | 影响 | 建议 |
|---|---|---|---|
| 1 | 私教年卡复用 courseId 91 还是新建 SKU | 存量订单/报表/门店映射 | 新建(1.9) |
| 2 | 年卡每人限购几张 | uk_user_limit 是否启用 |
每人 1 张 |
| 3 | 年卡是否允许叠券 | 下单校验 | 不叠券(二币抵扣本期不做) |
| 4 | 付款时效 15 分钟、到期宽限 2 分钟、支付发起安全余量 60 秒是否接受 | 释放任务参数 | 接受 |
| 5 | 泰融不支持传有效期/关单时,是否接受「到期查单 + 晚回调补偿(有余量履约、无余量退款)」 | 体验与资损兜底 | 必须接受,否则无法上线 15 分钟 |
| 6 | 晚到支付无余量的退款:自动原路退还是进审批 | 5.4 实现 | 自动原路退 + 告警 |
| 7 | 已售年卡退款后名额是否回池 | pool.sold 是否扣减 | 不回池 |
| 8 | 700 券:有效期多久;是否限同店再购;退款联动(下列择一):A 首单退款即作废未用券、券已用则不追;B 首单退款作废券,券已用则从退款中扣 700;C 不联动 | grant/退款流程 | 需产品定,技术均可支持 |
| 9 | 「首单」定义 | 发券条件 | 已确认:活动开始后首笔支付成功的同款订单,活动前历史不看(5.6) |
| 10 | 二币比例全局改为 1,还是只活动期内订单按 100:1 | source_score 是否改 |
已确认:全局长期改为 1,二币替换积分 |
| 11 | 二币发放周期 | 入账规则 | 已确认:长期按实付发放,Excel 11-01—11-30 不再适用 |
| 12 | 会员卡 0 元单/补录单是否记二币 | 现状按 totalPrice 记(1.8) | 按实付,0 元不记 |
| 13 | 退款是否扣回二币;余额不足能否为负 | CLAWBACK | 需产品定 |
| 14 | 二币下单抵扣 | M6 范围 | 已确认:本期不做,后续功能;上限等届时再定 |
| 15 | 星期二月卡:只校验下单时间还是支付时间也须周二 | 5.10 | 只校验下单 |
| 16 | 杭州年卡综合馆/私教馆门店清单;新郑/费县/沂水门店清单 | 档位配置 | 运营提供 store_id 清单 |
| 17 | 订阅模板内容与申请进度 | M8 是否能上线 | 尽快申请 |
| 18 | 活动商品是否参与门店现有其它主题促销(同店同期只能一个) | 运营配置冲突 | 活动期停用其它促销 |