perf(F5+): 行数据按需拉取(结果行版本信号)+ 修复 progress/light 恒判 missing

行数据按需拉取(审查 F5 后续):
- V125 给 biz_file_result 补 updated_at(DEFAULT/ON UPDATE 由数据库维护,
  实体标注 insertStrategy/updateStrategy=NEVER —— 否则 selectById→updateById 的
  写回会把旧值写回去、ON UPDATE 不触发,版本信号静默冻结)
- 装配器回传 rowsVersion=「最后变更时间毫秒#行数」,5 个品牌工具页版本未变即跳过
  带行明细的重型 batch;前端变更信号为 rowsVersion + status/fileStatus/fileReady 复合
  (任务收尾常见「行早写完、之后才置成功」,只看行版本会把界面卡在旧状态)

修复线上缺陷(同一功能验证时暴露):
- TaskProgressLightAssembler 列裁剪漏选 module_type 却用它做模块过滤 →
  getModuleType() 恒为 null → light 恒把任务判成 missing;第七批把 light 接进
  跟价/定时匹配/商品风险的轮询后,消费方会把运行中任务判为 FAILED
- 补选中列 + 守卫用例 taskQueryMustSelectModuleType(已反向验证:去掉修复即红)
- 前端 lightClaimsAllTasksMissing:整体性 missing 结论用重型端点复核后再采信

契约与文档:light 白名单补 rowsVersion(Java 契约测试 / spec 06 §2 / 12 个端点描述)
测试:mvn test 2901 全绿;前端 npm test 765 全绿
This commit is contained in:
2026-09-14 12:08:25 +08:00
parent 9166656673
commit 5ea52e5291
32 changed files with 641 additions and 36 deletions
@@ -0,0 +1,61 @@
import { test } from 'node:test'
import assert from 'node:assert/strict'
import { readFileSync } from 'node:fs'
import { fileURLToPath } from 'node:url'
import { dirname, join } from 'node:path'
/**
* 「行数据按需拉取」接线守卫(审查 F5 后续):
* 5 个品牌工具页用轻量端点的结果行版本跳过重型 batch;这里守住两件最容易写错的事
* ——①接线没被误删;②跳过分支的返回值必须符合调用方契约(曾经把 shopDataCrawl 写成裸 return
* 而调用方 waitForTerminal 把 falsy 当「任务已消失 → FAILED」,会误杀正在跑的任务)。
*/
const PAGES_DIR = join(dirname(fileURLToPath(import.meta.url)), '../src/pages/brand/components')
const WIRED_PAGES = [
'BrandQueryAsinTab.vue',
'BrandWithdrawTab.vue',
'BrandPatrolDeleteTab.vue',
'BrandShopDataCrawlTab.vue',
'BrandAppearancePatentTab.vue',
]
function sourceOf(page: string) {
return readFileSync(join(PAGES_DIR, page), 'utf8')
}
test('已接线的品牌工具页仍使用结果行版本跳过重型 batch', () => {
for (const page of WIRED_PAGES) {
const source = sourceOf(page)
assert.ok(source.includes('createRowsVersionTracker'), `${page} 缺少版本跟踪器声明`)
assert.ok(source.includes('selectTasksNeedingRows'), `${page} 缺少按需拉取判断`)
assert.ok(source.includes('getModuleProgressLight'), `${page} 缺少轻量端点调用`)
}
})
test('轻量请求失败必须回退为全量拉取(不得因此少拉任务)', () => {
// 多任务页:catch 里把 heavyIds 还原成全部任务
for (const page of ['BrandQueryAsinTab.vue', 'BrandWithdrawTab.vue', 'BrandPatrolDeleteTab.vue']) {
assert.match(sourceOf(page), /catch\s*\{[\s\S]{0,200}?heavyIds = ids/, `${page} 缺少轻量失败回退`)
}
assert.match(sourceOf('BrandAppearancePatentTab.vue'), /catch\s*\{[\s\S]{0,200}?heavyIds = taskIds/,
'BrandAppearancePatentTab 缺少轻量失败回退')
// 单任务页:catch 里不能有 return(直接落到批量拉取)
const shopDataCrawl = sourceOf('BrandShopDataCrawlTab.vue')
const catchBlock = shopDataCrawl.match(/catch\s*\{([^}]*)\}/)?.[1] ?? ''
assert.ok(catchBlock.length > 0, 'BrandShopDataCrawlTab 未找到 catch 分支')
assert.ok(!catchBlock.includes('return'), 'BrandShopDataCrawlTab 轻量失败时不得提前返回,必须照旧拉批量')
})
test('跳过分支的返回值符合调用方契约', () => {
const shopDataCrawl = sourceOf('BrandShopDataCrawlTab.vue')
// waitForTerminal: `if (!exists) return 'FAILED'` —— 任务仍在时必须返回 true
assert.match(shopDataCrawl, /selectTasksNeedingRows\([\s\S]{0,200}?return true/,
'BrandShopDataCrawlTab 跳过分支必须 return truefalse/undefined 会被当成任务已消失)')
const patrolDelete = sourceOf('BrandPatrolDeleteTab.vue')
// waitForTaskTerminal: `missingTaskIds.includes(taskId)` —— 未消失时返回空数组
assert.match(patrolDelete, /selectTasksNeedingRows\([\s\S]{0,300}?return \[\]/,
'BrandPatrolDeleteTab 跳过分支必须返回空 missingTaskIds 数组')
})