跳转到内容

S01:选单工作集构建,通俗完整版

草稿难度:中级#业务逻辑#居民收益#选单工作集#异步任务

业务逻辑专辑 / 居民收益付款 / S01

先理解“准备选单工作台”这条主线,再展开候选筛选、REVISE、并发接管与故障恢复。本文完整保留原文 24 节,按平台格式连续展开,方便目录跳转、全文搜索和代码复制。

快速阅读: 任务目标 · NEW 怎么构建 · REVISE 怎么重提 · 两层写权限 · 故障排查

文档来源、源码基线与适用范围

Section titled “文档来源、源码基线与适用范围”

对应任务:residentIncomePaymentSelectionSessionBuildAsyncTask

本文依据用户提供的《S01:residentIncomePaymentSelectionSessionBuildAsyncTask 业务背景与逻辑详解》改写。文中的“当前实现”均指原文分析的源码基线,不代表重新核验了今天的代码或运行环境。

源码基线:zxbaif,分支 Ian/review/01,提交 7fbe3ccd4e004b0c4c4dfd38f32257af0f8c9c84

原文数据库核验时间:2026-09-04 10:18(Asia/Shanghai)。核验范围:原文作者的数据库 MCP 连接中的 zhongxin_test_financialzhongxin_test_base,以及同一连接下 zhongxin_financialzhongxin_base 的对象存在性。原文以源码、Mapper、数据库结构及聚合数据为依据,旧文档只提供线索。

下文中的公司 A、B、C、D、处理批次和 worker 小故事只是帮助理解的例子,不是实际数据库记录。原文未解释的业务枚举,不另行猜测含义。

你在页面上选择合作方、项目公司和账期,点击“开始选单”。这时系统不是立刻付款,而是先准备一个可以查看、调整、提交的工作台。

原文名称 通俗理解 别和什么混淆
fi_monthly_income_difference 原始候选来源:差异台账 它不是本次已经选好的付款清单
selection_session 这一次选单的工作台档案,记着范围、模式、状态和进度 它不是浏览器登录 Session
selection_session_item 工作台里的具体明细 它还不是正式付款单明细
fi_async_task 通知后台“把这个工作台准备好”的持久化任务记录 任务成功不等于付款成功

主线只有一条:

用户确定范围
→ 创建/复用 Session,并保存构建任务
→ S01 在后台分批准备工作台明细
→ 页面查看、汇总
→ S02 人工或批量调整
→ S03 提交并生成付款单版本
→ 后续锁激活、审核、付款结果处理

下文沿用原文的 24 节顺序。第一次阅读先把业务故事连起来;SQL、索引和源码定位可以在需要排障时再对照。

S01 的任务是“准备选单工作台”,不是“直接生成付款单”,也不是“重新计算居民收益”。

它拿到创建 Session 时确定的业务范围,把范围内需要处理的候选事实写成数据库里的 selection_session_item。后面的分页、人工调整、校验、提交,都围绕这份工作集进行。

为什么不直接查差异台账,让用户一边查一边提交?原文指出四个问题。

数据太多。 一次范围可能命中几十万条台账。扫描、远程查询、计算快照、落库,都放在创建接口里,会让一次用户请求承担整个长任务。

用户操作需要稳定的对象。 第一页、第二页、调整、提交,应当围绕同一个工作集,而不是每次临时从不断变化的台账重新拼清单。

失败后需要接着做。 某批 SQL 失败、远程服务异常、进程重启,不应该总是从第一条开始。系统需要记住已经确认完成的位置。

多人抢到同一个任务时,需要明确谁能写。 主动唤醒、定时扫描、人工补偿、重启接管,都可能撞上同一任务。旧执行者恢复后,不能继续覆盖新执行者的进度或结果。

代码分工也要分清:XXL Handler 是触发入口;SelectionSessionBuildAsyncTaskService 是 worker 接口;复杂构建逻辑主要在 SelectionSessionBuildServiceImplSelectionSessionItemMapperSelectionSessionMapper。只读 Job 类,看不到整项业务。

2. 业务背景:为什么需要“Session + 工作集”

Section titled “2. 业务背景:为什么需要“Session + 工作集””

2.1 原始候选不是可以直接付款的清单

Section titled “2.1 原始候选不是可以直接付款的清单”

差异台账 fi_monthly_income_difference 记录平台与合作方居民收益的差异、账单关联、付款状态、当前可付金额等事实。但一条台账是否应该进入“这一次选单”,还要叠加下面这些条件。

需要考虑什么 放进业务里怎么理解
操作人、合作方、项目公司权限 这次操作允许处理哪些数据
起止账期 用户本次选择了哪个时间范围
服务端固定付款状态 [20,60,70] 只从规定的候选付款状态中找数据
付款单账单年月规则 用户选了时间范围,还要进一步满足合作方的账期规则
RESERVED/ACTIVE 业务锁 已被其他付款流程占用的数据不能照常加入
平台站、合作方站、小单站映射 不同系统中的站点标识需要对齐
合格/不合格规则 进入候选池后,还要分流,不是所有候选都合格
当前可付金额 本次明细需要保留相关金额事实
付款周期配置、复合周期事实、配置版本 后面要用的规则和计算依据需要记录下来
REVISE 原单保留明细 重提不能只看当前台账,还得继承原付款单发布版本

所以,工作集不是简单复制源表,而是“某一次选单操作专用的工作台”。

2.2 创建接口只负责把事情接下来

Section titled “2.2 创建接口只负责把事情接下来”

createOrContinueSelectionSession 在事务中做权限检查、范围标准化、防重、Session 落库、异步任务受理,不在这里构建 selection_session_item

可以理解成:窗口先把申请及范围登记好,把要干的活记录下来;大量扫描和计算交给后台。这样创建事务只承担稳定受理,不被几十万行扫描、远程映射、快照计算拖成大事务。

2.3 NEW 和 REVISE:一个是新建,一个是重提

Section titled “2.3 NEW 和 REVISE:一个是新建,一个是重提”

NEW 是新建付款单:从当前差异台账候选池准备工作集。

REVISE 是审核不通过后的原付款单重提:先保留原付款单发布版本中的正式明细,再追加当前新增候选。

例如,原单原来有一批正式明细,退回后台账又发生变化。REVISE 的做法不是把原单全部按今天的台账重算,而是先继承原单发布时的事实,再处理新增候选。

两者共用任务调度、租约、游标、工作集分页、后续提交能力;区别在于 REVISE 同时处理“历史发布事实”和“当前新增事实”。

把完整链路拆成“受理”和“执行”,就不容易迷路。

【受理】
用户创建或继续 Session
→ SelectionSessionServiceImpl
→ 标准化 scope,写 selection_session
→ 同一事务插入或复用 fi_async_task
→ 事务提交后触发 afterCommit kick
→ Kick Dispatcher
→ SelectionSessionBuildAsyncTaskServiceImpl.kickExact
【另外两种触发方式】
XXL 定时扫描 / XXL 人工定向补偿
→ ResidentIncomePaymentSelectionSessionBuildJob
→ executePendingTasks / executeManualRetry
【以上入口最终汇合】
领取 fi_async_task 的本次执行权(attempt)
→ 校验 task 身份及 REVISE 不变量
→ 领取 selection_session 的构建租约(build lease)
→ SelectionSessionBuildServiceImpl
→ 按 NEW / REVISE / 项目公司范围调整执行
→ 分批写 selection_session_item
→ 更新游标、计数、心跳
→ Session 进入 READY
→ fi_async_task 写为 SUCCESS

这里有三个术语。

