← 返回文档导航

活动奖励顺延(订单取消后重新命中合适订单)

同一用户在活动期内可能下多单,活动奖励名额被"第一笔下单"占用;若该单取消,名额与奖励应在符合条件的前提下顺延到下一笔合适的订单,而不是白白释放。

实现落地状态(代码在 beex-service · main,前三阶段已上线生产)

已实现并合并 main、全量测试通过(mybatis 103 + seahub-core 633 全绿)。前三阶段已部署生产、Flyway V6 已在生产库核验;第 3b 主动顺延已部署但开关默认关闭。
阶段内容状态代码 / commit
① 止血取消/退货置 REVERSED 释放名额;计数/预算双读 DONE/CONSUMED 排除 REVERSED(含 sumAmountByRuleAndUser✅ 已上线CampaignActionRecordRepository.markReversedByRefId · CampaignOrderEventService.onAffiliateOrderInvalidated · d0b4b396
② 返佣基金券闭环settle 改为消费预占(RESERVED→CONSUMED),退款经 releaseReservedBalance → restoreConsumedCouponBalance 回滚已用额度;新增 findReservationCouponId✅ 已上线RebateFundCouponService.settle · CouponRepository · 55cba9fb
③a 表/仓库地基Flyway V6 + PrdSchemaInitializer 加列(release_at/claimed_at/worker_id/retry_count/next_retry_at/last_error/revoke_reason/reassigned_from|to_record_id/rule_version/reward_basis_json/rule_snapshot_json/updated_at)与索引 (status,release_at)/(status,next_retry_at)/(ref_id);Worker 状态原语 findDueCandidateIds / claimForExecution / markConsumed / markFailed / reclaimStaleClaimed✅ 已上线(V6 已在生产库核验)V6__campaign_action_record_settlement_lifecycle.sql · CampaignActionRecordRepository(Impl) · 642f7d79
③b 主动顺延取消后按 releaseAt(settledAt)升序,让最早的合适订单重新参评补位;引擎幂等 + 名额/预算门槛防重复、防超发🟡 已部署,开关默认关CampaignOrderEventService.reassignAfterInvalidate · efadbe7c
启用开关:seahub.campaign.reward-reassign-enabled(env SEAHUB_CAMPAIGN_REWARD_REASSIGN_ENABLED,默认 false)。建议先在测试环境开启验证,再灰度到生产。
有意留作独立灰度的硬化项:本文 P0-1 / P0-3b / P1-8 描述的"releaseAt 独立结算 Worker + CLAIMED 原子预占(高并发绝对防超发)+ 冻结窗口 winner 选择"——③a 的表结构与仓库原语已就绪并测过,但把"订单完成写 PROCESSING + 独立 Worker"接到核心引擎属于资金主链路大改,应在此地基上单独灰度接入,不做一次性大爆改。当前 ③b 同步顺延在单路径执行 + 幂等 + 名额门槛下已安全满足"取消后奖励顺延到合适订单"。

全流程图(下单 → 完成 → 冻结 → 取消 → 顺延)

flowchart TD
  A["用户下单
AffiliateOrderCreated"] --> B["预占活动奖励名额
coupon_reservations / 记录 RESERVED"] B --> C{"订单是否完成?
AFFILIATE_ORDER_DONE"} C -->|"完成"| D["按规则登记候选
campaign_action_record status=PROCESSING
暂占, 未最终计入限次/预算"] C -->|"部分退款 / 退货处理中"| E["佣金冻结
暂不恢复券、不顺延
等平台最终确认"] E -->|"平台确认订单恢复"| D E -->|"退款 / 退货完成"| F D --> FZ{"到达 releaseAt?
结算/确认收货 + 冻结期"} FZ -->|"冻结期内被取消/退款"| F FZ -->|"到达 releaseAt"| WIN["结算 Worker 抢占+原子预占 CLAIMED
releaseAt 最早前 N 笔, N=min(用户/受益人剩余次数, 预算余笔)
成功→CONSUMED; 失败→FAILED 重试; 未入选→保持 PROCESSING 候选(不 SKIPPED)"] WIN --> G(["奖励最终生效, 锁定在获奖订单"]) G -->|"同活动内后续订单命中同规则"| H["名额 / 预算已被占用
计数仅统计 CONSUMED
后续订单被挡住"] G -->|"获奖订单取消 / 退货完成 isInvalid"| F["取消处理入口
AffiliateRefundService + 顺延服务"] F --> I["冲正佣金 reverseCommission
已进入可提现余额→追扣 flagRecoveryRequired"] I --> J["取该订单占用的奖励记录
findActiveByRefId(order.id)"] J --> K["逐条冲正奖励 + 置 REVERSED
名额 / 预算释放"] K --> R["普通券恢复 restoreUsedCoupons
返佣基金券按 RESERVED→CONSUMED 闭环回滚"] K --> L{"找顺延目标订单
同用户 + 同规则 + 活动窗口内 + 订单有效
+ 满足规则条件 + 未获该奖励
按 releaseAt(settledAt+冻结期) 取释放名额内最早的候选(可多笔)"} L -->|"找到目标订单"| M["顺延: 目标候选置/保持 PROCESSING 并提升优先级
写 reassigned_from=原记录; 原记录保持 REVERSED + reassigned_to
发放由 Worker 在目标 releaseAt 抢占执行(不在此直接发)"] L -->|"暂无合适订单"| N["仅释放名额 / 预算
后续新订单自然重新命中
因计数已排除 REVERSED"] M --> O(["奖励已顺延到合适订单"]) N --> P(["名额已释放, 等待下一笔订单"])

状态:RESERVED 预占 → PROCESSING 候选(未入选保持候选,不即 SKIPPED) → CLAIMED(Worker 抢占+原子预占名额/预算) → CONSUMED 生效(计名额) / FAILED 重试(耗尽提升下一候选) / SKIPPED(终态:窗口结束或占满)。生效额度=CLAIMED在途+CONSUMED;下游统一幂等键+唯一约束防重复发。

时序图(跨组件调用,重点看取消 → 顺延)

流程图看"分支与状态流转",时序图看"谁在什么时候调用谁"。两张图互补:下面这张聚焦取消订单后各组件的调用时序。
sequenceDiagram
  autonumber
  participant U as 订单流程/事件
  participant ENG as 活动规则引擎
  participant REC as campaign_action_record
  participant RWD as 奖励发放
  participant WAL as 钱包/券仓库
  participant REF as 退款冲正服务
  participant RSN as 顺延服务

  rect rgb(240,246,255)
  Note over U,WAL: 下单 → 完成
  U->>RWD: 订单A创建事件
  RWD->>WAL: 预占额度 reserveRebateFund,状态 RESERVED
  U->>ENG: 订单A完成 AFFILIATE_ORDER_DONE
  ENG->>REC: 登记候选 status=PROCESSING(未最终计入限次/预算)
  Note over ENG,WAL: 独立 Worker 扫描到期 releaseAt(settledAt + 冻结期),抢占锁+重试
  ENG->>REC: 事务内原子抢占执行权:releaseAt 最早前 N 笔(N=剩余可用次数),其余→SKIPPED
  ENG->>RWD: 对抢占订单执行发放
  RWD->>WAL: 券消费/基金核销/活动返现入账
  ENG->>REC: 执行成功→CONSUMED(计名额);失败→FAILED(可重试)
  end

  rect rgb(255,244,242)
  Note over U,RSN: 订单A取消/退货完成 isInvalid
  U->>REF: 订单A状态变更为无效
  REF->>WAL: reverseCommission 冲正A的佣金
  REF-->>REF: 已进入可提现余额则 flagRecoveryRequired 追扣
  U->>RSN: 触发奖励顺延 A invalid
  RSN->>REC: findActiveByRefId(A) 取A占用的奖励
  loop 每条被A占用的奖励
    RSN->>WAL: 冲正该奖励,券恢复/基金回滚/返现冲正
    RSN->>REC: markReversed,名额与预算释放
    RSN->>ENG: 找最早过冻结期生效的目标订单B(releaseAt=确认收货+冻结期),同用户+同规则+窗口内+满足条件+未获奖
    alt 找到目标订单B
      RSN->>REC: 标记 B 候选为 PROCESSING 并提升优先级;写 reassigned_from=原记录,原记录 REVERSED + reassigned_to
      Note over REC,WAL: 由结算 Worker 在 B 的 releaseAt 抢占+预占后发放(不在此直接发)
    else 暂无合适订单
      RSN-->>RSN: 仅释放,等后续新订单自然命中,计数已排除 REVERSED
    end
  end
  end

1. 需求

活动进行中,一个用户往往会产生多笔订单,其中任意一笔都可能满足某条活动规则的资格。当前是"谁先下单谁先占用名额/预算"。问题在于:如果占用名额的那笔订单后来取消/退货,这份活动奖励目前只是被释放,不会回头补给同一活动窗口内、同样符合条件的另一笔订单。用户要的是:取消后,这份奖励在合理条件下顺延命中一笔合适的订单(只要仍符合活动条件、仍在有效窗口内)。

2. 现状:奖励为什么会被"第一单独占"

活动奖励由 CampaignRuleEngine 在订单事件(AFFILIATE_ORDER_DONE 等)上按规则发放,占用靠 campaign_action_record 记录 + 三道门槛:

门槛代码含义
每人最多次数countByRuleAndUser(rule,user) ≥ maxIssuePerUser(CampaignRuleEngine:67)该用户对该规则已发 N 次即不再发
每受益人次数countByRuleAndBeneficiary(...) ≥ maxIssuePerBeneficiary同上,按受益人维度
预算sumAmountByRule(rule)+amount > budgetAmountMinor(CampaignRuleEngine:89)规则总预算用尽即不再发
根因:这三个统计当前不按 status 过滤,把已发的记录一律计入。所以第一笔订单发了奖励、写下 campaign_action_record 后,后续订单就被"名额已满/预算已用"挡住;即便第一笔订单取消,记录仍在、仍被计数,名额也不会释放,更不会顺延。
好消息:campaign_action_record 已经带 ref_id(来源订单)和 status 两列 —— 我们能精确找到"某笔取消订单占用了哪些奖励记录",并把它们置为失效。这就是顺延方案的抓手。

归属口径:按"先过冻结期生效",而非"先下单"

两个让用户疑惑的点:
  1. 券/奖励显示挂在"第一个下单"的订单上,但那一单可能还在冻结期、随时可能退款,真正"有效"的订单也许是另一笔——用户会觉得"奖励怎么挂错单了"。
  2. 到底以哪一单为准?取决于确认收货时间 + 冻结期:奖励在订单结算后进入冻结期(releaseAt = settledAt + releaseDelayDays,见 AffiliateCouponRewardService:86-98),冻结期内可被退款冲正,过了冻结期才不可逆、才算真实生效

修正口径:活动奖励(尤其"首单奖励"这类限量名额)的归属,以最早通过冻结期(确认收货 + release delay)并保持有效的订单为准,不是下单时间最早的订单。因为先下单的那笔若在冻结期内退款,它其实从未"真正有效",不该锁死名额。

排序键 = 生效时间:候选订单的排序/归属键是 releaseAt(= 确认收货 / 结算 settledAt + 冻结天数 releaseDelayDays),取最早者;而不是下单时间 orderedAt

3. 核心模型:奖励分配状态机(修订版)

给每一份"活动奖励分配"(落在 campaign_action_record.status + 配套资金/券记录)引入完整生命周期:

RESERVED(下单预占;券额度coupon_reservations通用名额/预算走额度表,二者分开,见次要缺口④)→ PROCESSING(订单完成,登记为候选,未计名额;未入选者保持此态待提升,不立即 SKIPPED)→ CLAIMED(Worker 抢占执行权并原子预占名额+预算)→ CONSUMED(发放成功、预占转已消费,此时计入限次/预算)  / FAILED(发放失败退避重试;耗尽则释放预占并提升下一候选)  / SKIPPED终态:仅活动窗口结束或名额被占满才标记)  → REVERSED(生效后取消/退货,冲正、释放名额)

关键规则(修订): ① 名额/预算以额度预占为准CLAIMED 时已原子预占,成功转 CONSUMED 保留预占、失败释放;生效额度 = CLAIMED(在途)+ CONSUMED(含在途占位以防并发超发),PROCESSING/SKIPPED/FAILED/REVERSED 不计。 ② 订单完成不直接 CONSUMED,先 PROCESSING 候选;到 releaseAt 由独立 Worker 原子抢占执行权、执行成功才转 CONSUMED(见 P0-1、P0-3b)。 ③ 顺延不新增单一 REASSIGNED 状态,改用关系列表达:原记录保持 REVERSED + reassigned_to_record_id,新记录 reassigned_from_record_id(见 P1-4)。

审核回应与修订(v2 · 逐条 P0/P1/P2)

经代码核实,问题成立,已修订。核实到的事实:动作记录当前硬编码写入 status="DONE"CampaignActionRecordRepositoryImpl:44),计数/预算不按状态过滤;引擎先写记录后执行动作CampaignRuleEngine:93-98recordIfAbsentexecutor.apply 之前);释放时间基点实际用 orderedAtCampaignOrderEventService.fireOrderEventfirstTime(orderedAt,settledAt,…) 优先 orderedAt,CampaignCashRewardWriter:71at.plusDays(releaseDelayDays))。

