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 数组')
})
+59
View File
@@ -7,6 +7,10 @@ import {
type ProgressLightModule,
type TaskProgressLightItem,
} from '../src/shared/api/progress-light.ts'
import {
getPollingProgressBatch,
lightClaimsAllTasksMissing,
} from '../src/shared/api/task-progress-polling.ts'
import type { TaskProgressBatchVo } from '../src/shared/api/types/task.ts'
function setupWindow() {
@@ -164,3 +168,58 @@ test('test_light_reexport', async () => {
assert.equal(javaModules.getModuleProgressLight, direct.getModuleProgressLight, 'java-modules 应 re-export getModuleProgressLight')
assert.equal(javaModules.TaskProgressLightItem, direct.TaskProgressLightItem, '类型应同一引用')
})
test('test_light_all_missing_needs_heavy_recheck', async (t) => {
setupWindow()
// light 恒判 missing2026-09-14 线上真实故障形态)→ 必须用重型端点复核,不能直接采信
mockRequest(t, () => okResponse({ items: [], missingTaskIds: [9101] }))
let heavyCalls = 0
const heavy = { items: [{ taskId: 9101, status: 'RUNNING' }], missingTaskIds: [] }
const batch = await getPollingProgressBatch<{ items: unknown[]; missingTaskIds: number[] }>(
'priceTrack',
[9101],
{ fallback: () => { heavyCalls += 1; return Promise.resolve(heavy) } },
)
assert.equal(heavyCalls, 1, '整体性 missing 结论必须复核')
assert.deepEqual(batch, heavy, '以重型端点结果为准')
})
test('test_light_all_missing_confirmed_by_heavy', async (t) => {
setupWindow()
mockRequest(t, () => okResponse({ items: [], missingTaskIds: [9102] }))
const heavy = { items: [], missingTaskIds: [9102] }
const batch = await getPollingProgressBatch<{ items: unknown[]; missingTaskIds: number[] }>(
'withdraw',
[9102],
{ fallback: () => Promise.resolve(heavy) },
)
assert.deepEqual(batch, heavy, '重型端点也判不存在时结论一致')
})
test('test_light_with_items_skips_recheck', async (t) => {
setupWindow()
mockRequest(t, () => okResponse({ items: [{ taskId: 9103, status: 'RUNNING' }], missingTaskIds: [] }))
let heavyCalls = 0
const batch = await getPollingProgressBatch<{ items: unknown[]; missingTaskIds: number[] }>(
'shopMatch',
[9103],
{ fallback: () => { heavyCalls += 1; return Promise.resolve({ items: [], missingTaskIds: [] }) } },
)
assert.equal(heavyCalls, 0, '有条目时不做复核,保持省流')
assert.equal((batch.items || []).length, 1)
})
test('test_light_claims_all_missing_semantics', () => {
assert.equal(lightClaimsAllTasksMissing({ items: [], missingTaskIds: [1] }, [1]), true)
assert.equal(lightClaimsAllTasksMissing({ items: [], missingTaskIds: [1] }, [1, 2]), false, '部分缺失不算整体性结论')
assert.equal(lightClaimsAllTasksMissing({ items: [{ taskId: 1 }], missingTaskIds: [] }, [1]), false, '有条目即不成立')
assert.equal(lightClaimsAllTasksMissing({ items: [], missingTaskIds: [] }, [1]), false)
assert.equal(lightClaimsAllTasksMissing(null, [1]), false)
assert.equal(lightClaimsAllTasksMissing({ items: [], missingTaskIds: [1] }, []), false)
})
@@ -0,0 +1,89 @@
import { test } from 'node:test'
import assert from 'node:assert/strict'
import { createRowsVersionTracker } from '../src/shared/api/task-progress-polling.ts'
test('首次见到的任务需要拉取行数据', () => {
const tracker = createRowsVersionTracker()
const need = tracker.selectTasksNeedingRows([{ taskId: 1, rowsVersion: '100' }], [1])
assert.deepEqual(need, [1])
})
test('版本未变时不再拉取,版本变化后重新拉取', () => {
const tracker = createRowsVersionTracker()
tracker.selectTasksNeedingRows([{ taskId: 1, rowsVersion: '100' }], [1])
assert.deepEqual(tracker.selectTasksNeedingRows([{ taskId: 1, rowsVersion: '100' }], [1]), [], '版本相同跳过')
assert.deepEqual(tracker.selectTasksNeedingRows([{ taskId: 1, rowsVersion: '101' }], [1]), [1], '版本变化要拉')
})
test('拿不到版本(老后端/字段缺失)时照旧拉取', () => {
const tracker = createRowsVersionTracker()
assert.deepEqual(tracker.selectTasksNeedingRows([{ taskId: 1 }], [1]), [1])
assert.deepEqual(tracker.selectTasksNeedingRows([{ taskId: 1, rowsVersion: null }], [1]), [1])
assert.deepEqual(tracker.selectTasksNeedingRows([], [7]), [7], '响应里没有该任务也拉')
})
test('多任务各自独立判断', () => {
const tracker = createRowsVersionTracker()
tracker.selectTasksNeedingRows(
[{ taskId: 1, rowsVersion: 'a' }, { taskId: 2, rowsVersion: 'b' }],
[1, 2],
)
const need = tracker.selectTasksNeedingRows(
[{ taskId: 1, rowsVersion: 'a' }, { taskId: 2, rowsVersion: 'b2' }],
[1, 2],
)
assert.deepEqual(need, [2])
})
test('forget 后该任务会被重新拉取(任务重跑场景)', () => {
const tracker = createRowsVersionTracker()
tracker.selectTasksNeedingRows([{ taskId: 5, rowsVersion: 'v1' }], [5])
tracker.forget(5)
assert.deepEqual(tracker.selectTasksNeedingRows([{ taskId: 5, rowsVersion: 'v1' }], [5]), [5])
})
test('任务状态推进(行没变)也要重新拉取', () => {
const tracker = createRowsVersionTracker()
tracker.selectTasksNeedingRows([{ taskId: 1, rowsVersion: '100', status: 'RUNNING' }], [1])
assert.deepEqual(
tracker.selectTasksNeedingRows([{ taskId: 1, rowsVersion: '100', status: 'RUNNING' }], [1]),
[],
'行版本与状态都没变:跳过',
)
assert.deepEqual(
tracker.selectTasksNeedingRows([{ taskId: 1, rowsVersion: '100', status: 'SUCCESS' }], [1]),
[1],
'行版本没变但任务已成功:必须拉取,否则界面卡在运行中',
)
})
test('结果文件状态变化也要重新拉取', () => {
const tracker = createRowsVersionTracker()
tracker.selectTasksNeedingRows([{ taskId: 1, status: 'RUNNING', fileStatus: 'RUNNING', fileReady: false }], [1])
assert.deepEqual(
tracker.selectTasksNeedingRows([{ taskId: 1, status: 'RUNNING', fileStatus: 'RUNNING', fileReady: false }], [1]),
[],
)
assert.deepEqual(
tracker.selectTasksNeedingRows([{ taskId: 1, status: 'RUNNING', fileStatus: 'SUCCESS', fileReady: true }], [1]),
[1],
'文件就绪变化要能触发拉取(下载按钮依赖它)',
)
})
test('四项信号全空视为拿不到信号:每轮都拉取', () => {
const tracker = createRowsVersionTracker()
assert.deepEqual(tracker.selectTasksNeedingRows([{ taskId: 1 }], [1]), [1])
assert.deepEqual(tracker.selectTasksNeedingRows([{ taskId: 1 }], [1]), [1], '老后端不给信号时保持旧行为')
})