任务 seed:受理时持久化的任务记录。有了它,任务就不只存在于某个进程的内存里。

afterCommit kick:事务提交以后,主动喊后台“有新任务了”。它的目标是让用户少等一会儿,不负责替代任务记录。

XXL 扫描:定时检查数据库任务,处理 kick 丢失、进程重启、失败退避时间到期等情况。人工补偿则可以按 taskCodebusinessKey 指定原任务。

任务 seed、追溯记录、afterCommit 注册由任务受理模块统一完成。核心关系是:数据库任务保证事情还在,kick 负责尽快叫人来做。

Handler 名称就是 residentIncomePaymentSelectionSessionBuildAsyncTask。它不负责那些复杂的选单规则,只负责解析参数、合并选择器、分派执行模式。

参数既支持裸 JSON,也支持 { "data": ... } 包装。单值 taskCodebusinessKey 与列表 taskCodesbusinessKeys 会合并处理。没有选择器就自动扫描;有选择器就人工定向重跑。

参数 含义
maxTaskCount 本轮最多选几个任务,默认 100,上限 500;不是每批构建多少条 item
taskCode / taskCodes 按任务唯一编码选择
businessKey / businessKeys 按 Session 业务键选择

自动扫描示例:

{"maxTaskCount":100}

对某个 Session 人工补偿:

{"businessKey":"SESSION_BUILD:123456","maxTaskCount":1}

特别注意:JSON 解析失败时,当前实现会当成空参数,转而自动扫描。 因此,错误 JSON 并不意味着“这次什么也没执行”。人工补偿时必须核对实际传入的参数。

字段 回答的问题 当前规则
task_type 这是什么种类的任务? RESIDENT_INCOME_PAYMENT_SESSION_BUILD
business_key 它属于哪个 Session? SESSION_BUILD:{sessionId}
task_code 数据库里怎么唯一识别这条技术任务? 普通构建为 RIPSB:{MD5摘要};范围调整把受理 stateVersion 加入摘要输入

真正给消费者使用的业务参数,以 task_data 为准。它至少包含:

{
"sessionId": 123456,
"expectedStateVersion": 7,
"projectScopeAdjustment": false,
"taskScene": "SESSION_BUILD"
}

项目公司范围调整还会冻结下面四项。

字段 通俗含义
buildProjectCompanyIdList 本轮只给这些新增项目公司构建数据
targetProjectCompanyIdList 全部完成后应当发布的完整项目范围
removedProjectCompanyIdList 要删除的范围,但成功前不能先删
targetScopeHash 服务端对完整目标范围计算并校验的哈希

消费者不是从 task_code 反推 sessionId,而是解析 task_data,再核对 taskScenebusinessKeytaskCode 是否彼此一致。

businessKey 便于人按 Session 查问题、做补偿;taskCode 长度固定且数据库唯一,便于幂等插入或复用。

同一个 Session 可能调整多轮项目公司范围。业务键仍然指向同一个 Session,但任务编码要用版本区分不同轮次。selection_session.active_task_id 记录当前轮次真正对应的任务主键,防止旧轮次仅凭同一个业务键再次混进来。

5.3 任务有哪些状态,扫描怎么挑选

Section titled “5.3 任务有哪些状态,扫描怎么挑选”
状态 通俗含义
0 PENDING 等待执行
1 RUNNING 有执行者正在处理
2 SUCCESS 技术任务已成功收口
3 FAILED 本次处理失败
4 CANCELLED 任务已取消

自动扫描从 PENDING/FAILED/RUNNING 中选任务,还要求 next_execute_time 已到、retry_count < max_retry_count。排序是 next_execute_time → retry_count → id,本轮数量不超过规范化后的 maxTaskCount

为什么 RUNNING 也能选?为了恢复“状态还写着运行中,但进程已经中断”的任务。不过,真正领取原 RUNNING 任务时,还要求它的 update_time 已经超过 10 分钟。

worker 可以从自动扫描、人工补偿、主动 kick 三个入口被触发。单条任务按下面顺序执行。

第一步,用 CAS 领取 fi_async_task:条件仍然符合才更新为 RUNNING,并把 running_attempt 加一。这里 CAS 可以理解为“只有数据库里的状态和我预期一致,我才拿得到执行权”,不是无条件覆盖。

第二步,解析 task_data 并校验三层身份,再校验 REVISE 持久化身份不变量。

第三步,在短事务里锁定任务 owner,在正式写工作集之前再次检查 REVISE 不变量。这里的“不变量”指不能因为一次重跑就变化的持久化身份约束,不是普通可随意修正的输入。

第四步,调用 SelectionSessionBuildService.buildSelectionSession(taskDto, taskId)

最后,根据结果收口:

发生了什么 怎么处理
正常构建成功 当前 attempt 把任务写为 SUCCESS
Session 已经 READY,或已经终态 不再重复构建,幂等收口为 SUCCESS
其他 worker 持有有效 Session lease 任务回到 PENDING,5 秒后再试,不增加失败次数
真正发生异常 FAILED,retry_count + 1,记录退避时间及错误
永久身份错误 耗尽自动重试,避免坏任务不断访问数据库
当前 attempt 已被新 attempt 替换 fenced-out 处理,只记 skipped,旧 attempt 不得写最终状态

一条失败不会打断这一轮后面的其他任务。最终给 XXL 的是“选中多少、成功多少、失败多少、跳过多少”的摘要。

7. 两层所有权:本任务最核心的并发设计

Section titled “7. 两层所有权:本任务最核心的并发设计”

先看一个例子:执行者 A 做到一半暂停了很久,执行者 B 接管。随后 A 又恢复运行。

问题不是“能不能再启动 B”,而是:B 接管后,A 还会不会把 B 的任务状态、进度、工作集结果覆盖掉? 原文用两层所有权处理这两类写入。

它可以理解成“这条任务的第几次有效执行”。领取任务的条件包含:

id + task_type + 可执行状态 + 预期 running_attempt
+ 自动调度的时间/重试条件
+ 原 RUNNING 任务的超时条件

领取成功以后,running_attempt + 1。后续任务状态写回必须仍然匹配:

id + task_type + RUNNING + 本次 running_attempt

例如 A 拿到第 7 次执行,B 接管后变成第 8 次。A 再拿“第 7 次”的身份写最终任务状态,就不能覆盖第 8 次的结果。

拿到任务执行权,只能说明“可以尝试执行”;真正操作工作集,还要拿到 Session 的构建租约。租约就像有有效期的工作台写入许可证,关键字段如下。

字段 作用
build_worker_id 这次由谁构建
build_lease_version 这是第几代构建写权限
build_lease_expire_time 写权限有效到什么时候
build_heartbeat_time 最近一次构建心跳
status = BUILDING Session 正处于构建状态

领取时,通过 CAS 接管 INIT/BUILD_FAILED,或者租约已过期的 BUILDING,形成新的 BUILDING owner,并让 build_lease_version + 1

续租、推进游标、写 READY、写 BUILD_FAILED,都要匹配当前的 workerId + leaseVersion。因此,旧 worker 即使醒过来,也不能继续按旧身份推进这些业务结果。

两层不是同一个锁的两个名字:running_attempt 保护技术任务状态;Session lease 保护工作集、游标、Session 状态。任务的 RUNNING 超时与 Session lease 都是 10 分钟,但网络、进程暂停、不同写入时点会让它们短暂不一致,不能互相替代。

这里的 fencing 就是“新一代接管后,旧一代的写入资格失效”。10 分钟是接管和租约相关时长,不是“整个构建必须 10 分钟完成”的承诺。