P0-1 归属须在 releaseAt 原子选出,而非完成即占用

订单完成只登记 PROCESSING 候选,不占名额。到达 releaseAt(口径 P1-5)时由独立结算 Worker(P0-3b)按 releaseAt 升序原子抢占执行权前 N 笔——N = min(用户剩余次数 maxIssuePerUser−已占, 受益人剩余次数 maxIssuePerBeneficiary−已占, 预算余额可发笔数)(三者取小,见次要缺口①);抢占即在 CLAIMED 原子预占名额+预算,成功转 CONSUMED、失败转 FAILED 重试。未入选候选保持 PROCESSING(不立即 SKIPPED),以便获选者重试耗尽/被退款时提升下一候选(修复 blocking #1)。顺延目标同样走"候选→Worker 抢占→CONSUMED",原记录保持 REVERSED,仅用 reassigned_from/to 关联。

P0-2 状态口径与迁移(现网写的是 DONE)

不能直接给统计加 status='CONSUMED' 过滤——现网历史与新写入都是 DONE,会全部漏计导致重复发奖。改造须三件事一起: 统一状态机(DONE 语义并入 CONSUMED),计数只算 CONSUMED; 数据迁移把历史 DONE 归一为 CONSUMED 过渡期计数双读兼容 status in ('DONE','CONSUMED')。上线顺序:迁移+双读 → 切写入为新状态机 → 收敛去兼容。

P0-3 动作未成功不得占名额/预算

现在 recordIfAbsent(写 DONE)在 executor.apply 之前,执行器 no-op / return / 抛错时记录仍算数。正确顺序:原子抢占执行权(PROCESSING→CLAIMED)并同步原子预占名额+预算 → 执行动作 → 成功才转 CONSUMED;失败转 FAILED释放预占、可重试,见 P1-9),无需发放转 SKIPPED预占必须在 CLAIMED 完成而非执行后,否则多 Worker 并发都能过额度校验而超发(blocking #2)。

P0-3b 谁在 releaseAt 处理候选:独立结算 Worker

候选不会自己生效,需独立定时 Worker①扫描 status='PROCESSING' 且 release_at≤now(30–60s/轮,走 (status,release_at) 索引)。②抢占+预占(同一事务原子)update … set status='CLAIMED',worker_id,claimed_at where id=? and status='PROCESSING' 拿执行权,并原子预占名额/预算(额度表 update … set used=used+? where used+?≤limit,影响行数=0=额度满→退回 PROCESSING 或标 SKIPPED)。③执行发放:成功→CONSUMED(保留预占);失败→FAILED④FAILED 完整转换:置 next_retry_at 退避,下轮 FAILED→CLAIMED 重试;达最大次数→释放预占 + 落 FAILED 终态 + 提升下一候选(把某个 PROCESSING 候选纳入本轮)。⑤故障恢复CLAIMEDclaimed_at + 租约 未收敛(Worker 崩溃)下轮回收(回可重试),靠统一幂等键防重复发。

P0-3c 退款 vs 发放 并发(blocking #3)

Worker 已 CLAIMED 但尚未入账时订单可能同时退款。在真正入账前再次锁定并复核订单状态select … for update 订单/权益行 + 复核 isInvalid)。各状态对退款的响应:

时点/状态遇到退款如何响应
PROCESSING(候选未抢占)REVERSED/丢弃,不发;不影响其它候选
CLAIMED(已预占未入账)入账前复核到已退款:放弃发放、释放预占,置 REVERSED提升下一候选
入账瞬间并发入账与退款竞争同一订单/权益行锁,串行化:先入账则转 CONSUMED 再走正常冲正;先退款则发放侧复核到 invalid 直接放弃
CONSUMED(已入账)走正常冲正/追扣 + 顺延(§4)

P0-3d 幂等:成功入账但状态更新失败不得重复发(blocking #4)

钱包/券已发放但 campaign_action_record 更新失败、Worker 重试会再发。要求所有下游写入(钱包 creditPending、券 consume、基金核销、活动返现)统一用同一把幂等键 idempotencyKey = rule_id + ref_id(顺延用目标 ref_id),并由数据库唯一约束兜底(钱包/券流水按 (ref_type,ref_id,idempotency_key) 唯一,重复写被拒、幂等返回既有结果)。这样"发放成功→记录更新失败→重试"时,下游因唯一约束不会二次发放,重试只需把记录补成 CONSUMED

P1-4 保留历史事实:拆出顺延关系列

原记录保持 REVERSED,新增 reassigned_to_record_id 指向顺延目标记录;顺延产生的新记录新增 reassigned_from_record_id。单条状态不再既表"已撤销"又表"顺延到哪笔"。

P1-5 冻结时间唯一口径(已定:settledAt)✅

口径已确定:releaseAt = settledAt + releaseDelayDays,全链路统一到此。落地需同步改造:

P1-6 动作类型 × 退款场景 处理矩阵

动作类型预占/撤销/顺延退款处理
SHOW_PLACEMENT / NOTIFY_WA展示/通知类,不涉资金,不撤销不顺延
现金类(活动返现 ACTIVITY_CASHBACK / 返佣基金)PENDING 冲正、已提现追扣;名额释放并顺延
优惠券(发放型)按券状态分档已发未领:撤回;已领未用:恢复可用;已用:按券结算规则冲正,一般不追券本身

只对订单绑定的资金权益执行预占/撤销/顺延;展示与通知类不参与。

P1-7 返佣基金闭环

统一为 预占 RESERVED → 结算消费预占(RESERVED→CONSUMED,RebateFundCouponService.settle 改为"消费预占"而非直接 consumeBalance)→ 退款释放/顺延;退款回滚基金券已用额度(改走预占回滚,或去掉 restoreUsedCoupons 对 REBATE_FUND 的排除)。

P1-8 并发控制

count + sum + insert 即使同事务也可能两单并发通过。方案:CLAIMED 时原子预占——额度表原子扣减 update quota set used = used + ? where rule_id=? and used + ? ≤ limit(影响行数判成功),或"用户×规则权益"级行锁;并加唯一有效权益约束unique(rule_id, ref_id) 挡同单重复 + 额度表乐观锁挡跨单超发)。生效额度含 CLAIMED 在途占位,不能只算 CONSUMED,否则两 Worker 入账前都通过校验→超发。仅靠订单幂等键不够。

P1-9 部分退款

接入现有部分退款审核(AffiliatePartialRefundReviewService):退款金额变化只重算奖励金额与资格释放差额额度,不改动 releaseAt(除非平台重新给出结算时间 settledAt,才据此回算)。再看订单是否仍满足门槛(如 minGmv)——仍满足则按新基数调整、不顺延;不再满足则整份撤销并顺延

P2-10 表结构与规则快照

campaign_action_record 增列:status 扩到 PROCESSING/CLAIMED/CONSUMED/SKIPPED/FAILED/REVERSEDrevoke_reasonreassigned_from_record_idreassigned_to_record_idrelease_atupdated_at重试语义(本轮 #9)retry_countnext_retry_atlast_errorclaimed_atworker_id,超过最大重试次数落 FAILED 终态并把名额顺延给下一候选可解释/可冲正(本轮 #10)amount_minor(已有)、reward_basis_json(计算依据:基数、bps、命中规则参数)、rule_version / rule_snapshot_json(规则版本或快照)——活动下架或规则变化后仍能准确解释、冲正、顺延。索引:(rule_id,user_id,status)(ref_id)(rule_id,status,release_at)(status,next_retry_at)顺延必须沿用原始 rule_id/规则快照,不得用退款时的新规则重新匹配。

4. 顺延触发与匹配算法

4.1 触发时机

订单变为无效(取消/退货完成,即 AffiliateOrderStatusPolicy.isInvalid)时,在冲正佣金的同一链路里额外执行"活动奖励顺延"。

4.2 处理步骤(对每笔取消订单)

  1. 取占用记录:查 campaign_action_recordref_id = 取消订单.id 且 status = 有效 的所有记录(即这笔订单占用的活动奖励)。
  2. 冲正 + 置失效:逐条冲正它发放的奖励(活动返现走佣金冲正;券走恢复;返佣基金券按额度回滚),并把记录 statusREVERSED(从此不计入次数/预算)。
  3. 找顺延目标:为这条被释放的奖励,寻找一笔最合适的后续订单(详见 4.3)。
  4. 顺延(经 Worker,不在此直接发):若找到目标,把目标订单的候选置/保持 PROCESSING 并提升优先级,写 reassigned_from_record_id=原记录;原记录保持 REVERSED 并写 reassigned_to_record_id=新记录真正发放由结算 Worker 在目标订单 releaseAt 抢占执行(P0-3b),不在退款链路里直接发,避免绕过预占/幂等(修复"顺延绕过 Worker")。沿用原始 rule_id/规则快照
  5. 暂无目标:若当前找不到合适订单,只做释放;名额/预算已回收,之后任意一笔新的合适订单会自然重新命中(因为计数已排除 REVERSED)。

4.3 "合适订单"的判定(同一条规则内)

并发与幂等(汇总):① 名额在 CLAIMED 原子预占,生效额度含在途占位(P1-8);② 入账前二次锁定+复核订单(P0-3c);③ 所有下游写入统一 idempotencyKey=rule_id+ref_id + 数据库唯一约束兜底,重复写被拒(P0-3d);④ 顺延仅改状态/优先级、由 Worker 发放,不在退款链路直接发。

5. 需要的代码改动(具体到类/方法/SQL)

改动位置说明
计数/预算按状态过滤countByRuleAndUser / countByRuleAndBeneficiary / sumAmountByRule / sumAmountByRuleAndUser(勿漏)过渡期用双读兼容 status in ('DONE','CONSUMED')(配合 DONE→CONSUMED 迁移,见 P0-2),迁移收敛后再改为仅 CONSUMED直接只过滤 CONSUMED 会把现网 DONE 全漏计导致重复发奖
按订单查/失效记录新增 findActiveByRefId(refId)markReversed(recordId, reason)找出取消订单占用的奖励并置失效。
顺延服务新增 CampaignRewardReassignmentService,由订单 isInvalid 事件触发编排:冲正 → 置 REVERSED → 找目标 → 重新发放。与 AffiliateRefundService 同一事务/同一监听。
候选订单查询新增 affiliateOrderRepository.findEligibleOrdersForRule(userId, 窗口, 排除已获该规则奖励的订单)按生效时间 releaseAt(确认收货 + 冻结期)升序返回候选,交给 matchesConditions 复核。
奖励冲正复用活动返现走 walletRepository.reverseCommission(同 AffiliateRefundService);券走恢复;返佣基金券需先修好"预占—消费"闭环见下方与"返佣基金券缺口"的联动。
后台可配"顺延"动作CampaignAdminServiceAFFILIATE_ORDER_REFUNDED/CANCELLED 事件当前只允许 NOTIFY_WA增加"释放并顺延奖励"作为可配置动作,或作为引擎内置行为(推荐内置,避免运营漏配导致漏顺延)。
与返佣基金券缺口的联动:当前 RebateFundCouponService.settle() 直接 consumeBalance()、不消费预占,退款时 restoreUsedCoupons明确排除 REBATE_FUND,导致基金券已用额度回不来。顺延方案落地前需先把这条"预占 RESERVED→CONSUMED、退款回滚"的闭环补上,否则基金券类活动奖励无法正确冲正、更无法顺延。

6. 待你拍板的设计决策(含推荐)

决策点推荐
顺延范围同一用户、同一规则、同一活动窗口内的其它有效订单(贴合你描述的"一个用户多单"场景)。
目标选择生效时间 releaseAt(确认收货 + 冻结期)升序取最早的、尚未获得该规则奖励且当前有效的订单(不是按下单时间)。
找不到目标时只释放,不落空账;后续新订单自然重新命中(依赖计数排除 REVERSED)。
已进入可提现的奖励被取消原订单走追扣/人工复核(沿用 flagRecoveryRequired);顺延给新订单的是新的一笔 pending,按目标订单的释放期重新计时。
冻结期与顺延窗口目标订单归因时间落在活动窗口内;归属以"先过冻结期(确认收货 + release delay)生效"为准;默认以活动 start–end 为界。
顺延是否可无限次建议每份奖励在活动窗口内可多次顺延,直到窗口结束或预算/次数达上限。

7. 分期实施与验证

  1. 第一步(止血):先做 DONE→CONSUMED 数据迁移 + 计数/预算双读兼容 status in ('DONE','CONSUMED')(含 sumAmountByRuleAndUser)+ 取消时置 REVERSED。取消后名额/预算立即释放且不误发;迁移收敛后再收窄为仅 CONSUMED
  2. 第二步(返佣基金券闭环):settle 消费预占(RESERVED→CONSUMED)、退款按状态回滚基金券已用额度。
  3. 第三步(主动顺延):新增顺延服务,取消时立刻回头找最早的合适订单并发放;无目标则等新订单。
  4. 第四步(结算 Worker + 原子预占 + 幂等):加独立 Worker 在 releaseAt 抢占+原子预占(CLAIMED)、成功 CONSUMED;下游统一幂等键 + 唯一约束;入账前复核订单(P0-3c/3d)。
  5. 验证:单测/集成覆盖——"A 先占→A 取消→提升 B 命中"、"无 B→新建 C 命中"、"预算/次数回收"、"基金券额度回退"、幂等(重复取消/重试不重复冲正/发放);并补齐(本轮):Worker 崩溃恢复(CLAIMED 租约超时回收、不重复发)、并发退款 vs 发放(P0-3c 各状态)、额度竞争(多 Worker/多订单并发不超发不超预算)、部分退款(重算金额与资格、releaseAt 不变);测试环境端到端 + 对账 campaign_action_record 状态流转与钱包/券一致。