feat(任务派发): 客户端兜底拉取页面未推送成功的任务 + 修店铺匹配定时任务误杀

问题:任务派发链路的"推送"只存在于页面里(Java 解析落库 PENDING → activate →
pywebview 桥 enqueue_json 推本机队列)。只解析没点启动、推送前关页面、在纯浏览器
打开,任务都会停在 PENDING,2 小时后被 StaleTaskRepairService 标失败
(「任务长期未被领取,已自动失败」,09-11 生产清理过 263 条同画像)。

- 服务端新增 GET /api/tasks/pull-pending(TaskClientPullController,身份从 JWT 取,
  不接受 user_id 参数):只挑创建超 5 分钟仍 PENDING 的本用户任务,逐条条件更新认领
  (PENDING→RUNNING + 接管 owner_instance_id)——与页面 activate 同一谓词,天然互斥,
  不会重复执行;认领后组装不出载荷则标 FAILED,不留 RUNNING 孤儿
- payload 由各业务模块实现 ClientTaskPullSpi 组装(task 侧不 import 业务模块,同 G5):
  首批 SIMILAR_ASIN / COLLECT_DATA / APPEARANCE_PATENT——这三个 Python 消费端会自行
  回拉明细,故载荷极简、客户端零模块知识;开关 aiimage.client-task-pull.enabled 默认 false
- 防双执行:三处 activate 由「非终态即可」收紧为只认 PENDING,未命中抛
  「任务已在执行中(可能已由客户端自动接管),无需重复启动」(顺带堵住整行 updateById
  把认领写入的 owner 覆盖回去的竞态);两个前端页 activate 失败即提示并停止入队
- 客户端:amazon/main.py 新增 pending_task_pull_worker,启动点挂在 app_client/main.py
  的 start_task_monitor(独立入口的 worker 线上并不生效);开关 pending_pull_enabled 用
  getattr 读取,避免 test/ 下的旧 config 缺键导致整包导入失败
- 同批修:StaleTaskRepairService 的 SCHEDULED 分支改按 scheduled_at + 120min 判死
  (原按 updated_at 会必杀排期 >2h 的店铺匹配定时任务,而 activate 又被 scheduledAt-90s 挡住)
- 测试:TaskClientPullServiceTest / StaleTaskRepairServiceTest / CollectDataTaskPullSpiImplTest /
  CollectDataActivateGuardTest 共 20 例;客户端 pending_task_pull_worker 7 例并更新启动顺序契约测试
This commit is contained in:
2026-09-14 16:23:34 +08:00
parent 8cab9d4bad
commit 24c5a09c7f
18 changed files with 1254 additions and 16 deletions
@@ -456,7 +456,15 @@ async function pushToPythonQueue() {
nonEmptyArrayKeys: ['groups'],
})
if (!(await passGuard(guard))) return
await activateAppearancePatentTask(taskId)
// 任务可能已被客户端「兜底拉取」领走(页面长时间没点启动):服务端会拒绝 activate,
// 此时必须直接返回,不能再入队,否则同一任务会被执行两次
try {
await activateAppearancePatentTask(taskId)
} catch (activateError) {
ElMessage.warning(activateError instanceof Error ? activateError.message : '任务已在执行中,无需重复启动')
await Promise.all([loadDashboard(), loadHistory({ force: true })])
return
}
const result = await api.enqueue_json(payload)
if (!result?.success) {
ElMessage.error(result?.error || '启动失败')
@@ -533,7 +533,15 @@ async function pushToPythonQueue() {
requiredDataKeys: ['taskId', 'aliprice_usename', 'aliprice_pwd'],
})
if (!(await passGuard(guard))) return
await activateSimilarAsinTask(taskId)
// 任务可能已被客户端「兜底拉取」领走(页面长时间没点启动):服务端会拒绝 activate,
// 此时必须直接返回,不能再入队,否则同一任务会被执行两次
try {
await activateSimilarAsinTask(taskId)
} catch (activateError) {
ElMessage.warning(activateError instanceof Error ? activateError.message : '任务已在执行中,无需重复启动')
await Promise.all([loadDashboard(), loadHistory({ force: true })])
return
}
const result = await api.enqueue_json(payload)
if (!result?.success) {
ElMessage.error(result?.error || '启动失败')