Compare commits

...

7 Commits

Author SHA1 Message Date
用户CI Test fdfe228754 fix(deps): 精确锁定pyOpenSSL==26.2.0,与cryptography 46.0.5兼容
CI/CD Pipeline / Validate Code Quality And Tests (pull_request) Successful in 1m18s
CI/CD Pipeline / Frontend Lint (pull_request) Successful in 2m0s
CI/CD Pipeline / Build & Push Staging (Watchtower auto-deploy) (pull_request) Has been skipped
CI/CD Pipeline / Build Production Runtime Images (pull_request) Has been skipped
CI/CD Pipeline / Staging E2E Tests (pull_request) Has been skipped
CI/CD Pipeline / Staging API Integration Tests (pull_request) Has been skipped
CI/CD Pipeline / Deploy Production (pull_request) Has been skipped
CI/CD Pipeline / Production Browser E2E (pull_request) Has been skipped
2026-07-09 16:03:27 +08:00
用户CI Test f3cd1f4b34 fix(deps): 使用pyOpenSSL>=24.0.0让pip自动解析兼容版本
CI/CD Pipeline / Validate Code Quality And Tests (pull_request) Successful in 1m15s
CI/CD Pipeline / Frontend Lint (pull_request) Successful in 1m25s
CI/CD Pipeline / Build & Push Staging (Watchtower auto-deploy) (pull_request) Has been skipped
CI/CD Pipeline / Build Production Runtime Images (pull_request) Has been skipped
CI/CD Pipeline / Staging E2E Tests (pull_request) Has been skipped
CI/CD Pipeline / Staging API Integration Tests (pull_request) Has been skipped
CI/CD Pipeline / Deploy Production (pull_request) Has been skipped
CI/CD Pipeline / Production Browser E2E (pull_request) Has been skipped
2026-07-09 16:00:03 +08:00
用户CI Test b3b166baef fix(deps): 更新pyOpenSSL版本至25.0.0以匹配cryptography 46.0.5
CI/CD Pipeline / Validate Code Quality And Tests (pull_request) Failing after 3s
CI/CD Pipeline / Frontend Lint (pull_request) Successful in 1m3s
CI/CD Pipeline / Build & Push Staging (Watchtower auto-deploy) (pull_request) Has been skipped
CI/CD Pipeline / Build Production Runtime Images (pull_request) Has been skipped
CI/CD Pipeline / Staging E2E Tests (pull_request) Has been skipped
CI/CD Pipeline / Staging API Integration Tests (pull_request) Has been skipped
CI/CD Pipeline / Deploy Production (pull_request) Has been skipped
CI/CD Pipeline / Production Browser E2E (pull_request) Has been skipped
2026-07-09 15:58:11 +08:00
用户CI Test c81dbfa64a fix(deps): 添加pyOpenSSL锁定以解决系统包与cryptography版本不兼容
CI/CD Pipeline / Validate Code Quality And Tests (pull_request) Failing after 7s
CI/CD Pipeline / Frontend Lint (pull_request) Successful in 1m29s
CI/CD Pipeline / Build & Push Staging (Watchtower auto-deploy) (pull_request) Has been skipped
CI/CD Pipeline / Build Production Runtime Images (pull_request) Has been skipped
CI/CD Pipeline / Staging E2E Tests (pull_request) Has been skipped
CI/CD Pipeline / Staging API Integration Tests (pull_request) Has been skipped
CI/CD Pipeline / Deploy Production (pull_request) Has been skipped
CI/CD Pipeline / Production Browser E2E (pull_request) Has been skipped
2026-07-09 15:56:01 +08:00
用户CI Test 51b3263222 fix(deps): 锁定cryptography版本以修复CI环境GEN_EMAIL兼容性错误
CI/CD Pipeline / Validate Code Quality And Tests (pull_request) Failing after 21s
CI/CD Pipeline / Frontend Lint (pull_request) Successful in 2m0s
CI/CD Pipeline / Build & Push Staging (Watchtower auto-deploy) (pull_request) Has been skipped
CI/CD Pipeline / Build Production Runtime Images (pull_request) Has been skipped
CI/CD Pipeline / Staging E2E Tests (pull_request) Has been skipped
CI/CD Pipeline / Staging API Integration Tests (pull_request) Has been skipped
CI/CD Pipeline / Deploy Production (pull_request) Has been skipped
CI/CD Pipeline / Production Browser E2E (pull_request) Has been skipped
2026-07-09 15:52:27 +08:00
xiaoxia 5ec71f5e1e fix(generation): 修复生成任务状态机缺失导致状态永不更新的问题 (#198)
CI/CD Pipeline / Validate Code Quality And Tests (push) Successful in 1m22s
CI/CD Pipeline / Frontend Lint (push) Successful in 1m47s
CI/CD Pipeline / Build Production Runtime Images (push) Has been skipped
CI/CD Pipeline / Deploy Production (push) Has been skipped
CI/CD Pipeline / Production Browser E2E (push) Has been skipped
CI/CD Pipeline / Build & Push Staging (Watchtower auto-deploy) (push) Successful in 3m56s
CI/CD Pipeline / Staging E2E Tests (push) Successful in 1m25s
CI/CD Pipeline / Staging API Integration Tests (push) Successful in 3m47s
2026-07-09 15:42:43 +08:00
xiaoxia f8b004930d feat(test): P0级测试质量体系 - 环境变量清单 + 覆盖率门禁 (#197)
CI/CD Pipeline / Validate Code Quality And Tests (push) Failing after 19s
CI/CD Pipeline / Frontend Lint (push) Successful in 1m30s
CI/CD Pipeline / Build & Push Staging (Watchtower auto-deploy) (push) Has been skipped
CI/CD Pipeline / Build Production Runtime Images (push) Has been skipped
CI/CD Pipeline / Staging E2E Tests (push) Has been skipped
CI/CD Pipeline / Staging API Integration Tests (push) Has been skipped
CI/CD Pipeline / Deploy Production (push) Has been skipped
CI/CD Pipeline / Production Browser E2E (push) Has been skipped
2026-07-09 15:42:19 +08:00
7 changed files with 946 additions and 15 deletions
+4 -2
View File
@@ -164,7 +164,8 @@ jobs:
USE_IN_MEMORY_DB: "true"
run: |
set -eu
PYTHONPATH="$PWD/apps/api:$PWD" python3 -m pytest tests/unit -q
PYTHONPATH="$PWD/apps/api:$PWD" python3 -m pytest tests/unit -q \
--cov=apps --cov-report=term --cov-report=xml
- name: Start PostgreSQL for integration tests
shell: sh
@@ -209,7 +210,8 @@ jobs:
run: |
set -eu
pip install -q pytest-rerunfailures
PYTHONPATH="$PWD/apps/api:$PWD" python3 -m pytest tests/integration -q --timeout=60 -x --reruns 2 --reruns-delay 1 -m "not performance"
PYTHONPATH="$PWD/apps/api:$PWD" python3 -m pytest tests/integration -q --timeout=60 -x --reruns 2 --reruns-delay 1 -m "not performance" \
--cov=apps --cov-append --cov-report=term --cov-report=xml --cov-fail-under=50
- name: Run API performance baseline tests
shell: sh
+84 -13
View File
@@ -14,6 +14,7 @@ from urllib.parse import urlparse
import oss2
from worker_app.celery_app import celery_app
from worker_app.db import SessionLocal
OUTPUT_WIDTH = 1280
OUTPUT_HEIGHT = 720
@@ -28,6 +29,58 @@ PUBLIC_API_BASE_URL = os.getenv("PUBLIC_API_BASE_URL", "https://api.xiaoxiajianj
logger = logging.getLogger(__name__)
# ── 状态更新辅助函数 ──────────────────────────────────────────────────────────
def _update_task_status(task_id: str, status_action: str, **kwargs) -> bool:
"""更新 GenerationTask 状态(独立 session,异常不向外抛出)。
Args:
task_id: 任务 ID
status_action: 状态动作名,如 "mark_processing" / "mark_completed" / "mark_failed"
**kwargs: 传递给对应方法的参数
Returns:
True 表示更新成功,False 表示更新失败
"""
try:
from packages.adapters.sqlalchemy_impl.generation_task_repository import (
SQLAlchemyGenerationTaskRepository,
)
session = SessionLocal()
try:
repo = SQLAlchemyGenerationTaskRepository(session)
task = repo.get(task_id)
if task is None:
logger.warning("更新任务状态失败:任务不存在 task_id=%s", task_id)
return False
action = getattr(task, status_action, None)
if action is None:
logger.warning("未知的状态动作: %s", status_action)
return False
action(**kwargs)
repo.update(task)
logger.info("GenerationTask 状态更新成功: task_id=%s action=%s", task_id, status_action)
return True
finally:
session.close()
except Exception as e:
logger.error(
"更新 GenerationTask 状态异常: task_id=%s action=%s error=%s",
task_id,
status_action,
e,
exc_info=True,
)
return False
# ── FFmpeg / OSS helpers ─────────────────────────────────────────────────────
def _run_ffmpeg(command: list[str]) -> None:
"""执行 FFmpeg 命令"""
subprocess.run(command, check=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True) # nosec B603
@@ -155,8 +208,6 @@ def _download_library_assets(
"""
# 导入模型和会话
try:
from worker_app.db import SessionLocal
from packages.adapters.sqlalchemy_impl.models import AssetModel
session = SessionLocal()
@@ -231,6 +282,9 @@ def _process_with_editing_mode(
)
# ── Celery Task ──────────────────────────────────────────────────────────────
@celery_app.task(bind=True, name="worker.generate_video", max_retries=2)
def generate_video(self, task_id: str) -> dict:
"""
@@ -242,19 +296,21 @@ def generate_video(self, task_id: str) -> dict:
Returns:
生成结果字典
"""
from worker_app.db import SessionLocal
from packages.domain import EditingMode
from packages.adapters.sqlalchemy_impl.generation_task_repository import (
SQLAlchemyGenerationTaskRepository,
)
from packages.domain import EditingMode, GeneratedVideo, GenerationTaskStatus
logger.info("开始生成视频任务: task_id=%s", task_id)
# 从数据库加载任务信息
session = SessionLocal()
try:
from packages.adapters.sqlalchemy_impl.generation_task_repository import (
SQLAlchemyGenerationTaskRepository,
)
task_repo = SQLAlchemyGenerationTaskRepository(session)
gen_task = task_repo.get(task_id)
if gen_task is None:
logger.error("生成任务不存在: task_id=%s", task_id)
return {"status": "failed", "error": f"generation task {task_id} not found"}
project_id = gen_task.project_id
asset_library_id = gen_task.asset_library_id
@@ -265,6 +321,9 @@ def generate_video(self, task_id: str) -> dict:
finally:
session.close()
# 标记任务为 running
_update_task_status(task_id, "mark_processing")
try:
editing_mode = EditingMode(mode)
except ValueError:
@@ -315,7 +374,7 @@ def generate_video(self, task_id: str) -> dict:
file_url = f"{GENERATED_FILES_URL_PREFIX}/{task_id}/{output_name}"
# 创建 GeneratedVideo 记录 + 查重
_create_video_record_and_dedup(
video_count = _create_video_record_and_dedup(
task_id=task_id,
project_id=project_id,
batch_id=batch_id,
@@ -326,6 +385,11 @@ def generate_video(self, task_id: str) -> dict:
mode=editing_mode.value,
)
# 标记任务为 completed
_update_task_status(task_id, "mark_completed", result_count=video_count or 1)
logger.info("视频生成完成: task_id=%s duration=%.2fs file_size=%d", task_id, duration, file_size)
return {
"status": "completed",
"task_id": task_id,
@@ -337,7 +401,9 @@ def generate_video(self, task_id: str) -> dict:
"mode": editing_mode.value,
}
except Exception as error:
logger.error(f"Video generation failed: {error}")
logger.error(f"Video generation failed: {error}", exc_info=True)
# 标记任务为 failed
_update_task_status(task_id, "mark_failed", error_message=str(error))
return {
"status": "failed",
"task_id": task_id,
@@ -355,12 +421,15 @@ def _create_video_record_and_dedup(
duration: float,
video_path: str,
mode: str,
) -> None:
"""创建 GeneratedVideo 记录,计算指纹并执行查重(历史 + 批次)。"""
) -> int:
"""创建 GeneratedVideo 记录,计算指纹并执行查重(历史 + 批次)。
Returns:
创建的视频记录数量(1 表示成功,0 表示失败)
"""
from uuid import uuid4
from video_processing.dedup import VideoDeduplicator
from worker_app.db import SessionLocal
from packages.adapters.sqlalchemy_impl.generated_video_repository import (
SQLAlchemyGeneratedVideoRepository,
@@ -395,7 +464,7 @@ def _create_video_record_and_dedup(
except Exception as fp_err:
logger.warning(f"Fingerprint computation failed for {video_id}: {fp_err}")
session.commit()
return
return 1
generated_video.video_fingerprint = fingerprint.to_dict()
@@ -420,8 +489,10 @@ def _create_video_record_and_dedup(
video_repo.update(generated_video)
session.commit()
logger.info(f"GeneratedVideo record created: {video_id} (task={task_id}, dup={generated_video.is_duplicate})")
return 1
except Exception as e:
logger.error(f"Failed to create video record / dedup for task {task_id}: {e}")
session.rollback()
return 0
finally:
session.close()
+235
View File
@@ -0,0 +1,235 @@
# CI 必需环境变量清单
> 本文档整理小虾 SaaS 项目中所有从环境变量读取的配置项,明确哪些是 CI 测试必须的、哪些是可选的。
> 最后更新:2026-07-09
## 目录
- [一、配置来源说明](#一配置来源说明)
- [二、CI 必需环境变量(P0](#二ci-必需环境变量p0)
- [三、可选环境变量(有默认值)](#三可选环境变量有默认值)
- [四、测试专用环境变量](#四测试专用环境变量)
- [五、Worker 服务环境变量](#五worker-服务环境变量)
- [六、当前 CI 配置对照](#六当前-ci-配置对照)
---
## 一、配置来源说明
项目的环境变量配置主要来自以下几处:
| 来源 | 文件路径 | 说明 |
|------|---------|------|
| API 主配置 | `apps/api/app/config.py` | pydantic `Settings` 类,API 服务核心配置 |
| Worker 配置 | `apps/worker/worker_app/core/config.py` | pydantic `WorkerSettings` 类,Worker 服务配置 |
| 共享配置 | `packages/shared/config.py` | pydantic `SharedSettings` 类,API + Worker 共享配置 |
| 直接读取 | 各模块中 `os.environ` / `os.getenv` | 散落在各业务模块中的直接读取 |
> **注意**pydantic-settings 配置默认 `case_sensitive=False`,即环境变量名不区分大小写,但习惯上使用大写。
---
## 二、CI 必需环境变量(P0
以下变量是 CI 运行测试**必须配置**的,缺失会导致测试启动失败或核心功能异常。
| 变量名 | 用途说明 | 默认值 | 影响范围 |
|--------|---------|--------|---------|
| `DATABASE_URL` | 数据库连接字符串 | `postgresql+psycopg://postgres:postgres@localhost:5432/xiaoxia_saas` | 集成测试、 Alembic 迁移验证 |
| `USE_IN_MEMORY_DB` | 是否使用内存数据库(SQLite) | `false` | 单元测试(设为 `true` 可跳过 PostgreSQL 依赖) |
| `JWT_SECRET_KEY` | JWT 签名密钥,**无安全默认值**,必须显式设置 | `None`(启动校验失败) | 所有涉及认证的 API 测试 |
> **说明**
> - 单元测试通过 `USE_IN_MEMORY_DB=true` 使用 SQLite 内存数据库,无需 PostgreSQL
> - 集成测试需要真实 PostgreSQL,需设置 `DATABASE_URL`
> - `JWT_SECRET_KEY` 在测试文件中通过 `os.environ.setdefault()` 设置了测试用默认值,CI 中可不额外配置,但生产环境必须配置
---
## 三、可选环境变量(有默认值)
以下变量都有合理的默认值,CI 中可以不配置,使用默认值即可。
### 3.1 应用基础配置
| 变量名 | 用途说明 | 默认值 |
|--------|---------|--------|
| `APP_NAME` | 应用名称 | `xiaoxia-saas` |
| `APP_VERSION` | 应用版本号 | `0.1.61` / `unknown` |
| `ENVIRONMENT` | 运行环境标识 | `development` |
| `DEBUG` | 是否开启调试模式 | `true` |
| `APP_BASE_URL` | 应用基础 URL(用于生成邮件链接等) | `http://localhost:3000` |
| `API_HOST` | API 服务绑定地址 | `0.0.0.0` |
| `API_PORT` | API 服务端口 | `8000` |
| `API_PREFIX` | API 路由前缀 | `/api/v1` |
| `APP_ENV` | 环境标识(用于加载 .env.{env} 文件) | `development` |
| `LOG_LEVEL` | 日志级别 | `INFO` |
### 3.2 数据库连接池配置
| 变量名 | 用途说明 | 默认值 |
|--------|---------|--------|
| `DATABASE_POOL_SIZE` | 连接池大小 | `20` |
| `DATABASE_MAX_OVERFLOW` | 最大溢出连接数 | `10` (API) / `40` (Worker) |
| `DATABASE_POOL_TIMEOUT` | 获取连接超时时间(秒) | `30` |
| `DATABASE_POOL_RECYLE` / `DATABASE_POOL_RECYCLE` | 连接回收时间(秒) | `3600` |
| `AUTO_CREATE_SCHEMA` | 是否自动创建表结构 | `false` |
### 3.3 Redis / Celery 配置
| 变量名 | 用途说明 | 默认值 |
|--------|---------|--------|
| `REDIS_URL` | Redis 连接地址 | `redis://localhost:6379/0` |
| `REDIS_MAX_CONNECTION` | Redis 最大连接数 | `50` |
| `ENABLE_REDIS_SESSIONS` | 是否启用 Redis 会话存储 | `false` |
| `CELERY_BROKER_URL` / `BROKER_URL` | Celery Broker 地址 | `redis://localhost:6379/0` |
| `CELERY_RESULT_BACKEND` / `RESULT_BACKEND` | Celery 结果后端 | `redis://localhost:6379/1` |
### 3.4 JWT 配置
| 变量名 | 用途说明 | 默认值 |
|--------|---------|--------|
| `JWT_ALGORITHM` | JWT 签名算法 | `HS256`(隐式默认) |
| `JWT_ACCESS_TOKEN_EXPIRE_MINUTES` | Access Token 过期时间(分钟) | `30`(隐式默认) |
| `JWT_REFRESH_TOKEN_EXPIRE_DAYS` | Refresh Token 过期时间(天) | `30`(隐式默认) |
| `JWT_SECRET_KEY_OLD` | 旧 JWT 密钥(用于密钥轮换) | `None` |
| `SECRET_ROTATION_DAYS` | 密钥轮换建议天数 | `90` |
### 3.5 邮件配置
| 变量名 | 用途说明 | 默认值 |
|--------|---------|--------|
| `ENABLE_EMAIL_DELIVERY` | 是否启用邮件发送 | `false` |
| `SMTP_HOST` | SMTP 服务器地址 | `smtp.gmail.com` |
| `SMTP_PORT` | SMTP 端口 | `587` |
| `SMTP_USER` | SMTP 用户名 | `""`(空) |
| `SMTP_PASSWORD` | SMTP 密码 | `""`(空) |
| `SMTP_FROM_EMAIL` | 发件人邮箱 | `""`(空) |
| `SMTP_FROM_NAME` | 发件人名称 | `小虾 SaaS` |
| `SMTP_USE_TLS` | 是否使用 TLS | `true` |
### 3.6 OSS 阿里云存储配置
| 变量名 | 用途说明 | 默认值 |
|--------|---------|--------|
| `OSS_ENDPOINT` | OSS Endpoint | `oss-cn-hangzhou.aliiyuncs.com` |
| `OSS_ACCESS_KEY_ID` | OSS Access Key ID | `""`(空) |
| `OSS_ACCESS_KEY_SECRET` | OSS Access Key Secret | `""`(空) |
| `OSS_BUCKET_NAME` | OSS Bucket 名称 | `xiaoxia-autocut` |
| `OSS_DIRECT_UPLOAD_MAX_MB` / `MAX_UPLOAD_SIZE_MB` | 直传最大文件大小(MB | `2000` |
| `OSS_DIRECT_UPLOAD_EXPIRE_SECONDS` | 直传签名过期时间(秒) | `900` |
### 3.7 CosyVoice 语音合成配置
| 变量名 | 用途说明 | 默认值 |
|--------|---------|--------|
| `COSYVOICE_API_KEY` | CosyVoice API Key | `""`(空) |
| `COSYVOICE_BASE_URL` | CosyVoice API 地址 | `https://dashscope.aliyuncs.com/api/v1/services/aigc/text2audio` |
| `COSYVOICE_MODEL` | CosyVoice 模型 | `cosyvoice-v1` |
| `COSYVOICE_VOICE` | 默认音色 | `longxiaochun` |
| `COSYVOICE_SAMPLE_RATE` | 采样率 | `22050` |
| `COSYVOICE_FORMAT` | 输出格式 | `mp3` |
### 3.8 CORS 配置
| 变量名 | 用途说明 | 默认值 |
|--------|---------|--------|
| `CORS_ORIGINS_RAW` | CORS 允许的源(逗号分隔) | `http://localhost:3000,http://localhost:5173,http://localhost:8000` |
### 3.9 文件存储 / 生成文件配置
| 变量名 | 用途说明 | 默认值 |
|--------|---------|--------|
| `GENERATED_FILES_DIR` | 生成文件本地存储目录 | `/app/generated` |
| `GENERATED_FILES_URL_PREFIX` | 生成文件访问 URL 前缀 | `/generated-files` |
| `VIDEO_OUTPUT_DIR` | 视频输出目录 | `{tempdir}/video_output` |
| `PUBLIC_API_BASE_URL` | 公开 API 基础 URL | `https://api.xiaoxiajianji.com` |
### 3.10 监控 / 指标配置
| 变量名 | 用途说明 | 默认值 |
|--------|---------|--------|
| `METRICS_AUTH_TOKEN` | Prometheus 指标接口认证 Token | `""`(空,不启用认证) |
### 3.11 内部 API 配置
| 变量名 | 用途说明 | 默认值 |
|--------|---------|--------|
| `INTERNAL_API_KEYS` | 内部 API 调用密钥列表(逗号分隔) | `""`(空) |
---
## 四、测试专用环境变量
以下变量仅在测试或冒烟测试脚本中使用。
| 变量名 | 用途说明 | 默认值 | 使用位置 |
|--------|---------|--------|---------|
| `SMOKE_TEST_PASSWORD` | 冒烟测试用的测试账号密码 | `changeme` | `scripts/smoke_*.py` |
| `MIGRATION_SINCE_REVISION` | 迁移安全检查的起始版本 | `None` | `scripts/check_migration_safety.py` |
| `MIGRATION_DIFF_AGAINST` | 迁移 diff 对比的目标分支/版本 | `None` | `scripts/check_migration_safety.py` |
---
## 五、Worker 服务环境变量
以下变量主要用于 Worker(Celery)服务,CI 的单元/集成测试通常不涉及。
| 变量名 | 用途说明 | 默认值 |
|--------|---------|--------|
| `WORKER_NAME` | Worker 名称 | `xiaoxia-saas-worker` |
| `WORKER_CONCURRENCY` | Worker 并发数 | `4` |
| `WORKER_MAX_TASKS_PER_CHILD` | 每个子进程最大任务数 | `1000` |
---
## 六、当前 CI 配置对照
当前 `.gitea/workflows/ci-cd.yml``validate` job 配置的环境变量:
| 变量名 | CI 配置值 | 是否必需 | 备注 |
|--------|----------|---------|------|
| `DATABASE_URL` | `postgresql+psycopg://postgres:postgres@127.0.0.1:5432/xiaoxia_saas` | ✅ 是 | Job 级别配置 |
| `USE_IN_MEMORY_DB` | `"false"`Job 级) / `"true"`(单元测试 step 级) | ✅ 是 | 单元测试 step 覆盖为 `true` |
| `JWT_SECRET_KEY` | (未配置) | ⚠️ 测试内置 | 测试文件中通过 `setdefault` 设置了测试密钥 |
### 6.1 CI 环境变量现状评估
-**数据库配置完备**DATABASE_URL + USE_IN_MEMORY_DB 已正确配置
-**JWT 密钥**:测试代码内置默认值,CI 可正常运行
- ⚠️ **缺少 Redis 配置**:但当前测试不依赖 Redis,使用默认值即可
- ⚠️ **缺少邮件/OSS/语音配置**:均为可选,CI 中使用空默认值不影响核心测试
### 6.2 建议后续补充
如果未来测试覆盖到以下功能,需要在 CI 中补充对应配置:
1. **Redis 相关测试** → 配置 `REDIS_URL`
2. **邮件发送测试** → 配置 `ENABLE_EMAIL_DELIVERY` 及 SMTP 相关变量
3. **OSS 上传测试** → 配置 OSS 相关变量(或使用 mock)
4. **语音合成测试** → 配置 CosyVoice 相关变量(或使用 mock
---
## 附录:环境变量读取位置索引
### pydantic Settings 类
- `apps/api/app/config.py``Settings` 类(API 主配置)
- `apps/worker/worker_app/core/config.py``WorkerSettings` 类(Worker 配置)
- `packages/shared/config.py``SharedSettings` 类(共享配置)
### 直接 os.environ / os.getenv 读取
| 变量名 | 文件位置 |
|--------|---------|
| `VIDEO_OUTPUT_DIR` | `apps/worker/video_processing/video_compose_service.py``apps/worker/worker_app/tasks/compose_video.py` |
| `INTERNAL_API_KEYS` | `apps/api/app/api/routes/auth.py` |
| `APP_ENV` / `ENV` | `apps/api/app/api/routes/auth.py`、各 config.py 的 `get_settings()` |
| `GENERATED_FILES_DIR` | `apps/worker/worker_app/tasks/generation.py``apps/api/main.py``scripts/cleanup_generated_files.py` |
| `GENERATED_FILES_URL_PREFIX` | `apps/worker/worker_app/tasks/generation.py``apps/api/main.py``apps/api/app/core/storage.py` |
| `PUBLIC_API_BASE_URL` | `apps/worker/worker_app/tasks/generation.py` |
| `METRICS_AUTH_TOKEN` | `apps/api/app/middleware/prometheus_metrics.py` |
| `APP_VERSION` | `apps/api/app/middleware/prometheus_metrics.py` |
| `SMOKE_TEST_PASSWORD` | `scripts/smoke_*.py` |
| `MIGRATION_SINCE_REVISION` | `scripts/check_migration_safety.py` |
| `MIGRATION_DIFF_AGAINST` | `scripts/check_migration_safety.py` |
| `DATABASE_URL` | `alembic/env.py` |
+160
View File
@@ -1,3 +1,11 @@
"""GenerationTask 领域模型 — 视频生成任务.
状态机:
pending → running → completed
↘ failed → pending (重试)
↘ cancelled
"""
from __future__ import annotations
import sys
@@ -17,11 +25,43 @@ from uuid import uuid4
class GenerationTaskStatus(StrEnum):
"""生成任务状态枚举。"""
PENDING = "pending"
"""待处理(任务已创建,等待执行)"""
RUNNING = "running"
"""运行中(正在生成视频)"""
COMPLETED = "completed"
"""已完成(视频生成成功)"""
FAILED = "failed"
"""失败(生成失败)"""
CANCELLED = "cancelled"
"""已取消(用户取消或系统取消)"""
# 终态集合
TERMINAL_STATUSES = frozenset(
{GenerationTaskStatus.COMPLETED, GenerationTaskStatus.FAILED, GenerationTaskStatus.CANCELLED}
)
# 合法状态转换
_VALID_TRANSITIONS: dict[GenerationTaskStatus, set[GenerationTaskStatus]] = {
GenerationTaskStatus.PENDING: {
GenerationTaskStatus.RUNNING,
GenerationTaskStatus.FAILED,
GenerationTaskStatus.CANCELLED,
},
GenerationTaskStatus.RUNNING: {
GenerationTaskStatus.COMPLETED,
GenerationTaskStatus.FAILED,
GenerationTaskStatus.CANCELLED,
},
GenerationTaskStatus.FAILED: {GenerationTaskStatus.PENDING}, # 重试回到 pending
}
@dataclass(slots=True)
@@ -83,3 +123,123 @@ class GenerationTask:
asset_select_mode=asset_select_mode,
batch_id=batch_id,
)
# ── 状态查询 ────────────────────────────────────────────────────────────
@property
def is_terminal(self) -> bool:
"""是否处于终态(completed / failed / cancelled)。"""
return self.status in TERMINAL_STATUSES
@property
def is_completed(self) -> bool:
"""是否已完成。"""
return self.status == GenerationTaskStatus.COMPLETED
@property
def is_failed(self) -> bool:
"""是否失败。"""
return self.status == GenerationTaskStatus.FAILED
@property
def is_running(self) -> bool:
"""是否运行中。"""
return self.status == GenerationTaskStatus.RUNNING
# ── 状态转换 ────────────────────────────────────────────────────────────
def transition_to(self, new_status: GenerationTaskStatus | str) -> None:
"""执行状态转换。
Args:
new_status: 目标状态
Raises:
ValueError: 非法状态转换
"""
if isinstance(new_status, str):
try:
new_status = GenerationTaskStatus(new_status)
except ValueError:
raise ValueError(f"无效状态: {new_status}")
allowed = _VALID_TRANSITIONS.get(self.status, set())
if new_status not in allowed:
raise ValueError(
f"非法状态转换: {self.status.value}{new_status.value}"
f"允许: {{{', '.join(sorted(s.value for s in allowed))}}}"
)
self.status = new_status
def mark_processing(self) -> None:
"""标记为处理中(pending → running)。
设置 started_at,清除 error_message。
Raises:
ValueError: 当前状态不允许转换到 running
"""
self.transition_to(GenerationTaskStatus.RUNNING)
self.started_at = datetime.now(timezone.utc)
self.error_message = ""
def mark_completed(self, result_count: int = 1) -> None:
"""标记为已完成(running → completed)。
设置 completed_at、progress=100.0、result_count,清除 error_message。
Args:
result_count: 生成的视频数量,默认为 1
Raises:
ValueError: 当前状态不允许转换到 completed
"""
self.transition_to(GenerationTaskStatus.COMPLETED)
self.completed_at = datetime.now(timezone.utc)
self.progress = 100.0
self.result_count = result_count
self.error_message = ""
def mark_failed(self, error_message: str) -> None:
"""标记为失败(pending / running → failed)。
设置 error_message、completed_at。
Args:
error_message: 错误信息
Raises:
ValueError: 当前状态不允许转换到 failed
"""
self.transition_to(GenerationTaskStatus.FAILED)
self.error_message = error_message
self.completed_at = datetime.now(timezone.utc)
def mark_cancelled(self) -> None:
"""标记为已取消(pending / running → cancelled)。
设置 completed_at。
Raises:
ValueError: 当前状态不允许转换到 cancelled
"""
self.transition_to(GenerationTaskStatus.CANCELLED)
self.completed_at = datetime.now(timezone.utc)
def mark_pending_from_failed(self) -> None:
"""从失败状态重置为待处理(用于重试)。
清除 error_message、started_at、completed_at、progress。
Raises:
ValueError: 当前状态不是 failed
"""
if self.status != GenerationTaskStatus.FAILED:
raise ValueError(f"只有 failed 状态的任务可以重置为 pending,当前状态: {self.status.value}")
self.transition_to(GenerationTaskStatus.PENDING)
self.error_message = ""
self.started_at = None
self.completed_at = None
self.progress = 0.0
self.result_count = 0
+5
View File
@@ -1,3 +1,8 @@
[pytest]
pythonpath = . apps/api apps/worker
testpaths = tests
# ===== 覆盖率配置 =====
# 覆盖率统计范围(供 --cov 使用时的默认源)
# 注意:addopts 不默认开启 --cov,避免影响本地开发调试
# CI 中通过命令行参数显式开启:--cov=apps --cov-report=term --cov-report=xml --cov-fail-under=50
+3
View File
@@ -24,6 +24,9 @@ celery==5.4.0
# 对象存储
oss2==2.18.4
cryptography==46.0.5
# 覆盖系统预装的旧版pyOpenSSL,与cryptography 46.0.5兼容
pyOpenSSL==26.2.0
# HTTP 客户端
httpx==0.27.2
+455
View File
@@ -0,0 +1,455 @@
"""GenerationTask 领域模型状态机单元测试.
覆盖:
- 初始状态为 pending
- mark_processing: pending → running
- mark_completed: running → completed
- mark_failed: pending/running → failed
- mark_cancelled: pending/running → cancelled
- mark_pending_from_failed: failed → pending(重试)
- 非法状态转换抛出 ValueError
- is_terminal / is_completed / is_failed / is_running 属性
- 状态转换时的时间戳设置
"""
from __future__ import annotations
import pytest
from packages.domain.generation_task import (
TERMINAL_STATUSES,
GenerationTask,
GenerationTaskStatus,
)
def _make_task(**overrides) -> GenerationTask:
"""创建一个测试用的 GenerationTask。"""
defaults = dict(
id="task-test-001",
project_id="proj-1",
asset_library_id="lib-1",
)
defaults.update(overrides)
return GenerationTask(**defaults)
# ── 初始状态 ──────────────────────────────────────────────────────────────────
class TestInitialState:
"""测试初始状态。"""
def test_default_status_is_pending(self) -> None:
"""新创建的任务默认状态为 pending。"""
task = _make_task()
assert task.status == GenerationTaskStatus.PENDING
assert task.progress == 0.0
assert task.result_count == 0
assert task.error_message == ""
assert task.started_at is None
assert task.completed_at is None
def test_create_factory_returns_pending(self) -> None:
"""GenerationTask.create() 返回的任务状态为 pending。"""
task = GenerationTask.create(
project_id="proj-1",
asset_library_id="lib-1",
created_by_user_id="user-1",
)
assert task.status == GenerationTaskStatus.PENDING
assert task.progress == 0.0
assert task.result_count == 0
def test_is_not_terminal_initially(self) -> None:
"""初始状态不是终态。"""
task = _make_task()
assert not task.is_terminal
assert not task.is_completed
assert not task.is_failed
assert not task.is_running
def test_terminal_statuses_constant(self) -> None:
"""终态集合包含 completed / failed / cancelled。"""
assert GenerationTaskStatus.COMPLETED in TERMINAL_STATUSES
assert GenerationTaskStatus.FAILED in TERMINAL_STATUSES
assert GenerationTaskStatus.CANCELLED in TERMINAL_STATUSES
assert GenerationTaskStatus.PENDING not in TERMINAL_STATUSES
assert GenerationTaskStatus.RUNNING not in TERMINAL_STATUSES
# ── mark_processing ──────────────────────────────────────────────────────────
class TestMarkProcessing:
"""测试 pending → running 转换。"""
def test_pending_to_running_success(self) -> None:
"""pending 状态的任务可以标记为 running。"""
task = _make_task()
task.mark_processing()
assert task.status == GenerationTaskStatus.RUNNING
assert task.is_running
assert task.started_at is not None
assert task.error_message == ""
def test_started_at_is_set(self) -> None:
"""mark_processing 设置 started_at 时间戳。"""
task = _make_task()
assert task.started_at is None
task.mark_processing()
assert task.started_at is not None
def test_error_message_cleared(self) -> None:
"""mark_processing 清除 error_message(如果有的话)。"""
task = _make_task()
# 注意:pending 状态通常没有 error_message,这里验证确保被清除
task.error_message = "some old error"
# 直接设置状态绕过校验(模拟异常场景)
task.status = GenerationTaskStatus.PENDING
task.mark_processing()
assert task.error_message == ""
def test_running_to_running_raises(self) -> None:
"""running 状态不能再次 mark_processing。"""
task = _make_task()
task.mark_processing()
with pytest.raises(ValueError, match="非法状态转换"):
task.mark_processing()
def test_completed_to_running_raises(self) -> None:
"""completed 状态不能回到 running。"""
task = _make_task()
task.mark_processing()
task.mark_completed()
with pytest.raises(ValueError, match="非法状态转换"):
task.mark_processing()
def test_failed_to_running_raises(self) -> None:
"""failed 状态不能直接到 running(应先重置为 pending)。"""
task = _make_task()
task.mark_processing()
task.mark_failed("some error")
with pytest.raises(ValueError, match="非法状态转换"):
task.mark_processing()
# ── mark_completed ───────────────────────────────────────────────────────────
class TestMarkCompleted:
"""测试 running → completed 转换。"""
def test_running_to_completed_success(self) -> None:
"""running 状态的任务可以标记为 completed。"""
task = _make_task()
task.mark_processing()
task.mark_completed()
assert task.status == GenerationTaskStatus.COMPLETED
assert task.is_completed
assert task.is_terminal
assert task.completed_at is not None
def test_progress_set_to_100(self) -> None:
"""mark_completed 设置 progress 为 100.0。"""
task = _make_task()
task.mark_processing()
task.progress = 50.0 # 模拟中间进度
task.mark_completed()
assert task.progress == 100.0
def test_default_result_count_is_1(self) -> None:
"""默认 result_count 为 1。"""
task = _make_task()
task.mark_processing()
task.mark_completed()
assert task.result_count == 1
def test_custom_result_count(self) -> None:
"""可以指定 result_count。"""
task = _make_task()
task.mark_processing()
task.mark_completed(result_count=5)
assert task.result_count == 5
def test_error_message_cleared(self) -> None:
"""mark_completed 清除 error_message。"""
task = _make_task()
task.mark_processing()
task.error_message = "temporary error"
task.mark_completed()
assert task.error_message == ""
def test_completed_at_is_set(self) -> None:
"""mark_completed 设置 completed_at。"""
task = _make_task()
task.mark_processing()
assert task.completed_at is None
task.mark_completed()
assert task.completed_at is not None
def test_pending_to_completed_raises(self) -> None:
"""pending 状态不能直接到 completed。"""
task = _make_task()
with pytest.raises(ValueError, match="非法状态转换"):
task.mark_completed()
def test_completed_to_completed_raises(self) -> None:
"""completed 状态不能再次 mark_completed。"""
task = _make_task()
task.mark_processing()
task.mark_completed()
with pytest.raises(ValueError, match="非法状态转换"):
task.mark_completed()
def test_failed_to_completed_raises(self) -> None:
"""failed 状态不能直接到 completed。"""
task = _make_task()
task.mark_processing()
task.mark_failed("error")
with pytest.raises(ValueError, match="非法状态转换"):
task.mark_completed()
# ── mark_failed ──────────────────────────────────────────────────────────────
class TestMarkFailed:
"""测试 pending/running → failed 转换。"""
def test_pending_to_failed_success(self) -> None:
"""pending 状态可以直接标记为 failed。"""
task = _make_task()
task.mark_failed("资源不足")
assert task.status == GenerationTaskStatus.FAILED
assert task.is_failed
assert task.is_terminal
assert task.error_message == "资源不足"
assert task.completed_at is not None
def test_running_to_failed_success(self) -> None:
"""running 状态可以标记为 failed。"""
task = _make_task()
task.mark_processing()
task.mark_failed("生成失败:FFmpeg 错误")
assert task.status == GenerationTaskStatus.FAILED
assert task.is_failed
assert task.is_terminal
assert task.error_message == "生成失败:FFmpeg 错误"
assert task.completed_at is not None
def test_completed_to_failed_raises(self) -> None:
"""completed 状态不能标记为 failed。"""
task = _make_task()
task.mark_processing()
task.mark_completed()
with pytest.raises(ValueError, match="非法状态转换"):
task.mark_failed("late error")
def test_failed_to_failed_raises(self) -> None:
"""failed 状态不能再次 mark_failed。"""
task = _make_task()
task.mark_failed("first error")
with pytest.raises(ValueError, match="非法状态转换"):
task.mark_failed("second error")
def test_error_message_preserved(self) -> None:
"""错误信息被正确保存。"""
task = _make_task()
error_msg = "FFmpeg returned non-zero exit status 1"
task.mark_failed(error_msg)
assert task.error_message == error_msg
# ── mark_cancelled ───────────────────────────────────────────────────────────
class TestMarkCancelled:
"""测试 pending/running → cancelled 转换。"""
def test_pending_to_cancelled_success(self) -> None:
"""pending 状态可以取消。"""
task = _make_task()
task.mark_cancelled()
assert task.status == GenerationTaskStatus.CANCELLED
assert task.is_terminal
assert task.completed_at is not None
def test_running_to_cancelled_success(self) -> None:
"""running 状态可以取消。"""
task = _make_task()
task.mark_processing()
task.mark_cancelled()
assert task.status == GenerationTaskStatus.CANCELLED
assert task.is_terminal
def test_completed_to_cancelled_raises(self) -> None:
"""completed 状态不能取消。"""
task = _make_task()
task.mark_processing()
task.mark_completed()
with pytest.raises(ValueError, match="非法状态转换"):
task.mark_cancelled()
def test_failed_to_cancelled_raises(self) -> None:
"""failed 状态不能取消。"""
task = _make_task()
task.mark_failed("some error")
with pytest.raises(ValueError, match="非法状态转换"):
task.mark_cancelled()
# ── mark_pending_from_failed (重试) ─────────────────────────────────────────
class TestMarkPendingFromFailed:
"""测试 failed → pending(重试)转换。"""
def test_failed_to_pending_success(self) -> None:
"""failed 状态可以重置为 pending(用于重试)。"""
task = _make_task()
task.mark_processing()
task.mark_failed("临时错误")
task.mark_pending_from_failed()
assert task.status == GenerationTaskStatus.PENDING
assert not task.is_terminal
assert task.error_message == ""
assert task.started_at is None
assert task.completed_at is None
assert task.progress == 0.0
assert task.result_count == 0
def test_pending_to_pending_raises(self) -> None:
"""pending 状态不能调用 mark_pending_from_failed。"""
task = _make_task()
with pytest.raises(ValueError, match="只有 failed 状态"):
task.mark_pending_from_failed()
def test_running_to_pending_raises(self) -> None:
"""running 状态不能调用 mark_pending_from_failed。"""
task = _make_task()
task.mark_processing()
with pytest.raises(ValueError, match="只有 failed 状态"):
task.mark_pending_from_failed()
def test_completed_to_pending_raises(self) -> None:
"""completed 状态不能调用 mark_pending_from_failed。"""
task = _make_task()
task.mark_processing()
task.mark_completed()
with pytest.raises(ValueError, match="只有 failed 状态"):
task.mark_pending_from_failed()
# ── transition_to 通用方法 ───────────────────────────────────────────────────
class TestTransitionTo:
"""测试通用的 transition_to 方法。"""
def test_string_status_conversion(self) -> None:
"""可以传入字符串形式的状态。"""
task = _make_task()
task.transition_to("running")
assert task.status == GenerationTaskStatus.RUNNING
def test_invalid_string_raises(self) -> None:
"""无效的状态字符串抛出 ValueError。"""
task = _make_task()
with pytest.raises(ValueError, match="无效状态"):
task.transition_to("invalid_status")
def test_enum_status(self) -> None:
"""可以传入枚举形式的状态。"""
task = _make_task()
task.transition_to(GenerationTaskStatus.RUNNING)
assert task.status == GenerationTaskStatus.RUNNING
def test_error_message_includes_allowed_statuses(self) -> None:
"""错误信息包含允许的状态列表。"""
task = _make_task()
task.mark_processing()
task.mark_completed()
with pytest.raises(ValueError) as exc_info:
task.transition_to(GenerationTaskStatus.RUNNING)
assert "completed" in str(exc_info.value)
assert "running" in str(exc_info.value)
# ── 完整流转路径 ─────────────────────────────────────────────────────────────
class TestFullFlow:
"""测试完整的状态流转路径。"""
def test_happy_path(self) -> None:
"""正常路径:pending → running → completed。"""
task = _make_task()
assert task.status == GenerationTaskStatus.PENDING
assert not task.is_terminal
task.mark_processing()
assert task.status == GenerationTaskStatus.RUNNING
assert task.started_at is not None
assert not task.is_terminal
task.mark_completed(result_count=3)
assert task.status == GenerationTaskStatus.COMPLETED
assert task.is_completed
assert task.is_terminal
assert task.completed_at is not None
assert task.result_count == 3
assert task.progress == 100.0
def test_failure_path_from_running(self) -> None:
"""失败路径:pending → running → failed。"""
task = _make_task()
task.mark_processing()
assert task.is_running
task.mark_failed("网络超时")
assert task.is_failed
assert task.is_terminal
assert task.error_message == "网络超时"
assert task.completed_at is not None
def test_failure_path_from_pending(self) -> None:
"""失败路径:pending → failed(启动前校验失败等)。"""
task = _make_task()
task.mark_failed("参数校验失败")
assert task.is_failed
assert task.is_terminal
def test_retry_path(self) -> None:
"""重试路径:pending → running → failed → pending → running → completed。"""
task = _make_task()
# 第一次尝试失败
task.mark_processing()
task.mark_failed("临时错误")
assert task.is_failed
# 重试
task.mark_pending_from_failed()
assert task.status == GenerationTaskStatus.PENDING
assert task.error_message == ""
# 第二次成功
task.mark_processing()
task.mark_completed()
assert task.is_completed
def test_cancel_from_pending(self) -> None:
"""取消路径:pending → cancelled。"""
task = _make_task()
task.mark_cancelled()
assert task.status == GenerationTaskStatus.CANCELLED
assert task.is_terminal
def test_cancel_from_running(self) -> None:
"""取消路径:pending → running → cancelled。"""
task = _make_task()
task.mark_processing()
task.mark_cancelled()
assert task.status == GenerationTaskStatus.CANCELLED
assert task.is_terminal