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-24 稳定 V1 P0 数据安全整改

- Pytest 启动时改用每进程独立的系统临时 sandbox,并无条件隔离数据库、任务目录、上传临时目录和发布包目录;即使外部 `DATABASE_PATH` 指向活动库,测试也不会连接或清理真实数据。
- `test_task_query_service` 的整表清理增加第二道 fail-closed 路径校验:数据库不在本次 pytest sandbox 或文件名不是 `test_workflow.sqlite3` 时立即中止。
- 任务永久删除改为两阶段:托管媒体先原子移动到同卷隔离区并写 manifest,数据库提交失败时逆序恢复;数据库成功后才最终清除,清除失败返回 `cleanup_pending` 并保留恢复证据。外部唯一原片继续不移动、不删除。
- 新增默认 dry-run 的 `scripts/repair_foreign_key_integrity.py`。活动库预演确认 17 条异常来自 8 个历史缺失的 `output_clip` 父记录;修复只新增 8 个 `is_active=0`、无媒体路径的 tombstone,保留 589 条发布任务、4 条字幕任务、28 条 `NEED_REVIEW` 及发布事件历史。
- 修复前备份为 `data/backups/workflow-before-foreign-key-repair-20260824-130321-535723-35a0f972.sqlite3`;备份 `quick_check=ok` 并保留修复前 17 条外键异常,活动库修复后 `quick_check=ok`、`foreign_key_check=0`。
- 三类 SQLite Online Backup 统一转换为 `journal_mode=DELETE` 的便携单文件快照,避免迁移、媒体清理和外键修复备份依赖 WAL/SHM sidecar。
- 新增测试覆盖恶意活动库环境变量、清理 guard、第二个目录移动失败、数据库提交失败、最终清理失败、外键 dry-run/备份/tombstone/拒绝路径和单文件备份。
- Windows Web 由 Alter 托管并会自动重启,未停止 Alter;数据库修复在 `BEGIN IMMEDIATE` 锁内完成备份和写入。修复后 `8001 /health=ok`,Scheduler `running=true`、`consecutive_failures=0`,Windows Worker `8765 /health=ok`;未触发真实投稿。
- 最终验证通过:P0 定向测试 `42 passed`、完整测试 `512 passed`、Ruff、Python 编译与 Git 空白检查全部成功。全量测试故意继承活动库路径后仍使用临时 sandbox;正式 Scheduler 同期持续运行,因此活动库 mtime 会变化,但数据库大小稳定且 `foreign_key_check` 始终为 0。

## 2026-08-24 字幕审核、异步渲染与自动流水线整合(PR 4)

- 全自动流水线在切片后创建原片/切片字幕草稿,并停在 `pending_subtitle_review`;不会继续生成文案或发送任务。
Expand Down
20 changes: 20 additions & 0 deletions NEXT_STEPS.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,25 @@
# Next Steps

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

- [x] P0.1:Pytest 与活动数据库、媒体目录彻底隔离,危险整表清理增加 fail-closed 校验。
- [x] P0.2:修复活动库 17 条孤儿外键;以 8 个不可见 tombstone 保留发布、字幕和人工复核历史,最终 `foreign_key_check=0`。
- [x] P0.3:永久删除改为“同卷隔离暂存 → 数据库提交 → 最终清理”,提交失败可恢复,最终清理失败可重试。
- [x] P0.4:SQLite Online Backup 固定为不依赖 WAL/SHM 的单文件快照。
- [ ] P1.1:收紧媒体读取与任务目录边界,补齐核心读写路径的 traversal / arbitrary-file 回归测试。
- [ ] P1.2:为 Workflow Job 和 Publish Job 增加 lease owner / execution generation fencing,阻止旧 Worker 回写新执行。
- [ ] P1.3:补齐任务状态转移约束、批处理原子性、取消/重启恢复和明确失败状态。
- [ ] P1.4:统一第三方 AI/FFmpeg 超时、错误 JSON、429/5xx 与重试幂等边界,并避免重复计费。
- [ ] P1.5:在不扩大个人本地项目范围的前提下处理密钥日志、输入校验和本地管理员接口门禁。
- [ ] P2:拆分 God Service、去除查询重复、补核心集成/故障测试和可观测性;不做全面重构。

