Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions DEVELOPMENT_LOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,17 @@
# Development Log

## 2026-08-25 任务 20 状态恢复与新建流程精简

- 已确认任务 `20 - E1829 110128 舒淇,安以轩P29-29` 的第 2 次切片批次、12 条 active 成片和发送中心 12/12 关联完整,原“失败”来自完成后重复触发 AI 分析并尝试删除仍被切片引用的候选记录。
- 修复前 SQLite Online Backup 为 `backups/workflow-before-task20-repair-20260824-233721.sqlite3`;只把任务 `3210d91ee1fb` 恢复为 `completed / 100%` 并清空误报错误,候选、切片、Workflow Job、AI run 和发布任务 ID 前后完全一致,修复依据已写入任务日志。
- 手动 AI 入口新增原子重入保护:活跃全自动 Job、已有 active 切片或已有发送中心记录时返回 HTTP 409;候选替换在同一事务内先检查 `output_clip` 引用,改为可理解的业务冲突,不再暴露 SQLite 外键错误,也不会把已完成任务降级为失败。
- 新建任务只保留本机视频上传;任务名称历史候选最多显示最近 5 个未删除且去重后的名称,JSON 已有文件创建入口、目录浏览 API 和 NAS 页面能力已移除。
- `source_type`、`nas_file_path` 物理列继续保留兼容;账本迁移 `20260824_02_task_upload_only` 在写入前创建 SQLite Online Backup,将旧来源归一到 `original_video_path + upload`,不删除外部视频。
- “每小时高光密度”和候选总上限仅在长直播高光模式显示和提交;通用与康熙模式不读取页面参数,服务端固定使用兼容默认值。
- 新增 `POST /api/subtitles/tasks/{task_id}/skip-to-review`;明确跳过字幕后进入片段审核,不再自动续跑文案和发送中心。审核保存并完整关联后才标记完成;关联不完整时保留审核状态与错误。
- 本轮未修改 AI Provider、Prompt、模型、候选算法或真实发布逻辑,也没有重跑任务 20 的 AI、切片或发布任务。
- 活动库迁移备份为 `data/backups/workflow-before-task-upload-only-20260825-000702-586803-d14c09b5.sqlite3`;迁移后旧来源记录为 0,`integrity_check=ok`、`foreign_key_check=0`。完整测试 `672 passed`,Ruff、Python Compileall、JavaScript 语法和浏览器烟测通过;8001、Scheduler 与 8765 Windows Worker 健康。

## 2026-08-24 稳定 V1 P1.3c 数据库迁移账本与唯一索引 Fail-Closed

- 新增 `schema_migrations` 账本;每条正式新迁移记录稳定 version、name、checksum 和完成时间,同版本定义漂移时拒绝启动。
Expand Down
8 changes: 8 additions & 0 deletions NEXT_STEPS.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,12 @@
# Next Steps

## 2026-08-25 任务 20 修复后检查

1. 打开 `http://127.0.0.1:8001/`,确认任务 20 显示“已完成”、无外键错误,并仍为 12 条 active 切片和发送中心已关联 12/12。
2. 打开“新建任务”:任务名候选最多 5 个,只能上传本机文件;通用/康熙模式不显示密度设置,长直播模式才显示。
3. 对低风险全自动测试任务选择“跳过字幕,进入片段审核”,确认不会立即生成发送中心任务;在片段审核保存后才同步。
4. 不需要点击“立即发送”;本轮不会执行真实投稿,也不改变 `NEED_REVIEW` 的人工确认边界。

## 2026-08-24 稳定 V1 整改路线

