Compare commits
16 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 6897a4be96 | |||
| 6d2d5abff2 | |||
| e3f5ab5611 | |||
| d061bccdd2 | |||
| a3967c6829 | |||
| 97a81a39f3 | |||
| eab471703e | |||
| 32a53ab9e6 | |||
| e50ba67c11 | |||
| e8f9e2dabe | |||
| 3f773795ed | |||
| 29ed5df884 | |||
| 188e535af8 | |||
| d90bc6bbfc | |||
| 19d5dcbc5a | |||
| 1a57878f76 |
@@ -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
@@ -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/*" \
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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=旧VideoComposeService,unified=新UnifiedRenderService
|
||||
|
||||
+11
-280
@@ -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"]
|
||||
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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,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);
|
||||
|
||||
@@ -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";
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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
|
||||
)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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}"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 测试来验证
|
||||
|
||||
@@ -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" # 保持不变
|
||||
@@ -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")
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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 实体 ─────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@@ -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"))
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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 路径
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
邮件服务测试
|
||||
"""
|
||||
|
||||
from unittest.mock import MagicMock, Mock, patch
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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"))
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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"))
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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 安全钳制 ──────────────────────────────────
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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"))
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
Template Use Cases 单元测试 — 剪辑计划模板 CRUD + 业务规则校验
|
||||
"""
|
||||
|
||||
from unittest.mock import MagicMock, Mock
|
||||
from unittest.mock import Mock
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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"))
|
||||
|
||||
@@ -13,7 +13,6 @@ import uuid
|
||||
import pytest
|
||||
|
||||
from packages.adapters.sqlalchemy_impl.models import (
|
||||
Base,
|
||||
ClassificationJobModel,
|
||||
EditPlanClipModel,
|
||||
EditPlanModel,
|
||||
|
||||
@@ -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 实体 ─────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from unittest.mock import MagicMock, patch
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
4. 边界条件与异常场景
|
||||
"""
|
||||
|
||||
from unittest.mock import MagicMock, Mock, call
|
||||
from unittest.mock import Mock
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
Reference in New Issue
Block a user