### 本轮人工检查

1. 正常打开 `http://127.0.0.1:8001/` 和发送中心,确认页面可用、Scheduler 正常、Windows Worker 正常。
2. 不需要点击“立即发送”;本轮没有执行真实投稿,也没有改变 28 条 `NEED_REVIEW` 的人工确认边界。
3. 若未来永久删除返回 `cleanup_pending`,不要手工移动隔离目录;保留返回信息和 manifest,使用后续安全清理入口重试。
4. 修复前数据库备份位于 `data/backups/workflow-before-foreign-key-repair-20260824-130321-535723-35a0f972.sqlite3`,只有活动库无法通过完整性检查时才考虑恢复,不要直接覆盖当前数据库。

## 2026-08-23 长直播四阶段进度

- [x] PR 1:模式必选、已有文件入口、媒体/磁盘预检、持久化重型 Job、转写断点与词级时间戳。
Expand Down
84 changes: 84 additions & 0 deletions STABILITY_REMEDIATION_TASK.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
# 稳定 V1 整改任务书

## 背景

工程审计确认当前项目属于“可用 V1”,但存在会伤害真实数据或让失败状态不可恢复的 P0 风险。本轮目标是按小步、可测试、可回滚的方式把项目提升到“稳定 V1”,不做全面重构,也不扩大产品范围。

## 本轮目标

1. 隔离 Pytest 数据库和媒体目录,任何外部 `DATABASE_PATH` 都不能让测试连接活动库;危险清理夹具必须在删除前再次 fail-closed 校验。
2. 为 SQLite 外键异常提供默认只读预演、应用前强制备份、事务内修复、修复后完整性复查的工具;只在验证备份后处理已确认的孤儿引用。
3. 把永久删除改为“托管目录暂存隔离 -> 数据库提交 -> 延迟清除”;数据库失败时可把文件恢复原位,外部唯一原片始终不动。
4. 完成独立测试与验收,并记录剩余 P1/P2 风险和下一轮顺序。

## 允许修改范围

- `tests/conftest.py` 及与本轮 P0 直接相关的测试。
- `app/services/storage_service.py`
- `app/services/task_lifecycle_service.py`
- `app/services/database_backup_service.py`(仅复用或补充安全备份能力)。
- `scripts/` 下新增或调整本轮修复、验证脚本。
- `DEVELOPMENT_LOG.md`、`NEXT_STEPS.md`、本任务书和必要审计文档。

## 禁止修改范围

- 不改变 AI Provider、投稿平台、字幕和切片的正常业务语义。
- 不更改生产 Schema,不删除任务、发布或字幕历史。
- 不读取、输出或提交 `.env`、Token、Cookie、账号凭据。
- 不绕过平台登录、验证码、风控或人工确认。
- 不自动合并 PR,不强制推送,不删除分支。

## 已确定实现要求

### P0.1 测试隔离

- Pytest 启动时无条件使用进程级临时根目录,不继承调用者传入的活动库路径。
- 临时数据库和媒体目录必须位于同一隔离根目录。
- 对整表清理增加第二道路径校验;路径不在 Pytest 隔离根目录时立即中止。
- 验证从命令行故意传入活动库路径时,测试仍不会连接或改写活动库。

### P0.2 外键修复

- 工具默认 dry-run;只有显式 `--apply` 才写入。
- 应用前使用 SQLite Online Backup API 创建唯一备份并执行 `quick_check`。
- 只处理当前检测到且策略明确的孤儿 `publish_jobs.output_clip_id` 和 `subtitle_jobs.output_clip_id`。
- 修复必须单事务提交;提交前后执行 `foreign_key_check`,不允许产生新异常。
- 尽量保留历史证据;若表约束不允许安全置空,则先归档必要字段再做最小删除,并在报告中逐条列出。