- [x] P0.1:Pytest 与活动数据库、媒体目录彻底隔离,危险整表清理增加 fail-closed 校验。
Expand All @@ -12,6 +19,7 @@
- [x] P1.3a:任务状态接口受控转换;Task/Job lease 代际一致;自动流水线取消恢复、READY 终态和关联发布清理明确;切片批次编号、输出目录、Task 终态和数据库激活原子化。
- [x] P1.3b:自动流水线已增加版本化步骤 checkpoint、输入/产物证据、旧 Job 原位重试和重领恢复;已覆盖 AI、转写、切片、文案、排期与发布草稿的失效边界。
- [x] P1.3c:已增加 `schema_migrations` 版本/checksum 账本、迁移前 Online Backup 和 fail-closed v2 活动发布唯一索引;重复数据、索引漂移或账本异常都会拒绝启动。本轮未直接迁移活动库。
- [x] 任务 20 专项:完成状态恢复、重复 AI 409 保护、上传单入口、最近 5 个任务名、长直播参数显隐和“跳过字幕后进入片段审核”闭环。
- [ ] P1.3d:处理字幕批准批次原子性、字幕/切片中断目录清理和剩余跨进程恢复边界。
- [ ] P1.4:统一第三方 AI/FFmpeg 超时、错误 JSON、429/5xx 与重试幂等边界,并避免重复计费。
- [ ] P1.5:在不扩大个人本地项目范围的前提下处理密钥日志、输入校验和本地管理员接口门禁。
Expand Down
78 changes: 78 additions & 0 deletions app/db/database.py
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,27 @@
f"DROP INDEX {PUBLISH_ACTIVE_UNIQUE_INDEX_LEGACY_NAME}"
).encode("utf-8")
).hexdigest()
TASK_UPLOAD_ONLY_MIGRATION_VERSION = "20260824_02_task_upload_only"
TASK_UPLOAD_ONLY_MIGRATION_NAME = "任务视频来源归一为本机上传"
TASK_UPLOAD_ONLY_MIGRATION_SQL = """
UPDATE tasks
SET original_video_path = CASE
WHEN original_video_path IS NULL OR TRIM(original_video_path) = '' THEN nas_file_path
ELSE original_video_path
END,
source_type = 'upload',
nas_file_path = NULL
WHERE source_type != 'upload'
OR source_type IS NULL
OR (nas_file_path IS NOT NULL AND TRIM(nas_file_path) != '')
""".strip()
TASK_UPLOAD_ONLY_MIGRATION_CHECKSUM = hashlib.sha256(
(
f"{TASK_UPLOAD_ONLY_MIGRATION_VERSION}\n"
f"{TASK_UPLOAD_ONLY_MIGRATION_NAME}\n"
f"{TASK_UPLOAD_ONLY_MIGRATION_SQL}"
).encode("utf-8")
).hexdigest()


class SchemaMigrationError(RuntimeError):
Expand Down Expand Up @@ -79,6 +100,7 @@ def init_db() -> None:
needs_subtitle_editor_backup = _requires_subtitle_editor_schema_migration(settings.database_path)
needs_subtitle_auto_backup = _requires_subtitle_auto_schema_migration(settings.database_path)
needs_publish_index_backup = _requires_publish_active_index_migration(settings.database_path)
needs_task_upload_only_backup = _requires_task_upload_only_migration(settings.database_path)
if needs_long_live_backup:
create_schema_migration_backup(
settings.database_path,
Expand Down Expand Up @@ -116,6 +138,12 @@ def init_db() -> None:
settings.data_dir / "backups",
"publish-active-unique-index-v2",
)
if needs_task_upload_only_backup:
create_schema_migration_backup(
settings.database_path,
settings.data_dir / "backups",
"task-upload-only",
)