普通构建 markBuildReady 会清空 build_lease_expire_timeactive_task_id不会清空 build_worker_id。范围调整的最终发布 SQL 则会清空 build_worker_id

所以,不能仅用 build_worker_id IS NULL 或 workerId 是否有值判断 worker 是否还活跃;要结合 statusbuild_lease_expire_timebuild_lease_version 判断。两条完成路径的字段清理行为确实不同。

正式领取 Session lease 前,有两类强校验。这里是在解释当前代码做了什么,不是另行评价这些校验是否应该增减。

8.1 算法版本必须是本文源码基线的 v5

Section titled “8.1 算法版本必须是本文源码基线的 v5”

ResidentIncomePaymentSelectionAlgorithmVersionProvider 集中定义版本 v5

旧 Session 不能直接由新算法继续构建,更不能只把版本字段手工改成 v5。可以把它理解成:不能一张工作台的前半部分按旧规则准备,后半部分按新规则准备,最后再统一贴一个版本标签。

遇到旧版本错误,应通过业务入口废弃旧 Session 并重建,而不是修改版本号伪装兼容。

8.2 候选付款状态必须严格是整数数组 [20,60,70]

Section titled “8.2 候选付款状态必须严格是整数数组 [20,60,70]”

不是“包含这三个值就行”,而是长度、顺序都相同,且每个元素必须是 JSON Integer。

[20,60,70] → 符合原文要求
[60,20,70] → 顺序不对
[20,60] → 缺项
[20,60,70,80] → 多项
["20",60,70] → 有字符串
[20.0,60,70] → 有小数
损坏的 JSON → 不能接受

这个候选范围由服务端写入 Session。客户端传子集、超集,都不会成为实际候选范围。原文给出的原因是避免 Fastjson 类型强转或旧客户端参数悄悄改变候选口径。

原文没有展开 20、60、70 各自的业务名称,这里不补猜。

NEW 就是“为一张新付款单准备选单工作台”。它可以理解成:明确规则 → 找候选 → 分批搬入 → 补齐快照 → 确认本批覆盖 → 记进度。

9.1 先准备这次构建要用的上下文

Section titled “9.1 先准备这次构建要用的上下文”

selection_session.project_company_scope_json 读取项目公司范围,用 Session 创建时间换算基准月,再查询合作方的付款单账单年月规则。

没有配置规则、规则 code 非法,都会直接失败,不猜一个默认规则继续。账单年月规则和合作方默认付款周期配置会缓存在本次构建上下文中,减少重复远程调用。

countBuildCandidates 用与后面游标分页相同的候选条件计数,写入 build_total_count

例如,ID 最大是 900000,但真正符合条件的只有 10000 条,总候选量应该是 10000,不是 900000。diff_id 是定位和推进用的主键,不是业务条数。

这里的 keyset 分页可以理解成“记住上次走到了哪个主键,下次从它后面继续”,而不是每次用大 OFFSET 跳过前面很多行。

每一批先确定终点及候选条数,原文的简化 SQL 是:

SELECT MAX(batch.id), COUNT(*)
FROM (
SELECT d.id
FROM fi_monthly_income_difference d
...相同候选条件...
ORDER BY d.id
LIMIT :batchSize
) batch;

这是说明形状的 SQL,其中的省略号不是可直接执行的 SQL 内容。完整候选条件见下一小节。

常规批次使用 2500 条。总量大于 2500 时,第一批缩到 1000 条,后续使用 2500 条。首批完成前 processed_count 还是 0,首批缩小是为了让页面更早出现真实进度;后续较大批次则保留吞吐。

原文 Mapper 的共同条件是:

d.id > lastDiffId
并且 d.partner_org_id = Session 合作方
并且 d.share_month 在 Session 起止月内
并且 d.partner_bill_id 非空
并且不存在 RESERVED / ACTIVE 业务锁
并且 d.payment_status 在 (20,60,70) 内
并且 d.project_company_id 属于本轮明确构建范围
并且:账单年月规则为 UNLIMITED,或者账期等于规则算出的目标月

两个容易混淆的地方:用户选的起止月与合作方规则都要满足;能进入候选池,也不代表后面一定分到“合格”。

项目公司范围为空时,SQL 明确加 1 = 0,即不选任何行,不会把空范围解释成“这个合作方的所有项目公司”。这是原文说的 fail closed:拿不准或范围缺失时,不擅自扩大处理范围。

索引也按范围区分:

情况 当前 SQL 强制使用的索引及顺序
单项目公司 idx_selection_build_scope_cursor(partner_org_id, project_company_id, payment_status, share_month, id)
多项目公司 idx_selection_build_cursor(partner_org_id, id, project_company_id, share_month, payment_status)

原文已在其连接的测试库核验这两个索引存在,不代表本次重新确认了其他环境。

9.5 把候选写入工作集,并做初始分流

Section titled “9.5 把候选写入工作集,并做初始分流”

通过 INSERT ... SELECT 写入 selection_session_item。搬过去的不只有 ID,还有后续操作需要使用的事实和规则快照。

写入的信息 具体内容
关联定位 差异台账、大小单、大小账户、站点 locator
所属范围 合作方、项目公司、账期
业务判断依据 付款状态、预租金校核、补付标识、多个差异标识
金额 pre_rent / paid_amount / current_payment_amount / available_payable_amount
来源变化依据 来源更新时间、source_financial_version
规则 账期规则快照
初始结果 初始分流、最终分流、最终状态

这里的 locator 就是“用来准确找到对应业务记录的关联标识”,不是一个额外的新业务对象。

初始合格条件需要同时满足:

farmer_rent_different = 0
AND pre_rent_check_result IN (10,30)
AND current_payment_amount > 0

符合则进合格分流 10,否则进不合格分流 20。原文未展开 pre_rent_check_result 中 10、30 各自的完整业务名称,这里保留编码。

最终明细状态的判断顺序也重要:关键 locator 缺失是 BLOCKED;业务规则不通过是 UNQUALIFIED;其余是 PAYABLE这些是工作集明细的状态,不是已经付款的结果。

一个代码细节必须保留:buildFinalItemStatusCase 虽然有 current_payment_amount <= 0 → AMOUNT_BLOCKED 分支,但前面的“业务规则不通过”已经包含金额不大于 0 的情况。因此在本文这条初始 INSERT 路径,零金额先变成 UNQUALIFIED,后面的 AMOUNT_BLOCKED 分支到不了。

数据库里有历史 AMOUNT_BLOCKED,可能来自旧算法或后续调整路径,不能据此倒推出当前 INSERT 会进入那个分支。这是原文指出的重复条件,本次仍然只解释,不修改代码。

9.6 为什么先固定批次终点,再处理这个区间

Section titled “9.6 为什么先固定批次终点,再处理这个区间”

代码中这个区间写作 (lastDiffId, nextLastDiffId]。假设上次游标是 100,这次找到的终点是 180,后面的站点映射、INSERT、快照更新、覆盖校验,都围绕同一个区间:

(100,180]
也就是:大于 100,小于等于 180

这样重试当前批次时,处理边界是明确的,不会每个步骤都重新 LIMIT 一次,各自搬到不同的区间。

这里的数字只表示主键范围,不代表其中一定有 80 条候选。源表主键可以不连续,范围中也可能有不符合候选条件的行。

9.7 重跑时,怎样避免重复,也避免静默漏处理

Section titled “9.7 重跑时,怎样避免重复,也避免静默漏处理”

先统计当前 Session、当前游标区间中已经存在的工作集行,再执行写入,最后检查:

existingCount + insertedCount >= candidateCount
已有行数 + 本次新增行数 >= 本批候选数

