LIANER双十一 · 后端实现文档产品方案 v0.6

练二双十一 · 后端实现文档

双十一活动后端实现文档

读者:后端开发 / 测试 / 运维。编写日期 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/(前端仓库)

口径基线(与本文冲突的旧说法一律作废)


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 私教年卡商品

1.10 城市档位与门店维度

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。要点:


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';

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,后续版本;本期不建表)';

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": [ "..." ]
}

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 }

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" }
}

与现有链路衔接:

  1. double11Lock 内部复用私教建单核心(从 OrderCommonBusiness.privateOrderInfo 抽出「校验+组装+insert」方法,见 5.2),写入 activity_code/expires_at/activity_snapshot,同事务占名额。
  2. 前端拿 orderNo 调现有 /front/trCreateOrder(PrivateBuyImpl.java:154-200),后端在该接口内对活动订单加时效校验并向渠道传有效期(5.3)。
  3. 支付成功由真实回调完成占位 → 已售(5.4),前端轮询 getDouble11Quota 或订单详情 /front/privateOrderInfoById 确认。
  4. 同一用户已有有效占位:HELD 时直接返回原订单(与 mock 幂等行为一致),不报错;RELEASING 时返回 D11_RELEASING。
  5. 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 }

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 }
  ]
}

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:

5.2 抢名额 double11Lock(M2)

改动:

  1. 从 OrderCommonBusiness.privateOrderInfo(:796-1098)抽出 buildPrivateOrder(req, ctx):只做校验与组装,不加锁、不插库;原方法改为调用它,行为不变。
  2. 新 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;

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());                              // 传给渠道
}

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;
    }
}

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;
    });
}

5.6 700 元复购券(M4)

  1. 发券:在 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 重试
    }
}
  1. 用券:ComboBuyImpl.comboOrderInfo(:410-449)在 canUserCoupon 后加 couponScope.matches(userCoupon, TARGET_COMBO, comboId, combo.getProductGroup()),不匹配返回 COUPON_SCOPE_MISMATCH。私教/会员卡下单(OrderCommonBusiness.java:1018、:534)同样加 scope 校验:带 scope 的券在不匹配的商品上一律拒绝。
  2. 时间校验:CouponBusiness.canUserCoupon(:219-226)增加 startTime <= now <= endTime,三条下单路径统一受益;ComboBuyImpl.java:430-433 的局部校验可保留。
  3. 锁券 CAS:lockUserCoupon SQL 改为 where id=? and user_id=? and lock=0 and status=0,返回影响行数;为 0 时返回 COUPON_LOCKED。所有调用点(OrderCommonBusiness.java:539、:1024、ComboBuyImpl.java:448 等)检查返回值。
  4. 列表:CouponFrontImpl.getUserCouponList(:70-117)支持 targetType/targetId:计算 usable/unusableReason,并下发 scopeType/scopeName。
  5. 退款联动:政策未定,见第 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);
}

替换调用点:

5.9 二币下单抵扣(M6,后续版本,本期不做)

产品已确认本期不上线,以下仅为后续版本预案。无现成能力(1.8)。方案:

  1. 确认页入参 useScorePoints;校验 points <= 余额、points <= 上限(上限规则待确认,如不超过实付 X% 或每单最多 N 个)、orderPrice - points >= 0.01(避免 0 元单走 0 元直接成功分支 OrderCommonBusiness.java:1056-1061,或明确 0 元单是否允许)。
  2. 建单事务内:total_score 条件扣减 → 插 lianer_score_deduct(LOCKED) → 订单写 score_deduct_points/amount,orderPrice = totalPrice - coupon - points。
  3. 支付成功:LOCKED → USED;二币入账按抵扣后实付计算。
  4. 关单/超时/释放:LOCKED → RETURNED 并加回余额(CAS 保证一次)。
  5. 退款:政策待定(退回二币 / 不退)。
  6. 抵扣不应计入等级:扣减不调用 addUserScoreAndLevel(现状 ScoreImpl.java:166-172 只在 s>0 时加等级)。

5.10 疯狂星期二月卡(M7)

改 OrderCommonBusiness.memberOrderInfo(:421-640),对 activity_item.weekday_rule='TUE' 的卡:

5.11 预约开抢(M8)


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 限流


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 活动商品是否参与门店现有其它主题促销(同店同期只能一个) 运营配置冲突 活动期停用其它促销