with get_connection() as connection:
connection.executescript(
Expand Down Expand Up @@ -721,6 +749,31 @@ def _requires_publish_active_index_migration(database_path) -> bool:
connection.close()


def _requires_task_upload_only_migration(database_path) -> bool:
"""只有确实存在旧 NAS 来源数据时才在归一化前备份。"""
if not database_path.exists() or database_path.stat().st_size == 0:
return False
connection = None
try:
connection = sqlite3.connect(f"{database_path.resolve().as_uri()}?mode=ro", uri=True, timeout=10)
columns = {row[1] for row in connection.execute("PRAGMA table_info(tasks)").fetchall()}
if not {"source_type", "nas_file_path"} <= columns:
return False
row = connection.execute(
"""
SELECT 1 FROM tasks
WHERE source_type != 'upload'
OR source_type IS NULL
OR (nas_file_path IS NOT NULL AND TRIM(nas_file_path) != '')
LIMIT 1
"""
).fetchone()
return row is not None
finally:
if connection is not None:
connection.close()


def _get_table_columns(connection: sqlite3.Connection, table_name: str) -> set[str]:
rows = connection.execute(f"PRAGMA table_info({table_name})").fetchall()
return {row["name"] for row in rows}
Expand Down Expand Up @@ -856,6 +909,24 @@ def _apply_publish_active_unique_index_migration(connection: sqlite3.Connection)
connection.execute(f"DROP INDEX IF EXISTS {PUBLISH_ACTIVE_UNIQUE_INDEX_LEGACY_NAME}")


def _apply_task_upload_only_migration(connection: sqlite3.Connection) -> None:
connection.execute(TASK_UPLOAD_ONLY_MIGRATION_SQL)


def _verify_task_upload_only_migration(connection: sqlite3.Connection) -> None:
row = connection.execute(
"""
SELECT 1 FROM tasks
WHERE source_type != 'upload'
OR source_type IS NULL
OR (nas_file_path IS NOT NULL AND TRIM(nas_file_path) != '')
LIMIT 1
"""
).fetchone()
if row is not None:
raise SchemaMigrationError("仍存在未归一化的 NAS 视频来源记录")


def _registered_schema_migrations() -> tuple[SchemaMigration, ...]:
return (
SchemaMigration(
Expand All @@ -865,6 +936,13 @@ def _registered_schema_migrations() -> tuple[SchemaMigration, ...]:
apply=_apply_publish_active_unique_index_migration,
verify=_verify_publish_active_unique_index_migration,
),
SchemaMigration(
version=TASK_UPLOAD_ONLY_MIGRATION_VERSION,
name=TASK_UPLOAD_ONLY_MIGRATION_NAME,
checksum=TASK_UPLOAD_ONLY_MIGRATION_CHECKSUM,
apply=_apply_task_upload_only_migration,
verify=_verify_task_upload_only_migration,
),
)


Expand Down
3 changes: 1 addition & 2 deletions app/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@

from app.core.config import settings
from app.db.database import init_db
from app.routers import ai_prompts, files, media, pages, publish, settings as settings_router, subtitles, tasks
from app.routers import ai_prompts, media, pages, publish, settings as settings_router, subtitles, tasks
from app.services.publish_scheduler import start_scheduler_background
from app.services.storage_service import configure_runtime_media_storage
from app.services.job_worker import WorkflowJobRunner
Expand Down Expand Up @@ -143,7 +143,6 @@ async def security_middleware(request: Request, call_next):
app.include_router(ai_prompts.router)
app.include_router(tasks.router)
app.include_router(subtitles.router)
app.include_router(files.router)
app.include_router(media.router)
app.include_router(publish.router)
app.include_router(settings_router.router)
Expand Down
2 changes: 0 additions & 2 deletions app/models/task.py
Original file line number Diff line number Diff line change
Expand Up @@ -43,10 +43,8 @@ class TaskStatus(str, Enum):

class TaskCreate(BaseModel):
task_name: str = Field(..., min_length=1, max_length=120)
source_type: Literal["upload", "nas"] = "upload"
platform: Literal["douyin", "bilibili", "general"] = "general"
original_video_path: Optional[str] = None
nas_file_path: Optional[str] = None
max_clip_duration: int = Field(default=10, ge=1, le=60)
candidate_clip_count: int = Field(default=12, ge=1, le=50)
selection_profile: Literal["general", "variety_comedy", "long_live_talk"]
Expand Down
11 changes: 0 additions & 11 deletions app/routers/files.py

This file was deleted.

6 changes: 6 additions & 0 deletions app/routers/subtitles.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@
enqueue_task_subtitle_render,
prepare_task_subtitle_review,
skip_task_subtitles_and_resume,
skip_task_subtitles_to_review,
)
from app.services.subtitle_data_service import (
SubtitleRevisionConflict,
Expand Down Expand Up @@ -72,6 +73,11 @@ def skip_and_resume(task_id: str) -> dict:
return _call(lambda: skip_task_subtitles_and_resume(task_id))


@router.post("/tasks/{task_id}/skip-to-review")
def skip_to_review(task_id: str) -> dict:
return _call(lambda: skip_task_subtitles_to_review(task_id))


@router.get("/tasks/{task_id}/jobs")
def list_subtitle_jobs(task_id: str) -> dict:
jobs = [
Expand Down
20 changes: 6 additions & 14 deletions app/routers/tasks.py
Original file line number Diff line number Diff line change
Expand Up @@ -37,17 +37,6 @@ async def list_tasks() -> list[dict]:
return task_service.list_tasks()


@router.post("")
async def create_task(payload: TaskCreate, background_tasks: BackgroundTasks) -> dict:
try:
result = task_service.create_task_record(payload)
if payload.auto_mode:
result["auto_pipeline"] = start_auto_pipeline(result["id"], background_tasks=background_tasks)
return result
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc


@router.post("/upload")
async def create_upload_task(
background_tasks: BackgroundTasks,
Expand Down Expand Up @@ -87,15 +76,14 @@ async def create_upload_task(
)
payload = TaskCreate(
task_name=task_name,
source_type="upload",
platform=platform,
original_video_path=str(saved_path),
max_clip_duration=max_clip_duration,
candidate_clip_count=candidate_clip_count,
selection_profile=selection_profile,
final_clip_target=final_clip_target,
highlight_density_per_hour=highlight_density_per_hour,
highlight_total_limit=highlight_total_limit,
highlight_density_per_hour=(highlight_density_per_hour if selection_profile == "long_live_talk" else 4),
highlight_total_limit=(highlight_total_limit if selection_profile == "long_live_talk" else 30),
ai_preference=ai_preference,
auto_mode=auto_mode,
auto_clip_count=auto_clip_count,
Expand Down Expand Up @@ -297,6 +285,8 @@ async def process_ai_analysis(
) -> dict:
try:
return await run_in_threadpool(task_service.process_task_ai_analysis, task_id, provider=provider)
except task_service.AIAnalysisConflictError as exc:
raise HTTPException(status_code=409, detail=str(exc)) from exc
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc

Expand All @@ -317,6 +307,8 @@ async def get_ai_analysis_runs(task_id: str) -> dict:
async def restore_ai_analysis_run(task_id: str, run_id: str) -> dict:
try:
return task_service.restore_ai_analysis_run(task_id, run_id)
except task_service.AIAnalysisConflictError as exc:
raise HTTPException(status_code=409, detail=str(exc)) from exc
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc

Expand Down
Loading