第一次执行,主要由新增行数覆盖;上次 INSERT 成功,但快照或游标更新失败,重跑时已有行就参与覆盖。不能仅仅因为“这次 INSERT 没有报错”就前进,还要通过这项覆盖检查。

例如本批候选 1000 条,上一次已经插入了 1000 条,但之后失败。本次新增可能是 0,已有是 1000,仍然有机会通过覆盖检查;不能把“新增为 0”直接当成没处理。

下面三条数据库唯一约束则分别防止三类重复:

唯一约束 防止什么
uk_session_diff(session_id, diff_id) 同一 Session 重复进入同一差异台账行
uk_session_source_order_bill(session_id, source_order_bill_id) REVISE 重复保留同一来源正式明细
uk_session_station_month(session_id, station_id, bill_yearmonth) 同一 Session 中,同一个平台站同一个账期重复进入

10. 平台站解析与“无小单”兼容

Section titled “10. 平台站解析与“无小单”兼容”

难点不是单纯“station_id 有没有值”,而是不同历史来源里的站点 ID 未必是同一种 ID。平台站、合作方站、小单站不能不加区别地混用。

有的候选源站点为空,只能根据合作方账单、合作方账户等 locator 反查。当前有三处处理。

处理时点 做什么 出问题怎么办
INSERT 前,源 station_id 为空 批量解析对应的平台站 不能唯一解析就硬失败,避免违反 station_id NOT NULL,也不能造一个站点 ID
INSERT 后 对已写入候选统一归一平台站 映射缺失或冲突,记录 hard block 原因
REVISE 新候选 INSERT 前 用解析后的“平台站 + 账期”查重 检查是否与已保留行或本批其他候选重复

“无小单”还受 partnerOnlyPaymentEnabled 控制。

开关关闭时,真实无小单行仍按 SMALL_MAPPING_MISSING 阻断。

开关开启时,也不是任何无小单行都能放行;必须具有完整的平台站、合作方账单、合作方账户、合作方、账期 locator,才允许继续原来的 10/20 分流。允许继续分流,不等于自动变成合格。

平台站是后续业务锁和唯一性判断的统一维度。不能为了绕过错误,手填合作方站 ID 充当平台站 ID。

11. 账期规则、配置版本和付款周期快照

Section titled “11. 账期规则、配置版本和付款周期快照”

这一章要分清三件事:选哪些账期、用的是哪一版配置、每条明细的付款周期怎么计算。

11.1 账单年月规则:这次究竟选择哪些月份

Section titled “11.1 账单年月规则:这次究竟选择哪些月份”

合作方规则在扫描候选前读取,并同时用于总量统计、游标查询、INSERT、item 快照。它们不能各用一套月范围,否则统计和实际写入会对不上。

UNLIMITED 不额外增加“等于目标月”的条件,不代表取消 Session 本身的起止月限制。其他规则只选择计算出的目标月,例如当月、上月、上季度末等,具体由配置模块计算。

11.2 配置版本:记住“当时用的是哪份规则”

Section titled “11.2 配置版本:记住“当时用的是哪份规则””

原文的“方案 A”路径按整批写入:

partner_config_version = 账期规则的合作方配置版本
payment_cycle_config_version = 合作方默认付款周期配置版本
source_config_version = max(上面两个版本)

工作台准备好之后,配置可能又改了。这些字段给提交前的检查保留依据,用来判断创建工作集后配置是否变化,而不是让后续只能猜“当时按哪个规则算的”。

11.3 付款周期:简单规则批量写,复杂规则取齐事实后逐行算

Section titled “11.3 付款周期:简单规则批量写,复杂规则取齐事实后逐行算”

固定付款周期走整个区间的 UPDATE,不需要再把 2500 行读回应用逐行处理。

复合周期,或者“所有已生成账单最后一期”这种情况,则需要批量预取更多事实:电站备案方式、分享规则里的 rent_pay_method、小单账户付款方式变更类型、“账户 + 站点”的最大已生成账期、合作方默认付款周期配置。

然后逐行计算并冻结下面的信息。

冻结内容 作用
payment_cycle / resolved_payment_cycle 原始或解析后的付款周期结果
配置来源、配置 ID、生效月 说明结果用的是哪份配置、从何时生效
复合周期因子 JSON 保留复合计算的依据
可付款截止账期 保留计算出的截止范围
计算描述、不可计算原因 让后续能解释结果或失败原因

当前付款周期算不出来,不直接等于“不能选单、不能提交”。 本文源码中会写 cycle_status=NOT_CALCULABLE 及原因,但 payment_cycle_blocked 写 0。原文依据源码注释说明:付款周期只参与应付金额计算,不再直接阻断选单和提交。

这也不等于“周期计算问题完全没有业务影响”:原文仍然说它参与应付金额计算。数据库里的历史 CYCLE_BLOCKED 行,也不能反过来证明 v5 仍直接阻断。

REVISE 是退回后重提,主线不是“重新做一遍 NEW”,而是:

REVISE_RETAINED_SEED:保留原单正式明细
→ REVISE_UNAVAILABLE_DETECT:兼容清理旧标记
→ REVISE_NEW_CANDIDATE:追加当前新增候选
→ READY

12.1 第一阶段:先继承原单发布时的明细

Section titled “12.1 第一阶段:先继承原单发布时的明细”

来源不是当前差异台账,而是 fi_resident_income_payment_order_bill。筛选包括:

payment_order_id = targetPaymentOrderId
AND data_version = sourceDataVersion
AND submit_round = base_info_json.sourceSubmitRound
AND deleted = 0
AND 属于本轮项目公司范围

三个身份条件一起说明:继承的是“哪张付款单、哪个数据版本、哪次提交轮次”的正式明细,不是随便找这个付款单的历史行。

以来源正式明细主键 source_order_bill_id 为游标,每批 2500 条。写入后标记 revise_origin='RETAINED_ORIGINAL',并复制原单的金额、分流、账期规则、付款周期、版本快照。

这一步强调的是“原单发布时是什么,就先继承什么”,不是拿当前台账重新推导原单。

12.2 第二阶段:名字还在,但已经不再做旧逻辑

Section titled “12.2 第二阶段:名字还在,但已经不再做旧逻辑”

REVISE_UNAVAILABLE_DETECT 看起来像是在“重新检测哪些原单明细不可用”。但当前实现已经收敛为兼容清理:续租,把阶段切过去,清理历史 retained unavailable 标记。

它不再读取实时台账或锁状态,去生成旧的六类不可用原因。原文没有列出这六类的具体名称,这里不另行补写。

因此,读代码不能仅凭阶段名推断它今天还在执行完整的历史检测逻辑。

12.3 第三阶段:再补进当前新增候选

Section titled “12.3 第三阶段:再补进当前新增候选”

当前新增候选仍从 fi_monthly_income_difference 获取,使用和 NEW 相同的合作方、账期、付款状态、锁、项目公司过滤,并排除:本 Session 已有的相同 diff_id、已有的相同“平台站 + 账期”,以及平台站解析后才发现的 retained/new 重复。

新增行标记 revise_origin='REVISE_NEW_CANDIDATE'。对原付款单项目范围之外的新项目公司,默认 included_in_revise=1,即默认纳入本次重提。

例子:原单已有某平台站 7 月的明细。当前台账里也有这座站 7 月的候选,不能因为来源不同,就再重复加入一条。

12.4 同一个 last_diff_id,在两个阶段含义不同

Section titled “12.4 同一个 last_diff_id,在两个阶段含义不同”

这是原文最容易让人看错的字段之一。

