Compare commits

...

16 Commits

Author SHA1 Message Date
Deploy Agent 6897a4be96 fix(frontend): 移除 Dashboard.tsx 未使用的 Tag import
CI/CD Pipeline / Unit Tests (push) Failing after 7s
CI/CD Pipeline / Validate Code Quality And Tests (push) Failing after 9s
CI/CD Pipeline / Integration Tests (push) Failing after 5s
CI/CD Pipeline / Frontend Lint (push) Failing after 1m25s
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
TS6133: 'Tag' is declared but its value is never read.
Phase 3 Mock 替换后遗留的未使用 import。
2026-07-13 18:25:29 +08:00
xiaoxia 6d2d5abff2 refactor(frontend): Phase 4 前端 P0+P1 清理
CI/CD Pipeline / Validate Code Quality And Tests (push) Failing after 6s
CI/CD Pipeline / Unit Tests (push) Failing after 9s
CI/CD Pipeline / Integration Tests (push) Failing after 7s
CI/CD Pipeline / Frontend Lint (push) Failing after 1m7s
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 API Integration Tests (push) Has been skipped
CI/CD Pipeline / Staging E2E Tests (push) Has been skipped
CI/CD Pipeline / Deploy Production (push) Has been skipped
CI/CD Pipeline / Production Browser E2E (push) Has been skipped
Merge PR #275: refactor(frontend): Phase 4 前端 P0+P1 清理 — CSS 死代码 + 未使用组件删除 into develop
2026-07-13 18:21:14 +08:00
xiaoxia e3f5ab5611 fix(accounts): 加回误删的 acc-account-list + 清除响应式死代码
CI/CD Pipeline / Validate Code Quality And Tests (pull_request) Failing after 23s
CI/CD Pipeline / Integration Tests (pull_request) Failing after 10s
CI/CD Pipeline / Unit Tests (pull_request) Failing after 52s
CI/CD Pipeline / Frontend Lint (pull_request) Failing after 1m11s
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
- 加回 acc-account-list(Accounts.tsx 第89行引用)
- 清除 480px 响应式中残留的 acc-account-row / acc-account-avatar
2026-07-13 18:16:36 +08:00
xiaoxia d061bccdd2 Phase 4 Backend P0 cleanup
CI/CD Pipeline / Validate Code Quality And Tests (push) Failing after 6s
CI/CD Pipeline / Unit Tests (push) Failing after 9s
CI/CD Pipeline / Integration Tests (push) Failing after 5s
CI/CD Pipeline / Frontend Lint (push) Failing after 1m10s
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
Merge PR #276: Phase 4 Backend P0 cleanup into develop
2026-07-13 18:04:49 +08:00
CI Bot a3967c6829 Phase 4 Backend P0 cleanup
CI/CD Pipeline / Validate Code Quality And Tests (pull_request) Failing after 6s
CI/CD Pipeline / Unit Tests (pull_request) Failing after 10s
CI/CD Pipeline / Frontend Lint (pull_request) Failing after 1m6s
CI/CD Pipeline / Build Production Runtime Images (pull_request) Has been skipped
CI/CD Pipeline / Build & Push Staging (Watchtower auto-deploy) (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 / Staging E2E Tests (pull_request) Has been skipped
CI/CD Pipeline / Production Browser E2E (pull_request) Has been skipped
CI/CD Pipeline / Integration Tests (pull_request) Failing after 28m22s
1. Remove unused aiosmtplib dependency from requirements.txt
2. Remove 5 unused config items (API_PREFIX, REDIS_MAX_CONNECTION,
   JWT_SECRET_KEY_OLD, SECRET_ROTATION_DAYS, LOG_LEVEL) from config.py,
   .env.production.example, and init_production_env.sh.
   Kept ENABLE_REDIS_SESSIONS (actively used in dependencies.py).
3. Fix project permission check TODOs in test_error_scenarios.py —
   permissions are implemented, updated assertions to expect 403.
4. Merge two Storage implementations into shared package:
   - packages/shared/storage.py: merged API features (HTTPS endpoint
     fix, diagnose(), detailed logging) into SharedStorageService
   - apps/api/app/core/storage.py: thin re-export wrapper for
     backward compatibility
   - Updated test mocks to target packages.shared.storage
2026-07-13 17:45:37 +08:00
xiaoxia 97a81a39f3 refactor(ui): 删除未使用组件 Form.tsx(全项目无引用)
CI/CD Pipeline / Validate Code Quality And Tests (pull_request) Failing after 9s
CI/CD Pipeline / Integration Tests (pull_request) Failing after 4s
CI/CD Pipeline / Unit Tests (pull_request) Failing after 45s
CI/CD Pipeline / Frontend Lint (pull_request) Failing after 1m39s
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-13 17:39:13 +08:00
xiaoxia eab471703e refactor(ui): 删除未使用组件 Pagination.tsx(全项目无引用) 2026-07-13 17:39:13 +08:00
xiaoxia 32a53ab9e6 refactor(ui): 删除未使用组件 Table.tsx(全项目无引用) 2026-07-13 17:39:12 +08:00
xiaoxia e50ba67c11 refactor(ui): 移除未使用组件导出(Table / Form / Pagination) 2026-07-13 17:39:12 +08:00
xiaoxia e8f9e2dabe refactor(accounts): 清理旧版列表视图样式(8个无引用类名,约64行)
移除:acc-account-list / acc-account-row / acc-account-avatar /
acc-account-info / acc-account-name / acc-status-pill /
acc-status--active / acc-status--expired / acc-status--limited
2026-07-13 17:39:12 +08:00
xiaoxia 3f773795ed refactor(dashboard): 清理 CSS 死代码(36个无引用类名→移除)+ 新增 xx-dashboard-empty 空状态样式
移除的未使用类名:
- xx-dashboard-welcome / xx-dashboard-main / xx-dashboard-left-col
- xx-kpi-card / xx-kpi-icon / xx-kpi-value / xx-kpi-label / xx-kpi-trend-*
- xx-task-item / xx-task-info / xx-task-status-* / xx-task-time / xx-task-action
- xx-chart-bar-wrapper / xx-chart-bar / xx-chart-bar-value / xx-chart-labels / xx-chart-label / xx-chart-total
- xx-quick-card / xx-quick-card-icon
- xx-storage-bar-* / xx-storage-section*
- xx-dashboard-section--start

新增:xx-dashboard-empty(P0 修复 — Dashboard 空状态裸奔)
2026-07-13 17:39:11 +08:00
xiaoxia 29ed5df884 feat(frontend): Phase 3 前端深度清理
CI/CD Pipeline / Frontend Lint (push) Failing after 2m1s
CI/CD Pipeline / Unit Tests (push) Failing after 26m42s
CI/CD Pipeline / Validate Code Quality And Tests (push) Failing after 26m54s
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
CI/CD Pipeline / Integration Tests (push) Has been cancelled
Merge PR #264: feat(frontend): Phase 3 前端深度清理 — Mock 数据真替换 + Admin.css 全清 into develop
2026-07-13 17:07:59 +08:00
xiaoxia 188e535af8 Merge pull request 'fix(ci): 修复新Runner pip失败 + 增加并发控制' (#271) from fix/ci-pip-and-concurrency into develop
CI/CD Pipeline / Validate Code Quality And Tests (push) Failing after 6s
CI/CD Pipeline / Unit Tests (push) Failing after 8s
CI/CD Pipeline / Integration Tests (push) Failing after 1m11s
CI/CD Pipeline / Frontend Lint (push) Successful in 2m53s
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-13 17:05:04 +08:00
xiaoxia d90bc6bbfc fix(ci): add --break-system-packages for pip + concurrency config
CI/CD Pipeline / Frontend Lint (pull_request) Failing after 16s
CI/CD Pipeline / Unit Tests (pull_request) Failing after 2m6s
CI/CD Pipeline / Validate Code Quality And Tests (pull_request) Failing after 4m21s
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
CI/CD Pipeline / Integration Tests (pull_request) Failing after 5m10s
2026-07-13 16:49:36 +08:00
xiaoxia 19d5dcbc5a chore(backend): Phase 3 后端清理
CI/CD Pipeline / Validate Code Quality And Tests (push) Failing after 22s
CI/CD Pipeline / Unit Tests (push) Failing after 19s
CI/CD Pipeline / Frontend Lint (push) Failing after 25s
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
CI/CD Pipeline / Integration Tests (push) Failing after 14s
Merge PR #265: chore(backend): Phase 3 清理 — 未使用依赖 + pyflakes 警告 + 测试冗余 into develop
2026-07-13 16:38:36 +08:00
CI Bot 1a57878f76 chore(backend): Phase 3 清理 — 未使用依赖删除 + pyflakes 警告清零 + 测试文件冗余清理
CI/CD Pipeline / Validate Code Quality And Tests (pull_request) Failing after 1m12s
CI/CD Pipeline / Unit Tests (pull_request) Failing after 2m17s
CI/CD Pipeline / Integration Tests (pull_request) Failing after 1m33s
CI/CD Pipeline / Frontend Lint (pull_request) Successful in 3m14s
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 / Deploy Production (pull_request) Has been skipped
CI/CD Pipeline / Staging API Integration Tests (pull_request) Has been skipped
CI/CD Pipeline / Production Browser E2E (pull_request) Has been skipped
1. 未使用依赖清理:
   - 从 requirements-base.txt 移除 cryptography 和 pyOpenSSL

2. pyflakes 警告清零 (apps/ + packages/ + tests/):
   - 移除 17 处未使用的 import (F401)
   - 修复 26 处未使用的局部变量 (F841):
     * 有副作用的赋值转为裸调用
     * 无副作用的赋值直接删除
   - 修复 1 处未使用的异常变量 (F841)
   - 修复 1 处空 except 块

3. 测试文件冗余清理:
   - 删除 tests/integration/test_project_management.py (模块级 skip,测试不存在的模块)
   - 删除 tests/integration/fixtures/duplication_routes_fixed.py (未被引用)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-13 16:14:46 +08:00
90 changed files with 231 additions and 1612 deletions
-1
View File
@@ -5,7 +5,6 @@ APP_ENV=production
ENVIRONMENT=production
DEBUG=false
USE_IN_MEMORY_DB=false
LOG_LEVEL=WARNING
# ==================== 数据库(必须修改)====================
DATABASE_URL=postgresql://prod_user:CHANGE_THIS_PASSWORD@db-prod:5432/xiaoxia_prod
+14 -10
View File
@@ -19,6 +19,10 @@ on:
permissions:
contents: read
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
jobs:
validate:
name: Validate Code Quality And Tests
@@ -88,9 +92,9 @@ jobs:
shell: sh
run: |
set -eu
python3 -m pip install -q -r requirements-base.txt
python3 -m pip install -q -r requirements.txt
python3 -m pip install -q -r requirements-dev.txt
python3 -m pip install --break-system-packages -q -r requirements-base.txt
python3 -m pip install --break-system-packages -q -r requirements.txt
python3 -m pip install --break-system-packages -q -r requirements-dev.txt
python3 -m black --version
python3 -m isort --version-number
python3 -m flake8 --version
@@ -217,9 +221,9 @@ jobs:
shell: sh
run: |
set -eu
python3 -m pip install -q -r requirements-base.txt
python3 -m pip install -q -r requirements.txt
python3 -m pip install -q -r requirements-dev.txt
python3 -m pip install --break-system-packages -q -r requirements-base.txt
python3 -m pip install --break-system-packages -q -r requirements.txt
python3 -m pip install --break-system-packages -q -r requirements-dev.txt
pytest --version
- name: Run unit tests with coverage
@@ -316,9 +320,9 @@ jobs:
shell: sh
run: |
set -eu
python3 -m pip install -q -r requirements-base.txt
python3 -m pip install -q -r requirements.txt
python3 -m pip install -q -r requirements-dev.txt
python3 -m pip install --break-system-packages -q -r requirements-base.txt
python3 -m pip install --break-system-packages -q -r requirements.txt
python3 -m pip install --break-system-packages -q -r requirements-dev.txt
pytest --version
- name: Start Redis
@@ -390,7 +394,7 @@ jobs:
shell: sh
run: |
set -eu
pip install -q pytest-rerunfailures
pip install --break-system-packages -q pytest-rerunfailures
PYTHONPATH="$PWD/apps/api:$PWD" python3 -m coverage run --append \
--source=apps/api/app,packages \
--omit="*/migrations/*,*/tests/*,*/test_*.py,*/site-packages/*" \
-1
View File
@@ -23,7 +23,6 @@ from fastapi import APIRouter, Depends, HTTPException, Query, status
from pydantic import BaseModel, Field
from packages.adapters.redis.feature_flag_store import (
FEATURE_FLAG_REDIS_PREFIX,
FeatureFlagConfig,
RedisFeatureFlagStore,
)
@@ -10,7 +10,6 @@ from app.core.task_enqueue import (
USER_PENDING_LIMIT,
GlobalQueueFull,
UserPendingLimitExceeded,
check_queue_limits,
safe_enqueue_generation_task,
)
from app.dependencies import (
-8
View File
@@ -19,7 +19,6 @@ class Settings(BaseSettings):
# Container bind address; external expose is controlled by Docker/Nginx.
API_HOST: str = "0.0.0.0" # nosec: B104
API_PORT: int = 8000
API_PREFIX: str = "/api/v1"
DATABASE_URL: str = "postgresql+psycopg://postgres:postgres@localhost:5432/xiaoxia_saas"
DATABASE_POOL_SIZE: int = 20
@@ -30,16 +29,10 @@ class Settings(BaseSettings):
AUTO_CREATE_SCHEMA: bool = False
REDIS_URL: str = "redis://localhost:6379/0"
REDIS_MAX_CONNECTION: int = 50
ENABLE_REDIS_SESSIONS: bool = False
# JWT secret key - MUST be set via environment variable, no default allowed
JWT_SECRET_KEY: Optional[str] = None
# 旧的 JWT secret key(用于密钥轮换期间验证旧 token)
# 在密钥轮换时,先设置新密钥,旧密钥保留在此处直到所有旧 token 过期
JWT_SECRET_KEY_OLD: Optional[str] = None
# 密钥轮换天数(到达此天数后建议更换密钥)
SECRET_ROTATION_DAYS: int = 90
# JWT 算法与过期时间(与 .env.example 对齐)
JWT_ALGORITHM: str = "HS256"
@@ -111,7 +104,6 @@ class Settings(BaseSettings):
)
OSS_DIRECT_UPLOAD_EXPIRE_SECONDS: int = 900
LOG_LEVEL: str = "INFO"
CORS_ORIGINS_RAW: str = "http://localhost:3000,http://localhost:5173,http://localhost:8000"
# 渲染引擎选择:legacy=旧VideoComposeServiceunified=新UnifiedRenderService
+11 -280
View File
@@ -1,283 +1,14 @@
"""阿里云 OSS 存储服务"""
"""Backward-compatible re-export from shared storage.
import base64
import datetime as dt
import hashlib
import hmac
import json
import logging
import os
from urllib.parse import urlparse
All storage logic now lives in ``packages.shared.storage``.
This module keeps old import paths working so existing code
does not need to change.
"""
try:
import oss2
except ImportError: # pragma: no cover - exercised in minimal local/test environments
oss2 = None
from app.config import get_settings
from packages.shared.storage import (
SharedStorageService as OSSStorageService,
get_shared_storage_service,
get_storage_service,
)
logger = logging.getLogger(__name__)
class OSSStorageService:
"""阿里云 OSS 存储服务"""
def __init__(self):
settings = get_settings()
self.bucket_name = settings.OSS_BUCKET_NAME
self.public_url = f"https://{settings.OSS_BUCKET_NAME}.{settings.OSS_ENDPOINT}"
self.local_url_prefix = os.getenv("GENERATED_FILES_URL_PREFIX", "/generated-files")
self.bucket = None
has_key_id = bool(settings.OSS_ACCESS_KEY_ID)
has_key_secret = bool(settings.OSS_ACCESS_KEY_SECRET)
if has_key_id and has_key_secret:
if oss2 is not None:
try:
# P0-2 修复:oss2.Bucket 的 endpoint 必须带 https:// 前缀,
# 否则 sign_url 默认生成 HTTP URL。
bucket_endpoint = settings.OSS_ENDPOINT
if not bucket_endpoint.startswith(("http://", "https://")):
bucket_endpoint = f"https://{bucket_endpoint}"
auth = oss2.Auth(
settings.OSS_ACCESS_KEY_ID,
settings.OSS_ACCESS_KEY_SECRET,
)
self.bucket = oss2.Bucket(
auth,
bucket_endpoint,
settings.OSS_BUCKET_NAME,
)
logger.info(
"OSS initialized: endpoint=%s bucket=%s",
settings.OSS_ENDPOINT,
settings.OSS_BUCKET_NAME,
)
except Exception as error:
logger.error("Failed to initialize OSS bucket client: %s", error)
else:
logger.error("oss2 SDK is not installed — OSS operations will fail")
else:
missing = []
if not has_key_id:
missing.append("OSS_ACCESS_KEY_ID")
if not has_key_secret:
missing.append("OSS_ACCESS_KEY_SECRET")
logger.error("OSS credentials not configured — missing: %s", ", ".join(missing))
self.access_key_id = settings.OSS_ACCESS_KEY_ID
self.access_key_secret = settings.OSS_ACCESS_KEY_SECRET
self.endpoint = settings.OSS_ENDPOINT
def diagnose(self) -> None:
"""启动诊断:输出 OSS 配置状态,帮助排查预签名 URL 问题。"""
key_id_display = (
f"{self.access_key_id[:4]}...{self.access_key_id[-4:]}" if len(self.access_key_id) > 8 else "(empty)"
)
logger.info(
"[OSS诊断] endpoint=%s bucket_name=%s access_key_id=%s",
self.endpoint,
self.bucket_name,
key_id_display,
)
if self.bucket is None:
logger.error(
"[OSS诊断] ❌ bucket=None — 预签名URL不可用!"
"原因: OSS_ACCESS_KEY_ID/OSS_ACCESS_KEY_SECRET 未配置或 oss2 未安装。"
"请检查服务器 .env 文件(如 /var/lib/xiaoxia-saas-staging/.env"
)
else:
logger.info("[OSS诊断] ✅ bucket 已配置,预签名URL可用")
def _is_local_generated_url(self, storage_key_or_url: str) -> bool:
parsed = urlparse(storage_key_or_url)
path = parsed.path if parsed.scheme else storage_key_or_url
return path.startswith(f"{self.local_url_prefix}/")
def create_direct_upload_post(
self,
storage_key: str,
content_type: str,
max_size_bytes: int,
expires_seconds: int,
) -> dict[str, object]:
"""创建浏览器直传 OSS 的 PostObject 表单。"""
if not self.access_key_id or not self.access_key_secret:
raise RuntimeError("OSS storage is not configured")
normalized_key = self._normalize_storage_key(storage_key)
if not normalized_key.startswith("uploads/"):
raise ValueError("direct upload key must be under uploads/")
expiration = (dt.datetime.now(dt.timezone.utc) + dt.timedelta(seconds=expires_seconds)).strftime(
"%Y-%m-%dT%H:%M:%S.000Z"
)
policy = {
"expiration": expiration,
"conditions": [
{"bucket": self.bucket_name},
{"key": normalized_key},
["content-length-range", 1, max_size_bytes],
["starts-with", "$Content-Type", content_type.split("/", 1)[0] + "/" if "/" in content_type else ""],
],
}
encoded_policy = base64.b64encode(json.dumps(policy, separators=(",", ":")).encode("utf-8")).decode("ascii")
signature = base64.b64encode(
hmac.new(self.access_key_secret.encode("utf-8"), encoded_policy.encode("utf-8"), hashlib.sha1).digest()
).decode("ascii")
return {
"url": self.public_url,
"method": "POST",
"storage_key": normalized_key,
"expires_at": expiration,
"fields": {
"key": normalized_key,
"OSSAccessKeyId": self.access_key_id,
"policy": encoded_policy,
"Signature": signature,
"success_action_status": "201",
"Content-Type": content_type,
},
}
def upload_file(
self,
file_or_path,
storage_key: str,
content_type: str = "application/octet-stream",
) -> str:
"""
上传文件到 OSS
Args:
file_or_path: 文件对象或本地文件路径
storage_key: 存储键(文件路径)
content_type: 内容类型
Returns:
文件公网 URL
"""
if self.bucket is None:
raise RuntimeError("OSS storage is not configured")
try:
# 如果是字符串路径,从本地文件上传
if isinstance(file_or_path, str):
self.bucket.put_object_from_file(storage_key, file_or_path, headers={"Content-Type": content_type})
else:
# 文件对象
file_or_path.seek(0)
self.bucket.put_object(storage_key, file_or_path, headers={"Content-Type": content_type})
return f"{self.public_url}/{storage_key}"
except Exception as e:
raise Exception(f"Failed to upload file to OSS: {e}")
def get_url(self, storage_key: str) -> str:
"""获取文件公网 URL"""
return f"{self.public_url}/{storage_key}"
def get_download_url(self, storage_key_or_url: str, expires_seconds: int = 3600) -> str:
"""
获取文件下载签名 URL(用于私有文件)
Args:
storage_key_or_url: 存储键或完整 URL
expires_seconds: 过期时间(秒)
Returns:
签名 URL
"""
if self.bucket is None:
if self._is_local_generated_url(storage_key_or_url):
return storage_key_or_url
logger.warning(
"get_download_url: OSS bucket not configured, returning raw URL. " "storage_key_or_url=%s",
storage_key_or_url[:200],
)
return self.get_url(self._normalize_storage_key(storage_key_or_url))
storage_key = self._normalize_storage_key(storage_key_or_url)
try:
signed = self.bucket.sign_url("GET", storage_key, expires_seconds)
logger.info(
"get_download_url: signed URL generated. storage_key=%s url_prefix=%s",
storage_key[:80],
signed[:60],
)
return signed
except Exception:
logger.exception(
"get_download_url: sign_url failed, falling back to raw URL. " "storage_key=%s",
storage_key[:200],
)
return self.get_url(storage_key)
def _normalize_storage_key(self, storage_key_or_url: str) -> str:
"""从 URL 中提取存储键"""
if storage_key_or_url.startswith("http://") or storage_key_or_url.startswith("https://"):
parsed = urlparse(storage_key_or_url)
# 移除开头的 /
return parsed.path.lstrip("/")
return storage_key_or_url.lstrip("/")
def download_file(self, storage_key: str, local_path: str):
"""
从 OSS 下载文件到本地
Args:
storage_key: 存储键
local_path: 本地文件路径
"""
if self.bucket is None:
raise RuntimeError("OSS storage is not configured")
try:
os.makedirs(os.path.dirname(local_path), exist_ok=True)
self.bucket.get_object_to_file(storage_key, local_path)
except Exception as e:
raise Exception(f"Failed to download file from OSS: {e}")
def delete_file(self, storage_key: str):
"""
删除 OSS 文件
Args:
storage_key: 存储键
"""
if self.bucket is None:
return
try:
self.bucket.delete_object(storage_key)
except Exception as error:
logger.warning(
"Failed to delete file from OSS",
extra={"storage_key": storage_key, "error": str(error)},
)
def file_exists(self, storage_key: str) -> bool:
"""
检查文件是否存在
Args:
storage_key: 存储键
Returns:
是否存在
"""
if self.bucket is None:
return False
return self.bucket.object_exists(storage_key)
_storage_service = None
def get_storage_service() -> OSSStorageService:
"""获取存储服务实例(全局单例)"""
global _storage_service
if _storage_service is None:
_storage_service = OSSStorageService()
_storage_service.diagnose()
return _storage_service
__all__ = ["OSSStorageService", "get_storage_service", "get_shared_storage_service"]
-37
View File
@@ -1,37 +0,0 @@
/**
* V21 Form 表单
* 封装 Ant Design Form,应用 V21 设计系统样式
*/
import React from "react";
import { Form as AntForm } from "antd";
import type { FormProps as AntFormProps } from "antd";
import classNames from "classnames";
import "./ui.css";
export interface FormProps extends AntFormProps {
/** 紧凑模式(减小表单项间距) */
compact?: boolean;
}
const Form = ({ className, compact, children, ...rest }: FormProps) => {
const v21Class = classNames(
"xx-form",
compact && "xx-form-compact",
className,
);
return (
<AntForm
className={v21Class}
{...(rest as Omit<FormProps, "className" | "compact" | "children">)}
>
{children as React.ReactNode}
</AntForm>
);
};
/** 导出 Form 的子组件(保持 antd API 一致) */
export const FormItem = AntForm.Item;
export const FormList = AntForm.List;
export const FormProvider = AntForm.Provider;
export default Form;
-25
View File
@@ -1,25 +0,0 @@
/**
* V21 Pagination 分页
* 封装 Ant Design Pagination,应用 V21 设计系统样式
*/
import React from "react";
import { Pagination as AntPagination } from "antd";
import type { PaginationProps as AntPaginationProps } from "antd";
import classNames from "classnames";
import "./ui.css";
export interface PaginationProps extends AntPaginationProps {
/** 使用 V21 样式 */
v21?: boolean;
}
const Pagination: React.FC<PaginationProps> = ({
className,
v21 = true,
...rest
}) => {
const v21Class = classNames(v21 && "xx-pagination", className);
return <AntPagination className={v21Class} {...rest} />;
};
export default Pagination;
-29
View File
@@ -1,29 +0,0 @@
/**
* V21 Table 表格
* 封装 Ant Design Table,应用 V21 设计系统样式
*/
import React from "react";
import { Table as AntTable } from "antd";
import type { TableProps as AntTableProps } from "antd";
import classNames from "classnames";
import "./ui.css";
export interface TableProps<
RecordType = unknown,
> extends AntTableProps<RecordType> {
/** 使用 V21 样式 */
v21?: boolean;
}
function Table<RecordType extends object = Record<string, unknown>>({
className,
v21 = true,
...rest
}: TableProps<RecordType>) {
const v21Class = classNames(v21 && "xx-table", className);
return <AntTable<RecordType> className={v21Class} {...rest} />;
}
export default Table as <RecordType extends object = Record<string, unknown>>(
props: TableProps<RecordType> & React.RefAttributes<HTMLDivElement>,
) => React.ReactElement;
-9
View File
@@ -15,9 +15,6 @@ export type { SelectProps } from "./Select";
export { default as Modal } from "./Modal";
export type { ModalProps } from "./Modal";
export { default as Table } from "./Table";
export type { TableProps } from "./Table";
export { default as Card } from "./Card";
export type { CardProps } from "./Card";
@@ -26,9 +23,3 @@ export type { TagProps, TagVariant } from "./Tag";
export { Tooltip, Popover } from "./Tooltip";
export type { TooltipProps, PopoverProps } from "./Tooltip";
export { default as Form, FormItem, FormList, FormProvider } from "./Form";
export type { FormProps } from "./Form";
export { default as Pagination } from "./Pagination";
export type { PaginationProps } from "./Pagination";
-82
View File
@@ -82,77 +82,6 @@
gap: 10px;
}
/* ── 账号行 ─────────────────────────────────────────────── */
.acc-account-row {
display: flex;
align-items: center;
gap: 10px;
padding: 12px;
background: var(--bg-secondary);
border-radius: var(--radius-md);
transition: var(--transition-fast);
}
.acc-account-row:hover {
background: var(--primary-soft);
}
.acc-account-avatar {
width: 36px;
height: 36px;
border-radius: 50%;
display: grid;
place-items: center;
color: var(--text-inverse);
font-weight: var(--font-weight-bold);
font-size: var(--font-size-sm);
flex-shrink: 0;
}
.acc-account-info {
flex: 1;
min-width: 0;
}
.acc-account-name {
font-size: var(--font-size-base);
font-weight: var(--font-weight-semibold);
color: var(--text-primary);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
/* ── 状态标签 ───────────────────────────────────────────── */
.acc-status-pill {
display: inline-flex;
align-items: center;
gap: 4px;
padding: 2px 8px;
border-radius: var(--radius-full);
font-size: var(--font-size-xs);
font-weight: var(--font-weight-semibold);
line-height: 1.6;
margin-top: 2px;
}
.acc-status--active {
background: var(--success-soft);
color: var(--secondary-color);
}
.acc-status--expired {
background: var(--error-soft);
color: var(--error-color);
}
.acc-status--limited {
background: var(--warning-soft);
color: var(--accent-color);
}
/* ── 空状态 ─────────────────────────────────────────────── */
.acc-empty {
@@ -235,17 +164,6 @@
font-size: var(--font-size-base);
}
.acc-account-row {
padding: 10px;
gap: 8px;
}
.acc-account-avatar {
width: 32px;
height: 32px;
font-size: var(--font-size-xs);
}
.acc-stats-bar {
padding: var(--space-sm) var(--space-md);
font-size: var(--font-size-sm);
+1 -1
View File
@@ -5,7 +5,7 @@
*/
import React from "react";
import { useNavigate } from "react-router-dom";
import { Button, Tag } from "@/components/ui";
import { Button } from "@/components/ui";
import { DatabaseOutlined } from "@ant-design/icons";
import "./dashboard.css";
+39 -369
View File
@@ -1,6 +1,6 @@
/**
* 控制台页面 - V21 设计系统样式
* KPI 卡片网格 + 快速入口 + 最近任务卡片列表 + 使用统计图表 + 公告
* KPI 卡片网格 + 快速入口 + 最近任务 + 使用统计图表 + 公告
* 统一使用 CSS 变量,支持深色/浅色主题
*/
@import "../../styles/global.css";
@@ -13,26 +13,6 @@
padding: var(--space-xl);
}
/* ============================================================
欢迎头部
============================================================ */
.xx-dashboard-welcome {
margin-bottom: var(--space-lg);
}
.xx-dashboard-welcome h2 {
margin: 0 0 var(--space-xs);
font-size: var(--font-size-xl);
font-weight: var(--font-weight-bold);
color: var(--text-primary);
}
.xx-dashboard-welcome p {
margin: 0;
font-size: var(--font-size-sm);
color: var(--text-secondary);
}
/* ============================================================
KPI 卡片网格
============================================================ */
@@ -43,76 +23,6 @@
margin-bottom: var(--space-lg);
}
.xx-kpi-card {
background: linear-gradient(180deg, var(--bg-primary), var(--bg-secondary));
border: 1px solid var(--border-color);
border-radius: var(--radius-lg);
padding: 20px;
transition: 0.18s ease;
position: relative;
overflow: hidden;
}
.xx-kpi-card:hover {
transform: translateY(-2px);
box-shadow: var(--shadow-sm);
}
.xx-kpi-icon {
width: 40px;
height: 40px;
border-radius: var(--radius-md);
display: flex;
align-items: center;
justify-content: center;
font-size: var(--font-size-lg);
margin-bottom: var(--space-sm);
}
.xx-kpi-value {
font-size: 30px;
font-weight: 800;
color: var(--text-primary);
line-height: 1.2;
margin-bottom: var(--space-xs);
}
.xx-kpi-label {
font-size: var(--font-size-sm);
color: var(--text-secondary);
margin-bottom: var(--space-sm);
}
.xx-kpi-trend {
font-size: 12px;
margin-top: 8px;
display: inline-flex;
align-items: center;
gap: 4px;
}
.xx-kpi-trend--up {
color: var(--success-color);
}
.xx-kpi-trend--down {
color: var(--error-color);
}
.xx-kpi-trend--neutral {
color: var(--text-tertiary);
}
/* ============================================================
主内容区两栏布局
============================================================ */
.xx-dashboard-main {
display: grid;
grid-template-columns: 1fr 320px;
gap: var(--space-md);
margin-bottom: var(--space-lg);
}
/* ============================================================
区块卡片
============================================================ */
@@ -161,7 +71,25 @@
}
/* ============================================================
最近任务卡片列表
空状态
============================================================ */
.xx-dashboard-empty {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: var(--space-xl) var(--space-md);
text-align: center;
color: var(--text-tertiary);
}
.xx-dashboard-empty p {
margin: 0;
font-size: var(--font-size-sm);
}
/* ============================================================
最近任务列表
============================================================ */
.xx-task-list {
display: flex;
@@ -169,86 +97,6 @@
gap: var(--space-sm);
}
.xx-task-item {
background: var(--bg-primary);
border: 1px solid var(--border-color);
border-radius: var(--radius-md);
padding: var(--space-sm) var(--space-md);
display: grid;
grid-template-columns: 1fr auto auto auto;
gap: var(--space-md);
align-items: center;
transition: var(--transition-all);
}
.xx-task-item:hover {
border-color: var(--primary-color);
box-shadow: var(--shadow-sm);
}
.xx-task-info h4 {
margin: 0 0 var(--space-xs);
font-size: var(--font-size-sm);
font-weight: 600;
color: var(--text-primary);
}
.xx-task-info span {
font-size: var(--font-size-xs);
color: var(--text-secondary);
}
.xx-task-status {
display: inline-flex;
align-items: center;
gap: var(--space-xs);
padding: 3px 10px;
border-radius: var(--radius-sm);
font-size: var(--font-size-xs);
font-weight: 500;
white-space: nowrap;
}
.xx-task-status--completed {
color: var(--success-color);
background: var(--success-soft);
border: 1px solid var(--success-border);
}
.xx-task-status--processing {
color: var(--info-color);
background: var(--primary-soft);
border: 1px solid var(--color-primary-200);
}
.xx-task-status--pending {
color: var(--text-secondary);
background: var(--bg-secondary);
border: 1px solid var(--border-color);
}
.xx-task-status--failed {
color: var(--error-color);
background: var(--error-soft);
border: 1px solid var(--error-border);
}
.xx-task-time {
text-align: right;
font-size: var(--font-size-xs);
color: var(--text-secondary);
min-width: 100px;
}
.xx-task-time span {
display: block;
margin-bottom: var(--space-xxs);
}
.xx-task-action {
font-size: var(--font-size-xs);
}
/* ============================================================
使用统计图表(纯 CSS 柱状图)
============================================================ */
@@ -264,69 +112,27 @@
padding-top: var(--space-sm);
}
.xx-chart-bar-wrapper {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
height: 100%;
justify-content: flex-end;
}
.xx-chart-bar {
width: 100%;
max-width: 36px;
border-radius: var(--radius-sm) var(--radius-sm) 0 0;
background: var(--gradient-primary);
transition: height 0.6s cubic-bezier(0.34, 1.56, 0.64, 1);
position: relative;
min-height: 4px;
cursor: pointer;
}
.xx-chart-bar:hover {
opacity: 0.85;
}
.xx-chart-bar:active {
opacity: 0.7;
transform: scaleY(0.97);
transform-origin: bottom;
}
.xx-chart-bar-value {
position: absolute;
top: -20px;
left: 50%;
transform: translateX(-50%);
font-size: var(--font-size-xs);
font-weight: 600;
color: var(--text-primary);
white-space: nowrap;
opacity: 0;
transition: var(--transition-opacity);
}
.xx-chart-bar:hover .xx-chart-bar-value {
opacity: 1;
}
.xx-chart-labels {
display: flex;
gap: var(--space-sm);
margin-top: var(--space-sm);
}
.xx-chart-label {
flex: 1;
text-align: center;
font-size: var(--font-size-xs);
color: var(--text-secondary);
}
/* ============================================================
快速入口卡片网格
快速入口
============================================================ */
.xx-quick-entry-section {
margin-bottom: var(--space-md);
}
.xx-quick-entry-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: var(--space-md);
}
.xx-quick-entry-title {
margin: 0;
font-size: var(--font-size-base);
font-weight: var(--font-weight-semibold);
color: var(--text-primary);
}
.xx-quick-grid {
display: grid;
grid-template-columns: repeat(4, 1fr);
@@ -334,53 +140,6 @@
margin-bottom: var(--space-lg);
}
.xx-quick-card {
background: var(--bg-primary);
border: 1px solid var(--border-color);
border-radius: var(--radius-lg);
padding: var(--space-lg);
cursor: pointer;
transition: var(--transition-all);
text-align: center;
}
.xx-quick-card:hover {
border-color: var(--primary-color);
box-shadow: var(--shadow-md);
transform: translateY(-2px);
}
.xx-quick-card:active {
transform: translateY(0) scale(0.98);
box-shadow: var(--shadow-sm);
transition-duration: 0.1s;
}
.xx-quick-card-icon {
width: 52px;
height: 52px;
border-radius: var(--radius-md);
display: flex;
align-items: center;
justify-content: center;
font-size: var(--font-size-xl);
margin: 0 auto var(--space-md);
color: var(--text-inverse);
}
.xx-quick-card h3 {
margin: 0 0 var(--space-sm);
font-size: var(--font-size-md);
font-weight: var(--font-weight-semibold);
color: var(--text-primary);
}
.xx-quick-card p {
margin: 0;
color: var(--text-secondary);
font-size: var(--font-size-sm);
}
/* ============================================================
公告区域
============================================================ */
@@ -451,90 +210,10 @@
color: var(--text-secondary);
}
/* ============================================================
存储用量条
============================================================ */
.xx-storage-bar {
margin-top: var(--space-sm);
}
.xx-storage-bar-track {
height: var(--space-sm);
background: var(--bg-secondary);
border-radius: var(--space-xs);
overflow: hidden;
}
.xx-storage-bar-fill {
height: 100%;
border-radius: var(--space-xs);
background: var(--gradient-primary);
transition: width 0.6s ease;
}
.xx-storage-bar-label {
display: flex;
justify-content: space-between;
font-size: var(--font-size-xs);
color: var(--text-secondary);
margin-top: var(--space-xs);
}
/* ============================================================
迁移自内联样式的工具类
============================================================ */
.xx-dashboard-left-col {
display: flex;
flex-direction: column;
gap: var(--space-md);
}
.xx-chart-total {
font-size: var(--font-size-xs);
color: var(--text-secondary);
}
.xx-dashboard-section--start {
align-self: start;
}
.xx-storage-section {
margin-top: var(--space-md);
}
.xx-storage-section-title {
font-size: 13px;
font-weight: 500;
color: var(--text-primary);
margin-bottom: 4px;
}
.xx-quick-entry-section {
margin-bottom: var(--space-md);
}
.xx-quick-entry-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: var(--space-md);
}
.xx-quick-entry-title {
margin: 0;
font-size: var(--font-size-base);
font-weight: var(--font-weight-semibold);
color: var(--text-primary);
}
/* ============================================================
响应式
============================================================ */
@media (max-width: 1200px) {
.xx-dashboard-main {
grid-template-columns: 1fr;
}
.xx-kpi-grid {
grid-template-columns: repeat(2, 1fr);
}
@@ -557,15 +236,6 @@
grid-template-columns: 1fr;
}
.xx-task-item {
grid-template-columns: 1fr;
gap: var(--space-sm);
}
.xx-task-time {
text-align: left;
}
.xx-chart-bars {
height: 120px;
}
-3
View File
@@ -1,10 +1,8 @@
"""Video deduplication module - compute fingerprints and detect duplicates."""
import hashlib
import json
import logging
import os
import subprocess
import tempfile
from dataclasses import dataclass
from typing import Optional
@@ -327,7 +325,6 @@ def check_duplicate_task(self: Task, generated_video_id: str) -> dict:
raise ValueError(f"Generated video {generated_video_id} not found")
local_path = os.path.join(temp_dir, f"{generated_video_id}.mp4")
storage_key = video.file_url.split("/")[-1]
storage_service.download_file(
f"projects/{video.project_id}/generated/{generated_video_id}/{generated_video_id}.mp4", local_path
)
+1 -1
View File
@@ -81,7 +81,7 @@ def run_ffmpeg(
timeout=timeout,
)
return (result.stdout or "", result.stderr or "")
except subprocess.TimeoutExpired as e:
except subprocess.TimeoutExpired:
logger.error(
"FFmpeg 命令超时 (%ds): command=%s",
timeout or -1,
@@ -11,7 +11,6 @@ import logging
import os
import threading
from pathlib import Path
from typing import Optional
from urllib.parse import urlparse
import oss2
@@ -5,7 +5,6 @@
import os
import tempfile
from dataclasses import dataclass
from pathlib import Path
from typing import List
import ffmpeg
@@ -17,15 +17,15 @@ import logging
import tempfile
from dataclasses import dataclass
from pathlib import Path
from typing import Any, Callable
from typing import Callable
from sqlalchemy.orm import Session
from video_processing.oss_helpers import download_asset, upload_to_oss
from video_processing.unified_render_service import RenderResult, UnifiedRenderService
from video_processing.unified_render_service import UnifiedRenderService
from packages.adapters.sqlalchemy_impl.edit_plan_clip_repository import SQLAlchemyEditPlanClipRepository
from packages.adapters.sqlalchemy_impl.edit_plan_repository import SQLAlchemyEditPlanRepository
from packages.domain.edit_plan import EditPlan, EditPlanStatus
from packages.domain.edit_plan import EditPlanStatus
from packages.domain.edit_plan_clip import EditPlanClip, EditPlanClipStatus
logger = logging.getLogger(__name__)
@@ -10,12 +10,10 @@ from __future__ import annotations
import json
import logging
import math
import os
import subprocess
import tempfile
from dataclasses import dataclass, field
from typing import Any
import numpy as np
from PIL import Image
@@ -1,4 +1,3 @@
from celery import Task
from celery.utils.log import get_task_logger
from worker_app.celery_app import celery_app
@@ -9,7 +8,6 @@ from packages.adapters.sqlalchemy_impl.classification_job_repository import (
SQLAlchemyClassificationJobRepository,
)
from packages.domain import (
ClassificationJob,
ClassificationJobStatus,
ClassificationStatus,
)
@@ -5,12 +5,9 @@
from __future__ import annotations
import logging
import os
import shutil
import subprocess
import tempfile
from datetime import datetime, timezone
from pathlib import Path
from celery.utils.log import get_task_logger
@@ -21,7 +21,6 @@ import logging
import tempfile
from datetime import datetime, timezone
from pathlib import Path
from typing import Optional
from worker_app.celery_app import celery_app
from worker_app.db import SessionLocal
+1 -5
View File
@@ -13,15 +13,13 @@
from __future__ import annotations
import json
import logging
import os
import tempfile
import time
from dataclasses import dataclass, field
from datetime import datetime, timezone
from pathlib import Path
from typing import Any, Optional
from typing import Any
from worker_app.celery_app import celery_app
from worker_app.db import SessionLocal
@@ -174,7 +172,6 @@ def _build_plan_and_clips_from_task(
path_duration[p] = probe_duration(p)
clips: list[_VirtualClip] = []
n = len(downloaded_paths)
if mode == "pip":
# 1 main + N-1 overlay
@@ -912,7 +909,6 @@ def generate_video(self, task_id: str) -> dict:
render_result = render_service.render()
render_output_path = render_result.output_path
render_duration = render_result.duration
render_file_size = render_result.file_size
render_elapsed = time.monotonic() - render_start
logger.info(
"[task_id=%s] [渲染] unified 引擎完成: 耗时=%.1fs",
-3
View File
@@ -1,9 +1,6 @@
import subprocess
from datetime import datetime, timezone
from typing import Optional
from celery import Celery
from celery.app.task import Task
from celery.utils.log import get_task_logger
from worker_app.celery_app import celery_app
from worker_app.core.asset_types import infer_mime_type_from_storage_key
@@ -1,14 +1,11 @@
"""Voice extraction tasks - extract voice tracks and background music from videos."""
import json
import logging
import os
import subprocess
import tempfile
from typing import Optional
from celery import Task
from sqlalchemy.orm import Session
from worker_app.celery_app import celery_app
from worker_app.db import SessionLocal
+72 -11
View File
@@ -30,21 +30,67 @@ class SharedStorageService:
self.local_url_prefix = os.getenv("GENERATED_FILES_URL_PREFIX", "/generated-files")
self.bucket = None
if settings.oss_access_key_id and settings.oss_access_key_secret:
has_key_id = bool(settings.oss_access_key_id)
has_key_secret = bool(settings.oss_access_key_secret)
if has_key_id and has_key_secret:
if oss2 is not None:
auth = oss2.Auth(
settings.oss_access_key_id,
settings.oss_access_key_secret,
)
self.bucket = oss2.Bucket(
auth,
settings.oss_endpoint,
settings.oss_bucket_name,
)
try:
# P0-2 修复:oss2.Bucket 的 endpoint 必须带 https:// 前缀,
# 否则 sign_url 默认生成 HTTP URL。
bucket_endpoint = settings.oss_endpoint
if not bucket_endpoint.startswith(("http://", "https://")):
bucket_endpoint = f"https://{bucket_endpoint}"
auth = oss2.Auth(
settings.oss_access_key_id,
settings.oss_access_key_secret,
)
self.bucket = oss2.Bucket(
auth,
bucket_endpoint,
settings.oss_bucket_name,
)
logger.info(
"OSS initialized: endpoint=%s bucket=%s",
settings.oss_endpoint,
settings.oss_bucket_name,
)
except Exception as error:
logger.error("Failed to initialize OSS bucket client: %s", error)
else:
logger.error("oss2 SDK is not installed — OSS operations will fail")
else:
missing = []
if not has_key_id:
missing.append("OSS_ACCESS_KEY_ID")
if not has_key_secret:
missing.append("OSS_ACCESS_KEY_SECRET")
logger.error("OSS credentials not configured — missing: %s", ", ".join(missing))
self.access_key_id = settings.oss_access_key_id
self.access_key_secret = settings.oss_access_key_secret
self.endpoint = settings.oss_endpoint
def diagnose(self) -> None:
"""启动诊断:输出 OSS 配置状态,帮助排查预签名 URL 问题。"""
key_id_display = (
f"{self.access_key_id[:4]}...{self.access_key_id[-4:]}" if len(self.access_key_id) > 8 else "(empty)"
)
logger.info(
"[OSS诊断] endpoint=%s bucket_name=%s access_key_id=%s",
self.endpoint,
self.bucket_name,
key_id_display,
)
if self.bucket is None:
logger.error(
"[OSS诊断] ❌ bucket=None — 预签名URL不可用!"
"原因: OSS_ACCESS_KEY_ID/OSS_ACCESS_KEY_SECRET 未配置或 oss2 未安装。"
"请检查服务器 .env 文件(如 /var/lib/xiaoxia-saas-staging/.env"
)
else:
logger.info("[OSS诊断] ✅ bucket 已配置,预签名URL可用")
def _is_local_generated_url(self, storage_key_or_url: str) -> bool:
parsed = urlparse(storage_key_or_url)
path = parsed.path if parsed.scheme else storage_key_or_url
@@ -90,12 +136,26 @@ class SharedStorageService:
if self.bucket is None:
if self._is_local_generated_url(storage_key_or_url):
return storage_key_or_url
logger.warning(
"get_download_url: OSS bucket not configured, returning raw URL. storage_key_or_url=%s",
storage_key_or_url[:200],
)
return self.get_url(self._normalize_storage_key(storage_key_or_url))
storage_key = self._normalize_storage_key(storage_key_or_url)
try:
return self.bucket.sign_url("GET", storage_key, expires_seconds)
signed = self.bucket.sign_url("GET", storage_key, expires_seconds)
logger.info(
"get_download_url: signed URL generated. storage_key=%s url_prefix=%s",
storage_key[:80],
signed[:60],
)
return signed
except Exception:
logger.exception(
"get_download_url: sign_url failed, falling back to raw URL. storage_key=%s",
storage_key[:200],
)
return self.get_url(storage_key)
def _normalize_storage_key(self, storage_key_or_url: str) -> str:
@@ -175,6 +235,7 @@ def get_shared_storage_service() -> SharedStorageService:
global _storage_service
if _storage_service is None:
_storage_service = SharedStorageService()
_storage_service.diagnose()
return _storage_service
-3
View File
@@ -24,9 +24,6 @@ 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
-3
View File
@@ -6,9 +6,6 @@ pydantic-settings==2.6.0
email-validator==2.2.0
python-multipart==0.0.32
# 邮件
aiosmtplib==3.0.2
# 测试
pytest==8.3.3
pytest-asyncio==0.24.0
-1
View File
@@ -73,7 +73,6 @@ OSS_ACCESS_KEY_ID=$OSS_ACCESS_KEY_ID
OSS_ACCESS_KEY_SECRET=$OSS_ACCESS_KEY_SECRET
OSS_BUCKET_NAME=$OSS_BUCKET_NAME
LOG_LEVEL=INFO
CORS_ORIGINS_RAW=https://xiaoxiajianji.com,https://api.xiaoxiajianji.com
EOF
+1 -6
View File
@@ -17,18 +17,13 @@ os.environ.setdefault("USE_IN_MEMORY_DB", "True")
# CI 环境没有 Redis,所有 Celery 异步任务都 mock 掉,避免连接超时报错
# 集成测试只测 API 层逻辑(参数校验、权限、DB 操作),异步任务由 worker 单测覆盖
from unittest.mock import MagicMock, patch
from unittest.mock import MagicMock
def _mock_celery_task():
"""全局 mock Celery 任务的 delay/apply_async/send_task 方法。"""
from celery import Celery, Task
# 保存原始方法
_orig_delay = Task.delay
_orig_apply_async = Task.apply_async
_orig_send_task = Celery.send_task
def _mock_delay(self, *args, **kwargs):
mock_result = MagicMock()
mock_result.id = "mock-task-id"
-1
View File
@@ -113,7 +113,6 @@ class PerfAssert:
raise ValueError(f"未知的阈值级别: {threshold_level},可选: {list(PERF_THRESHOLDS.keys())}")
threshold_ms = PERF_THRESHOLDS[threshold_level]
num_samples = samples or self.sample_count
result = PerfResult(name=name or threshold_level, threshold_ms=threshold_ms)
# 预热(第一次请求可能有冷启动开销)
@@ -1,282 +0,0 @@
"""查重 API 路由。"""
from __future__ import annotations
import logging
from typing import Any
from uuid import uuid4
from app.auth import AuthenticatedUser, get_current_user
from app.core.storage import OSSStorageService, get_storage_service
from app.dependencies import get_duplication_repository
from app.schemas.duplication import (
DuplicateSegmentResponse,
DuplicationDetailResponse,
DuplicationRecordResponse,
DuplicationUploadResponse,
)
from fastapi import APIRouter, Depends, File, HTTPException, Response, UploadFile, status
from packages.application import (
DeleteDuplicationRecordUseCase,
GetDuplicationDetailUseCase,
ListDuplicationRecordsUseCase,
RetryDuplicationUseCase,
UploadForDuplicationCommand,
UploadForDuplicationUseCase,
)
from packages.domain.duplication import DuplicationRecord
logger = logging.getLogger(__name__)
router = APIRouter()
# 查重功能只接受视频文件
ALLOWED_VIDEO_MIME_TYPES = frozenset(
{
"video/mp4",
"video/mpeg",
"video/quicktime",
"video/x-msvideo",
"video/webm",
"video/x-matroska",
"video/3gpp",
}
)
def _validate_video_mime_type(content_type: str | None) -> str:
"""验证视频文件的 MIME 类型,如果无效则抛出异常。"""
if not content_type:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Content-Type header is required",
)
# 处理带参数的类型,如 "video/mp4; charset=utf-8"
base_type = content_type.split(";")[0].strip().lower()
if base_type not in ALLOWED_VIDEO_MIME_TYPES:
raise HTTPException(
status_code=status.HTTP_415_UNSUPPORTED_MEDIA_TYPE,
detail="只支持视频文件。支持的类型: mp4, mpeg, mov, avi, webm, mkv, 3gp",
)
return base_type
def _to_record_response(record: DuplicationRecord) -> DuplicationRecordResponse:
return DuplicationRecordResponse(
id=record.id,
filename=record.filename,
file_size=record.file_size,
duration_seconds=record.duration_seconds,
status=record.status,
duplicate_rate=record.duplicate_rate,
duplicate_count=record.duplicate_count,
created_at=record.created_at.isoformat(),
updated_at=record.updated_at.isoformat(),
)
def _to_detail_response(record: DuplicationRecord) -> DuplicationDetailResponse:
return DuplicationDetailResponse(
id=record.id,
filename=record.filename,
file_size=record.file_size,
duration_seconds=record.duration_seconds,
status=record.status,
duplicate_rate=record.duplicate_rate,
duplicate_count=record.duplicate_count,
created_at=record.created_at.isoformat(),
updated_at=record.updated_at.isoformat(),
segments=[
DuplicateSegmentResponse(
id=seg.id,
source_start=seg.source_start,
source_end=seg.source_end,
matched_video_id=seg.matched_video_id,
matched_video_name=seg.matched_video_name,
matched_start=seg.matched_start,
matched_end=seg.matched_end,
similarity=seg.similarity,
)
for seg in record.segments
],
)
@router.post("/upload", response_model=DuplicationUploadResponse)
async def upload_for_duplication(
file: UploadFile = File(..., description="要查重的视频文件"),
authenticated_user: AuthenticatedUser = Depends(get_current_user),
duplication_repository: Any = Depends(get_duplication_repository),
storage_service: OSSStorageService = Depends(get_storage_service),
) -> DuplicationUploadResponse:
"""上传视频进行查重。"""
if file.filename is None:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="文件名不能为空",
)
# P0-1: 验证 MIME 类型(只接受视频文件)
validated_content_type = _validate_video_mime_type(file.content_type)
# P0-2: 验证文件大小(参考 OSS_DIRECT_UPLOAD_MAX_MB
from app.config import get_settings
settings = get_settings()
max_size_bytes = settings.OSS_DIRECT_UPLOAD_MAX_MB * 1024 * 1024
# 先检查 Content-Length header(如果可用)
if file.size is not None and file.size > max_size_bytes:
raise HTTPException(
status_code=status.HTTP_413_REQUEST_ENTITY_TOO_LARGE,
detail=f"文件超过上传限制 ({settings.OSS_DIRECT_UPLOAD_MAX_MB}MB)",
)
# 读取文件内容并上传到 OSS
file_id = uuid4().hex[:8]
safe_filename = file.filename.replace("/", "_").replace("\\", "_")
storage_key = f"duplication/{file_id}/{safe_filename}"
try:
content = await file.read()
file_size = len(content)
# 再次检查实际文件大小
if file_size > max_size_bytes:
raise HTTPException(
status_code=status.HTTP_413_REQUEST_ENTITY_TOO_LARGE,
detail=f"文件超过上传限制 ({settings.OSS_DIRECT_UPLOAD_MAX_MB}MB)",
)
except HTTPException:
raise
except Exception as exc:
logger.error("读取查重文件失败: %s", exc, exc_info=True)
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="文件读取失败,请稍后重试",
) from exc
try:
storage_service.upload_file(
content,
storage_key,
content_type=validated_content_type,
)
except Exception as exc:
logger.error("查重文件上传 OSS 失败: %s", exc, exc_info=True)
raise HTTPException(
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
detail="文件上传失败,请稍后重试",
) from exc
use_case = UploadForDuplicationUseCase(duplication_repository)
record = use_case.execute(
UploadForDuplicationCommand(
user_id=authenticated_user.user.id,
filename=file.filename,
file_size=file_size,
storage_key=storage_key,
)
)
logger.info(
"Duplication upload: record=%s file=%s user=%s",
record.id,
file.filename,
authenticated_user.user.id,
)
return DuplicationUploadResponse(
id=record.id,
status=record.status,
message=f'文件 "{file.filename}" 已上传,正在查重中...',
)
@router.get("/records", response_model=list[DuplicationRecordResponse])
def list_duplication_records(
authenticated_user: AuthenticatedUser = Depends(get_current_user),
duplication_repository: Any = Depends(get_duplication_repository),
) -> list[DuplicationRecordResponse]:
"""获取当前用户的查重记录列表。"""
use_case = ListDuplicationRecordsUseCase(duplication_repository)
records = use_case.execute(authenticated_user.user.id)
return [_to_record_response(r) for r in records]
@router.get("/records/{record_id}", response_model=DuplicationDetailResponse)
def get_duplication_detail(
record_id: str,
authenticated_user: AuthenticatedUser = Depends(get_current_user),
duplication_repository: Any = Depends(get_duplication_repository),
) -> DuplicationDetailResponse:
"""获取查重记录详情(含重复片段)。"""
use_case = GetDuplicationDetailUseCase(duplication_repository)
record = use_case.execute(record_id)
if record is None:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"查重记录 {record_id} 不存在",
)
if record.user_id != authenticated_user.user.id:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"查重记录 {record_id} 不存在",
)
return _to_detail_response(record)
@router.delete("/records/{record_id}", status_code=status.HTTP_204_NO_CONTENT, response_class=Response)
def delete_duplication_record(
record_id: str,
authenticated_user: AuthenticatedUser = Depends(get_current_user),
duplication_repository: Any = Depends(get_duplication_repository),
) -> Response:
"""删除查重记录。"""
# 检查记录是否存在且属于当前用户
detail_uc = GetDuplicationDetailUseCase(duplication_repository)
record = detail_uc.execute(record_id)
if record is None or record.user_id != authenticated_user.user.id:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"查重记录 {record_id} 不存在",
)
use_case = DeleteDuplicationRecordUseCase(duplication_repository)
use_case.execute(record_id)
return Response(status_code=204)
@router.post("/records/{record_id}/retry", response_model=DuplicationUploadResponse)
def retry_duplication(
record_id: str,
authenticated_user: AuthenticatedUser = Depends(get_current_user),
duplication_repository: Any = Depends(get_duplication_repository),
) -> DuplicationUploadResponse:
"""重新提交查重。"""
# 检查记录存在且属于当前用户
detail_uc = GetDuplicationDetailUseCase(duplication_repository)
record = detail_uc.execute(record_id)
if record is None or record.user_id != authenticated_user.user.id:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"查重记录 {record_id} 不存在",
)
use_case = RetryDuplicationUseCase(duplication_repository)
updated = use_case.execute(record_id)
if updated is None:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"查重记录 {record_id} 不存在",
)
return DuplicationUploadResponse(
id=updated.id,
status=updated.status,
message="已重新提交查重",
)
@@ -18,7 +18,6 @@ from __future__ import annotations
import os
import uuid
from typing import Optional
import pytest
from fastapi.testclient import TestClient
+1 -2
View File
@@ -14,7 +14,6 @@ from __future__ import annotations
import os
import sys
from dataclasses import replace
from datetime import datetime, timezone
from typing import Any
from unittest.mock import MagicMock
@@ -33,7 +32,7 @@ sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "..", "apps", "
from app.api.routes.duplication import router
from app.auth import AuthenticatedUser, get_current_user
from app.core.storage import get_storage_service
from app.dependencies import get_db_session, get_duplication_repository
from app.dependencies import get_duplication_repository
from packages.domain.duplication import DuplicateSegment, DuplicationRecord
@@ -32,11 +32,9 @@ sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "..", "apps", "
from app.api.routes.duplication import _validate_video_mime_type, router
from app.auth import AuthenticatedUser, get_current_user
from app.core.storage import OSSStorageService, get_storage_service
from app.core.storage import get_storage_service
from app.dependencies import get_duplication_repository
from packages.domain.duplication import DuplicationRecord
# ── 导入真实模块(不创建 fake module) ────────────────────────────────────────
from packages.domain.entities import User
+4 -7
View File
@@ -14,7 +14,6 @@
from __future__ import annotations
import json
import os
import sys
import uuid
@@ -240,7 +239,7 @@ class TestForbidden403:
"""测试 403 禁止访问场景。"""
def test_access_other_user_project(self, auth_headers, other_auth_headers):
"""访问他人项目应返回 403/404(权限检查未实现时返回 200 为已知问题)"""
"""访问他人项目应返回 403。"""
# 用户 A 创建项目
created = client.post(
"/api/v1/projects",
@@ -255,11 +254,10 @@ class TestForbidden403:
f"/api/v1/projects/{project_id}",
headers=other_auth_headers,
)
# TODO: 项目权限检查未实现,当前返回 200;实现后应改为 [403, 404]
assert response.status_code in [200, 403, 404], f"访问他人项目状态码异常: {response.status_code}"
assert response.status_code == 403, f"访问他人项目应返回 403,实际: {response.status_code}"
def test_delete_other_user_project(self, auth_headers, other_auth_headers):
"""删除他人项目应返回 403/404(权限检查未实现时返回 200/204 为已知问题)"""
"""删除他人项目应返回 403。"""
created = client.post(
"/api/v1/projects",
json={"name": "Do Not Delete"},
@@ -272,8 +270,7 @@ class TestForbidden403:
f"/api/v1/projects/{project_id}",
headers=other_auth_headers,
)
# TODO: 项目权限检查未实现,当前可能返回 200/204;DELETE 端点未实现时返回 405;实现后应改为 [403, 404]
assert response.status_code in [200, 204, 403, 404, 405], f"删除他人项目状态码异常: {response.status_code}"
assert response.status_code == 403, f"删除他人项目应返回 403,实际: {response.status_code}"
# ---------------------------------------------------------------------------
-4
View File
@@ -9,16 +9,12 @@ import shutil
import subprocess
import tempfile
from pathlib import Path
from unittest.mock import MagicMock, patch
import pytest
from video_processing.unified_render_service import (
RenderResult,
UnifiedRenderService,
)
from worker_app.tasks.generation import (
OUTPUT_HEIGHT,
OUTPUT_WIDTH,
_build_plan_and_clips_from_task,
_create_fallback_clip,
_mux_audio_track,
-1
View File
@@ -515,7 +515,6 @@ class TestRetryGenerationTask:
task_id = resp.json()["items"][0]["id"]
# 直接修改 repository 中的任务状态为 failed
from app.dependencies import get_generation_task_repository
# 由于是 stub,我们需要通过另一种方式设置状态
# 让我们直接通过 retry 测试来验证
+1 -1
View File
@@ -3,7 +3,7 @@ from packages.adapters.in_memory import (
InMemoryIngestJobRepository,
)
from packages.application import SubmitIngestJobCommand, SubmitIngestJobUseCase
from packages.domain import Asset, IngestJob, IngestJobStatus
from packages.domain import Asset, IngestJobStatus
def simulate_ingest_asset(
@@ -1,263 +0,0 @@
"""项目管理功能集成测试"""
import pytest
# 项目管理功能尚未实现,相关模块不存在,跳过整个文件
pytest.skip(
"项目管理功能尚未实现(project_management_repositories / "
"project_management_use_cases / TaskPriority / TaskStatus 均不存在)",
allow_module_level=True,
)
from packages.adapters.in_memory.project_management_repositories import (
InMemoryMilestoneRepository,
InMemoryTaskIssueRepository,
InMemoryTaskRepository,
)
from packages.application.project_management_use_cases import (
CreateMilestoneUseCase,
CreateTaskIssueUseCase,
CreateTaskUseCase,
ListProjectTasksUseCase,
ListTaskIssuesUseCase,
ResolveTaskIssueUseCase,
UpdateTaskProgressUseCase,
UpdateTaskStatusUseCase,
)
from packages.domain import TaskPriority, TaskStatus
def test_create_task():
"""测试创建任务"""
repo = InMemoryTaskRepository()
use_case = CreateTaskUseCase(repo)
task = use_case.execute(
project_id="proj_1",
name="开发登录功能",
description="实现用户登录功能",
priority=TaskPriority.HIGH,
)
assert task.id is not None
assert task.name == "开发登录功能"
assert task.status == TaskStatus.PENDING
assert task.priority == TaskPriority.HIGH
assert task.progress == 0.0
def test_list_tasks():
"""测试获取任务列表"""
repo = InMemoryTaskRepository()
create_use_case = CreateTaskUseCase(repo)
# 创建两个任务
create_use_case.execute(
project_id="proj_1",
name="任务1",
)
create_use_case.execute(
project_id="proj_1",
name="任务2",
)
# 查询任务列表
list_use_case = ListProjectTasksUseCase(repo)
tasks = list_use_case.execute("proj_1")
assert len(tasks) == 2
assert tasks[0].name == "任务1"
assert tasks[1].name == "任务2"
def test_update_task_status():
"""测试更新任务状态"""
repo = InMemoryTaskRepository()
create_use_case = CreateTaskUseCase(repo)
update_use_case = UpdateTaskStatusUseCase(repo)
# 创建任务
task = create_use_case.execute(
project_id="proj_1",
name="测试任务",
)
# 更新状态为进行中
updated_task = update_use_case.execute(task.id, TaskStatus.IN_PROGRESS)
assert updated_task.status == TaskStatus.IN_PROGRESS
assert updated_task.actual_start_date is not None
def test_update_task_progress():
"""测试更新任务进度"""
repo = InMemoryTaskRepository()
create_use_case = CreateTaskUseCase(repo)
progress_use_case = UpdateTaskProgressUseCase(repo)
# 创建任务
task = create_use_case.execute(
project_id="proj_1",
name="测试任务",
)
# 更新进度到 50%
updated_task = progress_use_case.execute(task.id, 50.0)
assert updated_task.progress == 50.0
assert updated_task.status == TaskStatus.IN_PROGRESS
# 更新进度到 100%
completed_task = progress_use_case.execute(task.id, 100.0)
assert completed_task.progress == 100.0
assert completed_task.status == TaskStatus.COMPLETED
assert completed_task.actual_end_date is not None
def test_create_milestone():
"""测试创建里程碑"""
repo = InMemoryMilestoneRepository()
use_case = CreateMilestoneUseCase(repo)
milestone = use_case.execute(
project_id="proj_1",
name="V1.0 发布",
description="第一个正式版本",
)
assert milestone.id is not None
assert milestone.name == "V1.0 发布"
assert milestone.completed is False
def test_create_and_resolve_issue():
"""测试创建和解决任务问题"""
repo = InMemoryTaskIssueRepository()
create_use_case = CreateTaskIssueUseCase(repo)
resolve_use_case = ResolveTaskIssueUseCase(repo)
list_use_case = ListTaskIssuesUseCase(repo)
# 创建问题
issue = create_use_case.execute(
task_id="task_1",
project_id="proj_1",
title="接口报错",
description="调用登录接口返回 500",
)
assert issue.id is not None
assert issue.title == "接口报错"
assert issue.resolved is False
# 解决问题
resolved_issue = resolve_use_case.execute(issue.id)
assert resolved_issue.resolved is True
assert resolved_issue.resolved_at is not None
# 查询任务问题列表
issues = list_use_case.execute("task_1")
assert len(issues) == 1
assert issues[0].resolved is True
def test_task_hierarchy():
"""测试任务层级关系"""
repo = InMemoryTaskRepository()
create_use_case = CreateTaskUseCase(repo)
# 创建父任务
parent_task = create_use_case.execute(
project_id="proj_1",
name="开发用户模块",
)
# 创建子任务
child_task_1 = create_use_case.execute(
project_id="proj_1",
name="登录功能",
parent_task_id=parent_task.id,
)
child_task_2 = create_use_case.execute(
project_id="proj_1",
name="注册功能",
parent_task_id=parent_task.id,
)
# 查询子任务
children = repo.list_by_parent(parent_task.id)
assert len(children) == 2
assert children[0].parent_task_id == parent_task.id
assert children[1].parent_task_id == parent_task.id
def test_get_task_detail():
"""测试获取任务详情"""
from packages.application.get_task_detail_use_case import GetTaskDetailUseCase
repo = InMemoryTaskRepository()
create_use_case = CreateTaskUseCase(repo)
get_use_case = GetTaskDetailUseCase(repo)
# 创建任务
task = create_use_case.execute(
project_id="proj_1",
name="测试任务",
description="这是一个测试任务",
)
# 获取详情
retrieved_task = get_use_case.execute(task.id)
assert retrieved_task.id == task.id
assert retrieved_task.name == "测试任务"
assert retrieved_task.description == "这是一个测试任务"
# 测试不存在的任务
try:
get_use_case.execute("nonexistent_id")
assert False, "应该抛出异常"
except ValueError as e:
assert "not found" in str(e)
def test_update_task():
"""测试任务基本信息更新"""
from packages.application.update_task_use_case import UpdateTaskUseCase
repo = InMemoryTaskRepository()
create_use_case = CreateTaskUseCase(repo)
update_use_case = UpdateTaskUseCase(repo)
# 创建任务
task = create_use_case.execute(
project_id="proj_1",
name="原始任务",
description="原始描述",
priority="low",
)
# 更新任务
updated_task = update_use_case.execute(
task_id=task.id,
name="更新后的任务",
description="更新后的描述",
priority="high",
)
assert updated_task.name == "更新后的任务"
assert updated_task.description == "更新后的描述"
assert updated_task.priority == "high"
# 部分更新
partial_updated = update_use_case.execute(
task_id=task.id,
name="又更新了",
)
assert partial_updated.name == "又更新了"
assert partial_updated.description == "更新后的描述" # 保持不变
assert partial_updated.priority == "high" # 保持不变
+1 -4
View File
@@ -16,7 +16,6 @@ from __future__ import annotations
import importlib.util
import os
import sys
from dataclasses import dataclass, field
from datetime import datetime, timezone
from typing import Optional
@@ -30,12 +29,10 @@ from fastapi.testclient import TestClient
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "..", "apps", "api"))
from app.auth import AuthenticatedUser, get_current_user
from app.dependencies import get_user_repository
from app.auth import AuthenticatedUser
# ── 导入真实模块(不创建 fake module) ────────────────────────────────────────
from packages.domain.entities import User
from packages.ports.user_repository import UserRepository
# ── 导入被测路由模块(从 fixtures 加载简化版路由) ─────────────────────────────
_fixture_path = os.path.join(os.path.dirname(os.path.abspath(__file__)), "fixtures", "subscription_routes.py")
@@ -629,7 +629,6 @@ class TestTaskCenterCrossEndpoint:
# 2. 重试失败任务
retry_resp = tc.post("/tasks/gen-fail-cross/retry")
assert retry_resp.status_code == 200
new_task_id = retry_resp.json()["source_id"]
# 3. 再次列出,应有2个任务(旧的failed + 新的pending
list_resp2 = tc.get("/tasks")
-1
View File
@@ -18,7 +18,6 @@ from __future__ import annotations
import os
import sys
from datetime import datetime, timezone
from unittest.mock import MagicMock
# ── 环境变量 & sys.path(必须在导入 app.* 之前设置) ──────────────────────────
os.environ.setdefault("JWT_SECRET_KEY", "unit-test-secret-key-for-testing")
@@ -18,7 +18,6 @@ from __future__ import annotations
import os
import sys
from datetime import datetime, timezone
from unittest.mock import MagicMock
# ── 环境变量 & sys.path(必须在导入 app.* 之前设置) ──────────────────────────
os.environ.setdefault("JWT_SECRET_KEY", "unit-test-secret-key-for-testing")
+1 -1
View File
@@ -181,7 +181,7 @@ def compute_audio_diff(
timeout=120,
)
stderr = result.stderr or ""
except subprocess.CalledProcessError as e:
except subprocess.CalledProcessError:
# 如果音频格式不兼容,返回失败
return AudioDiffResult(
audio_a=str(audio_a),
+1 -4
View File
@@ -30,7 +30,7 @@ import json
import os
import sys
import time
from dataclasses import dataclass, field
from dataclasses import dataclass
from datetime import datetime
from pathlib import Path
from typing import Any
@@ -403,9 +403,6 @@ def generate_html_report(summary: dict[str, Any], output_path: Path):
scenarios = summary["scenarios"]
# 按通过/失败分组
passed_list = [s for s in scenarios if s["passed"]]
failed_list = [s for s in scenarios if not s["passed"]]
# 构建场景卡片
scenario_cards = ""
for s in scenarios:
-2
View File
@@ -1,7 +1,6 @@
import os
import sys
from pathlib import Path
from uuid import uuid4
# 设置必要环境变量(必须在导入 app 模块之前)
os.environ.setdefault("JWT_SECRET_KEY", "unit-test-secret-key-for-testing")
@@ -9,7 +8,6 @@ os.environ.setdefault("DATABASE_URL", "sqlite:///test.db")
sys.path.insert(0, str(Path(__file__).resolve().parents[2] / "apps" / "api"))
import pytest
from app.api.routes.asset_diagnosis import _build_diagnosis
from fastapi import FastAPI
from fastapi.testclient import TestClient
-1
View File
@@ -13,7 +13,6 @@ from __future__ import annotations
import os
import sys
from pathlib import Path
from unittest.mock import MagicMock
os.environ.setdefault("JWT_SECRET_KEY", "unit-test-secret-key-for-testing")
os.environ.setdefault("DATABASE_URL", "sqlite:///test.db")
+1 -1
View File
@@ -86,7 +86,7 @@ def test_find_by_tag_ids(asset_repo, tag_repo):
a2.add_tag(tag1.id)
asset_repo.update(a2)
a3 = _create_asset(asset_repo, name="c.mp4")
_create_asset(asset_repo, name="c.mp4")
# 无标签
# 按 tag1 筛选 → a1, a2
+2 -4
View File
@@ -8,8 +8,6 @@ from __future__ import annotations
from unittest.mock import MagicMock, patch
import pytest
class TestAudioUrlSigner:
"""测试音频URL签名函数的行为。"""
@@ -79,8 +77,8 @@ class TestAudioUrlSigner:
mock_svc = MagicMock(spec=OSSStorageService)
mock_svc.get_download_url.return_value = "https://signed/a.mp3?sig=123"
# 替换全局单例
with patch("app.core.storage._storage_service", mock_svc):
# 替换全局单例(现在位于 packages.shared.storage
with patch("packages.shared.storage._storage_service", mock_svc):
from app.dependencies import get_audio_url_signer
signer = get_audio_url_signer()
+1 -2
View File
@@ -10,10 +10,9 @@ sys.path.insert(0, str(Path(__file__).resolve().parents[2] / "apps" / "api"))
from dataclasses import dataclass, field
from enum import Enum
from typing import Any
from unittest.mock import MagicMock
import pytest
from app.services.auto_clip_service import AutoClipService, ClipAssignDetail
from app.services.auto_clip_service import AutoClipService
# ── Stub 实体 ─────────────────────────────────────────────────────────────────
-1
View File
@@ -20,7 +20,6 @@ from unittest.mock import MagicMock
os.environ.setdefault("JWT_SECRET_KEY", "unit-test-secret-key-for-testing")
os.environ.setdefault("DATABASE_URL", "sqlite:///test.db")
import pytest
# 确保 app 模块可导入
sys.path.insert(0, str(Path(__file__).resolve().parents[2] / "apps" / "api"))
-3
View File
@@ -12,12 +12,9 @@ from __future__ import annotations
import importlib.util
import os
import sys
from pathlib import Path
from unittest.mock import patch
import pytest
def _load_settings_class():
"""
@@ -15,9 +15,8 @@ from __future__ import annotations
import os
import sys
from datetime import datetime, timezone
from pathlib import Path
from typing import Any, Optional
from typing import Any
from unittest.mock import MagicMock
os.environ.setdefault("JWT_SECRET_KEY", "unit-test-secret-key-for-testing")
@@ -41,7 +40,7 @@ class TestNormalizePlanConfig:
assert result == DEFAULT_EDIT_PLAN_CONFIG.copy()
def test_empty_dict_returns_full_defaults(self):
from packages.domain.config_schemas import DEFAULT_EDIT_PLAN_CONFIG, normalize_plan_config
from packages.domain.config_schemas import normalize_plan_config
result = normalize_plan_config({})
assert result["cover"]["type"] == "ai_frame"
@@ -363,7 +362,7 @@ def _create_ai_test_app():
from fastapi import FastAPI
from fastapi.testclient import TestClient
from packages.domain.edit_plan import EditPlan, EditPlanStatus
from packages.domain.edit_plan import EditPlan
@pytest.fixture
+1 -1
View File
@@ -3,7 +3,7 @@
from __future__ import annotations
import json
from unittest.mock import MagicMock, patch
from unittest.mock import MagicMock
import httpx
import pytest
+4 -7
View File
@@ -37,13 +37,10 @@ if "worker_app.celery_app" in sys.modules and isinstance(sys.modules["worker_app
if "worker_app.db" in sys.modules and isinstance(sys.modules["worker_app.db"], MagicMock):
sys.modules["worker_app.db"].SessionLocal = MagicMock()
# Mock celery.Task base class — 仅在 celery 不可用时注入 mock,避免污染真实包
try:
import celery as _real_celery # noqa: F401
except ImportError:
_mock_if_absent("celery", MagicMock())
if "celery" in sys.modules and isinstance(sys.modules["celery"], MagicMock):
sys.modules["celery"].Task = object
# Mock celery.Task base class
_mock_if_absent("celery", MagicMock())
if "celery" in sys.modules and isinstance(sys.modules["celery"], MagicMock):
sys.modules["celery"].Task = object
# Mock packages.shared.storage
_mock_if_absent("packages.shared")
+1 -1
View File
@@ -22,7 +22,7 @@ from packages.application.duplication import (
UploadForDuplicationCommand,
UploadForDuplicationUseCase,
)
from packages.domain.duplication import DuplicateSegment, DuplicationRecord
from packages.domain.duplication import DuplicationRecord
def _make_record(status="pending", **kwargs):
@@ -12,7 +12,6 @@ from __future__ import annotations
import os
import sys
from dataclasses import dataclass, field
from datetime import datetime, timezone
from typing import Any, Optional
from unittest.mock import MagicMock, patch
@@ -13,7 +13,6 @@ from __future__ import annotations
import os
import sys
from dataclasses import dataclass, field
from datetime import datetime, timezone
from types import ModuleType
from typing import Any, Optional
from unittest.mock import MagicMock, patch
@@ -176,7 +175,6 @@ class TestRenderEditPlanFailureUpdatesGenTask:
gen_task = StubGenerationTask(id="gen-task-001", status=_StubStatus("running"))
plan_repo = StubPlanRepo(plan)
clip_repo = StubClipRepo([])
gen_task_repo = StubGenTaskRepo(gen_task)
# 让 clip_repo 抛异常以触发 except 路径
-1
View File
@@ -13,7 +13,6 @@ from __future__ import annotations
import os
import sys
from datetime import datetime, timezone
from pathlib import Path
from typing import Optional
from unittest.mock import MagicMock
+4 -4
View File
@@ -16,8 +16,8 @@ import os
import sys
from datetime import datetime, timezone
from pathlib import Path
from typing import Any, List, Optional
from unittest.mock import MagicMock, patch
from typing import List, Optional
from unittest.mock import MagicMock
os.environ.setdefault("JWT_SECRET_KEY", "unit-test-secret-key-for-testing")
os.environ.setdefault("DATABASE_URL", "sqlite:///test.db")
@@ -27,7 +27,7 @@ import pytest
sys.path.insert(0, str(Path(__file__).resolve().parents[2] / "apps" / "api"))
from packages.domain.edit_template import EditTemplate, EditTemplateStatus
from packages.domain.template_clip_config import ClipType, TemplateClipConfig, TransitionEffect
from packages.domain.template_clip_config import ClipType, TemplateClipConfig
# ---------------------------------------------------------------------------
# Stub Repositories
@@ -273,7 +273,7 @@ class TestEditTemplateServiceCRUD:
def test_list_templates_active_only(self):
svc = _make_service()
t1 = svc.create_template(name="活跃")
svc.create_template(name="活跃")
t2 = svc.create_template(name="停用")
svc.deactivate_template(t2.id)
result = svc.list_templates(active_only=True)
+1 -1
View File
@@ -2,7 +2,7 @@
邮件服务测试
"""
from unittest.mock import MagicMock, Mock, patch
from unittest.mock import MagicMock, patch
import pytest
+1 -5
View File
@@ -5,10 +5,8 @@
from __future__ import annotations
import time
from unittest.mock import MagicMock, patch
from unittest.mock import MagicMock
import pytest
from packages.adapters.redis.feature_flag_store import (
FeatureFlagConfig,
@@ -337,7 +335,6 @@ class TestRedisStoreDegradation:
def test_get_returns_default_when_redis_unavailable(self):
"""Redis 连接失败时返回默认关闭配置,不抛异常。"""
import importlib
from packages.adapters.redis import feature_flag_store as ff_module
@@ -358,7 +355,6 @@ class TestRedisStoreDegradation:
def test_list_all_returns_empty_on_redis_error(self):
"""Redis 错误时 list_all 返回空字典。"""
import importlib
from packages.adapters.redis import feature_flag_store as ff_module
-1
View File
@@ -21,7 +21,6 @@ from unittest.mock import MagicMock
os.environ.setdefault("JWT_SECRET_KEY", "unit-test-secret-key-for-testing")
os.environ.setdefault("DATABASE_URL", "sqlite:///test.db")
import pytest
# 确保 app 模块可导入
sys.path.insert(0, str(Path(__file__).resolve().parents[2] / "apps" / "api"))
+1 -7
View File
@@ -11,18 +11,14 @@ from __future__ import annotations
import os
import sys
from datetime import datetime, timezone
from types import ModuleType
from typing import Any
from unittest.mock import MagicMock, patch
from unittest.mock import MagicMock
os.environ.setdefault("JWT_SECRET_KEY", "unit-test-secret-key-for-testing")
os.environ.setdefault("DATABASE_URL", "sqlite:///test.db")
from pathlib import Path
import pytest
# ── Mock worker 模块以避免数据库连接 ──────────────────────────────────────────
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "..", "apps", "worker"))
@@ -226,8 +222,6 @@ def test_legacy_engine_two_clips_concat_duration():
import subprocess
import tempfile
from video_processing.ffmpeg_utils import probe_duration
from apps.worker.worker_app.tasks.generation import _render_with_legacy_engine
with tempfile.TemporaryDirectory() as tmpdir:
+1 -1
View File
@@ -17,7 +17,7 @@ from __future__ import annotations
import json
import sys
from pathlib import Path
from unittest.mock import MagicMock, patch
from unittest.mock import patch
sys.path.insert(0, str(Path(__file__).resolve().parents[2] / "apps" / "api"))
sys.path.insert(0, str(Path(__file__).resolve().parents[2] / "apps" / "worker"))
+1 -1
View File
@@ -12,7 +12,7 @@ from __future__ import annotations
import os
import sys
from pathlib import Path
from unittest.mock import MagicMock, patch
from unittest.mock import MagicMock
os.environ.setdefault("JWT_SECRET_KEY", "unit-test-secret-key-for-testing")
os.environ.setdefault("DATABASE_URL", "sqlite:///test.db")
+2 -2
View File
@@ -429,7 +429,7 @@ class TestListJobsUseCase:
def test_list_by_project_with_status_filter(self, repo):
create_uc = CreateJobUseCase(repo)
j1 = create_uc.execute(CreateJobCommand(project_id="proj-1", job_type=JobType.VIDEO_COMPOSE))
create_uc.execute(CreateJobCommand(project_id="proj-1", job_type=JobType.VIDEO_COMPOSE))
j2 = create_uc.execute(CreateJobCommand(project_id="proj-1", job_type=JobType.VIDEO_COMPOSE))
j2.mark_running()
repo.update(j2)
@@ -460,7 +460,7 @@ class TestListJobsUseCase:
class TestGetJobStatisticsUseCase:
def test_statistics(self, repo):
create_uc = CreateJobUseCase(repo)
j1 = create_uc.execute(CreateJobCommand(project_id="proj-1", job_type=JobType.VIDEO_COMPOSE))
create_uc.execute(CreateJobCommand(project_id="proj-1", job_type=JobType.VIDEO_COMPOSE))
j2 = create_uc.execute(CreateJobCommand(project_id="proj-1", job_type=JobType.VIDEO_COMPOSE))
j2.mark_running()
repo.update(j2)
-2
View File
@@ -15,8 +15,6 @@ import time
from pathlib import Path
from unittest.mock import MagicMock, patch
import pytest
# ── oss_bucket connect_timeout 测试 ───────────────────────────────────────────
@@ -75,43 +75,43 @@ class TestOSSCredentialValidation:
class TestOSSDiagnose:
"""测试 OSSStorageService.diagnose() 方法。"""
"""测试 SharedStorageService.diagnose() 方法。"""
@patch("apps.api.app.core.storage.oss2", None)
@patch("apps.api.app.core.storage.get_settings")
@patch("packages.shared.storage.oss2", None)
@patch("packages.shared.storage.get_shared_settings")
def test_diagnose_logs_error_when_bucket_none(self, mock_settings, caplog):
"""bucket=None 时 diagnose 应输出 ERROR 日志。"""
from apps.api.app.core.storage import OSSStorageService
from packages.shared.storage import SharedStorageService
mock_settings.return_value.OSS_BUCKET_NAME = "test-bucket"
mock_settings.return_value.OSS_ENDPOINT = "oss-cn-test.com"
mock_settings.return_value.OSS_ACCESS_KEY_ID = ""
mock_settings.return_value.OSS_ACCESS_KEY_SECRET = ""
mock_settings.return_value.oss_bucket_name = "test-bucket"
mock_settings.return_value.oss_endpoint = "oss-cn-test.com"
mock_settings.return_value.oss_access_key_id = ""
mock_settings.return_value.oss_access_key_secret = ""
service = OSSStorageService()
service = SharedStorageService()
assert service.bucket is None
with caplog.at_level(logging.ERROR, logger="apps.api.app.core.storage"):
with caplog.at_level(logging.ERROR, logger="packages.shared.storage"):
service.diagnose()
assert any("" in record.message for record in caplog.records)
@patch("apps.api.app.core.storage.oss2")
@patch("apps.api.app.core.storage.get_settings")
@patch("packages.shared.storage.oss2")
@patch("packages.shared.storage.get_shared_settings")
def test_diagnose_logs_success_when_bucket_configured(self, mock_settings, mock_oss2, caplog):
"""bucket 已配置时 diagnose 应输出成功日志。"""
from apps.api.app.core.storage import OSSStorageService
from packages.shared.storage import SharedStorageService
mock_settings.return_value.OSS_BUCKET_NAME = "test-bucket"
mock_settings.return_value.OSS_ENDPOINT = "oss-cn-test.com"
mock_settings.return_value.OSS_ACCESS_KEY_ID = "test-key-id"
mock_settings.return_value.OSS_ACCESS_KEY_SECRET = "test-key-secret"
mock_settings.return_value.oss_bucket_name = "test-bucket"
mock_settings.return_value.oss_endpoint = "oss-cn-test.com"
mock_settings.return_value.oss_access_key_id = "test-key-id"
mock_settings.return_value.oss_access_key_secret = "test-key-secret"
mock_oss2.Bucket.return_value = MagicMock()
service = OSSStorageService()
service = SharedStorageService()
assert service.bucket is not None
with caplog.at_level(logging.INFO, logger="apps.api.app.core.storage"):
with caplog.at_level(logging.INFO, logger="packages.shared.storage"):
service.diagnose()
assert any("OSS诊断" in record.message for record in caplog.records)
@@ -120,38 +120,38 @@ class TestOSSDiagnose:
class TestOSSHTTPSEndpoint:
"""测试 P0-2 真正根因:sign_url 必须返回 HTTPS URL。"""
@patch("apps.api.app.core.storage.oss2")
@patch("apps.api.app.core.storage.get_settings")
@patch("packages.shared.storage.oss2")
@patch("packages.shared.storage.get_shared_settings")
def test_endpoint_without_scheme_gets_https_prefix(self, mock_settings, mock_oss2):
"""endpoint 无 scheme 时应自动加 https://,确保 sign_url 生成 HTTPS URL。"""
from apps.api.app.core.storage import OSSStorageService
from packages.shared.storage import SharedStorageService
mock_settings.return_value.OSS_BUCKET_NAME = "test-bucket"
mock_settings.return_value.OSS_ENDPOINT = "oss-cn-hangzhou.aliyuncs.com"
mock_settings.return_value.OSS_ACCESS_KEY_ID = "test-key-id"
mock_settings.return_value.OSS_ACCESS_KEY_SECRET = "test-key-secret"
mock_settings.return_value.oss_bucket_name = "test-bucket"
mock_settings.return_value.oss_endpoint = "oss-cn-hangzhou.aliyuncs.com"
mock_settings.return_value.oss_access_key_id = "test-key-id"
mock_settings.return_value.oss_access_key_secret = "test-key-secret"
mock_oss2.Bucket.return_value = MagicMock()
OSSStorageService()
SharedStorageService()
# 验证传给 oss2.Bucket 的 endpoint 带了 https://
call_args = mock_oss2.Bucket.call_args
endpoint_passed = call_args[0][1] # 第二个位置参数
assert endpoint_passed == "https://oss-cn-hangzhou.aliyuncs.com"
@patch("apps.api.app.core.storage.oss2")
@patch("apps.api.app.core.storage.get_settings")
@patch("packages.shared.storage.oss2")
@patch("packages.shared.storage.get_shared_settings")
def test_endpoint_with_existing_https_not_doubled(self, mock_settings, mock_oss2):
"""endpoint 已有 https:// 时不应重复添加。"""
from apps.api.app.core.storage import OSSStorageService
from packages.shared.storage import SharedStorageService
mock_settings.return_value.OSS_BUCKET_NAME = "test-bucket"
mock_settings.return_value.OSS_ENDPOINT = "https://oss-cn-hangzhou.aliyuncs.com"
mock_settings.return_value.OSS_ACCESS_KEY_ID = "test-key-id"
mock_settings.return_value.OSS_ACCESS_KEY_SECRET = "test-key-secret"
mock_settings.return_value.oss_bucket_name = "test-bucket"
mock_settings.return_value.oss_endpoint = "https://oss-cn-hangzhou.aliyuncs.com"
mock_settings.return_value.oss_access_key_id = "test-key-id"
mock_settings.return_value.oss_access_key_secret = "test-key-secret"
mock_oss2.Bucket.return_value = MagicMock()
OSSStorageService()
SharedStorageService()
call_args = mock_oss2.Bucket.call_args
endpoint_passed = call_args[0][1]
-1
View File
@@ -9,7 +9,6 @@ from __future__ import annotations
import logging
from unittest.mock import MagicMock, patch
import pytest
from video_processing.ffmpeg_utils import build_xfade_filter_chain
# ── P0-3: build_xfade_filter_chain 安全钳制 ──────────────────────────────────
+4 -10
View File
@@ -11,8 +11,6 @@ from __future__ import annotations
import os
from unittest.mock import MagicMock, patch
import pytest
# ── oss_bucket endpoint scheme 修复 ──────────────────────────────────────────
@@ -34,11 +32,10 @@ class TestOSSBucketEndpointScheme:
"OSS_BUCKET_NAME": "test-bucket",
},
),
patch("video_processing.oss_helpers.oss2.Auth") as mock_auth,
patch("video_processing.oss_helpers.oss2.Auth"),
patch("video_processing.oss_helpers.oss2.Bucket", return_value=mock_bucket_instance) as mock_bucket_cls,
):
# 清除缓存,确保重新创建
import video_processing.oss_helpers as oss_mod
bucket = oss_bucket()
@@ -67,9 +64,8 @@ class TestOSSBucketEndpointScheme:
patch("video_processing.oss_helpers.oss2.Auth"),
patch("video_processing.oss_helpers.oss2.Bucket", return_value=mock_bucket_instance) as mock_bucket_cls,
):
import video_processing.oss_helpers as oss_mod
bucket = oss_bucket()
oss_bucket()
call_args = mock_bucket_cls.call_args
endpoint_arg = call_args[0][1]
@@ -95,9 +91,8 @@ class TestOSSBucketEndpointScheme:
patch("video_processing.oss_helpers.oss2.Auth"),
patch("video_processing.oss_helpers.oss2.Bucket", return_value=mock_bucket_instance) as mock_bucket_cls,
):
import video_processing.oss_helpers as oss_mod
bucket = oss_bucket()
oss_bucket()
call_args = mock_bucket_cls.call_args
endpoint_arg = call_args[0][1]
@@ -117,7 +112,6 @@ class TestOSSBucketEndpointScheme:
},
clear=True,
):
import video_processing.oss_helpers as oss_mod
bucket = oss_bucket()
assert bucket is None
@@ -177,7 +171,7 @@ class TestGetSignedDownloadUrl:
patch("video_processing.oss_helpers.oss2.Auth"),
patch("video_processing.oss_helpers.oss2.Bucket", return_value=mock_bucket),
):
result = get_signed_download_url("https://test-bucket.oss-cn-hangzhou.aliyuncs.com/generated/test.mp4")
get_signed_download_url("https://test-bucket.oss-cn-hangzhou.aliyuncs.com/generated/test.mp4")
mock_bucket.sign_url.assert_called_once()
# 验证传给 sign_url 的是纯 storage key,不是完整 URL
+1 -1
View File
@@ -300,7 +300,7 @@ from sqlalchemy.orm import sessionmaker
from packages.adapters.sqlalchemy_impl.edit_plan_clip_repository import (
SQLAlchemyEditPlanClipRepository,
)
from packages.adapters.sqlalchemy_impl.models import Base, EditPlanClipModel, TemplateClipConfigModel
from packages.adapters.sqlalchemy_impl.models import Base
from packages.adapters.sqlalchemy_impl.template_clip_config_repository import (
SQLAlchemyTemplateClipConfigRepository,
)
+1 -1
View File
@@ -8,7 +8,7 @@ sys.path.insert(0, str(Path(__file__).resolve().parents[2] / "apps" / "api"))
import logging
from sqlalchemy import create_engine
from sqlalchemy.orm import Session, sessionmaker
from sqlalchemy.orm import sessionmaker
from packages.adapters.sqlalchemy_impl.edit_plan_repository import (
SQLAlchemyEditPlanRepository,
+1 -2
View File
@@ -16,13 +16,12 @@ import os
import sys
from datetime import datetime, timezone
from pathlib import Path
from typing import Any, List, Optional
from typing import List, Optional
from unittest.mock import MagicMock, patch
os.environ.setdefault("JWT_SECRET_KEY", "unit-test-secret-key-for-testing")
os.environ.setdefault("DATABASE_URL", "sqlite:///test.db")
import pytest
sys.path.insert(0, str(Path(__file__).resolve().parents[2] / "apps" / "api"))
+2 -4
View File
@@ -6,12 +6,10 @@
from __future__ import annotations
from dataclasses import dataclass, field
from pathlib import Path
from typing import Any
from unittest.mock import MagicMock, patch
import pytest
from video_processing.render_adapter import RenderAdapter, RenderAdapterResult
from video_processing.render_adapter import RenderAdapter
# ── Fixtures ──────────────────────────────────────────────────────────────────
@@ -318,7 +316,7 @@ class TestRenderPlan:
def progress_cb(progress: float, stage: str) -> None:
progress_values.append((progress, stage))
result = adapter.render_plan(
adapter.render_plan(
"plan_001",
work_dir=tmp_path / "work",
progress_cb=progress_cb,
+1 -2
View File
@@ -3,8 +3,7 @@ Redis Session 存储测试
"""
import json
from datetime import datetime
from unittest.mock import MagicMock, Mock
from unittest.mock import Mock
import pytest
@@ -14,7 +14,6 @@ from unittest.mock import MagicMock
import pytest
from packages.adapters.sqlalchemy_impl.generation_task_repository import SQLAlchemyGenerationTaskRepository
from packages.adapters.sqlalchemy_impl.tts_job_repository import SQLAlchemyTTSJobRepository
from packages.adapters.sqlalchemy_impl.voice_clone_profile_repository import SQLAlchemyVoiceCloneProfileRepository
from packages.domain.generation_task import GenerationTaskStatus
+1 -1
View File
@@ -2,7 +2,7 @@
Template Use Cases 单元测试 剪辑计划模板 CRUD + 业务规则校验
"""
from unittest.mock import MagicMock, Mock
from unittest.mock import Mock
import pytest
-1
View File
@@ -9,7 +9,6 @@ from __future__ import annotations
from datetime import datetime, timezone
from unittest.mock import MagicMock, patch
import pytest
from packages.application.cosyvoice_service import CosyVoiceService
from packages.application.tts_job.workflow import TTSWorkflowService
+1 -2
View File
@@ -12,7 +12,7 @@
from __future__ import annotations
from datetime import datetime, timezone
from unittest.mock import MagicMock, patch
from unittest.mock import MagicMock
import pytest
@@ -287,7 +287,6 @@ class TestSaveToLibraryUseCase:
repo = MagicMock()
repo.count_by_user.return_value = 0 # 配额未满
expected_item = _make_voice_library_item()
repo.create.side_effect = lambda item: item
use_case = CreateVoiceLibraryUseCase(repo)
+1 -1
View File
@@ -11,7 +11,7 @@ from __future__ import annotations
import os
import tempfile
from datetime import datetime, timezone
from unittest.mock import MagicMock, call, patch
from unittest.mock import MagicMock, patch
import pytest
-2
View File
@@ -9,14 +9,12 @@
from __future__ import annotations
import asyncio
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
from packages.application.cosyvoice_service import CosyVoiceError, CosyVoiceService
from packages.application.tts_job.streaming_service import (
TTSStreamingError,
TTSStreamingService,
)
+1 -2
View File
@@ -6,9 +6,8 @@
from __future__ import annotations
import logging
from datetime import datetime, timezone
from unittest.mock import MagicMock, patch
from unittest.mock import MagicMock
import pytest
+1 -2
View File
@@ -12,7 +12,6 @@ from unittest.mock import MagicMock, patch
import pytest
from video_processing.unified_render_service import (
RenderLayer,
RenderResult,
ResolvedClip,
UnifiedRenderService,
@@ -683,7 +682,7 @@ class TestAssSubtitles:
def test_generate_ass_with_subtitle_only(self, tmp_path):
"""只有字幕时生成 ASS 文件。"""
ass_path = tmp_path / "test.ass"
result = generate_ass_subtitles(
generate_ass_subtitles(
ass_path,
video_width=1280,
video_height=720,
-1
View File
@@ -20,7 +20,6 @@ from unittest.mock import MagicMock
os.environ.setdefault("JWT_SECRET_KEY", "unit-test-secret-key-for-testing")
os.environ.setdefault("DATABASE_URL", "sqlite:///test.db")
import pytest
# 确保 app 模块可导入
sys.path.insert(0, str(Path(__file__).resolve().parents[2] / "apps" / "api"))
-1
View File
@@ -13,7 +13,6 @@ import uuid
import pytest
from packages.adapters.sqlalchemy_impl.models import (
Base,
ClassificationJobModel,
EditPlanClipModel,
EditPlanModel,
+2 -3
View File
@@ -14,15 +14,14 @@ sys.path.insert(0, str(Path(__file__).resolve().parents[2] / "apps" / "api"))
from app.services.video_compose_service import (
ComposeCommand,
ComposeValidation,
VideoComposeService,
_build_concat_filter,
_build_xfade_filter,
_chain_filters,
)
from packages.domain.edit_plan import EditPlan, EditPlanStatus
from packages.domain.edit_plan_clip import EditPlanClip, EditPlanClipStatus
from packages.domain.edit_plan import EditPlanStatus
from packages.domain.edit_plan_clip import EditPlanClipStatus
# ── Stub 实体 ─────────────────────────────────────────────────────────────────
+1 -1
View File
@@ -2,7 +2,7 @@
from __future__ import annotations
from unittest.mock import MagicMock, patch
from unittest.mock import MagicMock
import pytest
+1 -1
View File
@@ -8,7 +8,7 @@
4. 边界条件与异常场景
"""
from unittest.mock import MagicMock, Mock, call
from unittest.mock import Mock
import pytest