### P0.3 两阶段永久删除

- 只处理经过现有托管根目录校验的目录。
- 文件先原子移动到同盘隔离区并写清单;任一步失败要恢复已经移动的目录。
- 数据库提交失败时必须恢复目录;不得留下“文件没了、任务仍可见”的半成功状态。
- 数据库提交成功后再清除隔离区;清除失败要返回明确的 `cleanup_pending`,不得把逻辑删除回滚成可见状态。
- 重复执行必须幂等;外部原片保持不变。

## 验收标准

- 相关 P0 回归测试全部通过。
- 全量测试、Lint/语法检查、前端语法检查通过,或对既有失败给出可复现证据。
- 活动数据库在运行普通测试前后文件哈希、大小和外键异常计数不发生变化。
- 外键修复应用前生成可读备份;修复后 `PRAGMA quick_check = ok` 且 `PRAGMA foreign_key_check` 为空。
- 模拟数据库提交失败时,暂存文件恢复到原路径,任务仍可见。
- 模拟最终清理失败时,任务保持已删除并返回可恢复的待清理状态。
- `git diff` 只包含本轮范围,且无敏感信息、调试残留或临时产物。

## 测试命令

具体临时目录由执行者生成,不得使用 `data/workflow.sqlite3`:

```powershell
pytest -q tests/test_task_query_service.py tests/test_media_storage_lifecycle.py tests/test_database_backup_service.py
pytest -q
ruff check app tests scripts
python -m compileall -q app scripts
node --check app/static/js/task-detail.js
```

## 返回格式

- 修改文件与关键行为。
- 测试命令、退出码、通过/失败数量。
- 活动数据库备份路径、修复前后外键计数和完整性结果(不含业务内容)。
- Commit、分支、Push 和 PR 状态。
- 未完成的 P1/P2 风险与下一轮建议。
13 changes: 13 additions & 0 deletions app/services/database_backup_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,16 @@ class BackupCleanupResult:
released_bytes: int


def _finalize_portable_backup(connection: sqlite3.Connection) -> None:
"""把 Online Backup 结果固定为无需 WAL/SHM sidecar 的单文件快照。"""
connection.commit()
row = connection.execute("PRAGMA journal_mode = DELETE").fetchone()
journal_mode = str(row[0]).lower() if row else ""
if journal_mode != "delete":
raise BackupSafetyError(f"备份无法切换为单文件 journal_mode:{journal_mode or 'unknown'}")
connection.commit()


def sqlite_quick_check(database_path: Path) -> str:
path = database_path.resolve()
if not path.is_file():
Expand Down Expand Up @@ -235,6 +245,7 @@ def create_publish_migration_backup(
)
backup_connection = sqlite3.connect(str(temporary_path), timeout=10)
source_connection.backup(backup_connection)
_finalize_portable_backup(backup_connection)
backup_connection.close()
backup_connection = None
source_connection.close()
Expand Down Expand Up @@ -280,6 +291,7 @@ def create_schema_migration_backup(database_path: Path, backup_dir: Path, label:
source_connection = sqlite3.connect(f"{database_path.as_uri()}?mode=ro", uri=True, timeout=10)
backup_connection = sqlite3.connect(str(temporary_path), timeout=10)
source_connection.backup(backup_connection)
_finalize_portable_backup(backup_connection)
backup_connection.close()
backup_connection = None
source_connection.close()
Expand Down Expand Up @@ -329,6 +341,7 @@ def create_media_cleanup_backup(
)
backup_connection = sqlite3.connect(str(temporary_path), timeout=10)
source_connection.backup(backup_connection)
_finalize_portable_backup(backup_connection)
backup_connection.close()
backup_connection = None
source_connection.close()
Expand Down
Loading