failure_phase last_diff_id 实际存的是什么
REVISE_RETAINED_SEED 来源正式明细的 source_order_bill_id 水位
REVISE_NEW_CANDIDATE 差异台账的 diff_id 水位

它像一个被两门课程共用的书签:当前读的是哪本书,决定页码属于哪本书。阶段切换时,如果当前 failure_phase 与目标阶段不同,恢复游标从 0 开始。

因此,排障必须把阶段和游标一起看,不能只凭 last_diff_id 的名字把它当差异台账 ID,更不能不看阶段就手工归零。

13. 已有 Session 的项目公司范围调整

Section titled “13. 已有 Session 的项目公司范围调整”

S01 除了首次构建,还处理 READY Session 中新增/删除项目公司的异步构建。

13.1 只删除与包含新增,不是一条处理路径

Section titled “13.1 只删除与包含新增,不是一条处理路径”

纯删除可以在创建/调整入口的短事务内完成。

只要包含新增,就要再次进入 S01,因为新增项目公司还需要扫描候选、解析站点、计算快照、准备项目账户信息,不能只往范围字段加个 ID 就结束。

13.2 受理时,先把这轮要改什么固定下来

Section titled “13.2 受理时,先把这轮要改什么固定下来”

通过 state_version CAS,把 Session 从 READY 改到 INIT,冻结:

targetScope:完整目标范围
addedIds = targetScope - currentScope
removedIds = currentScope - targetScope
targetScopeHash
expectedStateVersion

再把数据库实际接受的任务主键绑定到 active_task_id

例如旧范围是 {A,B,C},目标范围是 {B,C,D},本轮就是新增 D、删除 A、保留 B/C。这个例子仅说明差集关系;实际任务中的项目公司 ID 必须满足下文的数字列表约束。

13.3 worker 不会照单全收任务自己报的差集

Section titled “13.3 worker 不会照单全收任务自己报的差集”

领取 lease 前、领取 lease 后各检查一次:active_task_id 是否等于本次 taskId;Session、DTO、任务 ID 是否一致;当前范围、目标范围、added/removed 是否都是去重且升序的正数列表;重新计算的差集是否和任务冻结差集一致;added 和 removed 是否互不相交;重新计算的 targetScopeHash 是否等于任务值。

也就是说,任务说“我负责新增 D、删除 A”,worker 还会核对当前 Session,确认这确实是当前接受的这一轮改动。

旧任务、损坏任务会永久拒绝,不会由消费者自行排序、去重或修正后继续执行。这里的列表格式也是持久化契约的一部分。

13.4 先把新增准备好,再一次性发布目标范围

Section titled “13.4 先把新增准备好,再一次性发布目标范围”

构建时只处理 addedIds:NEW 只给新增项目公司构建候选;REVISE 只给新增项目公司执行 retained seed 和新候选追加。

新增项目公司的账户快照在事务外准备,准备前后续租。真正发布时进入最终短事务:锁 Session,再核对 taskId + workerId + leaseVersion,upsert 新增账户快照,然后才停用 removed 项目账户、删除 removed 项目的 item,重新计算提交阻断信息,最后原子发布完整目标 scope、scope hash、base info、operation version,并回到 READY。

套回 {A,B,C} → {B,C,D} 的例子:先准备 D,不急着删 A;等最终发布成功时,才一起删除 A、发布 {B,C,D}。远程准备失败时,不会先把 A 删掉而留下缺一块的工作台。

这是“延迟删除 + 原子发布”的含义。它不等于整个构建从头到尾是一个大事务;中间构建与最终发布是分开的。

14.1 不把整个 S01 包进一个大事务

Section titled “14.1 不把整个 S01 包进一个大事务”

如果所有批次加远程调用都放进一个大事务,会持有大量锁和 undo;失败要整体回滚,无法按游标续跑;页面在最终提交前看不到真实进度;远程调用也会把数据库事务拖得很长。

当前依靠的是:

唯一约束
+ 幂等 INSERT
+ 快照补写
+ 游标 CAS
+ lease fencing

每批确认完成后,持久化推进进度;失败时保留已经写入的行,供重试核对。不要把“分批处理”理解为“每一批内部的 INSERT、远程调用、快照、游标一定全部处于一个原子事务”;原文明确讨论了 INSERT 成功但快照失败、快照成功但游标失败等中间态。

同样,本文所说的“冻结工作集”,指保存这次操作的范围、明细事实和版本依据;原文没有承诺所有批次都是同一瞬间的全库事务快照。

14.2 已处理条数,不等于本次实际插入条数

Section titled “14.2 已处理条数,不等于本次实际插入条数”

processed_count 是已经确认处理的候选数;inserted_count 是实际新增行数。

幂等重跑时,已有行可以帮助确认候选已被覆盖,却不会变成这次的新插入。因此,两个计数不必永远相等,不能见到不相等就直接断言有漏数。

14.3 NEW 与 REVISE 的页面进度口径不同

Section titled “14.3 NEW 与 REVISE 的页面进度口径不同”

NEW 页面进度使用 Session 的 processed_count,总量取:

max(build_total_count, processed_count, inserted_count)

REVISE 不直接展示技术游标计数,而是实时统计最终可见工作集行数,并把同一个数作为 total。因为“保留原单”和“追加新增候选”共用技术进度字段,但用户关心的是最终有效明细有多少条。

任务表状态回答“后台任务怎么样了”,Session 状态回答“业务工作台怎么样了”。两套状态不能混为一谈。

S01 主要涉及下面这些变化:

初始 → INIT
INIT → BUILDING:成功领取构建 lease
BUILD_FAILED → BUILDING:重试并成功领取 lease
BUILDING → READY:所有阶段和最终 CAS 成功
BUILDING → BUILD_FAILED:当前 owner 记录失败
INIT → CANCELLED
BUILDING → EXPIRED
BUILD_FAILED → EXPIRED
READY → ADJUSTING
READY → SUBMITTING

项目公司范围调整还有前文说明的 READY → INIT 受理路径。这里展示的是与本文相关的变化,不是对整个系统所有状态迁移的补充定义。

Session 状态才是业务权威。XXL Handler 返回成功,只代表这一轮方法正常返回;仍要看成功/失败/跳过摘要,再核对 selection_session.status

worker 发现 Session 已 READY,或者已经 COMPLETED/EXPIRED/CANCELLED,会把残留技术任务幂等标为 SUCCESS。这表示“业务对象已经不再需要 S01 执行”,不一定表示这次重新构建成功。

所以:任务 SUCCESS 不等于 Session 当前 READY,更不等于已生成付款单或已付款。

16. 自动重试、人工补偿与永久拒绝

Section titled “16. 自动重试、人工补偿与永久拒绝”

16.1 可恢复失败:修好原因后,从原进度接着做

Section titled “16.1 可恢复失败:修好原因后,从原进度接着做”

普通异常会把任务改为 FAILED,retry_count + 1,写入 next_execute_time,保存最多 1000 字错误信息;同时由当前 lease owner 尝试把 Session 写为 BUILD_FAILED。

原文说明自动扫描最多重试 3 次。人工定向重跑不受 next_execute_time 和自动重试次数筛选限制,但依然必须领取当前 attempt,也仍受 Session lease 与当前 active_task_id 约束。

因此,人工补偿不是“越过所有保护强行执行”,也不是一个可以绕开当前轮次身份的入口。

16.2 正常竞争:别人有有效写权,不算失败

Section titled “16.2 正常竞争:别人有有效写权,不算失败”

任务领取成功,但另一个 worker 仍持有有效 build lease,当前任务回到 PENDING,下次执行时间延后 5 秒,不增加失败次数。

