B6/B7: failed终态前端可见 + SSE事件链核实
B6 错误态: - 后端已加 TaskStatus.FAILED + 异常区分重试中/耗尽(014迁移ALTER status ENUM) - 前端 TaskStatus 类型加 failed - RecentTasksBar/history STATUS_LABEL 补 failed 标签 - history 页 HISTORY_STATUSES 纳入 failed(否则失败任务前端找不到) - FatalErrorBanner + useSse error 不再静默丢弃 B7: 核实 SSE(ticket换JWT) + 事件消费链无缺口,标绿 tsc EXIT=0
This commit is contained in:
34
backend/alembic/versions/014_task_status_failed.py
Normal file
34
backend/alembic/versions/014_task_status_failed.py
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
"""014 generation_tasks.status ENUM 补 failed 终态(B6 错误态)
|
||||||
|
|
||||||
|
Revision ID: 014
|
||||||
|
Revises: 013
|
||||||
|
Create Date: 2026-06-16
|
||||||
|
|
||||||
|
status 是 MySQL ENUM,原7值无失败终态。Celery生成彻底失败(重试耗尽)时
|
||||||
|
要落 failed,区分"没跑(pending)"和"跑挂了(failed)"。不补 ENUM 会 Data truncated。
|
||||||
|
"""
|
||||||
|
from alembic import op
|
||||||
|
|
||||||
|
revision = "014"
|
||||||
|
down_revision = "013"
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
_ENUM_WITH_FAILED = (
|
||||||
|
"ENUM('pending','generating','pending_selection','pending_review',"
|
||||||
|
"'approved','rejected','archived','failed') NOT NULL DEFAULT 'pending'"
|
||||||
|
)
|
||||||
|
_ENUM_ORIG = (
|
||||||
|
"ENUM('pending','generating','pending_selection','pending_review',"
|
||||||
|
"'approved','rejected','archived') NOT NULL DEFAULT 'pending'"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade():
|
||||||
|
op.execute(f"ALTER TABLE generation_tasks MODIFY COLUMN status {_ENUM_WITH_FAILED}")
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade():
|
||||||
|
# 回退前先把 failed 行归位到 pending,避免越界
|
||||||
|
op.execute("UPDATE generation_tasks SET status='pending' WHERE status='failed'")
|
||||||
|
op.execute(f"ALTER TABLE generation_tasks MODIFY COLUMN status {_ENUM_ORIG}")
|
||||||
@@ -23,6 +23,7 @@ class TaskStatus(str, Enum):
|
|||||||
APPROVED = "approved"
|
APPROVED = "approved"
|
||||||
REJECTED = "rejected"
|
REJECTED = "rejected"
|
||||||
ARCHIVED = "archived"
|
ARCHIVED = "archived"
|
||||||
|
FAILED = "failed" # 生成彻底失败终态(重试耗尽):区分"没跑"和"跑挂了"(B6)
|
||||||
|
|
||||||
|
|
||||||
# ── 审核状态(generation_tasks 平铺字段)──────────────────
|
# ── 审核状态(generation_tasks 平铺字段)──────────────────
|
||||||
|
|||||||
@@ -140,16 +140,21 @@ def run_generation_pipeline(self, task_id: int) -> dict:
|
|||||||
|
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
logger.error("run_generation_pipeline failed: task_id=%s err=%s", task_id, exc)
|
logger.error("run_generation_pipeline failed: task_id=%s err=%s", task_id, exc)
|
||||||
|
# 重试耗尽才落 FAILED 终态;还能重试则保持 GENERATING(别伪装成"待开始")。
|
||||||
|
# B6:失败要可见,区分"没跑(pending)"和"跑挂了(failed)"。
|
||||||
|
exhausted = self.request.retries >= self.max_retries
|
||||||
try:
|
try:
|
||||||
from app.models.task import GenerationTask as GT
|
from app.models.task import GenerationTask as GT
|
||||||
from app.constants.enums import TaskStatus as TS
|
from app.constants.enums import TaskStatus as TS
|
||||||
t = db.query(GT).filter(GT.id == task_id).first()
|
t = db.query(GT).filter(GT.id == task_id).first()
|
||||||
if t:
|
if t:
|
||||||
t.status = TS.PENDING
|
t.status = TS.FAILED if exhausted else TS.GENERATING
|
||||||
db.commit()
|
db.commit()
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
_push_event_sync(task_id, workspace_id, "error", {"code": 50001, "message": str(exc)}, seq + 1)
|
_push_event_sync(task_id, workspace_id, "error", {"code": 50001, "message": str(exc)}, seq + 1)
|
||||||
|
if exhausted:
|
||||||
|
return {"task_id": task_id, "status": "failed", "error": str(exc)}
|
||||||
raise self.retry(exc=exc)
|
raise self.retry(exc=exc)
|
||||||
finally:
|
finally:
|
||||||
db.close()
|
db.close()
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ const STATUS_LABEL: Record<string, { text: string; color: string }> = {
|
|||||||
approved: { text: '已通过', color: 'text-green-600 bg-green-50' },
|
approved: { text: '已通过', color: 'text-green-600 bg-green-50' },
|
||||||
archived: { text: '已归档', color: 'text-gray-500 bg-gray-100' },
|
archived: { text: '已归档', color: 'text-gray-500 bg-gray-100' },
|
||||||
rejected: { text: '被打回', color: 'text-red-500 bg-red-50' },
|
rejected: { text: '被打回', color: 'text-red-500 bg-red-50' },
|
||||||
|
failed: { text: '生成失败', color: 'text-red-600 bg-red-50' },
|
||||||
};
|
};
|
||||||
|
|
||||||
// --- DownloadButton --- 触发打包→轮询→下载 zip
|
// --- DownloadButton --- 触发打包→轮询→下载 zip
|
||||||
|
|||||||
@@ -11,9 +11,9 @@ import { getErrorAction } from '@/types/errors';
|
|||||||
import { clsx } from 'clsx';
|
import { clsx } from 'clsx';
|
||||||
import { HistoryTable, HistorySkeleton, Pagination } from './components';
|
import { HistoryTable, HistorySkeleton, Pagination } from './components';
|
||||||
|
|
||||||
const HISTORY_STATUSES = ['approved', 'archived', 'rejected'];
|
const HISTORY_STATUSES = ['approved', 'archived', 'rejected', 'failed'];
|
||||||
const STATUS_LABELS: Record<string, string> = {
|
const STATUS_LABELS: Record<string, string> = {
|
||||||
approved: '已通过', archived: '已归档', rejected: '被打回',
|
approved: '已通过', archived: '已归档', rejected: '被打回', failed: '生成失败',
|
||||||
};
|
};
|
||||||
const PAGE_SIZE = 20;
|
const PAGE_SIZE = 20;
|
||||||
|
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import { ImageProgressCardSkeleton } from '@/components/tasks/ImageProgressCard'
|
|||||||
import { ImageProgressHeader } from '@/components/tasks/ImageProgressHeader';
|
import { ImageProgressHeader } from '@/components/tasks/ImageProgressHeader';
|
||||||
import { ImageActionBar } from '@/components/tasks/ImageActionBar';
|
import { ImageActionBar } from '@/components/tasks/ImageActionBar';
|
||||||
import { SseStatusBar } from '@/components/tasks/SseStatusBar';
|
import { SseStatusBar } from '@/components/tasks/SseStatusBar';
|
||||||
|
import { FatalErrorBanner } from '@/components/tasks/FatalErrorBanner';
|
||||||
import { useSse } from '@/hooks/useSse';
|
import { useSse } from '@/hooks/useSse';
|
||||||
import { useGenerationStore } from '@/stores/generationStore';
|
import { useGenerationStore } from '@/stores/generationStore';
|
||||||
import { useTaskStore } from '@/stores/taskStore';
|
import { useTaskStore } from '@/stores/taskStore';
|
||||||
@@ -50,6 +51,7 @@ export default function ImageSelectionPage() {
|
|||||||
)}
|
)}
|
||||||
<SseStatusBar status={connectionStatus} attempt={reconnectAttempt} />
|
<SseStatusBar status={connectionStatus} attempt={reconnectAttempt} />
|
||||||
<div className="p-6 flex-1 overflow-auto">
|
<div className="p-6 flex-1 overflow-auto">
|
||||||
|
<FatalErrorBanner />
|
||||||
<ImageProgressHeader imageTotal={imageTotal} imageDone={imageDone} pendingCount={pendingCount} />
|
<ImageProgressHeader imageTotal={imageTotal} imageDone={imageDone} pendingCount={pendingCount} />
|
||||||
{/* 图片网格(谁好谁先冒) */}
|
{/* 图片网格(谁好谁先冒) */}
|
||||||
<div className="grid grid-cols-3 xl:grid-cols-5 gap-3">
|
<div className="grid grid-cols-3 xl:grid-cols-5 gap-3">
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { ProgressBar } from '@/components/layout/ProgressBar';
|
|||||||
import { FlywheelBannerFromSse } from '@/components/FlywheelBanner';
|
import { FlywheelBannerFromSse } from '@/components/FlywheelBanner';
|
||||||
import { TextCandidateCard, TextCandidateCardSkeleton } from '@/components/tasks/TextCandidateCard';
|
import { TextCandidateCard, TextCandidateCardSkeleton } from '@/components/tasks/TextCandidateCard';
|
||||||
import { SseStatusBar } from '@/components/tasks/SseStatusBar';
|
import { SseStatusBar } from '@/components/tasks/SseStatusBar';
|
||||||
|
import { FatalErrorBanner } from '@/components/tasks/FatalErrorBanner';
|
||||||
import { useSse } from '@/hooks/useSse';
|
import { useSse } from '@/hooks/useSse';
|
||||||
import { useGenerationStore } from '@/stores/generationStore';
|
import { useGenerationStore } from '@/stores/generationStore';
|
||||||
import { useTaskStore } from '@/stores/taskStore';
|
import { useTaskStore } from '@/stores/taskStore';
|
||||||
@@ -50,6 +51,9 @@ export default function TextSelectionPage() {
|
|||||||
<SseStatusBar status={connectionStatus} attempt={reconnectAttempt} />
|
<SseStatusBar status={connectionStatus} attempt={reconnectAttempt} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* 任务级失败提示(B6):生成彻底失败时明确反馈,不再无限转圈 */}
|
||||||
|
<FatalErrorBanner />
|
||||||
|
|
||||||
{/* SSE 进度提示(生成中且无内容时) */}
|
{/* SSE 进度提示(生成中且无内容时) */}
|
||||||
{showSkeleton && (
|
{showSkeleton && (
|
||||||
<div className="mb-4 bg-brand-cream border border-brand-orange/20 rounded-xl px-4 py-3 text-sm text-text-secondary"
|
<div className="mb-4 bg-brand-cream border border-brand-orange/20 rounded-xl px-4 py-3 text-sm text-text-secondary"
|
||||||
|
|||||||
30
frontend/src/components/tasks/FatalErrorBanner.tsx
Normal file
30
frontend/src/components/tasks/FatalErrorBanner.tsx
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
'use client';
|
||||||
|
// FatalErrorBanner — 任务级致命错误提示(B6)。生成彻底失败时给用户明确反馈,
|
||||||
|
// 不再让页面无限转圈。从 generationStore.fatalError 读。
|
||||||
|
import { useGenerationStore } from '@/stores/generationStore';
|
||||||
|
|
||||||
|
export function FatalErrorBanner() {
|
||||||
|
const fatalError = useGenerationStore((s) => s.fatalError);
|
||||||
|
if (!fatalError) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="mb-4 bg-red-50 border border-status-error rounded-xl px-4 py-3"
|
||||||
|
role="alert"
|
||||||
|
aria-live="assertive"
|
||||||
|
>
|
||||||
|
<div className="flex items-start gap-2">
|
||||||
|
<span aria-hidden="true" className="text-status-error">⚠️</span>
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<p className="text-sm font-medium text-status-error">生成失败</p>
|
||||||
|
<p className="text-xs text-text-secondary mt-0.5 break-words">
|
||||||
|
{fatalError.message || '生成过程出错,请重试或联系管理员'}
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-text-tertiary mt-1">
|
||||||
|
可在任务列表对该任务重新生成;多次失败请检查 key 配置或网络。
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -19,6 +19,7 @@ const STATUS_LABELS: Record<TaskStatus, { label: string; color: string }> = {
|
|||||||
approved: { label: '已通过', color: 'text-status-success' },
|
approved: { label: '已通过', color: 'text-status-success' },
|
||||||
rejected: { label: '已打回', color: 'text-status-error' },
|
rejected: { label: '已打回', color: 'text-status-error' },
|
||||||
archived: { label: '已归档', color: 'text-text-tertiary' },
|
archived: { label: '已归档', color: 'text-text-tertiary' },
|
||||||
|
failed: { label: '生成失败', color: 'text-status-error' },
|
||||||
};
|
};
|
||||||
|
|
||||||
export function RecentTasksBar() {
|
export function RecentTasksBar() {
|
||||||
|
|||||||
@@ -121,6 +121,10 @@ function handleEvent(
|
|||||||
break;
|
break;
|
||||||
|
|
||||||
case 'error':
|
case 'error':
|
||||||
|
// 任务级致命错误:不再静默丢弃,落 fatalError 让 UI 提示(B6)
|
||||||
|
store.setFatalError(event.data.code, event.data.message);
|
||||||
|
break;
|
||||||
|
|
||||||
case 'heartbeat':
|
case 'heartbeat':
|
||||||
case 'analyze_done':
|
case 'analyze_done':
|
||||||
case 'task_done':
|
case 'task_done':
|
||||||
|
|||||||
@@ -28,6 +28,9 @@ interface GenerationState {
|
|||||||
imageCandidates: ImageCandidate[];
|
imageCandidates: ImageCandidate[];
|
||||||
failedBatches: FailedBatch[];
|
failedBatches: FailedBatch[];
|
||||||
|
|
||||||
|
// 任务级致命错误(SSE error事件,生成彻底失败)。区别于图片批次级 failedBatches。
|
||||||
|
fatalError: { code: number; message: string } | null;
|
||||||
|
|
||||||
// 飞轮注入(飞轮隐形,只显注入提示,无"训练AI"按钮)
|
// 飞轮注入(飞轮隐形,只显注入提示,无"训练AI"按钮)
|
||||||
flywheelInjected: {
|
flywheelInjected: {
|
||||||
recentPreference: string;
|
recentPreference: string;
|
||||||
@@ -41,6 +44,7 @@ interface GenerationState {
|
|||||||
setImageProgress: (done: number, total: number) => void;
|
setImageProgress: (done: number, total: number) => void;
|
||||||
addImageCandidate: (candidate: ImageCandidate) => void;
|
addImageCandidate: (candidate: ImageCandidate) => void;
|
||||||
markBatchFailed: (batch: string, reason: string, retryable: boolean) => void;
|
markBatchFailed: (batch: string, reason: string, retryable: boolean) => void;
|
||||||
|
setFatalError: (code: number, message: string) => void;
|
||||||
setFlywheelInjected: (data: { recentPreference: string; rejectReasons: string[] }) => void;
|
setFlywheelInjected: (data: { recentPreference: string; rejectReasons: string[] }) => void;
|
||||||
hydrateFromTask: (data: {
|
hydrateFromTask: (data: {
|
||||||
textCandidates: TextCandidate[];
|
textCandidates: TextCandidate[];
|
||||||
@@ -59,6 +63,7 @@ export const useGenerationStore = create<GenerationState>((set) => ({
|
|||||||
imageTotal: 0,
|
imageTotal: 0,
|
||||||
imageCandidates: [],
|
imageCandidates: [],
|
||||||
failedBatches: [],
|
failedBatches: [],
|
||||||
|
fatalError: null,
|
||||||
flywheelInjected: null,
|
flywheelInjected: null,
|
||||||
|
|
||||||
setConnectionStatus(s, attempt = 0) {
|
setConnectionStatus(s, attempt = 0) {
|
||||||
@@ -95,6 +100,10 @@ export const useGenerationStore = create<GenerationState>((set) => ({
|
|||||||
}));
|
}));
|
||||||
},
|
},
|
||||||
|
|
||||||
|
setFatalError(code, message) {
|
||||||
|
set({ fatalError: { code, message } });
|
||||||
|
},
|
||||||
|
|
||||||
setFlywheelInjected(data) {
|
setFlywheelInjected(data) {
|
||||||
set({ flywheelInjected: data });
|
set({ flywheelInjected: data });
|
||||||
},
|
},
|
||||||
@@ -116,7 +125,7 @@ export const useGenerationStore = create<GenerationState>((set) => ({
|
|||||||
connectionStatus: 'connecting', reconnectAttempt: 0,
|
connectionStatus: 'connecting', reconnectAttempt: 0,
|
||||||
textDone: 0, textTotal: 0, textCandidates: [],
|
textDone: 0, textTotal: 0, textCandidates: [],
|
||||||
imageDone: 0, imageTotal: 0, imageCandidates: [],
|
imageDone: 0, imageTotal: 0, imageCandidates: [],
|
||||||
failedBatches: [], flywheelInjected: null,
|
failedBatches: [], fatalError: null, flywheelInjected: null,
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
}));
|
}));
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
// 任务
|
// 任务
|
||||||
export type TaskStatus =
|
export type TaskStatus =
|
||||||
| 'pending' | 'generating' | 'pending_selection'
|
| 'pending' | 'generating' | 'pending_selection'
|
||||||
| 'pending_review' | 'approved' | 'rejected' | 'archived';
|
| 'pending_review' | 'approved' | 'rejected' | 'archived' | 'failed';
|
||||||
|
|
||||||
export interface TaskListItem {
|
export interface TaskListItem {
|
||||||
id: number;
|
id: number;
|
||||||
|
|||||||
@@ -43,11 +43,11 @@ KR1链路连通 + KR2质量达标 + KR3自主可验 + KR4引擎补全(第2环+
|
|||||||
### 桶A 必须做(主链可用性,最先修)
|
### 桶A 必须做(主链可用性,最先修)
|
||||||
| ID | 任务 | 根据 | 状态 |
|
| ID | 任务 | 根据 | 状态 |
|
||||||
|----|------|------|------|
|
|----|------|------|------|
|
||||||
| A1 | Sidebar 4个死链路由(/tasks /images /tasks/build /tasks/list 全不存在) | Sidebar.tsx确证 | □ |
|
| A1 | Sidebar 4个死链路由(/tasks /images /tasks/build /tasks/list 全不存在) | Sidebar.tsx确证 | ✅ 2026-06-16 删4死链+补回/review审核台(限supervisor+admin),tsc全量EXIT0,commit 77f52e7 |
|
||||||
| D1 | 改稿进飞轮(SignalType.text_edit + TextCandidate.edited + 改稿端点走task_actions) | 最强真实信号 | □ |
|
| D1 | 改稿进飞轮(SignalType.text_edit + TextCandidate.edited + 改稿端点走task_actions) | 最强真实信号 | ◐ 后端✅2026-06-16端到端真跑(edited False→True+飞轮信号落库weight=5,实测抓出ENUM未同步bug已修,commit e3fb6f2)。**前端改稿框入口攒批做(倩倩姐2026-06-16:后端先串完前端攒批)** |
|
||||||
| C2 | 审核界面显分(读score_json总分,不读eval_score NULL列) | 审核可判断 | □ |
|
| C2 | 审核界面显分(读score_json总分,不读eval_score NULL列) | 审核可判断 | ✅ 2026-06-16 review.py复用_score_array_to_obj算总分+7维,ReviewCard接ScoreDimBars(passLine=80红线),端到端验task40返回total=83,新旧维度兼容,commit ea423e8 |
|
||||||
| B7 | 事件处理补齐(SSE/轮询状态流转) | 前端诊断 | □ |
|
| B7 | 事件处理补齐(SSE/轮询状态流转) | 前端诊断 | ✅ 2026-06-16 核实已做无缺口:SSE签ticket换JWT(?ticket=合规红线)+useSse消费progress/done/error全链+轮询兜底,后端事件发布到位,标绿 |
|
||||||
| B6 | 错误态呈现(status=failed前端可见) | 前端诊断 | □ |
|
| B6 | 错误态呈现(status=failed前端可见) | 前端诊断 | ✅ 2026-06-16 后端加TaskStatus.FAILED终态+异常区分重试中vs耗尽(耗尽落failed不retry)+014迁移ALTER status ENUM;前端fatalError state+useSse error不再静默丢+FatalErrorBanner提示+failed标签(列表/历史页可见,history纳入HISTORY_STATUSES),tsc EXIT0 |
|
||||||
|
|
||||||
### 桶B 飞轮专项(产品最大亮点·做扎实+前端呈现)
|
### 桶B 飞轮专项(产品最大亮点·做扎实+前端呈现)
|
||||||
| ID | 任务 | 守的底线 | 状态 |
|
| ID | 任务 | 守的底线 | 状态 |
|
||||||
@@ -81,7 +81,7 @@ KR1链路连通 + KR2质量达标 + KR3自主可验 + KR4引擎补全(第2环+
|
|||||||
---
|
---
|
||||||
|
|
||||||
## 🔴 跨模块迁移版本号统一(防三环撞008车)
|
## 🔴 跨模块迁移版本号统一(防三环撞008车)
|
||||||
008=brand_keyword(第5环) / 009=features_json+analyze_status(第2环) / 010=fission_tasks表(第11环) / 011=benchmark_ids+source_fission_id(第2环S12+第11环合并)
|
008=brand_keyword / 009=benchmark_analyze_fields(含features_json) / 010=fission_tasks表 / 011=task_benchmark_fission_fields / 012=product_target_audience / **013=text_candidate edited+signal_type ENUM补text_edit(D1已落)**。下一个可用=014。
|
||||||
|
|
||||||
## 总验收(最高门禁)
|
## 总验收(最高门禁)
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user