feat(任务判死): 心跳正常但 180 分钟无结果上报的二次判死线(13 模块)+ 同期待发改动

判死线(治 28131 型「主线程卡死、心跳线程照发」):
- 判据改看 biz_task_scope_state.last_chunk_at(HTTP 心跳不刷新它);从未上报跳过不判
- 中央线覆盖 DELETE_BRAND/PRODUCT_RISK_RESOLVE/PRICE_TRACK/SHOP_MATCH/PATROL_DELETE/QUERY_ASIN/WITHDRAW
- 自带线接入 COLLECT_DATA/SIMILAR_ASIN/APPEARANCE_PATENT/SHOP_DATA_CRAWL/PUBLISH/BRAND
- 客户端心跳带处理位置 progressText,判死文案含最后位置;no-result-upload-timeout-minutes 默认 180(0 关闭)

同期带上另一工作流的待发改动:跟价换 IP 重试、品牌检测重试上限与 LLM 并发下调、
教程包后台管理页与 V126 迁移、admin-vue 教程记录页。
This commit is contained in:
2026-09-14 17:56:20 +08:00
parent b54f72d3f6
commit 52b55df7b2
36 changed files with 1538 additions and 57 deletions
@@ -0,0 +1,79 @@
import { expect, test, type Page } from '@playwright/test'
// 教程管理页验收(module 记录与版本):列表新增「版本号」列 + 上传时间列,
// 默认按上传时间降序;版本号/上传时间表头可点击切换升降序;空版本历史行沉底。
// 依赖 scripts/mock-admin-server.mjs 的教程包夹具(4 条,其中 1 条无版本号、1 条 09-01 历史行)。
async function openTutorial(page: Page) {
await page.goto('/admin-vue/records/tutorial')
await expect(page.locator('.admin-topbar h1')).toHaveText('教程管理')
await expect(page.locator('.panel-box tbody tr').first()).toBeVisible()
}
const versionTexts = (page: Page) =>
page.locator('.panel-box tbody tr td:nth-child(3)').allTextContents()
const fileTexts = (page: Page) =>
page.locator('.panel-box tbody tr td:nth-child(2) .file-name').allTextContents()
test('test_tutorial_list_default_desc_by_upload_time', async ({ page }) => {
await openTutorial(page)
// 表头:版本号、上传时间(均带排序标记)
await expect(page.locator('.sort-version')).toHaveText(/版本号/)
await expect(page.locator('.sort-time')).toHaveText(/上传时间/)
// 默认排序状态:上传时间降序(▼),版本号未激活(▲▼)
await expect(page.locator('.sort-time .sort-mark')).toHaveText('▼')
await expect(page.locator('.sort-version .sort-mark')).toHaveText('▲▼')
// 默认按上传时间降序:09-14 → 09-12 → 09-10 → 09-01;空版本行沉底
expect(await versionTexts(page)).toEqual(['v3.10.0', 'v3.2.0', 'v3.1.0', '—'])
expect(await fileTexts(page)).toEqual([
'数富AI-教学客户端-v4.zip',
'数富AI-教学客户端-v3.zip',
'数富AI-教学客户端-v2.zip',
'数富AI-教学客户端.zip',
])
// 当前生效 = 最新上传(夹具里 09-14 那条),不随列表排序变化
await expect(page.locator('.tag-active')).toHaveCount(1)
await expect(page.locator('.tag-active').locator('xpath=..')).toHaveText(/数富AI-教学客户端-v4\.zip/)
})
test('test_tutorial_sort_toggle_by_version_and_time', async ({ page }) => {
await openTutorial(page)
// 点「版本号」:首次为降序(v3.10.0 按自然序大于 v3.2.0),空版本行仍在末尾
await page.locator('.sort-version').click()
await expect(page.locator('.sort-version .sort-mark')).toHaveText('▼')
expect(await versionTexts(page)).toEqual(['v3.10.0', 'v3.2.0', 'v3.1.0', '—'])
// 再点一次切升序
await page.locator('.sort-version').click()
await expect(page.locator('.sort-version .sort-mark')).toHaveText('▲')
expect(await versionTexts(page)).toEqual(['v3.1.0', 'v3.2.0', 'v3.10.0', '—'])
// 点回「上传时间」:切换字段时回到降序默认
await page.locator('.sort-time').click()
await expect(page.locator('.sort-time .sort-mark')).toHaveText('▼')
await expect(page.locator('.sort-version .sort-mark')).toHaveText('▲▼')
expect(await fileTexts(page)).toEqual([
'数富AI-教学客户端-v4.zip',
'数富AI-教学客户端-v3.zip',
'数富AI-教学客户端-v2.zip',
'数富AI-教学客户端.zip',
])
})
test('test_tutorial_upload_dialog_has_version_field', async ({ page }) => {
await openTutorial(page)
await page.getByRole('button', { name: '上传教程包' }).click()
const dialog = page.locator('.el-dialog')
await expect(dialog).toBeVisible()
await expect(dialog.locator('.el-form-item').first()).toContainText('版本号')
await expect(dialog.locator('input').first()).toHaveAttribute('maxlength', '64')
// 不选文件直接提交:提示仍以 zip 为必填(版本号可空)
await dialog.getByRole('button', { name: '上传教程包' }).click()
await expect(dialog.locator('.el-alert')).toContainText('请选择 zip 压缩包')
})
@@ -142,6 +142,15 @@ function json(res, payload, status = 200) {
res.end(body)
}
/* ===================== 教程包夹具(教程管理页:版本号列 + 上传时间排序验收用) ===================== */
// 故意乱序给出,且含一条无版本号的历史行:页面默认应按上传时间降序、空版本行沉底。
const TUTORIAL_PACKAGES = [
{ id: 3, file_name: '数富AI-教学客户端-v3.zip', version: 'v3.2.0', object_key: 'tutorial/20260912090000-数富AI-教学客户端-v3.zip', file_size: 1048576, file_url: 'https://oss.aishufu.top/client/tutorial/t3.zip', created_at: '2026-09-12 09:00' },
{ id: 1, file_name: '数富AI-教学客户端.zip', version: '', object_key: 'tutorial/数富AI-教学客户端.zip', file_size: 0, file_url: 'https://oss.aishufu.top/client/tutorial/legacy.zip', created_at: '2026-09-01 08:00' },
{ id: 4, file_name: '数富AI-教学客户端-v4.zip', version: 'v3.10.0', object_key: 'tutorial/20260914103000-数富AI-教学客户端-v4.zip', file_size: 2097152, file_url: 'https://oss.aishufu.top/client/tutorial/t4.zip', created_at: '2026-09-14 10:30' },
{ id: 2, file_name: '数富AI-教学客户端-v2.zip', version: 'v3.1.0', object_key: 'tutorial/20260910120000-数富AI-教学客户端-v2.zip', file_size: 524288, file_url: 'https://oss.aishufu.top/client/tutorial/t2.zip', created_at: '2026-09-10 12:00' },
]
/* ===================== 站内通知夹具(铃铛面板:搜索/按天分组/分页验收用) ===================== */
const NOTIFICATIONS = [
...[6, 5, 4, 3, 2, 1].map((seq) => notif(seq, '2026-09-13', 22, seq, seq <= 3)),
@@ -243,6 +252,11 @@ const server = createServer((req, res) => {
if (url === '/api/admin/notifications') {
return json(res, notificationPage(new URL(req.url || '/', 'http://127.0.0.1').searchParams))
}
if (url === '/api/admin/tutorials') {
// 与 Java 侧同契约:created_at 降序返回(页面"当前生效"取第一条)。
const items = [...TUTORIAL_PACKAGES].sort((a, b) => (a.created_at < b.created_at ? 1 : -1))
return json(res, { success: true, data: { items } })
}
if (url.startsWith('/api/')) {
return json(res, { success: true, data: { items: [], total: 0 } })
}
@@ -2,7 +2,8 @@
import { formatDateTime } from '@/utils/datetime'
/** 教程管理页:工具台首页「立即下载教程」的包体管理(上传新包 / 列表 / 下载 / 删除)。
* 上传走浏览器直传 MinIOpresign → PUT 带进度 → confirm 落库),与软件版本管理页同一套链路;
* 工具台始终下载"最新上传"的包(列表第一条即当前生效)。 */
* 上传时可填版本号(仅展示与排序用);工具台始终下载"最新上传"的包(不随列表排序变化)。
* 列表默认按上传时间降序,「版本号」「上传时间」表头可点击切换升降序。 */
import { computed, onMounted, ref, watch } from 'vue'
import OldPagination from '@/components/OldPagination.vue'
import { ElMessage } from 'element-plus'
@@ -22,12 +23,64 @@ const filteredItems = computed(() => {
/** 全站分页统一:客户端分页(10/20/50/100)。 */
const page = ref(1)
const pageSize = ref(10)
const pagedItems = computed(() => filteredItems.value.slice((page.value - 1) * pageSize.value, page.value * pageSize.value))
/** 排序:默认按上传时间降序(最新上传在最前,与后端返回顺序一致);点表头切换升降序。 */
type SortKey = 'version' | 'createdAt'
const sortKey = ref<SortKey>('createdAt')
const sortAsc = ref(false)
function toggleSort(key: SortKey) {
if (sortKey.value === key) {
sortAsc.value = !sortAsc.value
} else {
sortKey.value = key
sortAsc.value = false
}
page.value = 1
}
/** 排序标记:未激活 ▲▼,激活时只留方向箭头(比 ⇅/↕ 字形支持好,避免 Windows 缺字形显示方框)。 */
function sortMark(key: SortKey): string {
if (sortKey.value !== key) return '▲▼'
return sortAsc.value ? '▲' : '▼'
}
function compareText(a: string, b: string): number {
return a.localeCompare(b, 'zh-Hans-CN', { numeric: true, sensitivity: 'base' })
}
const sortedItems = computed(() => {
const rows = [...filteredItems.value]
rows.sort((a, b) => {
// 版本号为空的历史行始终排在末尾,避免切换排序时"无版本"占满首页。
if (sortKey.value === 'version') {
const av = a.version
const bv = b.version
if (!av || !bv) {
if (!av && !bv) return 0
return av ? -1 : 1
}
const diff = compareText(av, bv)
return sortAsc.value ? diff : -diff
}
const at = a.createdAt
const bt = b.createdAt
if (!at || !bt) {
if (!at && !bt) return 0
return at ? -1 : 1
}
const diff = compareText(at, bt)
return sortAsc.value ? diff : -diff
})
return rows
})
const pagedItems = computed(() => sortedItems.value.slice((page.value - 1) * pageSize.value, page.value * pageSize.value))
function changePage(p: number) { page.value = p }
function changeSize(size: number) { pageSize.value = size; page.value = 1 }
// 搜索导致数据收缩时回钳页码,避免停在空页。
watch(filteredItems, () => {
page.value = Math.min(page.value, Math.max(1, Math.ceil(filteredItems.value.length / pageSize.value)))
// 搜索/排序导致数据收缩时回钳页码,避免停在空页。
watch(sortedItems, () => {
page.value = Math.min(page.value, Math.max(1, Math.ceil(sortedItems.value.length / pageSize.value)))
})
/** 当前生效包 = 列表第一条(工具台下发的就是它)。 */
@@ -80,6 +133,7 @@ async function removeOne(row: TutorialPackageItem) {
const uploadVisible = ref(false)
const uploading = ref(false)
const uploadPercent = ref(0)
const newVersion = ref('')
const pickedFile = ref<File | null>(null)
/** 弹窗内成功/失败文案留驻(对齐软件版本页交互)。 */
const uploadMsg = ref('')
@@ -103,6 +157,7 @@ function onFileChange(file: File) {
function openUpload() {
uploadMsg.value = ''
uploadMsgOk.value = false
newVersion.value = ''
pickedFile.value = null
uploadVisible.value = true
}
@@ -126,12 +181,13 @@ async function submitUpload() {
uploading.value = true
try {
// 浏览器直传 MinIOpresign → PUT(进度条)→ confirm 落库。
await uploadTutorialPackage(pickedFile.value, pickedFile.value.name, (p) => {
await uploadTutorialPackage(pickedFile.value, pickedFile.value.name, newVersion.value.trim(), (p) => {
uploadPercent.value = p
uploadMsg.value = p >= 100 ? '上传完成,正在登记教程包...' : `正在上传:${p}%`
})
uploadMsg.value = '上传成功,工具台「立即下载教程」已切换为该包'
uploadMsgOk.value = true
newVersion.value = ''
pickedFile.value = null
uploadPercent.value = 0
load()
@@ -165,8 +221,13 @@ onMounted(load)
<input type="checkbox" :checked="pagedItems.length > 0 && pagedItems.every((r) => selectedIds.has(r.id))" @change="toggleSelectAllPage" :disabled="!pagedItems.length" />
</th>
<th style="width: 260px">文件名</th>
<th style="width: 110px">
<button class="sort-th sort-version" type="button" @click="toggleSort('version')">版本号<span class="sort-mark">{{ sortMark('version') }}</span></button>
</th>
<th style="width: 110px">大小</th>
<th style="width: 150px">上传时间</th>
<th style="width: 150px">
<button class="sort-th sort-time" type="button" @click="toggleSort('createdAt')">上传时间<span class="sort-mark">{{ sortMark('createdAt') }}</span></button>
</th>
<th>下载链接</th>
<th style="width: 170px">操作</th>
</tr>
@@ -181,6 +242,10 @@ onMounted(load)
<span class="file-name" :title="row.fileName">{{ row.fileName }}</span>
<span v-if="row.id === activeId" class="tag-active">当前生效</span>
</td>
<td>
<span v-if="row.version" class="version-cell" :title="row.version">{{ row.version }}</span>
<span v-else class="dim"></span>
</td>
<td>{{ formatFileSize(row.fileSize) }}</td>
<td>{{ formatDateTime(row.createdAt) }}</td>
<td>
@@ -195,10 +260,10 @@ onMounted(load)
</tr>
</template>
<tr v-else-if="loading">
<td colspan="6" class="empty-tip">加载中...</td>
<td colspan="7" class="empty-tip">加载中...</td>
</tr>
<tr v-else>
<td colspan="6" class="empty-tip">{{ keyword ? '暂无匹配教程包' : '暂无教程包记录' }}</td>
<td colspan="7" class="empty-tip">{{ keyword ? '暂无匹配教程包' : '暂无教程包记录' }}</td>
</tr>
</tbody>
</table>
@@ -209,6 +274,10 @@ onMounted(load)
<el-dialog v-model="uploadVisible" title="上传教程包" width="520px">
<p class="upload-desc">上传教程 ZIP 包后工具台首页立即下载教程将以下载该包为准以最新上传的为主</p>
<el-form label-width="110px">
<el-form-item label="版本号">
<el-input v-model="newVersion" placeholder="例如:v2026.09 或留空" maxlength="64" />
<p class="zip-hint">仅作展示与排序用可留空不影响工具台按最新上传下载</p>
</el-form-item>
<el-form-item label="ZIP 压缩包" required>
<el-upload :auto-upload="false" :limit="1" accept=".zip" :on-change="(file: any) => onFileChange(file.raw)" :on-remove="() => (pickedFile = null)">
<el-button>选择文件</el-button>
@@ -368,6 +437,46 @@ h3 {
text-overflow: ellipsis;
vertical-align: middle;
}
/* 表头排序:按钮铺满单元格,仅 hover 时加深字色,保持旧版表头观感。 */
.sort-th {
display: inline-flex;
align-items: center;
gap: 4px;
width: 100%;
padding: 0;
border: 0;
background: none;
color: inherit;
font-family: inherit;
font-size: inherit;
font-weight: inherit;
letter-spacing: inherit;
text-align: left;
cursor: pointer;
}
.sort-th:hover {
color: #2f5d8b;
}
.sort-mark {
color: #8293a5;
font-size: 12px;
}
.sort-th:hover .sort-mark {
color: #5f85ad;
}
.sort-version {
width: 110px;
}
.sort-time {
width: 150px;
}
.version-cell {
display: inline-block;
max-width: 100%;
overflow: hidden;
text-overflow: ellipsis;
vertical-align: middle;
}
.tag-active {
display: inline-block;
margin-left: 6px;
@@ -22,11 +22,12 @@ export interface TutorialUploadTarget {
objectKey: string
uploadUrl: string
fileUrl: string
version: string
}
/** 第一步:向后端申请直传 PUT 预签名地址(登录会话签发,3 分钟有效)。 */
export async function requestTutorialPresign(fileName: string): Promise<TutorialUploadTarget> {
const { data } = await http.post<unknown>(TUTORIAL_PRESIGN_ENDPOINT, null, { params: { file_name: fileName } })
export async function requestTutorialPresign(fileName: string, version = ''): Promise<TutorialUploadTarget> {
const { data } = await http.post<unknown>(TUTORIAL_PRESIGN_ENDPOINT, null, { params: { file_name: fileName, version } })
const core = (unwrap(data) ?? {}) as Record<string, unknown>
const uploadUrl = typeof core.upload_url === 'string' ? core.upload_url : ''
const objectKey = typeof core.object_key === 'string' ? core.object_key : ''
@@ -37,13 +38,14 @@ export async function requestTutorialPresign(fileName: string): Promise<Tutorial
objectKey,
uploadUrl,
fileUrl: typeof core.file_url === 'string' ? core.file_url : '',
version: typeof core.version === 'string' ? core.version : version.trim(),
}
}
/** 第三步:直传完成后通知后端校验对象并写入记录(data.item 为新记录行)。 */
export async function confirmTutorialPackage(objectKey: string, fileName: string): Promise<TutorialPackageItem | null> {
export async function confirmTutorialPackage(objectKey: string, fileName: string, version = ''): Promise<TutorialPackageItem | null> {
const { data } = await http.post<unknown>(TUTORIAL_CONFIRM_ENDPOINT, null, {
params: { object_key: objectKey, file_name: fileName },
params: { object_key: objectKey, file_name: fileName, version },
})
return parseTutorialPackageUpload(data)
}
@@ -63,9 +65,10 @@ export async function deleteTutorialPackages(ids: number[]): Promise<number> {
export async function uploadTutorialPackage(
file: Blob,
fileName: string,
version = '',
onProgress?: (percent: number) => void,
): Promise<TutorialPackageItem | null> {
const target = await requestTutorialPresign(fileName)
const target = await requestTutorialPresign(fileName, version)
await directPut.put(target.uploadUrl, file, {
headers: { 'Content-Type': 'application/octet-stream' },
onUploadProgress: (event) => {
@@ -74,5 +77,5 @@ export async function uploadTutorialPackage(
onProgress(total > 0 ? Math.min(Math.round((event.loaded / total) * 100), 100) : 0)
},
})
return confirmTutorialPackage(target.objectKey, fileName)
return confirmTutorialPackage(target.objectKey, fileName, target.version)
}
@@ -4,6 +4,8 @@
export interface TutorialPackageItem {
id: number
fileName: string
/** 版本号(上传时填写,V126 之前的历史行为空串) */
version: string
objectKey: string
fileSize: number
fileUrl: string
@@ -19,6 +19,7 @@ export function toTutorialPackageItem(raw: unknown): TutorialPackageItem | null
return {
id,
fileName: text(r.file_name ?? r.fileName),
version: text(r.version),
objectKey: text(r.object_key ?? r.objectKey),
fileSize: numberOrNull(r.file_size ?? r.fileSize) ?? 0,
fileUrl: text(r.file_url ?? r.fileUrl),
@@ -14,16 +14,23 @@ test('align_tutorial_page_registered', () => {
test('align_tutorial_page_wiring', () => {
const page = readSource('src/pages/records/RecordsTutorialPage.vue')
// 上传入口:选择 zip → 直传(进度条)→ 成功提示留驻弹窗。
// 上传入口:版本号(可空)→ 选择 zip → 直传(进度条)→ 成功提示留驻弹窗。
assert.match(page, /上传教程包/, '存在上传入口按钮')
assert.match(page, /uploadTutorialPackage/, '上传走教程包直传链路')
assert.match(page, /label="版本号"/, '上传弹窗提供版本号输入')
assert.match(page, /newVersion\.value\.trim\(\)/, '版本号去空白后随上传提交')
assert.match(page, /请选择 zip 压缩包/, '未选文件时提示')
assert.match(page, /仅支持 \.zip 格式/, '格式校验提示')
assert.match(page, /上传成功,工具台「立即下载教程」已切换为该包/, '成功提示说明生效位置')
assert.match(page, /上传完成,正在登记教程包/, '进度文案')
// 列表:当前生效标记 + 下载 + 删除 + 空态。
// 列表:版本号列 + 上传时间列 + 当前生效标记 + 下载 + 删除 + 空态。
assert.match(page, /当前生效/, '最新上传的包标记当前生效')
assert.match(page, /formatFileSize/, '展示包体大小')
assert.match(page, /row\.version/, '展示版本号列')
assert.match(page, /toggleSort\('version'\)/, '版本号表头可点击排序')
assert.match(page, /toggleSort\('createdAt'\)/, '上传时间表头可点击排序')
assert.match(page, /const sortKey = ref<SortKey>\('createdAt'\)/, '默认排序字段为上传时间')
assert.match(page, /const sortAsc = ref\(false\)/, '默认降序')
assert.match(page, /下载/, '操作列提供下载')
assert.match(page, /<a v-if="row\.fileUrl" class="btn btn-sm dl-btn"[^>]*download/, '下载链接带 download 强制下载')
assert.match(page, /确认删除选中的/, '批量删除二次确认')
@@ -37,7 +44,8 @@ test('align_tutorial_api_contract', () => {
assert.match(api, /\$\{TUTORIAL_UPLOAD_ENDPOINT\}\/presign/, '直传预签名端点')
assert.match(api, /\$\{TUTORIAL_UPLOAD_ENDPOINT\}\/confirm/, '确认端点')
assert.match(api, /\$\{TUTORIAL_UPLOAD_ENDPOINT\}\/delete/, '删除端点')
assert.match(api, /params: \{ object_key: objectKey, file_name: fileName \}/, '确认回传对象 key 与文件名')
assert.match(api, /params: \{ file_name: fileName, version \}/, '预签名回传文件名与版本号')
assert.match(api, /params: \{ object_key: objectKey, file_name: fileName, version \}/, '确认回传对象 key、文件名与版本号')
assert.match(api, /withCredentials: false/, '直传实例不挂会话拦截器')
})
@@ -46,4 +54,5 @@ test('align_tutorial_model_parsers', () => {
assert.match(model, /r\.file_name \?\? r\.fileName/, '兼容 snake/camel 文件名')
assert.match(model, /r\.file_url \?\? r\.fileUrl/, '兼容 snake/camel 下载链接')
assert.match(model, /r\.file_size \?\? r\.fileSize/, '兼容 snake/camel 文件大小')
assert.match(model, /version: text\(r\.version\)/, '解析版本号(缺失为空串,兼容历史行)')
})