这是“有人正在做,我稍后再试”,不是“业务出了错”。

16.3 永久拒绝:反复重试不会自己变好的任务

Section titled “16.3 永久拒绝:反复重试不会自己变好的任务”

典型情况是 REVISE 持久化身份不变量损坏;范围调整的 activeTaskId、冻结差集、hash 损坏;旧轮次范围调整任务试图写当前 Session。

当前代码会耗尽自动重试或取消任务,防止坏任务持续扫描。范围调整坏任务保留原 taskId,要求修复后精确处理,不能新造一份看似一样的 taskData 去覆盖、绕过原有事实。

排障的主线是:先看 task,再看 Session,再看 item,必要时继续看下游业务状态。不要只凭一个状态码就下结论。

现象 优先核对 应当怎样理解
task FAILED,Session BUILD_FAILED error_messagefailure_phase、lease version 修复根因后,从原阶段和游标继续
task RUNNING 很久 task 的 update_time/running_attempt,Session 的 lease/heartbeat 任务时间超时,不自动等于 Session 写权已经失效
task SUCCESS,Session 不是 READY 是否已经 COMPLETED/EXPIRED/CANCELLED;是否只是幂等收口 不能据此断言工作集曾完整构建
last_diff_id 不动 当前阶段,同一批的映射、快照、SQL 错误 通常可能是同一批反复失败,不要直接改游标
processed_count > inserted_count 当前区间是否已经存在幂等行 可能是正常重跑,要结合覆盖检查
算法版本错误 selection_algorithm_version 是否为 v5 废弃旧 Session,通过业务入口重建
候选为 0 规则目标月、合作方/项目范围、付款状态、业务锁 0 候选也可以合法 READY,并不一定是构建失败
REVISE 漏行或重复 source data version、submit round、revise_origin、平台站+账期唯一性 不要把 REVISE 当作 NEW 重跑
平台站解析失败 source station、partner bill/account locator、Feign 返回 不能用合作方站 ID 冒充平台站
页面长期 INIT 是否存在 S01 seed、afterCommit kick 是否发生、XXL 是否启用 创建 Session 成功,不等于后台已经执行

这一章回答“这些机制分别是在防什么”,而不是要求把所有术语单独背下来。

18.1 持久化工作集,而不是每次实时拼装页面

Section titled “18.1 持久化工作集,而不是每次实时拼装页面”

把复杂事实保存为 selection_session_item,分页、汇总、调整、提交共享同一工作台。代价是多占存储,换来可重复操作、稳定分页、后续校验依据。

18.2 深模块与清晰 seam:入口简单,复杂性放在内部

Section titled “18.2 深模块与清晰 seam:入口简单,复杂性放在内部”

原文强调 worker 的对外消费、人工重跑接口,以及 XXL、主动 kick 这些触发 Adapter。任务筛选、claim、身份校验、异常分类、指标集中在 worker 实现,明细构建收敛在构建模块。

“深模块”可以理解成内部承担很多复杂工作,对外却不要求调用者理解全部细节;“seam”就是职责之间的接缝。调用入口不必知道每批 2500 条、REVISE 三阶段、平台站映射怎么实现。

对应前文的实际入口,自动扫描、人工补偿、主动 kick 均会进入 worker 编排;不要把本节对接口边界的概括理解成“系统没有 kick 路径”。

任务 seed 是恢复依据,kick 是降低等待的优化。kick 可以重复、可以丢失,但不能因此丢掉需要执行的业务任务。afterCommit 保证先提交 Session/任务,再让 worker 来处理。

任务 attempt 挡住旧任务执行代次的状态写回;Session lease version 挡住旧业务 worker 对工作集及进度的推进。技术调度状态与业务写权限分开,不让一个通用任务状态承担全部并发含义。

Session 保存范围和算法版本,item 保存账期规则、付款周期、金额、来源版本。后续提交既能知道“当时看到了什么”,也能检查“现在是否变化”。

18.6 fail closed:缺依据时,不扩大、不猜测

Section titled “18.6 fail closed:缺依据时,不扩大、不猜测”

项目公司范围空、付款状态范围空、账期规则缺失、算法版本不一致、任务身份损坏、平台站无法唯一解析时,不扩展范围,不猜默认值,不静默修复。

具体后果要看对应路径:例如空项目范围通过 1 = 0 不选行,缺失账期规则会失败,不能把所有情形统称为同一种异常。

不用大 OFFSET,按递增主键水位前进;先固定批次终点,再处理同一个 (上次游标, 本批终点] 区间;确认候选覆盖后才推进。这样兼顾吞吐、恢复、处理一致性。

保留行来自原付款单发布版本,当前台账只负责新增候选。原单被退回,不等于允许外部事实变化悄悄重写原单的历史内容。

项目范围混合增删时,先准备新增,删除留到最终短事务。业务范围不应暴露成“旧的先删了,新的还没准备完”的半成品。

19.1 多个事实源,ID 的含义还不统一

Section titled “19.1 多个事实源,ID 的含义还不统一”

差异台账、大小单、大小账户、平台站、合作方站、项目公司、付款锁、远程电站档案来自不同表或服务。站点映射一旦错,不只显示错名字,还可能影响唯一性、业务锁、付款周期、最终付款明细。

19.2 冻结什么,以及什么时候再检查变化

Section titled “19.2 冻结什么,以及什么时候再检查变化”

不冻结足够的内容,翻页和操作口径会漂移;冻结后不保留来源与配置版本,又无法识别后续变化。当前用 item 快照保留内容,用 source/config version 保留变化检测依据。

19.3 REVISE 同时处理两种时间的事实

Section titled “19.3 REVISE 同时处理两种时间的事实”

保留行尊重原单发布时,新候选按当前规则产生,最后还必须在“平台站 + 账期”上去重。原文所说的“双时间语义”,就是这两类事实不能一锅端地按同一时点重算。

10 分钟是 lease 时长,不是构建 SLA。某批 SQL 或远程调用很慢,另一个 worker 可能接管。关键进度和终态写入要带 lease version,避免旧 worker 回来污染新结果。

19.5 批次中间态必须可以解释和恢复

Section titled “19.5 批次中间态必须可以解释和恢复”

可能出现 INSERT 成功但快照失败,快照成功但游标 CAS 失败,或者 READY 前进程退出。恢复不能只靠一个“重试”按钮,而要靠唯一约束、existingCount、同区间重算、带版本保护的进度更新共同完成。

总量统计、游标扫描、INSERT、站点映射、配置快照、付款周期快照,都可能慢。单项目与多项目需要不同索引前缀;固定周期可整段更新,复合周期需要批量回读事实;首批又影响页面多久看到首次进度。

19.7 远程调用和本地数据库不是一个原子整体

Section titled “19.7 远程调用和本地数据库不是一个原子整体”

站点、合作方站、部分配置通过 Feign 获取。远程异常时,当前批失败并保留游标,不能靠把远程调用与全部数据库批次包成分布式大事务解决。

范围调整同样把远程账户准备放在最终短事务外,准备好后再做本地最终发布。

19.8 业务状态和技术任务状态必须分别看

Section titled “19.8 业务状态和技术任务状态必须分别看”

fi_async_task=SUCCESSselection_session=READY 不是同义词。Session 还可能继续提交、完成、过期或取消。排障需要沿 task → session → item → 下游状态读取,不能停在 XXL 的返回结果。

本节是原文在 2026-09-04 10:18(Asia/Shanghai)的核验记录。它不是本次重新连接数据库得出的实时状态,也不是对生产部署的确认。

