Files
huangzd1997 5ea52e5291 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 全绿
2026-09-14 12:08:25 +08:00

226 lines
9.4 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { test } from 'node:test'
import assert from 'node:assert/strict'
import { http } from '../src/shared/api/http.ts'
import { API_ENDPOINTS } from '../src/shared/api/endpoints.ts'
import {
getModuleProgressLight,
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() {
;(globalThis as Record<string, unknown>).window = {
localStorage: { getItem: () => '42' },
location: { origin: 'http://localhost' },
}
}
function mockRequest(
t: Parameters<typeof test>[1] extends (t: infer T) => unknown ? T : never,
impl: (config: { url?: string; method?: string; params?: Record<string, unknown>; data?: unknown; timeout?: number }) => Promise<unknown>,
) {
t.mock.method(http, 'request', impl as never)
}
const okResponse = (data: unknown) => Promise.resolve({ data: { success: true, message: 'ok', data } })
const LIGHT_MODULES: ProgressLightModule[] = [
'publish', 'deleteBrand', 'productRisk', 'shopMatch', 'patrolDelete',
'queryAsin', 'shopDataCrawl', 'withdraw', 'priceTrack', 'similarAsin',
'appearancePatent', 'collectData',
]
test('test_light_url_module', async (t) => {
setupWindow()
const seen: { url?: string }[] = []
mockRequest(t, (config) => {
seen.push(config)
return okResponse({ items: [], missingTaskIds: [] })
})
for (const module of LIGHT_MODULES) {
const endpoint = (API_ENDPOINTS as Record<string, Record<string, string>>)[module].progressLight
assert.ok(endpoint && endpoint.endsWith('/tasks/progress/light'), `${module} 应有 progressLight 端点: ${endpoint}`)
await getModuleProgressLight(module, [1])
}
assert.equal(seen.length, LIGHT_MODULES.length)
assert.deepEqual(seen.map((c) => c.url), LIGHT_MODULES.map((m) => {
const raw = (API_ENDPOINTS as Record<string, Record<string, string>>)[m].progressLight
return `/newApi${raw}`
}), '每个模块的 light URL 与端点常量一一对应')
})
test('test_light_payload', async (t) => {
setupWindow()
let captured: { url?: string; method?: string; data?: unknown } = {}
mockRequest(t, (config) => {
captured = config
return okResponse({ items: [], missingTaskIds: [] })
})
await getModuleProgressLight('similarAsin', [5, 3, 5])
assert.equal(captured.url, '/newApi/api/similar-asin/tasks/progress/light')
assert.equal(captured.method, 'POST')
assert.deepEqual(captured.data, { taskIds: [3, 5] }, 'taskIds 去重排序后进 body')
const empty = await getModuleProgressLight('similarAsin', [])
assert.deepEqual(empty, { items: [], missingTaskIds: [] }, '空 taskIds 不发请求直接返回空结构')
})
test('test_light_response_shape', async (t) => {
setupWindow()
mockRequest(t, () => okResponse({
items: [
{ taskId: 1, status: 'RUNNING', statusCode: null, fileStatus: null, fileError: null, fileReady: false, updatedAt: '2026-09-01T10:00:00' },
{ taskId: 2, status: 'SUCCESS', statusCode: null, fileStatus: 'SUCCESS', fileError: null, fileReady: true, updatedAt: '2026-09-01T10:05:00' },
],
missingTaskIds: [],
}))
const result = await getModuleProgressLight('publish', [1, 2])
assert.equal(result.items.length, 2, 'items 数组保留')
assert.equal(result.items[0].taskId, 1)
assert.equal(result.items[0].status, 'RUNNING')
assert.equal(result.items[0].fileReady, false)
assert.equal(result.items[1].fileStatus, 'SUCCESS')
assert.equal(result.items[1].fileReady, true)
assert.equal(result.items[1].updatedAt, '2026-09-01T10:05:00')
assert.deepEqual(result.missingTaskIds, [])
})
test('test_light_fallback_flag', async (t) => {
setupWindow()
let called = 0
mockRequest(t, () => {
called += 1
return okResponse({ items: [], missingTaskIds: [] })
})
const result = await getModuleProgressLight('withdraw', [7], { useLight: false })
assert.equal(called, 0, 'useLight=false 时不发起 light 请求(调用方自行回退旧端点)')
assert.deepEqual(result, { items: [], missingTaskIds: [] })
const on = await getModuleProgressLight('withdraw', [7], { useLight: true })
assert.equal(called, 1, 'useLight=true(默认)时发起 light 请求')
assert.deepEqual(on, { items: [], missingTaskIds: [] })
})
test('test_light_missing_ids', async (t) => {
setupWindow()
mockRequest(t, () => okResponse({
items: [{ taskId: 1, status: 'RUNNING' }],
missingTaskIds: [9, 10],
}))
const result = await getModuleProgressLight('collectData', [1, 9, 10])
assert.deepEqual(result.missingTaskIds, [9, 10], 'missingTaskIds 映射为 number[]')
assert.equal(result.items.length, 1)
mockRequest(t, () => okResponse({ items: [{ taskId: 1, status: 'RUNNING' }] }))
const noKey = await getModuleProgressLight('collectData', [1], { force: true })
assert.deepEqual(noKey.missingTaskIds, [], '后端缺 missingTaskIds 字段时默认空数组')
})
test('test_light_timeout', async (t) => {
setupWindow()
let captured: { timeout?: number } = {}
mockRequest(t, (config) => {
captured = config
return okResponse({ items: [], missingTaskIds: [] })
})
await getModuleProgressLight('priceTrack', [1], { force: true })
assert.equal(captured.timeout, 10000, '进度类请求默认 10s 超时')
})
test('test_light_type_compat', async (t) => {
setupWindow()
mockRequest(t, () => okResponse({
items: [{ taskId: 1, status: 'PENDING' }],
missingTaskIds: [],
}))
const result = await getModuleProgressLight('shopMatch', [1], { force: true })
const batch: TaskProgressBatchVo<TaskProgressLightItem> = result
assert.ok('items' in batch && 'missingTaskIds' in batch, '结构与现有 TaskProgressBatchVo 兼容')
assert.equal(batch.items[0].taskId, 1)
assert.equal(batch.items[0].status, 'PENDING')
})
test('test_light_snapshot', async (t) => {
setupWindow()
const calls: Array<{ url?: string; method?: string; params?: Record<string, unknown>; data?: unknown }> = []
mockRequest(t, (config) => {
calls.push(config)
return okResponse({ items: [{ taskId: 1, status: 'RUNNING', fileReady: false }], missingTaskIds: [] })
})
await getModuleProgressLight('appearancePatent', [1], { force: true })
await getModuleProgressLight('deleteBrand', [2], { force: true })
assert.equal(calls[0].url, '/newApi/api/appearance-patent/tasks/progress/light')
assert.equal(calls[0].method, 'POST')
assert.deepEqual(calls[0].data, { taskIds: [1] })
assert.equal(calls[1].url, '/newApi/api/delete-brand/tasks/progress/light')
assert.deepEqual(calls[1].data, { taskIds: [2] })
})
test('test_light_reexport', async () => {
setupWindow()
const javaModules = await import('../src/shared/api/java-modules.ts')
const direct = await import('../src/shared/api/progress-light.ts')
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)
})