在原文 MCP 连接的 zhongxin_test_financial 中,存在 fi_async_taskselection_sessionselection_session_itemselection_session_project_accountfi_monthly_income_difference、付款锁、付款单明细等新流程表。

当时核验项目 结果
RESIDENT_INCOME_PAYMENT_SESSION_BUILD 任务 830 条
Session 1030 个
Session item 614130 条
S01 SUCCESS 任务 794 条
S01 CANCELLED 任务 36 条
S01 PENDING / RUNNING / FAILED 当时均没有
taskData 未包含 projectScopeAdjustment 683 条
projectScopeAdjustment=false 126 条
projectScopeAdjustment=true 21 条
任务 JSON 与基本身份 830 条 JSON 均合法,均为 taskScene=SESSION_BUILD,business key 与 taskData.sessionId 匹配
历史 Session 算法版本 同时存在 v1、v2、v3、v5;本文源码只接受 v5
活动 active_task_id 当时聚合没有发现

缺少 projectScopeAdjustment 字段的历史记录体现任务 JSON 的演进;不能把这一统计直接改写成“这些旧任务当时都是损坏任务”。原文只给出了历史聚合及基本身份核验结果。

这些快照可以支持表结构、历史运行结果方面的判断,不能替代目标环境的部署版本与运行日志。

原文在 zhongxin_test_base.xxl_job_info 查到一条配置:

字段 当时的值
job_desc S01-居民收益付款选单 session 构建异步任务
job_cron 5/5 * * * * ?
路由 ROUND
参数 {"maxTaskCount":100}
阻塞策略 SERIAL_EXECUTION
XXL 自身失败重试 0
trigger_status 0

当时该 Handler 在测试库的 XXL 日志数量是 0,trigger_status=0 表明这条测试配置未启用。因此,源码存在 @XxlJob,并不能证明调度已经运行;XXL 自身重试为 0,也不要与前文任务表控制的自动重试机制混为一谈。

同一 MCP 连接下,zhongxin_base 没查到该 Handler 配置,zhongxin_financial 没发现 Session 新流程表。这个结论只适用于该连接当时可见的 schema,不能直接把它改写成“生产没有部署”。

以下是原文在 zhongxin_test_financial 核验的 S01 相关索引;第 9.4 节另外列出了差异台账两个候选扫描索引。

索引
fi_async_task idx_task_type_status_deleted_exec(task_type,task_status,deleted,next_execute_time,retry_count,id)
fi_async_task uk_task_code_business(task_code)
selection_session idx_session_build_lease(build_worker_id,build_lease_version,build_lease_expire_time)
selection_session uk_active_scope_key(active_scope_key)
selection_session uk_active_revise_key(active_revise_key)
selection_session_item uk_session_diff(session_id,diff_id)
selection_session_item uk_session_source_order_bill(session_id,source_order_bill_id)
selection_session_item uk_session_station_month(session_id,station_id,bill_yearmonth)
selection_session_project_account uk_session_project_company(session_id,project_company_id)

这一节保留原文 SQL。它们是排查模板,本次没有执行。使用前确认 datasource/schema,把 @session_id := 0 中的 0 换成要排查的真实 Session ID。

四组查询分别回答:这个 Session 的任务、状态和明细怎么样;REVISE 各来源明细分布怎样;有没有重复的平台站+账期;当前是否有仍有效的构建 lease。

第一组中,task 按业务键查询可能返回同一 Session 的不同轮次任务,要结合 active_task_id 判断当前轮次。最后一组用状态和到期时间判断活动 lease,而不是只看 workerId 是否为空;涉及接管和实际写权限,还要结合前文的版本及任务约束。

下面 SQL 已按 zhongxin_test_financial 当前字段校对;执行目标环境前仍需先确认 datasource/schema。

21.1 任务、Session、工作集联合定位

Section titled “21.1 任务、Session、工作集联合定位”
SET @session_id := 0;
SELECT id, task_code, task_type, business_key, task_status,
retry_count, max_retry_count, next_execute_time,
running_attempt, error_message, task_data,
create_time, update_time
FROM fi_async_task
WHERE deleted = 0
AND task_type = 'RESIDENT_INCOME_PAYMENT_SESSION_BUILD'
AND business_key = CONCAT('SESSION_BUILD:', @session_id)
ORDER BY id DESC;
SELECT id, mode, status, failure_phase,
selection_algorithm_version, payment_status_scope_json,
project_company_scope_json, scope_hash, active_task_id,
state_version, current_operation_version,
build_worker_id, build_lease_version,
build_lease_expire_time, build_heartbeat_time,
build_total_count, build_max_diff_id, last_diff_id,
processed_count, inserted_count,
expire_time, heartbeat_time, create_time, update_time
FROM selection_session
WHERE id = @session_id;
SELECT COUNT(*) AS item_count,
COUNT(DISTINCT diff_id) AS distinct_diff_count,
SUM(source_order_bill_id IS NOT NULL) AS retained_count,
MIN(diff_id) AS min_diff_id,
MAX(diff_id) AS max_diff_id,
SUM(final_item_status = 'PAYABLE') AS payable_count,
SUM(final_item_status = 'UNQUALIFIED') AS unqualified_count,
SUM(hard_block_flag = 1) AS hard_block_count
FROM selection_session_item
WHERE session_id = @session_id;
SELECT revise_origin, included_in_revise, removed_in_revise,
retained_unavailable_flag, final_item_status,
COUNT(*) AS item_count,
MIN(source_order_bill_id) AS min_source_order_bill_id,
MAX(source_order_bill_id) AS max_source_order_bill_id,
MIN(diff_id) AS min_diff_id,
MAX(diff_id) AS max_diff_id
FROM selection_session_item
WHERE session_id = @session_id
GROUP BY revise_origin, included_in_revise, removed_in_revise,
retained_unavailable_flag, final_item_status
ORDER BY revise_origin, final_item_status;
SELECT station_id, bill_yearmonth, COUNT(*) AS duplicate_count,
MIN(id) AS min_item_id, MAX(id) AS max_item_id
FROM selection_session_item
WHERE session_id = @session_id
GROUP BY station_id, bill_yearmonth
HAVING COUNT(*) > 1;
SELECT id, status, active_task_id,
build_worker_id, build_lease_version,
build_lease_expire_time,
CASE
WHEN status = 'BUILDING'
AND build_lease_expire_time > NOW() THEN 'ACTIVE_LEASE'
ELSE 'NO_ACTIVE_LEASE'
END AS lease_state
FROM selection_session
WHERE id = @session_id;

补偿优先复用原任务,使用原文给出的定向入口:

{"businessKey":"SESSION_BUILD:<sessionId>","maxTaskCount":1}

<sessionId> 是占位符,实际使用时替换为真实 Session ID。范围调整还要核对当前 active_task_id,不能仅凭相同 businessKey 判断就是当前轮次。

执行前至少确认下面八件事。

核对项 要确认什么
环境 datasource/schema 就是目标环境
算法 Session 算法版本为本文源码要求的 v5
状态范围 payment_status_scope_json 严格为 [20,60,70]
Session 状态 当前状态允许恢复
写权限 没有仍然有效的 build lease
REVISE 来源 sourceDataVersion/sourceSubmitRound/failurePhase 与来源正式明细一致
范围调整轮次 命中当前 active_task_id
根因 远程配置或站点映射等问题已经恢复

不要直接手工把 task_status 改回 PENDING;不要把 running_attemptbuild_lease_version 改小;不要删除 item 后把游标归零;不要改算法版本或状态 JSON 伪装兼容;不要为同一当前轮次另插任务绕过 active_task_id;不要在不理解 failure_phase 时改 last_diff_id

这些操作看上去像“让任务再跑一次”,实际可能破坏轮次身份、旧执行者隔离或断点恢复依据。

下面保留原文的源码定位,以相对工程路径和行号展示,避免本地文件链接在知识平台中跳转失效。原文路径前缀为 /Users/wangyi/BZ/zx-monitor/;这些定位不是本次已打开或重新核验的源码附件,其他机器需要在对应工程中按类名、方法和行号定位。行号只对应本文开头的源码基线。

  • XXL Handlerzxbaif/baie-business/financial-center/src/main/java/com/baie/financial/xxljob/ResidentIncomePaymentSelectionSessionBuildJob.java:39

  • Worker Interfacezxbaif/baie-business/financial-center/src/main/java/com/baie/financial/service/fi/SelectionSessionBuildAsyncTaskService.java:13

  • Worker Implementationzxbaif/baie-business/financial-center/src/main/java/com/baie/financial/service/fi/impl/SelectionSessionBuildAsyncTaskServiceImpl.java:57

  • 工作集构建 Interfacezxbaif/baie-business/financial-center/src/main/java/com/baie/financial/service/fi/SelectionSessionBuildService.java:12

  • 工作集构建 Implementationzxbaif/baie-business/financial-center/src/main/java/com/baie/financial/service/fi/impl/SelectionSessionBuildServiceImpl.java:79

  • Session 创建与任务受理zxbaif/baie-business/financial-center/src/main/java/com/baie/financial/service/fi/impl/SelectionSessionServiceImpl.java:200

  • 任务安全受理与 afterCommit kickzxbaif/baie-business/financial-center/src/main/java/com/baie/financial/service/fi/impl/ResidentIncomePaymentSelectionTaskAcceptanceServiceImpl.java:54

  • 任务身份规则zxbaif/baie-business/financial-center/src/main/java/com/baie/financial/utils/ResidentIncomePaymentSelectionAsyncTaskSupport.java:121

  • 当前算法版本zxbaif/baie-business/financial-center/src/main/java/com/baie/financial/utils/ResidentIncomePaymentSelectionAlgorithmVersionProvider.java:9

  • 任务 claim fencing SQLzxbaif/baie-business/financial-center/src/main/resources/mapper/FiAsyncTaskMapper.xml:90

  • Session build lease 与状态 CASzxbaif/baie-business/financial-center/src/main/resources/mapper/SelectionSessionMapper.xml:564

  • 候选过滤、工作集 INSERT 与 REVISE SQLzxbaif/baie-business/financial-center/src/main/resources/mapper/SelectionSessionItemMapper.xml:428

原文正文还引用了下列更细的入口或 SQL 位置,供逐项对照。

  • SelectionSessionServiceImpl.java:1803zxbaif/baie-business/financial-center/src/main/java/com/baie/financial/service/fi/impl/SelectionSessionServiceImpl.java:1803

  • SelectionSessionServiceImpl.java:1839zxbaif/baie-business/financial-center/src/main/java/com/baie/financial/service/fi/impl/SelectionSessionServiceImpl.java:1839

  • ResidentIncomePaymentSelectionTaskAcceptanceServiceImpl.java:195zxbaif/baie-business/financial-center/src/main/java/com/baie/financial/service/fi/impl/ResidentIncomePaymentSelectionTaskAcceptanceServiceImpl.java:195

  • SelectionSessionBuildAsyncTaskServiceImpl.java:507zxbaif/baie-business/financial-center/src/main/java/com/baie/financial/service/fi/impl/SelectionSessionBuildAsyncTaskServiceImpl.java:507

  • SelectionSessionBuildAsyncTaskServiceImpl.java:97zxbaif/baie-business/financial-center/src/main/java/com/baie/financial/service/fi/impl/SelectionSessionBuildAsyncTaskServiceImpl.java:97

  • SelectionSessionBuildAsyncTaskServiceImpl.java:116zxbaif/baie-business/financial-center/src/main/java/com/baie/financial/service/fi/impl/SelectionSessionBuildAsyncTaskServiceImpl.java:116

  • SelectionSessionBuildAsyncTaskServiceImpl.java:142zxbaif/baie-business/financial-center/src/main/java/com/baie/financial/service/fi/impl/SelectionSessionBuildAsyncTaskServiceImpl.java:142

  • FiAsyncTaskMapper.xml:127zxbaif/baie-business/financial-center/src/main/resources/mapper/FiAsyncTaskMapper.xml:127

  • SelectionSessionMapper.xml:629zxbaif/baie-business/financial-center/src/main/resources/mapper/SelectionSessionMapper.xml:629

  • SelectionSessionMapper.xml:805zxbaif/baie-business/financial-center/src/main/resources/mapper/SelectionSessionMapper.xml:805

  • SelectionSessionBuildServiceImpl.java:347zxbaif/baie-business/financial-center/src/main/java/com/baie/financial/service/fi/impl/SelectionSessionBuildServiceImpl.java:347

  • SelectionSessionServiceImpl.java:1250zxbaif/baie-business/financial-center/src/main/java/com/baie/financial/service/fi/impl/SelectionSessionServiceImpl.java:1250

  • SelectionSessionBuildServiceImpl.java:647zxbaif/baie-business/financial-center/src/main/java/com/baie/financial/service/fi/impl/SelectionSessionBuildServiceImpl.java:647

  • SelectionSessionItemMapper.xml:2598zxbaif/baie-business/financial-center/src/main/resources/mapper/SelectionSessionItemMapper.xml:2598

  • SelectionSessionItemMapper.xml:2624zxbaif/baie-business/financial-center/src/main/resources/mapper/SelectionSessionItemMapper.xml:2624

  • SelectionSessionItemMapper.xml:136zxbaif/baie-business/financial-center/src/main/resources/mapper/SelectionSessionItemMapper.xml:136

  • SelectionSessionItemMapper.xml:161zxbaif/baie-business/financial-center/src/main/resources/mapper/SelectionSessionItemMapper.xml:161

  • SelectionSessionItemMapper.xml:527zxbaif/baie-business/financial-center/src/main/resources/mapper/SelectionSessionItemMapper.xml:527

下表是原文对证据范围的分类,不表示这次通俗改写重新完成了这些验证。“源码能看到”与“目标环境已经按这份源码运行”是两回事。

结论 证据等级
当前源码调用链、状态、候选规则、REVISE 和范围调整逻辑 SOURCE_VERIFIED
zhongxin_test_financial 表字段、索引和聚合数据 DB_SCHEMA_AND_DATA_VERIFIED
zhongxin_test_base XXL 配置存在且当前未启用 DB_CONFIG_VERIFIED
当前分支单元测试是否全部通过 NOT_RUN,本次只做分析与文档,不修改代码
目标部署包是否等于本次源码提交 DEPLOYMENT_UNVERIFIED
目标环境 XXL/Nacos 开关与实际运行日志 ENV_UNVERIFIED
生产业务数据最终状态 PROD_UNVERIFIED

用户确定范围 → 保存 Session 和任务 → 后台取得本轮执行权与工作台写权 → NEW 从当前候选构建,REVISE 先继承原单再追加候选 → 分批落明细、补快照、确认覆盖、记录进度 → 全部完成后发布 READY → 后续调整和提交继续使用这份工作集。

遇到错误时,先判断是可恢复失败、正常竞争,还是身份损坏。恢复依靠原任务、原阶段、原游标,以及当前有效的 attempt 和 lease;不是靠随手改状态把系统“推过去”。


来源:本次上传的 S01 业务背景与逻辑详解。文中数据库数字与环境信息均为原文记录,不代表实时核验。