Compare commits

...

19 Commits

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

新增:xx-dashboard-empty(P0 修复 — Dashboard 空状态裸奔)
2026-07-13 17:39:11 +08:00
xiaoxia 29ed5df884 feat(frontend): Phase 3 前端深度清理
CI/CD Pipeline / Frontend Lint (push) Failing after 2m1s
CI/CD Pipeline / Unit Tests (push) Failing after 26m42s
CI/CD Pipeline / Validate Code Quality And Tests (push) Failing after 26m54s
CI/CD Pipeline / Build & Push Staging (Watchtower auto-deploy) (push) Has been skipped
CI/CD Pipeline / Build Production Runtime Images (push) Has been skipped
CI/CD Pipeline / Staging E2E Tests (push) Has been skipped
CI/CD Pipeline / Staging API Integration Tests (push) Has been skipped
CI/CD Pipeline / Deploy Production (push) Has been skipped
CI/CD Pipeline / Production Browser E2E (push) Has been skipped
CI/CD Pipeline / Integration Tests (push) Has been cancelled
Merge PR #264: feat(frontend): Phase 3 前端深度清理 — Mock 数据真替换 + Admin.css 全清 into develop
2026-07-13 17:07:59 +08:00
xiaoxia 188e535af8 Merge pull request 'fix(ci): 修复新Runner pip失败 + 增加并发控制' (#271) from fix/ci-pip-and-concurrency into develop
CI/CD Pipeline / Validate Code Quality And Tests (push) Failing after 6s
CI/CD Pipeline / Unit Tests (push) Failing after 8s
CI/CD Pipeline / Integration Tests (push) Failing after 1m11s
CI/CD Pipeline / Frontend Lint (push) Successful in 2m53s
CI/CD Pipeline / Build & Push Staging (Watchtower auto-deploy) (push) Has been skipped
CI/CD Pipeline / Build Production Runtime Images (push) Has been skipped
CI/CD Pipeline / Staging E2E Tests (push) Has been skipped
CI/CD Pipeline / Staging API Integration Tests (push) Has been skipped
CI/CD Pipeline / Deploy Production (push) Has been skipped
CI/CD Pipeline / Production Browser E2E (push) Has been skipped
2026-07-13 17:05:04 +08:00
xiaoxia 6227aa610c fix: Dashboard CSS 类名对齐已有样式 + 修复路由指向
CI/CD Pipeline / Frontend Lint (pull_request) Failing after 9s
CI/CD Pipeline / Validate Code Quality And Tests (pull_request) Failing after 11s
CI/CD Pipeline / Build & Push Staging (Watchtower auto-deploy) (pull_request) Has been skipped
CI/CD Pipeline / Build Production Runtime Images (pull_request) Has been skipped
CI/CD Pipeline / Staging API Integration Tests (pull_request) Has been skipped
CI/CD Pipeline / Deploy Production (pull_request) Has been skipped
CI/CD Pipeline / Staging E2E Tests (pull_request) Has been skipped
CI/CD Pipeline / Production Browser E2E (pull_request) Has been skipped
CI/CD Pipeline / Integration Tests (pull_request) Failing after 5s
CI/CD Pipeline / Unit Tests (pull_request) Failing after 1m2s
- 所有类名改回 dashboard.css 已有定义(xx-kpi-*/xx-task-*/xx-quick-*/xx-chart-*/xx-announcement-*)
- "查看全部"路由从 /asset-library 改为 /app/history
- vite build 验证通过
2026-07-13 16:57:21 +08:00
xiaoxia d90bc6bbfc fix(ci): add --break-system-packages for pip + concurrency config
CI/CD Pipeline / Frontend Lint (pull_request) Failing after 16s
CI/CD Pipeline / Unit Tests (pull_request) Failing after 2m6s
CI/CD Pipeline / Validate Code Quality And Tests (pull_request) Failing after 4m21s
CI/CD Pipeline / Build & Push Staging (Watchtower auto-deploy) (pull_request) Has been skipped
CI/CD Pipeline / Build Production Runtime Images (pull_request) Has been skipped
CI/CD Pipeline / Staging E2E Tests (pull_request) Has been skipped
CI/CD Pipeline / Staging API Integration Tests (pull_request) Has been skipped
CI/CD Pipeline / Deploy Production (pull_request) Has been skipped
CI/CD Pipeline / Production Browser E2E (pull_request) Has been skipped
CI/CD Pipeline / Integration Tests (pull_request) Failing after 5m10s
2026-07-13 16:49:36 +08:00
CI Test 3ebcc7e066 refactor: 补做 Phase 3 Mock 真替换 — accounts/Dashboard/GeneratePage
CI/CD Pipeline / Unit Tests (pull_request) Failing after 16s
CI/CD Pipeline / Frontend Lint (pull_request) Failing after 21s
CI/CD Pipeline / Validate Code Quality And Tests (pull_request) Failing after 39s
CI/CD Pipeline / Build & Push Staging (Watchtower auto-deploy) (pull_request) Has been skipped
CI/CD Pipeline / Build Production Runtime Images (pull_request) Has been skipped
CI/CD Pipeline / Staging E2E Tests (pull_request) Has been skipped
CI/CD Pipeline / Staging API Integration Tests (pull_request) Has been skipped
CI/CD Pipeline / Deploy Production (pull_request) Has been skipped
CI/CD Pipeline / Production Browser E2E (pull_request) Has been skipped
CI/CD Pipeline / Integration Tests (pull_request) Failing after 43s
- 删除 api/accounts.ts(163行全 mock),Accounts.tsx 改为占位状态
- Dashboard.tsx 移除硬编码 mock 数据(kpiData/quickEntries/recentTasks),改用空状态
- GeneratePage.tsx 移除 MOCK_TIMELINE 及 TimelineScene 接口,时间线预览改为占位提示
- 清理所有相关 TODO 注释和未使用的 import
- vite build 验证通过
2026-07-13 16:46:14 +08:00
xiaoxia 19d5dcbc5a chore(backend): Phase 3 后端清理
CI/CD Pipeline / Validate Code Quality And Tests (push) Failing after 22s
CI/CD Pipeline / Unit Tests (push) Failing after 19s
CI/CD Pipeline / Frontend Lint (push) Failing after 25s
CI/CD Pipeline / Build & Push Staging (Watchtower auto-deploy) (push) Has been skipped
CI/CD Pipeline / Build Production Runtime Images (push) Has been skipped
CI/CD Pipeline / Staging E2E Tests (push) Has been skipped
CI/CD Pipeline / Staging API Integration Tests (push) Has been skipped
CI/CD Pipeline / Deploy Production (push) Has been skipped
CI/CD Pipeline / Production Browser E2E (push) Has been skipped
CI/CD Pipeline / Integration Tests (push) Failing after 14s
Merge PR #265: chore(backend): Phase 3 清理 — 未使用依赖 + pyflakes 警告 + 测试冗余 into develop
2026-07-13 16:38:36 +08:00
CI Bot 1a57878f76 chore(backend): Phase 3 清理 — 未使用依赖删除 + pyflakes 警告清零 + 测试文件冗余清理
CI/CD Pipeline / Validate Code Quality And Tests (pull_request) Failing after 1m12s
CI/CD Pipeline / Unit Tests (pull_request) Failing after 2m17s
CI/CD Pipeline / Integration Tests (pull_request) Failing after 1m33s
CI/CD Pipeline / Frontend Lint (pull_request) Successful in 3m14s
CI/CD Pipeline / Build & Push Staging (Watchtower auto-deploy) (pull_request) Has been skipped
CI/CD Pipeline / Build Production Runtime Images (pull_request) Has been skipped
CI/CD Pipeline / Staging E2E Tests (pull_request) Has been skipped
CI/CD Pipeline / Deploy Production (pull_request) Has been skipped
CI/CD Pipeline / Staging API Integration Tests (pull_request) Has been skipped
CI/CD Pipeline / Production Browser E2E (pull_request) Has been skipped
1. 未使用依赖清理:
   - 从 requirements-base.txt 移除 cryptography 和 pyOpenSSL

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

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

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-13 16:14:46 +08:00
CI Test 923c6bad1c feat(frontend): Phase 3 前端深度清理 — Mock 数据真替换 + Admin.css 全清
CI/CD Pipeline / Validate Code Quality And Tests (pull_request) Failing after 1m22s
CI/CD Pipeline / Unit Tests (pull_request) Failing after 2m40s
CI/CD Pipeline / Integration Tests (pull_request) Failing after 1m44s
CI/CD Pipeline / Frontend Lint (pull_request) Failing after 3m19s
CI/CD Pipeline / Build & Push Staging (Watchtower auto-deploy) (pull_request) Has been skipped
CI/CD Pipeline / Build Production Runtime Images (pull_request) Has been skipped
CI/CD Pipeline / Staging E2E Tests (pull_request) Has been skipped
CI/CD Pipeline / Staging API Integration Tests (pull_request) Has been skipped
CI/CD Pipeline / Deploy Production (pull_request) Has been skipped
CI/CD Pipeline / Production Browser E2E (pull_request) Has been skipped
1. TitleLibrary.tsx: 移除 MOCK_CATEGORIES,分类从真实 API 数据动态派生
   - 删除 CategoryItem 接口、MOCK_CATEGORIES 常量
   - 用 useMemo 从 apiTitles.category 字段聚合分类列表
   - 分类筛选改为直接匹配 category 字段(移除 catToIndustry 硬编码映射)
   - 移除无后端支持的新建/删除分类功能(保留 TODO 注释)
   - TitleData 新增 category 字段

2. accounts.ts: 全文件标记为 Mock,添加明确 TODO
   - 文件头注明后端无 accounts 路由,待后端就绪后替换
   - 各 section 注释标注 mock 性质

3. Admin.css: 477 行 → 27 行
   - 仅保留 AdminComingSoon.tsx 实际使用的 2 个 class
   - 其余样式已迁移至 global.css / ui.css

4. 未使用依赖: 已确认 package.json 全部 26 个依赖均在使用,无需清理
2026-07-13 16:07:26 +08:00
95 changed files with 411 additions and 2994 deletions
-1
View File
@@ -5,7 +5,6 @@ APP_ENV=production
ENVIRONMENT=production
DEBUG=false
USE_IN_MEMORY_DB=false
LOG_LEVEL=WARNING
# ==================== 数据库(必须修改)====================
DATABASE_URL=postgresql://prod_user:CHANGE_THIS_PASSWORD@db-prod:5432/xiaoxia_prod
+14 -10
View File
@@ -19,6 +19,10 @@ on:
permissions:
contents: read
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
jobs:
validate:
name: Validate Code Quality And Tests
@@ -88,9 +92,9 @@ jobs:
shell: sh
run: |
set -eu
python3 -m pip install -q -r requirements-base.txt
python3 -m pip install -q -r requirements.txt
python3 -m pip install -q -r requirements-dev.txt
python3 -m pip install --break-system-packages -q -r requirements-base.txt
python3 -m pip install --break-system-packages -q -r requirements.txt
python3 -m pip install --break-system-packages -q -r requirements-dev.txt
python3 -m black --version
python3 -m isort --version-number
python3 -m flake8 --version
@@ -217,9 +221,9 @@ jobs:
shell: sh
run: |
set -eu
python3 -m pip install -q -r requirements-base.txt
python3 -m pip install -q -r requirements.txt
python3 -m pip install -q -r requirements-dev.txt
python3 -m pip install --break-system-packages -q -r requirements-base.txt
python3 -m pip install --break-system-packages -q -r requirements.txt
python3 -m pip install --break-system-packages -q -r requirements-dev.txt
pytest --version
- name: Run unit tests with coverage
@@ -316,9 +320,9 @@ jobs:
shell: sh
run: |
set -eu
python3 -m pip install -q -r requirements-base.txt
python3 -m pip install -q -r requirements.txt
python3 -m pip install -q -r requirements-dev.txt
python3 -m pip install --break-system-packages -q -r requirements-base.txt
python3 -m pip install --break-system-packages -q -r requirements.txt
python3 -m pip install --break-system-packages -q -r requirements-dev.txt
pytest --version
- name: Start Redis
@@ -390,7 +394,7 @@ jobs:
shell: sh
run: |
set -eu
pip install -q pytest-rerunfailures
pip install --break-system-packages -q pytest-rerunfailures
PYTHONPATH="$PWD/apps/api:$PWD" python3 -m coverage run --append \
--source=apps/api/app,packages \
--omit="*/migrations/*,*/tests/*,*/test_*.py,*/site-packages/*" \
-1
View File
@@ -23,7 +23,6 @@ from fastapi import APIRouter, Depends, HTTPException, Query, status
from pydantic import BaseModel, Field
from packages.adapters.redis.feature_flag_store import (
FEATURE_FLAG_REDIS_PREFIX,
FeatureFlagConfig,
RedisFeatureFlagStore,
)
@@ -10,7 +10,6 @@ from app.core.task_enqueue import (
USER_PENDING_LIMIT,
GlobalQueueFull,
UserPendingLimitExceeded,
check_queue_limits,
safe_enqueue_generation_task,
)
from app.dependencies import (
-8
View File
@@ -19,7 +19,6 @@ class Settings(BaseSettings):
# Container bind address; external expose is controlled by Docker/Nginx.
API_HOST: str = "0.0.0.0" # nosec: B104
API_PORT: int = 8000
API_PREFIX: str = "/api/v1"
DATABASE_URL: str = "postgresql+psycopg://postgres:postgres@localhost:5432/xiaoxia_saas"
DATABASE_POOL_SIZE: int = 20
@@ -30,16 +29,10 @@ class Settings(BaseSettings):
AUTO_CREATE_SCHEMA: bool = False
REDIS_URL: str = "redis://localhost:6379/0"
REDIS_MAX_CONNECTION: int = 50
ENABLE_REDIS_SESSIONS: bool = False
# JWT secret key - MUST be set via environment variable, no default allowed
JWT_SECRET_KEY: Optional[str] = None
# 旧的 JWT secret key(用于密钥轮换期间验证旧 token)
# 在密钥轮换时,先设置新密钥,旧密钥保留在此处直到所有旧 token 过期
JWT_SECRET_KEY_OLD: Optional[str] = None
# 密钥轮换天数(到达此天数后建议更换密钥)
SECRET_ROTATION_DAYS: int = 90
# JWT 算法与过期时间(与 .env.example 对齐)
JWT_ALGORITHM: str = "HS256"
@@ -111,7 +104,6 @@ class Settings(BaseSettings):
)
OSS_DIRECT_UPLOAD_EXPIRE_SECONDS: int = 900
LOG_LEVEL: str = "INFO"
CORS_ORIGINS_RAW: str = "http://localhost:3000,http://localhost:5173,http://localhost:8000"
# 渲染引擎选择:legacy=旧VideoComposeServiceunified=新UnifiedRenderService
+11 -280
View File
@@ -1,283 +1,14 @@
"""阿里云 OSS 存储服务"""
"""Backward-compatible re-export from shared storage.
import base64
import datetime as dt
import hashlib
import hmac
import json
import logging
import os
from urllib.parse import urlparse
All storage logic now lives in ``packages.shared.storage``.
This module keeps old import paths working so existing code
does not need to change.
"""
try:
import oss2
except ImportError: # pragma: no cover - exercised in minimal local/test environments
oss2 = None
from app.config import get_settings
from packages.shared.storage import (
SharedStorageService as OSSStorageService,
get_shared_storage_service,
get_storage_service,
)
logger = logging.getLogger(__name__)
class OSSStorageService:
"""阿里云 OSS 存储服务"""
def __init__(self):
settings = get_settings()
self.bucket_name = settings.OSS_BUCKET_NAME
self.public_url = f"https://{settings.OSS_BUCKET_NAME}.{settings.OSS_ENDPOINT}"
self.local_url_prefix = os.getenv("GENERATED_FILES_URL_PREFIX", "/generated-files")
self.bucket = None
has_key_id = bool(settings.OSS_ACCESS_KEY_ID)
has_key_secret = bool(settings.OSS_ACCESS_KEY_SECRET)
if has_key_id and has_key_secret:
if oss2 is not None:
try:
# P0-2 修复:oss2.Bucket 的 endpoint 必须带 https:// 前缀,
# 否则 sign_url 默认生成 HTTP URL。
bucket_endpoint = settings.OSS_ENDPOINT
if not bucket_endpoint.startswith(("http://", "https://")):
bucket_endpoint = f"https://{bucket_endpoint}"
auth = oss2.Auth(
settings.OSS_ACCESS_KEY_ID,
settings.OSS_ACCESS_KEY_SECRET,
)
self.bucket = oss2.Bucket(
auth,
bucket_endpoint,
settings.OSS_BUCKET_NAME,
)
logger.info(
"OSS initialized: endpoint=%s bucket=%s",
settings.OSS_ENDPOINT,
settings.OSS_BUCKET_NAME,
)
except Exception as error:
logger.error("Failed to initialize OSS bucket client: %s", error)
else:
logger.error("oss2 SDK is not installed — OSS operations will fail")
else:
missing = []
if not has_key_id:
missing.append("OSS_ACCESS_KEY_ID")
if not has_key_secret:
missing.append("OSS_ACCESS_KEY_SECRET")
logger.error("OSS credentials not configured — missing: %s", ", ".join(missing))
self.access_key_id = settings.OSS_ACCESS_KEY_ID
self.access_key_secret = settings.OSS_ACCESS_KEY_SECRET
self.endpoint = settings.OSS_ENDPOINT
def diagnose(self) -> None:
"""启动诊断:输出 OSS 配置状态,帮助排查预签名 URL 问题。"""
key_id_display = (
f"{self.access_key_id[:4]}...{self.access_key_id[-4:]}" if len(self.access_key_id) > 8 else "(empty)"
)
logger.info(
"[OSS诊断] endpoint=%s bucket_name=%s access_key_id=%s",
self.endpoint,
self.bucket_name,
key_id_display,
)
if self.bucket is None:
logger.error(
"[OSS诊断] ❌ bucket=None — 预签名URL不可用!"
"原因: OSS_ACCESS_KEY_ID/OSS_ACCESS_KEY_SECRET 未配置或 oss2 未安装。"
"请检查服务器 .env 文件(如 /var/lib/xiaoxia-saas-staging/.env"
)
else:
logger.info("[OSS诊断] ✅ bucket 已配置,预签名URL可用")
def _is_local_generated_url(self, storage_key_or_url: str) -> bool:
parsed = urlparse(storage_key_or_url)
path = parsed.path if parsed.scheme else storage_key_or_url
return path.startswith(f"{self.local_url_prefix}/")
def create_direct_upload_post(
self,
storage_key: str,
content_type: str,
max_size_bytes: int,
expires_seconds: int,
) -> dict[str, object]:
"""创建浏览器直传 OSS 的 PostObject 表单。"""
if not self.access_key_id or not self.access_key_secret:
raise RuntimeError("OSS storage is not configured")
normalized_key = self._normalize_storage_key(storage_key)
if not normalized_key.startswith("uploads/"):
raise ValueError("direct upload key must be under uploads/")
expiration = (dt.datetime.now(dt.timezone.utc) + dt.timedelta(seconds=expires_seconds)).strftime(
"%Y-%m-%dT%H:%M:%S.000Z"
)
policy = {
"expiration": expiration,
"conditions": [
{"bucket": self.bucket_name},
{"key": normalized_key},
["content-length-range", 1, max_size_bytes],
["starts-with", "$Content-Type", content_type.split("/", 1)[0] + "/" if "/" in content_type else ""],
],
}
encoded_policy = base64.b64encode(json.dumps(policy, separators=(",", ":")).encode("utf-8")).decode("ascii")
signature = base64.b64encode(
hmac.new(self.access_key_secret.encode("utf-8"), encoded_policy.encode("utf-8"), hashlib.sha1).digest()
).decode("ascii")
return {
"url": self.public_url,
"method": "POST",
"storage_key": normalized_key,
"expires_at": expiration,
"fields": {
"key": normalized_key,
"OSSAccessKeyId": self.access_key_id,
"policy": encoded_policy,
"Signature": signature,
"success_action_status": "201",
"Content-Type": content_type,
},
}
def upload_file(
self,
file_or_path,
storage_key: str,
content_type: str = "application/octet-stream",
) -> str:
"""
上传文件到 OSS
Args:
file_or_path: 文件对象或本地文件路径
storage_key: 存储键(文件路径)
content_type: 内容类型
Returns:
文件公网 URL
"""
if self.bucket is None:
raise RuntimeError("OSS storage is not configured")
try:
# 如果是字符串路径,从本地文件上传
if isinstance(file_or_path, str):
self.bucket.put_object_from_file(storage_key, file_or_path, headers={"Content-Type": content_type})
else:
# 文件对象
file_or_path.seek(0)
self.bucket.put_object(storage_key, file_or_path, headers={"Content-Type": content_type})
return f"{self.public_url}/{storage_key}"
except Exception as e:
raise Exception(f"Failed to upload file to OSS: {e}")
def get_url(self, storage_key: str) -> str:
"""获取文件公网 URL"""
return f"{self.public_url}/{storage_key}"
def get_download_url(self, storage_key_or_url: str, expires_seconds: int = 3600) -> str:
"""
获取文件下载签名 URL(用于私有文件)
Args:
storage_key_or_url: 存储键或完整 URL
expires_seconds: 过期时间(秒)
Returns:
签名 URL
"""
if self.bucket is None:
if self._is_local_generated_url(storage_key_or_url):
return storage_key_or_url
logger.warning(
"get_download_url: OSS bucket not configured, returning raw URL. " "storage_key_or_url=%s",
storage_key_or_url[:200],
)
return self.get_url(self._normalize_storage_key(storage_key_or_url))
storage_key = self._normalize_storage_key(storage_key_or_url)
try:
signed = self.bucket.sign_url("GET", storage_key, expires_seconds)
logger.info(
"get_download_url: signed URL generated. storage_key=%s url_prefix=%s",
storage_key[:80],
signed[:60],
)
return signed
except Exception:
logger.exception(
"get_download_url: sign_url failed, falling back to raw URL. " "storage_key=%s",
storage_key[:200],
)
return self.get_url(storage_key)
def _normalize_storage_key(self, storage_key_or_url: str) -> str:
"""从 URL 中提取存储键"""
if storage_key_or_url.startswith("http://") or storage_key_or_url.startswith("https://"):
parsed = urlparse(storage_key_or_url)
# 移除开头的 /
return parsed.path.lstrip("/")
return storage_key_or_url.lstrip("/")
def download_file(self, storage_key: str, local_path: str):
"""
从 OSS 下载文件到本地
Args:
storage_key: 存储键
local_path: 本地文件路径
"""
if self.bucket is None:
raise RuntimeError("OSS storage is not configured")
try:
os.makedirs(os.path.dirname(local_path), exist_ok=True)
self.bucket.get_object_to_file(storage_key, local_path)
except Exception as e:
raise Exception(f"Failed to download file from OSS: {e}")
def delete_file(self, storage_key: str):
"""
删除 OSS 文件
Args:
storage_key: 存储键
"""
if self.bucket is None:
return
try:
self.bucket.delete_object(storage_key)
except Exception as error:
logger.warning(
"Failed to delete file from OSS",
extra={"storage_key": storage_key, "error": str(error)},
)
def file_exists(self, storage_key: str) -> bool:
"""
检查文件是否存在
Args:
storage_key: 存储键
Returns:
是否存在
"""
if self.bucket is None:
return False
return self.bucket.object_exists(storage_key)
_storage_service = None
def get_storage_service() -> OSSStorageService:
"""获取存储服务实例(全局单例)"""
global _storage_service
if _storage_service is None:
_storage_service = OSSStorageService()
_storage_service.diagnose()
return _storage_service
__all__ = ["OSSStorageService", "get_storage_service", "get_shared_storage_service"]
-161
View File
@@ -1,161 +0,0 @@
/**
* 账号管理 Mock API
*
* 模拟多平台账号绑定/解绑操作
* 支持平台:抖音、快手、小红书、微信视频号
*/
/* ── 类型定义 ───────────────────────────────────────────── */
/** 平台 ID */
export type PlatformId = "douyin" | "kuaishou" | "xiaohongshu" | "wechat";
/** 账号状态 */
export type AccountStatus = "active" | "expired" | "limited";
/** 已绑定的账号 */
export interface Account {
id: string;
platform_id: PlatformId;
name: string;
avatar?: string;
status: AccountStatus;
bound_at: string;
}
/** 平台信息 */
export interface Platform {
id: PlatformId;
name: string;
subName: string;
icon: string;
gradient: string;
}
/** 绑定账号请求 */
export interface BindAccountRequest {
platform_id: PlatformId;
name: string;
}
/* ── 平台配置 ───────────────────────────────────────────── */
export const PLATFORMS: Platform[] = [
{
id: "douyin",
name: "抖音",
subName: "短视频发布平台",
icon: "📱",
gradient: "linear-gradient(135deg, #fe2c55, #25f4ee)",
},
{
id: "kuaishou",
name: "快手",
subName: "短视频发布平台",
icon: "🎬",
gradient: "linear-gradient(135deg, #ff4906, #ffba00)",
},
{
id: "xiaohongshu",
name: "小红书",
subName: "种草笔记发布平台",
icon: "📕",
gradient: "linear-gradient(135deg, #ff2442, #ff6b6b)",
},
{
id: "wechat",
name: "微信视频号",
subName: "视频号发布平台",
icon: "💬",
gradient: "linear-gradient(135deg, #07c160, #4cd964)",
},
];
/* ── Mock 数据 ───────────────────────────────────────────── */
let MOCK_ACCOUNTS: Account[] = [
{
id: "acc-001",
platform_id: "douyin",
name: "小虾官方号",
avatar: "🦐",
status: "active",
bound_at: "2025-12-01T10:00:00Z",
},
{
id: "acc-002",
platform_id: "douyin",
name: "小虾日常",
avatar: "🐟",
status: "active",
bound_at: "2025-12-15T14:30:00Z",
},
{
id: "acc-003",
platform_id: "kuaishou",
name: "小虾剪辑",
avatar: "🎬",
status: "active",
bound_at: "2026-01-05T09:00:00Z",
},
{
id: "acc-004",
platform_id: "xiaohongshu",
name: "小虾种草",
avatar: "📕",
status: "limited",
bound_at: "2026-02-20T16:00:00Z",
},
];
/* ── 模拟延迟 ───────────────────────────────────────────── */
const delay = (ms: number) => new Promise((r) => setTimeout(r, ms));
/* ── API 函数 ───────────────────────────────────────────── */
/** 获取指定平台的账号列表 */
export async function getAccountsByPlatform(
platformId: PlatformId,
): Promise<Account[]> {
await delay(300);
return MOCK_ACCOUNTS.filter((a) => a.platform_id === platformId);
}
/** 获取所有平台的账号总数 */
export async function getAllAccounts(): Promise<Account[]> {
await delay(200);
return [...MOCK_ACCOUNTS];
}
/** 绑定新账号 */
export async function bindAccount(data: BindAccountRequest): Promise<Account> {
await delay(500);
const newAccount: Account = {
id: `acc-${Date.now()}`,
platform_id: data.platform_id,
name: data.name,
avatar: undefined,
status: "active",
bound_at: new Date().toISOString(),
};
MOCK_ACCOUNTS = [...MOCK_ACCOUNTS, newAccount];
return newAccount;
}
/** 解绑账号 */
export async function unbindAccount(accountId: string): Promise<void> {
await delay(400);
MOCK_ACCOUNTS = MOCK_ACCOUNTS.filter((a) => a.id !== accountId);
}
/* ── 状态配置 ───────────────────────────────────────────── */
export const ACCOUNT_STATUS_CONFIG: Record<
AccountStatus,
{ label: string; className: string }
> = {
active: { label: "正常", className: "acc-status--active" },
expired: { label: "已过期", className: "acc-status--expired" },
limited: { label: "受限", className: "acc-status--limited" },
};
-37
View File
@@ -1,37 +0,0 @@
/**
* V21 Form 表单
* 封装 Ant Design Form,应用 V21 设计系统样式
*/
import React from "react";
import { Form as AntForm } from "antd";
import type { FormProps as AntFormProps } from "antd";
import classNames from "classnames";
import "./ui.css";
export interface FormProps extends AntFormProps {
/** 紧凑模式(减小表单项间距) */
compact?: boolean;
}
const Form = ({ className, compact, children, ...rest }: FormProps) => {
const v21Class = classNames(
"xx-form",
compact && "xx-form-compact",
className,
);
return (
<AntForm
className={v21Class}
{...(rest as Omit<FormProps, "className" | "compact" | "children">)}
>
{children as React.ReactNode}
</AntForm>
);
};
/** 导出 Form 的子组件(保持 antd API 一致) */
export const FormItem = AntForm.Item;
export const FormList = AntForm.List;
export const FormProvider = AntForm.Provider;
export default Form;
-25
View File
@@ -1,25 +0,0 @@
/**
* V21 Pagination 分页
* 封装 Ant Design Pagination,应用 V21 设计系统样式
*/
import React from "react";
import { Pagination as AntPagination } from "antd";
import type { PaginationProps as AntPaginationProps } from "antd";
import classNames from "classnames";
import "./ui.css";
export interface PaginationProps extends AntPaginationProps {
/** 使用 V21 样式 */
v21?: boolean;
}
const Pagination: React.FC<PaginationProps> = ({
className,
v21 = true,
...rest
}) => {
const v21Class = classNames(v21 && "xx-pagination", className);
return <AntPagination className={v21Class} {...rest} />;
};
export default Pagination;
-29
View File
@@ -1,29 +0,0 @@
/**
* V21 Table 表格
* 封装 Ant Design Table,应用 V21 设计系统样式
*/
import React from "react";
import { Table as AntTable } from "antd";
import type { TableProps as AntTableProps } from "antd";
import classNames from "classnames";
import "./ui.css";
export interface TableProps<
RecordType = unknown,
> extends AntTableProps<RecordType> {
/** 使用 V21 样式 */
v21?: boolean;
}
function Table<RecordType extends object = Record<string, unknown>>({
className,
v21 = true,
...rest
}: TableProps<RecordType>) {
const v21Class = classNames(v21 && "xx-table", className);
return <AntTable<RecordType> className={v21Class} {...rest} />;
}
export default Table as <RecordType extends object = Record<string, unknown>>(
props: TableProps<RecordType> & React.RefAttributes<HTMLDivElement>,
) => React.ReactElement;
-9
View File
@@ -15,9 +15,6 @@ export type { SelectProps } from "./Select";
export { default as Modal } from "./Modal";
export type { ModalProps } from "./Modal";
export { default as Table } from "./Table";
export type { TableProps } from "./Table";
export { default as Card } from "./Card";
export type { CardProps } from "./Card";
@@ -26,9 +23,3 @@ export type { TagProps, TagVariant } from "./Tag";
export { Tooltip, Popover } from "./Tooltip";
export type { TooltipProps, PopoverProps } from "./Tooltip";
export { default as Form, FormItem, FormList, FormProvider } from "./Form";
export type { FormProps } from "./Form";
export { default as Pagination } from "./Pagination";
export type { PaginationProps } from "./Pagination";
+75 -198
View File
@@ -2,194 +2,66 @@
* 账号管理页面 — V21 Design System
*
* 展示多平台账号绑定状态(抖音/快手/小红书/微信视频号)
* 支持绑定/解绑操作
* 后端账号管理 API 尚未就绪,当前展示占位状态
*
* 零 antd 直接导入,全部使用 CSS 变量
*/
import React, { useState, useCallback } from "react";
import { useQueries, useMutation, useQueryClient } from "@tanstack/react-query";
import React from "react";
import { Button } from "@/components/ui";
import PageHead from "@/components/layout/PageHead";
import {
PLATFORMS,
getAccountsByPlatform,
unbindAccount,
bindAccount,
ACCOUNT_STATUS_CONFIG,
type Platform,
type Account,
type PlatformId,
} from "@/api/accounts";
import "./accounts.css";
/* ── Toast 系统 ─────────────────────────────────────────── */
/* ── 类型定义 ───────────────────────────────────────────── */
interface Toast {
id: number;
message: string;
type: "success" | "error";
export type PlatformId =
| "douyin"
| "kuaishou"
| "xiaohongshu"
| "wechat";
export interface Platform {
id: PlatformId;
name: string;
subName: string;
icon: string;
gradient: string;
}
let toastIdCounter = 0;
/* ── 平台卡片组件 ───────────────────────────────────────── */
interface PlatformCardProps {
platform: Platform;
accounts: Account[];
isLoading: boolean;
onBind: (platformId: PlatformId) => void;
onUnbind: (accountId: string, accountName: string) => void;
}
const PlatformCard: React.FC<PlatformCardProps> = ({
platform,
accounts,
isLoading,
onBind,
onUnbind,
}) => {
return (
<div className="acc-card">
{/* 平台头部 */}
<div className="acc-card-header">
<div
className="acc-card-icon"
style={{ background: platform.gradient }}
>
{platform.icon}
</div>
<div>
<h3 className="acc-card-title">{platform.name}</h3>
<p className="acc-card-subtitle">{platform.subName}</p>
</div>
</div>
{/* 账号列表 */}
<div className="acc-account-list">
{isLoading ? (
<div className="acc-empty">
<p className="acc-empty-text"></p>
</div>
) : accounts.length > 0 ? (
accounts.map((account) => {
const statusCfg = ACCOUNT_STATUS_CONFIG[account.status];
return (
<div key={account.id} className="acc-account-row">
<div
className="acc-account-avatar"
style={{ background: platform.gradient }}
>
{account.avatar || platform.icon}
</div>
<div className="acc-account-info">
<div className="acc-account-name">{account.name}</div>
<span className={`acc-status-pill ${statusCfg.className}`}>
{statusCfg.label}
</span>
</div>
<Button
buttonType="ghost"
buttonSize="sm"
onClick={() => onUnbind(account.id, account.name)}
>
</Button>
</div>
);
})
) : (
<div className="acc-empty">
<div className="acc-empty-icon">🔓</div>
<p className="acc-empty-text"></p>
</div>
)}
</div>
{/* 绑定按钮 */}
<Button buttonType="ghost" onClick={() => onBind(platform.id)}>
+
</Button>
</div>
);
};
/** 支持的平台列表 */
const PLATFORMS: Platform[] = [
{
id: "douyin",
name: "抖音",
subName: "短视频发布",
icon: "🎵",
gradient: "linear-gradient(135deg, #000 0%, #333 100%)",
},
{
id: "kuaishou",
name: "快手",
subName: "短视频发布",
icon: "📹",
gradient: "linear-gradient(135deg, #ff6600 0%, #ff9933 100%)",
},
{
id: "xiaohongshu",
name: "小红书",
subName: "种草笔记 + 视频",
icon: "📕",
gradient: "linear-gradient(135deg, #fe2c55 0%, #ff6680 100%)",
},
{
id: "wechat",
name: "微信视频号",
subName: "视频号发布",
icon: "💬",
gradient: "linear-gradient(135deg, #07c160 0%, #38d97a 100%)",
},
];
/* ── 主页面 ─────────────────────────────────────────────── */
const Accounts: React.FC = () => {
const queryClient = useQueryClient();
const [toasts, setToasts] = useState<Toast[]>([]);
/** 显示 toast */
const showToast = useCallback((message: string, type: Toast["type"]) => {
const id = ++toastIdCounter;
setToasts((prev) => [...prev, { id, message, type }]);
setTimeout(() => {
setToasts((prev) => prev.filter((t) => t.id !== id));
}, 3000);
}, []);
/** 查询所有平台的账号 */
const _queriesResults = useQueries({
queries: PLATFORMS.map((platform) => ({
queryKey: ["accounts", platform.id] as const,
queryFn: () => getAccountsByPlatform(platform.id),
})),
});
const accountQueries = PLATFORMS.map((platform, i) => ({
platform,
..._queriesResults[i],
}));
/** 解绑 mutation */
const unbindMutation = useMutation({
mutationFn: unbindAccount,
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["accounts"] });
showToast("已解绑账号", "success");
},
onError: () => {
showToast("解绑失败", "error");
},
});
/** 绑定 mutation */
const bindMutation = useMutation({
mutationFn: bindAccount,
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["accounts"] });
showToast("账号绑定成功", "success");
},
onError: () => {
showToast("绑定失败", "error");
},
});
/** 绑定新账号 */
const handleBind = (platformId: PlatformId) => {
const platform = PLATFORMS.find((p) => p.id === platformId);
if (!platform) return;
const name = window.prompt(`请输入要绑定的${platform.name}账号名称:`);
if (name && name.trim()) {
bindMutation.mutate({ platform_id: platformId, name: name.trim() });
}
};
/** 解绑账号 */
const handleUnbind = (accountId: string, accountName: string) => {
if (window.confirm(`确定解绑账号「${accountName}」吗?`)) {
unbindMutation.mutate(accountId);
}
};
/** 统计已绑定账号数 */
const totalBound = accountQueries.reduce(
(sum, q) => sum + (q.data?.length ?? 0),
0,
);
const totalPlatforms = PLATFORMS.length;
return (
<div className="acc-page">
<PageHead
@@ -197,17 +69,34 @@ const Accounts: React.FC = () => {
description="绑定您的社交平台账号,用于视频一键发布到各平台"
/>
{/* 平台卡片网格 */}
{/* 平台卡片网格 — 占位状态 */}
<div className="acc-grid">
{accountQueries.map(({ platform, data, isLoading }) => (
<PlatformCard
key={platform.id}
platform={platform}
accounts={data ?? []}
isLoading={isLoading}
onBind={handleBind}
onUnbind={handleUnbind}
/>
{PLATFORMS.map((platform) => (
<div key={platform.id} className="acc-card">
<div className="acc-card-header">
<div
className="acc-card-icon"
style={{ background: platform.gradient }}
>
{platform.icon}
</div>
<div>
<h3 className="acc-card-title">{platform.name}</h3>
<p className="acc-card-subtitle">{platform.subName}</p>
</div>
</div>
<div className="acc-account-list">
<div className="acc-empty">
<div className="acc-empty-icon">🔒</div>
<p className="acc-empty-text">线</p>
</div>
</div>
<Button buttonType="ghost" disabled>
</Button>
</div>
))}
</div>
@@ -215,22 +104,10 @@ const Accounts: React.FC = () => {
<div className="acc-stats-bar">
<span className="acc-stats-icon">📊</span>
<span className="acc-stats-text">
<span className="acc-stats-highlight">{totalBound}</span>{" "}
/ {" "}
<span className="acc-stats-highlight">{totalPlatforms}</span>
<span className="acc-stats-highlight">{PLATFORMS.length}</span>{" "}
线
</span>
</div>
{/* Toast 提示 */}
{toasts.length > 0 && (
<div className="vc-toast-container">
{toasts.map((t) => (
<div key={t.id} className={`vc-toast vc-toast--${t.type}`}>
{t.type === "success" ? "✅" : "❌"} {t.message}
</div>
))}
</div>
)}
</div>
);
};
-82
View File
@@ -82,77 +82,6 @@
gap: 10px;
}
/* ── 账号行 ─────────────────────────────────────────────── */
.acc-account-row {
display: flex;
align-items: center;
gap: 10px;
padding: 12px;
background: var(--bg-secondary);
border-radius: var(--radius-md);
transition: var(--transition-fast);
}
.acc-account-row:hover {
background: var(--primary-soft);
}
.acc-account-avatar {
width: 36px;
height: 36px;
border-radius: 50%;
display: grid;
place-items: center;
color: var(--text-inverse);
font-weight: var(--font-weight-bold);
font-size: var(--font-size-sm);
flex-shrink: 0;
}
.acc-account-info {
flex: 1;
min-width: 0;
}
.acc-account-name {
font-size: var(--font-size-base);
font-weight: var(--font-weight-semibold);
color: var(--text-primary);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
/* ── 状态标签 ───────────────────────────────────────────── */
.acc-status-pill {
display: inline-flex;
align-items: center;
gap: 4px;
padding: 2px 8px;
border-radius: var(--radius-full);
font-size: var(--font-size-xs);
font-weight: var(--font-weight-semibold);
line-height: 1.6;
margin-top: 2px;
}
.acc-status--active {
background: var(--success-soft);
color: var(--secondary-color);
}
.acc-status--expired {
background: var(--error-soft);
color: var(--error-color);
}
.acc-status--limited {
background: var(--warning-soft);
color: var(--accent-color);
}
/* ── 空状态 ─────────────────────────────────────────────── */
.acc-empty {
@@ -235,17 +164,6 @@
font-size: var(--font-size-base);
}
.acc-account-row {
padding: 10px;
gap: 8px;
}
.acc-account-avatar {
width: 32px;
height: 32px;
font-size: var(--font-size-xs);
}
.acc-stats-bar {
padding: var(--space-sm) var(--space-md);
font-size: var(--font-size-sm);
+10 -460
View File
@@ -1,442 +1,20 @@
/* V21 Admin 页面样式 */
/* Admin 页面样式Phase 3 精简)
*
* 原始 477 行 → 精简至仅保留实际使用的 class。
* 已迁移至 global.css / ui.css 的样式不再重复定义:
* .xx-page-head → global.css
* .xx-primary-btn → global.css
* .xx-tag / .xx-card → ui.css / global.css
*
* 以下 class 仅被 AdminComingSoon.tsx 使用。
*/
/* 页面容器 */
.dashboard-page,
.analytics-page,
.user-management-page,
.log-viewer-page,
.system-monitor-page,
.admin-coming-soon-page {
padding: 32px;
max-width: 1400px;
margin: 0 auto;
}
/* 页面头部 */
.xx-page-head {
background: rgba(255, 255, 255, 0.94);
border: 1px solid rgba(226, 232, 240, 0.95);
border-radius: var(--radius-xl);
box-shadow: 0 24px 70px rgba(15, 23, 42, 0.09);
padding: 32px 40px;
margin-bottom: 32px;
display: flex;
justify-content: space-between;
align-items: center;
}
.xx-page-head-content {
display: flex;
flex-direction: column;
gap: 8px;
}
.xx-page-head h2 {
font-size: 28px;
font-weight: 900;
color: var(--slate, #0f172a);
margin: 0;
letter-spacing: -0.02em;
}
.xx-page-head p {
font-size: 15px;
color: var(--muted, #64748b);
margin: 0;
}
.xx-page-head-actions {
display: flex;
gap: 12px;
align-items: center;
}
/* 简化页面头部(无操作按钮) */
.xx-page-head-simple {
background: rgba(255, 255, 255, 0.94);
border: 1px solid rgba(226, 232, 240, 0.95);
border-radius: var(--radius-xl);
box-shadow: 0 24px 70px rgba(15, 23, 42, 0.09);
padding: 32px 40px;
margin-bottom: 32px;
}
.xx-page-head-simple h2 {
font-size: 28px;
font-weight: 900;
color: var(--slate, #0f172a);
margin: 0 0 8px;
letter-spacing: -0.02em;
}
.xx-page-head-simple p {
font-size: 15px;
color: var(--muted, #64748b);
margin: 0;
}
/* 统计卡片网格 - 4列 */
.xx-grid-4 {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 24px;
margin-bottom: 32px;
}
@media (max-width: 1200px) {
.xx-grid-4 {
grid-template-columns: repeat(2, 1fr);
}
}
@media (max-width: 768px) {
.xx-grid-4 {
grid-template-columns: 1fr;
}
}
/* 统计卡片 */
.xx-stat-card {
background: rgba(255, 255, 255, 0.94);
border: 1px solid rgba(226, 232, 240, 0.95);
border-radius: var(--radius-lg);
box-shadow: 0 10px 30px rgba(15, 23, 42, 0.06);
padding: 24px;
transition: all 0.3s;
}
.xx-stat-card:hover {
transform: translateY(-2px);
box-shadow: 0 16px 40px rgba(15, 23, 42, 0.08);
}
.xx-stat-card-header {
display: flex;
justify-content: space-between;
align-items: flex-start;
margin-bottom: 16px;
}
.xx-stat-card-icon {
width: 48px;
height: 48px;
border-radius: var(--radius-md);
display: flex;
align-items: center;
justify-content: center;
font-size: 22px;
}
.xx-stat-card-icon.primary {
background: linear-gradient(135deg, #6366f1, #4f46e5);
color: white;
}
.xx-stat-card-icon.success {
background: linear-gradient(135deg, #34d399, #10b981);
color: white;
}
.xx-stat-card-icon.warning {
background: linear-gradient(135deg, #fbbf24, #f59e0b);
color: white;
}
.xx-stat-card-icon.purple {
background: linear-gradient(135deg, #a78bfa, #8b5cf6);
color: white;
}
.xx-stat-card-icon.info {
background: linear-gradient(135deg, #60a5fa, #3b82f6);
color: white;
}
.xx-stat-card-icon.orange {
background: linear-gradient(135deg, #fb923c, #f97316);
color: white;
}
.xx-stat-card-label {
font-size: 14px;
color: var(--muted, #64748b);
font-weight: 500;
margin-bottom: 8px;
}
.xx-stat-card-value {
font-size: 32px;
font-weight: 900;
color: var(--slate, #0f172a);
line-height: 1.2;
letter-spacing: -0.02em;
}
.xx-stat-card-value.primary {
color: var(--indigo, #4f46e5);
}
.xx-stat-card-value.success {
color: var(--green, #10b981);
}
.xx-stat-card-value.warning {
color: var(--amber, #f59e0b);
}
.xx-stat-card-value.purple {
color: #8b5cf6;
}
.xx-stat-card-growth {
font-size: 13px;
color: var(--green, #10b981);
font-weight: 600;
margin-top: 8px;
display: flex;
align-items: center;
gap: 4px;
}
/* 数据表格 */
.xx-table-wrapper {
background: rgba(255, 255, 255, 0.94);
border: 1px solid rgba(226, 232, 240, 0.95);
border-radius: var(--radius-lg);
box-shadow: 0 10px 30px rgba(15, 23, 42, 0.06);
overflow: hidden;
}
.xx-table-wrapper .ant-table {
background: transparent;
}
.xx-table-wrapper .ant-table-thead > tr > th {
background: rgba(248, 250, 252, 0.8);
font-weight: 800;
font-size: 13px;
color: var(--slate, #0f172a);
text-transform: uppercase;
letter-spacing: 0.05em;
padding: 16px 20px;
}
.xx-table-wrapper .ant-table-tbody > tr > td {
padding: 16px 20px;
font-size: 14px;
}
.xx-table-wrapper .ant-table-tbody > tr:hover > td {
background: rgba(79, 70, 229, 0.03);
}
/* V21 按钮 */
.xx-primary-btn {
background: linear-gradient(135deg, #6366f1, #4f46e5) !important;
color: white !important;
border: none !important;
border-radius: var(--radius-md) !important;
font-weight: 700 !important;
box-shadow: 0 8px 20px rgba(79, 70, 229, 0.25) !important;
transition: all 0.2s !important;
}
.xx-primary-btn:hover {
box-shadow: 0 12px 28px rgba(79, 70, 229, 0.3) !important;
transform: translateY(-1px);
}
.xx-ghost-btn {
background: white !important;
border: 1px solid rgba(226, 232, 240, 0.95) !important;
color: var(--slate, #0f172a) !important;
border-radius: var(--radius-md) !important;
font-weight: 600 !important;
transition: all 0.2s !important;
}
.xx-ghost-btn:hover {
border-color: var(--indigo, #4f46e5) !important;
color: var(--indigo, #4f46e5) !important;
}
/* V21 输入框 */
.xx-search-input {
border-radius: var(--radius-md) !important;
border: 1px solid rgba(226, 232, 240, 0.95) !important;
padding: 8px 16px !important;
}
.xx-search-input:hover,
.xx-search-input:focus {
border-color: var(--indigo, #4f46e5) !important;
box-shadow: 0 0 0 3px rgba(79, 70, 229, 0.1) !important;
}
/* V21 Tag */
.xx-tag {
border-radius: var(--radius-xs) !important;
font-weight: 600 !important;
font-size: 12px !important;
padding: 4px 10px !important;
}
.xx-tag.info {
background: rgba(59, 130, 246, 0.1) !important;
color: #3b82f6 !important;
border: 1px solid rgba(59, 130, 246, 0.2) !important;
}
.xx-tag.success {
background: rgba(16, 185, 129, 0.1) !important;
color: #10b981 !important;
border: 1px solid rgba(16, 185, 129, 0.2) !important;
}
.xx-tag.warning {
background: rgba(245, 158, 11, 0.1) !important;
color: #f59e0b !important;
border: 1px solid rgba(245, 158, 11, 0.2) !important;
}
.xx-tag.error {
background: rgba(239, 68, 68, 0.1) !important;
color: #ef4444 !important;
border: 1px solid rgba(239, 68, 68, 0.2) !important;
}
.xx-tag.debug {
background: rgba(100, 116, 139, 0.1) !important;
color: #64748b !important;
border: 1px solid rgba(100, 116, 139, 0.2) !important;
}
/* V21 Progress */
.xx-progress-primary .ant-progress-circle .ant-progress-text {
color: var(--indigo, #4f46e5) !important;
font-weight: 700 !important;
}
.xx-progress-success .ant-progress-circle .ant-progress-text {
color: var(--green, #10b981) !important;
font-weight: 700 !important;
}
.xx-progress-warning .ant-progress-circle .ant-progress-text {
color: var(--amber, #f59e0b) !important;
font-weight: 700 !important;
}
/* 图表容器 */
.xx-chart-container {
background: rgba(255, 255, 255, 0.94);
border: 1px solid rgba(226, 232, 240, 0.95);
border-radius: var(--radius-lg);
box-shadow: 0 10px 30px rgba(15, 23, 42, 0.06);
padding: 24px;
}
.xx-chart-container .ant-card-head {
border-bottom: 1px solid rgba(226, 232, 240, 0.8);
padding: 20px 24px;
}
.xx-chart-container .ant-card-head-title {
font-weight: 800;
font-size: 17px;
color: var(--slate, #0f172a);
}
/* 筛选器区域 */
.xx-filter-bar {
background: rgba(255, 255, 255, 0.94);
border: 1px solid rgba(226, 232, 240, 0.95);
border-radius: var(--radius-lg);
box-shadow: 0 10px 30px rgba(15, 23, 42, 0.06);
padding: 20px 24px;
margin-bottom: 24px;
display: flex;
flex-wrap: wrap;
gap: 16px;
align-items: center;
}
/* 资源监控卡片 */
.xx-resource-card {
background: rgba(255, 255, 255, 0.94);
border: 1px solid rgba(226, 232, 240, 0.95);
border-radius: var(--radius-lg);
box-shadow: 0 10px 30px rgba(15, 23, 42, 0.06);
padding: 24px;
text-align: center;
}
.xx-resource-card-icon {
width: 64px;
height: 64px;
border-radius: var(--radius-md);
display: flex;
align-items: center;
justify-content: center;
margin: 0 auto 16px;
font-size: 28px;
}
.xx-resource-card-label {
font-size: 14px;
color: var(--muted, #64748b);
font-weight: 600;
margin-bottom: 16px;
}
/* 日期选择器 */
.xx-date-picker {
border-radius: var(--radius-md) !important;
}
/* Drawer */
.xx-drawer .ant-drawer-header {
border-bottom: 1px solid rgba(226, 232, 240, 0.8);
padding: 20px 24px;
}
.xx-drawer .ant-drawer-title {
font-weight: 800;
font-size: 18px;
color: var(--slate, #0f172a);
}
/* 日志详情 */
.xx-log-detail {
padding: 4px 0;
}
.xx-log-detail-label {
font-size: 13px;
font-weight: 700;
color: var(--slate, #0f172a);
margin-bottom: 4px;
}
.xx-log-detail-value {
font-size: 14px;
color: var(--muted, #64748b);
background: rgba(248, 250, 252, 0.8);
padding: 12px;
border-radius: var(--radius-sm);
margin-bottom: 16px;
}
.xx-log-detail-code {
font-family: "JetBrains Mono", "Fira Code", monospace;
background: rgba(248, 250, 252, 0.8);
padding: 12px;
border-radius: var(--radius-sm);
white-space: pre-wrap;
word-break: break-all;
}
/* Result 页面居中 */
.xx-result-center {
display: flex;
justify-content: center;
@@ -447,31 +25,3 @@
.xx-result-center .ant-result {
padding: 48px;
}
/* 刷新时间显示 */
.xx-refresh-time {
font-size: 13px;
color: var(--muted, #64748b);
margin-right: 12px;
}
/* 图表配色覆盖 */
.recharts-text {
fill: #64748b !important;
font-size: 12px !important;
}
.recharts-cartesian-grid-horizontal line,
.recharts-cartesian-grid-vertical line {
stroke: rgba(226, 232, 240, 0.8) !important;
}
/* 图表 Legend */
.recharts-legend-wrapper {
padding-top: 16px !important;
}
.recharts-legend-item-text {
color: #64748b !important;
font-size: 13px !important;
}
+57 -426
View File
@@ -1,454 +1,85 @@
/**
* 控制台页面 — V21 设计系统
* KPI 卡片网格 + 快速入口 + 最近任务卡片列表 + 使用统计图表 + 公告
* 使用 mock 数据,CSS 变量,V21 组件
* CSS 变量,V21 组件
*/
import React from "react";
import { useNavigate } from "react-router-dom";
import { Button, Tag } from "@/components/ui";
import {
VideoCameraOutlined,
AppstoreOutlined,
ThunderboltOutlined,
DatabaseOutlined,
FileTextOutlined,
} from "@ant-design/icons";
import { Button } from "@/components/ui";
import { DatabaseOutlined } from "@ant-design/icons";
import "./dashboard.css";
/* ============================================================
* Mock 数据
* ============================================================ */
interface KpiItem {
key: string;
icon: string;
iconGradient: string;
value: string;
label: string;
trend: string;
trendDirection: "up" | "down" | "neutral";
accent: string;
}
/* ── 主组件 ─────────────────────────────────────────────── */
// TODO: kpiData 当前使用硬编码 mock 数据,待后端提供 Dashboard 统计 API 后替换
const kpiData: KpiItem[] = [
{
key: "projects",
icon: "video",
iconGradient: "linear-gradient(135deg, #6366f1, #4f46e5)",
value: "12",
label: "项目总数",
trend: "↑ 2 本月新增",
trendDirection: "up",
accent: "#6366f1",
},
{
key: "assets",
icon: "appstore",
iconGradient: "linear-gradient(135deg, #0ea5e9, #0284c7)",
value: "486",
label: "素材总数",
trend: "↑ 38 本月上传",
trendDirection: "up",
accent: "#0ea5e9",
},
{
key: "generations",
icon: "thunderbolt",
iconGradient: "linear-gradient(135deg, #10b981, #059669)",
value: "156",
label: "本月生成数",
trend: "↑ 23% 较上月",
trendDirection: "up",
accent: "#10b981",
},
{
key: "storage",
icon: "database",
iconGradient: "linear-gradient(135deg, #f59e0b, #d97706)",
value: "2.4GB",
label: "存储空间",
trend: "已用 24%",
trendDirection: "neutral",
accent: "#f59e0b",
},
];
interface QuickEntry {
id: string;
icon: string;
iconGradient: string;
title: string;
description: string;
path: string;
}
// TODO: quickEntries 描述中含硬编码计数(如 486个素材),待后端 API 后动态化
const quickEntries: QuickEntry[] = [
{
id: "titles",
icon: "filetext",
iconGradient: "linear-gradient(135deg, #6366f1, #4f46e5)",
title: "标题库",
description: "24条标题 · 5个分类",
path: "/app/titles",
},
{
id: "assets",
icon: "appstore",
iconGradient: "linear-gradient(135deg, #0ea5e9, #0284c7)",
title: "素材库",
description: "486个素材 · 3个素材库",
path: "/app/assets",
},
{
id: "generate",
icon: "thunderbolt",
iconGradient: "linear-gradient(135deg, #10b981, #059669)",
title: "一键生成",
description: "开始创作新视频",
path: "/app/generate",
},
{
id: "products",
icon: "video",
iconGradient: "linear-gradient(135deg, #f59e0b, #d97706)",
title: "成片库",
description: "89个成片 · 3个待复核",
path: "/app/products",
},
];
type TaskStatus = "completed" | "processing" | "pending" | "failed";
interface RecentTask {
id: string;
name: string;
type: string;
template: string;
status: TaskStatus;
date: string;
duration?: string;
}
const statusLabel: Record<TaskStatus, string> = {
completed: "已完成",
processing: "进行中",
pending: "排队中",
failed: "失败",
};
// TODO: recentTasks 当前使用硬编码 mock 数据,待后端提供最近任务 API 后替换
const recentTasks: RecentTask[] = [
{
id: "t-1",
name: "产品介绍视频_春季促销",
type: "视频生成",
template: "商品展示模板",
status: "completed",
date: "2026-07-01 09:30",
duration: "2分18秒",
},
{
id: "t-2",
name: "品牌宣传片_终版",
type: "视频生成",
template: "品牌宣传模板",
status: "processing",
date: "2026-07-01 10:15",
},
{
id: "t-3",
name: "用户评价合集",
type: "视频生成",
template: "评价展示模板",
status: "completed",
date: "2026-06-30 16:42",
duration: "1分45秒",
},
{
id: "t-4",
name: "新品发布预告",
type: "视频生成",
template: "新品预告模板",
status: "pending",
date: "2026-06-30 14:20",
},
{
id: "t-5",
name: "活动回顾_618大促",
type: "视频生成",
template: "活动回顾模板",
status: "failed",
date: "2026-06-29 11:05",
},
];
interface ChartItem {
label: string;
value: number;
}
const weeklyData: ChartItem[] = [
{ label: "周一", value: 18 },
{ label: "周二", value: 25 },
{ label: "周三", value: 32 },
{ label: "周四", value: 28 },
{ label: "周五", value: 42 },
{ label: "周六", value: 15 },
{ label: "周日", value: 8 },
];
interface Announcement {
id: string;
tag: "update" | "notice" | "activity";
tagLabel: string;
title: string;
date: string;
}
const announcements: Announcement[] = [
{
id: "a-1",
tag: "update",
tagLabel: "更新",
title: "系统已升级至 v2.0,新增批量生成功能",
date: "2026-07-01",
},
{
id: "a-2",
tag: "activity",
tagLabel: "活动",
title: "7月创作挑战赛已开启,参与赢积分奖励",
date: "2026-06-28",
},
{
id: "a-3",
tag: "notice",
tagLabel: "公告",
title: "7月3日凌晨 2:00-4:00 系统维护通知",
date: "2026-06-25",
},
];
/* ============================================================
* 工具函数
* ============================================================ */
const getGreeting = () => {
const hour = new Date().getHours();
if (hour < 6) return "夜深了";
if (hour < 12) return "早上好";
if (hour < 14) return "中午好";
if (hour < 18) return "下午好";
return "晚上好";
};
const formatDate = () => {
const d = new Date();
const weekDays = ["日", "一", "二", "三", "四", "五", "六"];
return `${d.getFullYear()}${d.getMonth() + 1}${d.getDate()}日 星期${weekDays[d.getDay()]}`;
};
/** 图标名称 → Ant Design 组件映射 */
const iconMap: Record<string, React.ReactNode> = {
video: <VideoCameraOutlined />,
appstore: <AppstoreOutlined />,
thunderbolt: <ThunderboltOutlined />,
database: <DatabaseOutlined />,
filetext: <FileTextOutlined />,
};
/* ============================================================
* 组件
* ============================================================ */
const Dashboard: React.FC = () => {
const navigate = useNavigate();
const maxChart = Math.max(...weeklyData.map((d) => d.value));
return (
<div className="xx-dashboard-page">
{/* ── 欢迎头部 ─────────────────────────────────────────── */}
<div className="xx-dashboard-welcome">
<h2>{getGreeting()}</h2>
<p>{formatDate()} </p>
</div>
{/* ── KPI 卡片网格 ─────────────────────────────────────── */}
{/* KPI 卡片网格 */}
<div className="xx-kpi-grid">
{kpiData.map((item) => (
<div
key={item.key}
className="xx-kpi-card"
style={{ "--kpi-accent": item.accent } as React.CSSProperties}
>
<div
className="xx-kpi-icon"
style={{ background: item.iconGradient }}
>
{iconMap[item.icon] ?? item.icon}
</div>
<div className="xx-kpi-value">{item.value}</div>
<div className="xx-kpi-label">{item.label}</div>
<span
className={`xx-kpi-trend xx-kpi-trend--${item.trendDirection}`}
>
{item.trend}
</span>
</div>
))}
</div>
{/* ── 主内容区:左侧任务+图表 / 右侧公告 ──────────────── */}
<div className="xx-dashboard-main">
{/* 左列 */}
<div className="xx-dashboard-left-col">
{/* 最近任务 */}
<div className="xx-dashboard-section">
<div className="xx-dashboard-section-header">
<h3></h3>
<button onClick={() => navigate("/app/history")}></button>
</div>
<div className="xx-task-list">
{recentTasks.map((task) => (
<div key={task.id} className="xx-task-item">
<div className="xx-task-info">
<h4>{task.name}</h4>
<span>
{task.type} · {task.template}
</span>
</div>
<Tag
variant={
task.status === "completed"
? "success"
: task.status === "processing"
? "info"
: task.status === "failed"
? "error"
: "warning"
}
>
{statusLabel[task.status]}
</Tag>
<div className="xx-task-time">
<span>{task.date}</span>
{task.status === "completed"
? `耗时 ${task.duration}`
: task.status === "processing"
? "生成中..."
: task.status === "failed"
? "请重试"
: "等待中"}
</div>
<div className="xx-task-action">
<Button
buttonType="ghost"
buttonSize="sm"
onClick={() => navigate("/app/history")}
>
</Button>
</div>
</div>
))}
</div>
</div>
{/* 使用统计图表 */}
<div className="xx-dashboard-section">
<div className="xx-dashboard-section-header">
<h3></h3>
<span className="xx-chart-total">
{weeklyData.reduce((s, d) => s + d.value, 0)}
</span>
</div>
<div className="xx-chart-container">
<div className="xx-chart-bars">
{weeklyData.map((d, i) => (
<div key={i} className="xx-chart-bar-wrapper">
<div
className="xx-chart-bar"
style={{
height: `${(d.value / maxChart) * 100}%`,
}}
>
<span className="xx-chart-bar-value">{d.value}</span>
</div>
</div>
))}
</div>
<div className="xx-chart-labels">
{weeklyData.map((d, i) => (
<div key={i} className="xx-chart-label">
{d.label}
</div>
))}
</div>
</div>
</div>
</div>
{/* 右列 — 公告 + 存储用量 */}
<div className="xx-dashboard-section xx-dashboard-section--start">
<div className="xx-dashboard-section-header">
<h3></h3>
</div>
<div className="xx-announcement-list">
{announcements.map((a) => (
<div key={a.id} className="xx-announcement-item">
<span
className={`xx-announcement-tag xx-announcement-tag--${a.tag}`}
>
{a.tagLabel}
</span>
<div className="xx-announcement-content">
<h4>{a.title}</h4>
<time>{a.date}</time>
</div>
</div>
))}
</div>
{/* 存储用量 */}
<div className="xx-storage-section">
<div className="xx-storage-section-title"></div>
<div className="xx-storage-bar">
<div className="xx-storage-bar-track">
<div className="xx-storage-bar-fill" style={{ width: "24%" }} />
</div>
<div className="xx-storage-bar-label">
<span>2.4 GB </span>
<span>10 GB </span>
</div>
</div>
</div>
<div className="xx-dashboard-empty">
<p></p>
</div>
</div>
{/* ── 快速入口 ─────────────────────────────────────────── */}
{/* 快速入口 */}
<div className="xx-quick-entry-section">
<div className="xx-quick-entry-header">
<h3 className="xx-quick-entry-title"></h3>
</div>
<div className="xx-quick-grid">
{quickEntries.map((entry) => (
<div
key={entry.id}
className="xx-quick-card"
onClick={() => navigate(entry.path)}
>
<div
className="xx-quick-card-icon"
style={{ background: entry.iconGradient }}
>
{iconMap[entry.icon] ?? entry.icon}
</div>
<h3>{entry.title}</h3>
<p>{entry.description}</p>
</div>
))}
<div className="xx-dashboard-empty">
<p></p>
</div>
</div>
</div>
{/* 最近任务 */}
<section className="xx-dashboard-section">
<div className="xx-dashboard-section-header">
<h3></h3>
<Button buttonType="ghost" buttonSize="sm" onClick={() => navigate("/app/history")}>
</Button>
</div>
<div className="xx-task-list">
<div className="xx-dashboard-empty">
<p></p>
</div>
</div>
</section>
{/* 使用统计 */}
<section className="xx-dashboard-section" style={{ marginTop: "var(--space-md)" }}>
<div className="xx-dashboard-section-header">
<h3>使</h3>
</div>
<div className="xx-chart-container">
<div className="xx-chart-bars">
<div className="xx-dashboard-empty" style={{ width: "100%" }}>
<DatabaseOutlined style={{ fontSize: 24, marginBottom: 8 }} />
<p></p>
</div>
</div>
</div>
</section>
{/* 公告 */}
<section className="xx-dashboard-section" style={{ marginTop: "var(--space-md)" }}>
<div className="xx-dashboard-section-header">
<h3></h3>
</div>
<div className="xx-announcement-list">
<div className="xx-announcement-item">
<span className="xx-announcement-tag xx-announcement-tag--notice"></span>
<div className="xx-announcement-content">
<h4>使 SaaS </h4>
<time></time>
</div>
</div>
</div>
</section>
</div>
);
};
+39 -369
View File
@@ -1,6 +1,6 @@
/**
* 控制台页面 - V21 设计系统样式
* KPI 卡片网格 + 快速入口 + 最近任务卡片列表 + 使用统计图表 + 公告
* KPI 卡片网格 + 快速入口 + 最近任务 + 使用统计图表 + 公告
* 统一使用 CSS 变量,支持深色/浅色主题
*/
@import "../../styles/global.css";
@@ -13,26 +13,6 @@
padding: var(--space-xl);
}
/* ============================================================
欢迎头部
============================================================ */
.xx-dashboard-welcome {
margin-bottom: var(--space-lg);
}
.xx-dashboard-welcome h2 {
margin: 0 0 var(--space-xs);
font-size: var(--font-size-xl);
font-weight: var(--font-weight-bold);
color: var(--text-primary);
}
.xx-dashboard-welcome p {
margin: 0;
font-size: var(--font-size-sm);
color: var(--text-secondary);
}
/* ============================================================
KPI 卡片网格
============================================================ */
@@ -43,76 +23,6 @@
margin-bottom: var(--space-lg);
}
.xx-kpi-card {
background: linear-gradient(180deg, var(--bg-primary), var(--bg-secondary));
border: 1px solid var(--border-color);
border-radius: var(--radius-lg);
padding: 20px;
transition: 0.18s ease;
position: relative;
overflow: hidden;
}
.xx-kpi-card:hover {
transform: translateY(-2px);
box-shadow: var(--shadow-sm);
}
.xx-kpi-icon {
width: 40px;
height: 40px;
border-radius: var(--radius-md);
display: flex;
align-items: center;
justify-content: center;
font-size: var(--font-size-lg);
margin-bottom: var(--space-sm);
}
.xx-kpi-value {
font-size: 30px;
font-weight: 800;
color: var(--text-primary);
line-height: 1.2;
margin-bottom: var(--space-xs);
}
.xx-kpi-label {
font-size: var(--font-size-sm);
color: var(--text-secondary);
margin-bottom: var(--space-sm);
}
.xx-kpi-trend {
font-size: 12px;
margin-top: 8px;
display: inline-flex;
align-items: center;
gap: 4px;
}
.xx-kpi-trend--up {
color: var(--success-color);
}
.xx-kpi-trend--down {
color: var(--error-color);
}
.xx-kpi-trend--neutral {
color: var(--text-tertiary);
}
/* ============================================================
主内容区两栏布局
============================================================ */
.xx-dashboard-main {
display: grid;
grid-template-columns: 1fr 320px;
gap: var(--space-md);
margin-bottom: var(--space-lg);
}
/* ============================================================
区块卡片
============================================================ */
@@ -161,7 +71,25 @@
}
/* ============================================================
最近任务卡片列表
空状态
============================================================ */
.xx-dashboard-empty {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: var(--space-xl) var(--space-md);
text-align: center;
color: var(--text-tertiary);
}
.xx-dashboard-empty p {
margin: 0;
font-size: var(--font-size-sm);
}
/* ============================================================
最近任务列表
============================================================ */
.xx-task-list {
display: flex;
@@ -169,86 +97,6 @@
gap: var(--space-sm);
}
.xx-task-item {
background: var(--bg-primary);
border: 1px solid var(--border-color);
border-radius: var(--radius-md);
padding: var(--space-sm) var(--space-md);
display: grid;
grid-template-columns: 1fr auto auto auto;
gap: var(--space-md);
align-items: center;
transition: var(--transition-all);
}
.xx-task-item:hover {
border-color: var(--primary-color);
box-shadow: var(--shadow-sm);
}
.xx-task-info h4 {
margin: 0 0 var(--space-xs);
font-size: var(--font-size-sm);
font-weight: 600;
color: var(--text-primary);
}
.xx-task-info span {
font-size: var(--font-size-xs);
color: var(--text-secondary);
}
.xx-task-status {
display: inline-flex;
align-items: center;
gap: var(--space-xs);
padding: 3px 10px;
border-radius: var(--radius-sm);
font-size: var(--font-size-xs);
font-weight: 500;
white-space: nowrap;
}
.xx-task-status--completed {
color: var(--success-color);
background: var(--success-soft);
border: 1px solid var(--success-border);
}
.xx-task-status--processing {
color: var(--info-color);
background: var(--primary-soft);
border: 1px solid var(--color-primary-200);
}
.xx-task-status--pending {
color: var(--text-secondary);
background: var(--bg-secondary);
border: 1px solid var(--border-color);
}
.xx-task-status--failed {
color: var(--error-color);
background: var(--error-soft);
border: 1px solid var(--error-border);
}
.xx-task-time {
text-align: right;
font-size: var(--font-size-xs);
color: var(--text-secondary);
min-width: 100px;
}
.xx-task-time span {
display: block;
margin-bottom: var(--space-xxs);
}
.xx-task-action {
font-size: var(--font-size-xs);
}
/* ============================================================
使用统计图表(纯 CSS 柱状图)
============================================================ */
@@ -264,69 +112,27 @@
padding-top: var(--space-sm);
}
.xx-chart-bar-wrapper {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
height: 100%;
justify-content: flex-end;
}
.xx-chart-bar {
width: 100%;
max-width: 36px;
border-radius: var(--radius-sm) var(--radius-sm) 0 0;
background: var(--gradient-primary);
transition: height 0.6s cubic-bezier(0.34, 1.56, 0.64, 1);
position: relative;
min-height: 4px;
cursor: pointer;
}
.xx-chart-bar:hover {
opacity: 0.85;
}
.xx-chart-bar:active {
opacity: 0.7;
transform: scaleY(0.97);
transform-origin: bottom;
}
.xx-chart-bar-value {
position: absolute;
top: -20px;
left: 50%;
transform: translateX(-50%);
font-size: var(--font-size-xs);
font-weight: 600;
color: var(--text-primary);
white-space: nowrap;
opacity: 0;
transition: var(--transition-opacity);
}
.xx-chart-bar:hover .xx-chart-bar-value {
opacity: 1;
}
.xx-chart-labels {
display: flex;
gap: var(--space-sm);
margin-top: var(--space-sm);
}
.xx-chart-label {
flex: 1;
text-align: center;
font-size: var(--font-size-xs);
color: var(--text-secondary);
}
/* ============================================================
快速入口卡片网格
快速入口
============================================================ */
.xx-quick-entry-section {
margin-bottom: var(--space-md);
}
.xx-quick-entry-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: var(--space-md);
}
.xx-quick-entry-title {
margin: 0;
font-size: var(--font-size-base);
font-weight: var(--font-weight-semibold);
color: var(--text-primary);
}
.xx-quick-grid {
display: grid;
grid-template-columns: repeat(4, 1fr);
@@ -334,53 +140,6 @@
margin-bottom: var(--space-lg);
}
.xx-quick-card {
background: var(--bg-primary);
border: 1px solid var(--border-color);
border-radius: var(--radius-lg);
padding: var(--space-lg);
cursor: pointer;
transition: var(--transition-all);
text-align: center;
}
.xx-quick-card:hover {
border-color: var(--primary-color);
box-shadow: var(--shadow-md);
transform: translateY(-2px);
}
.xx-quick-card:active {
transform: translateY(0) scale(0.98);
box-shadow: var(--shadow-sm);
transition-duration: 0.1s;
}
.xx-quick-card-icon {
width: 52px;
height: 52px;
border-radius: var(--radius-md);
display: flex;
align-items: center;
justify-content: center;
font-size: var(--font-size-xl);
margin: 0 auto var(--space-md);
color: var(--text-inverse);
}
.xx-quick-card h3 {
margin: 0 0 var(--space-sm);
font-size: var(--font-size-md);
font-weight: var(--font-weight-semibold);
color: var(--text-primary);
}
.xx-quick-card p {
margin: 0;
color: var(--text-secondary);
font-size: var(--font-size-sm);
}
/* ============================================================
公告区域
============================================================ */
@@ -451,90 +210,10 @@
color: var(--text-secondary);
}
/* ============================================================
存储用量条
============================================================ */
.xx-storage-bar {
margin-top: var(--space-sm);
}
.xx-storage-bar-track {
height: var(--space-sm);
background: var(--bg-secondary);
border-radius: var(--space-xs);
overflow: hidden;
}
.xx-storage-bar-fill {
height: 100%;
border-radius: var(--space-xs);
background: var(--gradient-primary);
transition: width 0.6s ease;
}
.xx-storage-bar-label {
display: flex;
justify-content: space-between;
font-size: var(--font-size-xs);
color: var(--text-secondary);
margin-top: var(--space-xs);
}
/* ============================================================
迁移自内联样式的工具类
============================================================ */
.xx-dashboard-left-col {
display: flex;
flex-direction: column;
gap: var(--space-md);
}
.xx-chart-total {
font-size: var(--font-size-xs);
color: var(--text-secondary);
}
.xx-dashboard-section--start {
align-self: start;
}
.xx-storage-section {
margin-top: var(--space-md);
}
.xx-storage-section-title {
font-size: 13px;
font-weight: 500;
color: var(--text-primary);
margin-bottom: 4px;
}
.xx-quick-entry-section {
margin-bottom: var(--space-md);
}
.xx-quick-entry-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: var(--space-md);
}
.xx-quick-entry-title {
margin: 0;
font-size: var(--font-size-base);
font-weight: var(--font-weight-semibold);
color: var(--text-primary);
}
/* ============================================================
响应式
============================================================ */
@media (max-width: 1200px) {
.xx-dashboard-main {
grid-template-columns: 1fr;
}
.xx-kpi-grid {
grid-template-columns: repeat(2, 1fr);
}
@@ -557,15 +236,6 @@
grid-template-columns: 1fr;
}
.xx-task-item {
grid-template-columns: 1fr;
gap: var(--space-sm);
}
.xx-task-time {
text-align: left;
}
.xx-chart-bars {
height: 120px;
}
+12 -24
View File
@@ -69,22 +69,6 @@ const VOICE_GENDER_ICON: Record<string, string> = {
neutral: "✨",
};
/* ── 时间线 Mock ──
* TODO: 后端暂无时间线场景数据 API,当前使用硬编码预览数据。
* 待后端提供 timeline/scene 接口后替换为真实 API 调用。
*/
interface TimelineScene {
scene: string;
time: string;
duration: number;
}
const MOCK_TIMELINE: TimelineScene[] = [
{ scene: "主讲口播 · 开场钩子", time: "0-8s", duration: 8 },
{ scene: "产品特写 · B-roll", time: "8-20s", duration: 12 },
{ scene: "用户反馈 · 结尾", time: "20-30s", duration: 10 },
];
/* ── 步骤定义 ── */
const STEPS = [
{ key: 1, label: "选择模板" },
@@ -1758,15 +1742,19 @@ const GeneratePage: React.FC = () => {
<div className="xx-preview-title"></div>
{/* 时间线列表 */}
<div className="xx-preview-timeline">
{MOCK_TIMELINE.map((item, idx) => (
<div key={idx} className="xx-timeline-item">
<div className="num">{idx + 1}</div>
<span className="scene-name">{item.scene}</span>
<span>{item.time}</span>
{generated ? (
<div className="xx-preview-timeline">
<div className="xx-timeline-item">
<span className="scene-name"></span>
</div>
))}
</div>
</div>
) : (
<div className="xx-preview-timeline">
<div className="xx-timeline-item">
<span className="scene-name"></span>
</div>
</div>
)}
{/* 生成操作按钮 */}
<div className="xx-generate-actions">
+27 -114
View File
@@ -36,36 +36,24 @@ type TitleType = "hot" | "normal" | "creative";
type Industry = "general" | "food" | "tech" | "beauty" | "education" | "travel";
type Frequency = "all" | "high" | "medium" | "low";
interface CategoryItem {
id: string;
name: string;
count: number;
}
interface TitleData {
id: string;
content: string;
type: TitleType;
industry: Industry;
category: string;
usageCount: number;
isFavorited: boolean;
createdAt: string;
}
/* ============================================================
* Mock 数据
* ============================================================ */
// TODO: 分类数据当前为前端硬编码 mock,待后端提供标题分类 API 后替换
const MOCK_CATEGORIES: CategoryItem[] = [
{ id: "cat-all", name: "全部标题", count: 0 },
];
/** 后端 TitleItem → 前端 TitleData 映射 */
const toTitleData = (item: TitleItem): TitleData => ({
id: item.id,
content: item.content,
type: (item.category as TitleType) || "normal",
industry: "general",
category: item.category || "未分类",
usageCount: 0,
isFavorited: false,
createdAt: item.created_at?.slice(0, 10) || "",
@@ -246,9 +234,8 @@ const TitleCard: React.FC<{
const TitleLibrary: React.FC = () => {
const queryClient = useQueryClient();
/* 分类数据 */
const [categories, setCategories] = useState<CategoryItem[]>(MOCK_CATEGORIES);
const [activeCatId, setActiveCatId] = useState<string>(MOCK_CATEGORIES[0].id);
/* 分类数据 — 从真实标题数据动态派生 */
const [activeCatId, setActiveCatId] = useState<string>("cat-all");
/* 标题数据 — 真实 API */
const { data: apiTitles = [] } = useQuery({
@@ -261,6 +248,23 @@ const TitleLibrary: React.FC = () => {
[apiTitles],
);
/* 从真实标题数据动态派生分类(无需后端分类 API) */
const categories = useMemo(() => {
const cats = new Map<string, number>();
apiTitles.forEach((t) => {
const cat = t.category || "未分类";
cats.set(cat, (cats.get(cat) || 0) + 1);
});
return [
{ id: "cat-all", name: "全部标题", count: apiTitles.length },
...Array.from(cats.entries()).map(([name, count]) => ({
id: `cat-${name}`,
name,
count,
})),
];
}, [apiTitles]);
/* CRUD mutations */
const createMutation = useMutation({
mutationFn: (content: string) => createTitle({ content }),
@@ -297,10 +301,6 @@ const TitleLibrary: React.FC = () => {
const [editingId, setEditingId] = useState<string | null>(null);
const [editText, setEditText] = useState("");
/* 新建分类 */
const [createCatModalOpen, setCreateCatModalOpen] = useState(false);
const [newCatName, setNewCatName] = useState("");
/* 新建标题 */
const [createTitleModalOpen, setCreateTitleModalOpen] = useState(false);
const [newTitleContent, setNewTitleContent] = useState("");
@@ -318,19 +318,11 @@ const TitleLibrary: React.FC = () => {
const filteredTitles = useMemo(() => {
let list = titles;
/* 按分类过滤("全部标题" 不过滤) */
/* 按分类过滤("全部标题" 不过滤)— 直接匹配后端 category 字段 */
if (activeCatId !== "cat-all") {
const catName = activeCategory?.name || "";
const catToIndustry: Record<string, Industry> = {
: "food",
: "tech",
: "general",
穿: "beauty",
: "education",
};
const mappedIndustry = catToIndustry[catName];
if (mappedIndustry) {
list = list.filter((t) => t.industry === mappedIndustry);
if (catName) {
list = list.filter((t) => t.category === catName);
}
}
@@ -424,32 +416,6 @@ const TitleLibrary: React.FC = () => {
[deleteMutation],
);
/* 新建分类 */
const handleCreateCategory = () => {
if (!newCatName.trim()) {
message.warning("请输入分类名称");
return;
}
const cat: CategoryItem = {
id: `cat-${Date.now()}`,
name: newCatName.trim(),
count: 0,
};
setCategories((prev) => [...prev, cat]);
setActiveCatId(cat.id);
setCreateCatModalOpen(false);
setNewCatName("");
message.success(`分类 "${cat.name}" 创建成功`);
};
/* 删除分类 */
const handleDeleteCategory = (id: string) => {
setCategories((prev) => prev.filter((c) => c.id !== id));
if (activeCatId === id) {
setActiveCatId("cat-all");
}
message.success("分类已删除");
};
/* 新建标题 */
const handleCreateTitle = () => {
@@ -537,38 +503,12 @@ const TitleLibrary: React.FC = () => {
</h4>
<span>{cat.count} </span>
</div>
{cat.id !== "cat-all" && (
<Popconfirm
title={`确定删除分类 "${cat.name}"`}
onConfirm={(e) => {
e?.stopPropagation();
handleDeleteCategory(cat.id);
}}
onCancel={(e) => e?.stopPropagation()}
okText="删除"
cancelText="取消"
>
<button
className="xx-title-category-delete"
onClick={(e) => e.stopPropagation()}
title="删除分类"
>
<DeleteOutlined />
</button>
</Popconfirm>
)}
</div>
</div>
))}
{/* 新建分类 */}
<div
className="xx-title-category-add"
onClick={() => setCreateCatModalOpen(true)}
>
<PlusOutlined />
</div>
{/* TODO: 新建分类功能待后端分类 API 就绪后启用 */}
</div>
{/* ─── 右侧:内容区 ─── */}
@@ -674,34 +614,7 @@ const TitleLibrary: React.FC = () => {
</div>
</div>
{/* ─── 新建分类弹窗 ─── */}
<AntModal
title="新建分类"
open={createCatModalOpen}
onCancel={() => setCreateCatModalOpen(false)}
onOk={handleCreateCategory}
okText="创建"
cancelText="取消"
destroyOnClose
>
<div style={{ padding: "8px 0" }}>
<div
style={{
marginBottom: 6,
fontSize: "var(--font-size-sm)",
color: "var(--text-secondary)",
}}
>
</div>
<Input
placeholder="请输入分类名称"
value={newCatName}
onChange={(e) => setNewCatName(e.target.value)}
maxLength={30}
/>
</div>
</AntModal>
{/* ─── 新建标题弹窗 ─── */}
<AntModal
-3
View File
@@ -1,10 +1,8 @@
"""Video deduplication module - compute fingerprints and detect duplicates."""
import hashlib
import json
import logging
import os
import subprocess
import tempfile
from dataclasses import dataclass
from typing import Optional
@@ -327,7 +325,6 @@ def check_duplicate_task(self: Task, generated_video_id: str) -> dict:
raise ValueError(f"Generated video {generated_video_id} not found")
local_path = os.path.join(temp_dir, f"{generated_video_id}.mp4")
storage_key = video.file_url.split("/")[-1]
storage_service.download_file(
f"projects/{video.project_id}/generated/{generated_video_id}/{generated_video_id}.mp4", local_path
)
+1 -1
View File
@@ -81,7 +81,7 @@ def run_ffmpeg(
timeout=timeout,
)
return (result.stdout or "", result.stderr or "")
except subprocess.TimeoutExpired as e:
except subprocess.TimeoutExpired:
logger.error(
"FFmpeg 命令超时 (%ds): command=%s",
timeout or -1,
@@ -11,7 +11,6 @@ import logging
import os
import threading
from pathlib import Path
from typing import Optional
from urllib.parse import urlparse
import oss2
@@ -5,7 +5,6 @@
import os
import tempfile
from dataclasses import dataclass
from pathlib import Path
from typing import List
import ffmpeg
@@ -17,15 +17,15 @@ import logging
import tempfile
from dataclasses import dataclass
from pathlib import Path
from typing import Any, Callable
from typing import Callable
from sqlalchemy.orm import Session
from video_processing.oss_helpers import download_asset, upload_to_oss
from video_processing.unified_render_service import RenderResult, UnifiedRenderService
from video_processing.unified_render_service import UnifiedRenderService
from packages.adapters.sqlalchemy_impl.edit_plan_clip_repository import SQLAlchemyEditPlanClipRepository
from packages.adapters.sqlalchemy_impl.edit_plan_repository import SQLAlchemyEditPlanRepository
from packages.domain.edit_plan import EditPlan, EditPlanStatus
from packages.domain.edit_plan import EditPlanStatus
from packages.domain.edit_plan_clip import EditPlanClip, EditPlanClipStatus
logger = logging.getLogger(__name__)
@@ -10,12 +10,10 @@ from __future__ import annotations
import json
import logging
import math
import os
import subprocess
import tempfile
from dataclasses import dataclass, field
from typing import Any
import numpy as np
from PIL import Image
@@ -1,4 +1,3 @@
from celery import Task
from celery.utils.log import get_task_logger
from worker_app.celery_app import celery_app
@@ -9,7 +8,6 @@ from packages.adapters.sqlalchemy_impl.classification_job_repository import (
SQLAlchemyClassificationJobRepository,
)
from packages.domain import (
ClassificationJob,
ClassificationJobStatus,
ClassificationStatus,
)
@@ -5,12 +5,9 @@
from __future__ import annotations
import logging
import os
import shutil
import subprocess
import tempfile
from datetime import datetime, timezone
from pathlib import Path
from celery.utils.log import get_task_logger
@@ -21,7 +21,6 @@ import logging
import tempfile
from datetime import datetime, timezone
from pathlib import Path
from typing import Optional
from worker_app.celery_app import celery_app
from worker_app.db import SessionLocal
+1 -5
View File
@@ -13,15 +13,13 @@
from __future__ import annotations
import json
import logging
import os
import tempfile
import time
from dataclasses import dataclass, field
from datetime import datetime, timezone
from pathlib import Path
from typing import Any, Optional
from typing import Any
from worker_app.celery_app import celery_app
from worker_app.db import SessionLocal
@@ -174,7 +172,6 @@ def _build_plan_and_clips_from_task(
path_duration[p] = probe_duration(p)
clips: list[_VirtualClip] = []
n = len(downloaded_paths)
if mode == "pip":
# 1 main + N-1 overlay
@@ -912,7 +909,6 @@ def generate_video(self, task_id: str) -> dict:
render_result = render_service.render()
render_output_path = render_result.output_path
render_duration = render_result.duration
render_file_size = render_result.file_size
render_elapsed = time.monotonic() - render_start
logger.info(
"[task_id=%s] [渲染] unified 引擎完成: 耗时=%.1fs",
-3
View File
@@ -1,9 +1,6 @@
import subprocess
from datetime import datetime, timezone
from typing import Optional
from celery import Celery
from celery.app.task import Task
from celery.utils.log import get_task_logger
from worker_app.celery_app import celery_app
from worker_app.core.asset_types import infer_mime_type_from_storage_key
@@ -1,14 +1,11 @@
"""Voice extraction tasks - extract voice tracks and background music from videos."""
import json
import logging
import os
import subprocess
import tempfile
from typing import Optional
from celery import Task
from sqlalchemy.orm import Session
from worker_app.celery_app import celery_app
from worker_app.db import SessionLocal
+72 -11
View File
@@ -30,21 +30,67 @@ class SharedStorageService:
self.local_url_prefix = os.getenv("GENERATED_FILES_URL_PREFIX", "/generated-files")
self.bucket = None
if settings.oss_access_key_id and settings.oss_access_key_secret:
has_key_id = bool(settings.oss_access_key_id)
has_key_secret = bool(settings.oss_access_key_secret)
if has_key_id and has_key_secret:
if oss2 is not None:
auth = oss2.Auth(
settings.oss_access_key_id,
settings.oss_access_key_secret,
)
self.bucket = oss2.Bucket(
auth,
settings.oss_endpoint,
settings.oss_bucket_name,
)
try:
# P0-2 修复:oss2.Bucket 的 endpoint 必须带 https:// 前缀,
# 否则 sign_url 默认生成 HTTP URL。
bucket_endpoint = settings.oss_endpoint
if not bucket_endpoint.startswith(("http://", "https://")):
bucket_endpoint = f"https://{bucket_endpoint}"
auth = oss2.Auth(
settings.oss_access_key_id,
settings.oss_access_key_secret,
)
self.bucket = oss2.Bucket(
auth,
bucket_endpoint,
settings.oss_bucket_name,
)
logger.info(
"OSS initialized: endpoint=%s bucket=%s",
settings.oss_endpoint,
settings.oss_bucket_name,
)
except Exception as error:
logger.error("Failed to initialize OSS bucket client: %s", error)
else:
logger.error("oss2 SDK is not installed — OSS operations will fail")
else:
missing = []
if not has_key_id:
missing.append("OSS_ACCESS_KEY_ID")
if not has_key_secret:
missing.append("OSS_ACCESS_KEY_SECRET")
logger.error("OSS credentials not configured — missing: %s", ", ".join(missing))
self.access_key_id = settings.oss_access_key_id
self.access_key_secret = settings.oss_access_key_secret
self.endpoint = settings.oss_endpoint
def diagnose(self) -> None:
"""启动诊断:输出 OSS 配置状态,帮助排查预签名 URL 问题。"""
key_id_display = (
f"{self.access_key_id[:4]}...{self.access_key_id[-4:]}" if len(self.access_key_id) > 8 else "(empty)"
)
logger.info(
"[OSS诊断] endpoint=%s bucket_name=%s access_key_id=%s",
self.endpoint,
self.bucket_name,
key_id_display,
)
if self.bucket is None:
logger.error(
"[OSS诊断] ❌ bucket=None — 预签名URL不可用!"
"原因: OSS_ACCESS_KEY_ID/OSS_ACCESS_KEY_SECRET 未配置或 oss2 未安装。"
"请检查服务器 .env 文件(如 /var/lib/xiaoxia-saas-staging/.env"
)
else:
logger.info("[OSS诊断] ✅ bucket 已配置,预签名URL可用")
def _is_local_generated_url(self, storage_key_or_url: str) -> bool:
parsed = urlparse(storage_key_or_url)
path = parsed.path if parsed.scheme else storage_key_or_url
@@ -90,12 +136,26 @@ class SharedStorageService:
if self.bucket is None:
if self._is_local_generated_url(storage_key_or_url):
return storage_key_or_url
logger.warning(
"get_download_url: OSS bucket not configured, returning raw URL. storage_key_or_url=%s",
storage_key_or_url[:200],
)
return self.get_url(self._normalize_storage_key(storage_key_or_url))
storage_key = self._normalize_storage_key(storage_key_or_url)
try:
return self.bucket.sign_url("GET", storage_key, expires_seconds)
signed = self.bucket.sign_url("GET", storage_key, expires_seconds)
logger.info(
"get_download_url: signed URL generated. storage_key=%s url_prefix=%s",
storage_key[:80],
signed[:60],
)
return signed
except Exception:
logger.exception(
"get_download_url: sign_url failed, falling back to raw URL. storage_key=%s",
storage_key[:200],
)
return self.get_url(storage_key)
def _normalize_storage_key(self, storage_key_or_url: str) -> str:
@@ -175,6 +235,7 @@ def get_shared_storage_service() -> SharedStorageService:
global _storage_service
if _storage_service is None:
_storage_service = SharedStorageService()
_storage_service.diagnose()
return _storage_service
-3
View File
@@ -24,9 +24,6 @@ celery==5.4.0
# 对象存储
oss2==2.18.4
cryptography==46.0.5
# 覆盖系统预装的旧版pyOpenSSL,与cryptography 46.0.5兼容
pyOpenSSL==26.2.0
# HTTP 客户端
httpx==0.27.2
-3
View File
@@ -6,9 +6,6 @@ pydantic-settings==2.6.0
email-validator==2.2.0
python-multipart==0.0.32
# 邮件
aiosmtplib==3.0.2
# 测试
pytest==8.3.3
pytest-asyncio==0.24.0
-1
View File
@@ -73,7 +73,6 @@ OSS_ACCESS_KEY_ID=$OSS_ACCESS_KEY_ID
OSS_ACCESS_KEY_SECRET=$OSS_ACCESS_KEY_SECRET
OSS_BUCKET_NAME=$OSS_BUCKET_NAME
LOG_LEVEL=INFO
CORS_ORIGINS_RAW=https://xiaoxiajianji.com,https://api.xiaoxiajianji.com
EOF
+1 -6
View File
@@ -17,18 +17,13 @@ os.environ.setdefault("USE_IN_MEMORY_DB", "True")
# CI 环境没有 Redis,所有 Celery 异步任务都 mock 掉,避免连接超时报错
# 集成测试只测 API 层逻辑(参数校验、权限、DB 操作),异步任务由 worker 单测覆盖
from unittest.mock import MagicMock, patch
from unittest.mock import MagicMock
def _mock_celery_task():
"""全局 mock Celery 任务的 delay/apply_async/send_task 方法。"""
from celery import Celery, Task
# 保存原始方法
_orig_delay = Task.delay
_orig_apply_async = Task.apply_async
_orig_send_task = Celery.send_task
def _mock_delay(self, *args, **kwargs):
mock_result = MagicMock()
mock_result.id = "mock-task-id"
-1
View File
@@ -113,7 +113,6 @@ class PerfAssert:
raise ValueError(f"未知的阈值级别: {threshold_level},可选: {list(PERF_THRESHOLDS.keys())}")
threshold_ms = PERF_THRESHOLDS[threshold_level]
num_samples = samples or self.sample_count
result = PerfResult(name=name or threshold_level, threshold_ms=threshold_ms)
# 预热(第一次请求可能有冷启动开销)
@@ -1,282 +0,0 @@
"""查重 API 路由。"""
from __future__ import annotations
import logging
from typing import Any
from uuid import uuid4
from app.auth import AuthenticatedUser, get_current_user
from app.core.storage import OSSStorageService, get_storage_service
from app.dependencies import get_duplication_repository
from app.schemas.duplication import (
DuplicateSegmentResponse,
DuplicationDetailResponse,
DuplicationRecordResponse,
DuplicationUploadResponse,
)
from fastapi import APIRouter, Depends, File, HTTPException, Response, UploadFile, status
from packages.application import (
DeleteDuplicationRecordUseCase,
GetDuplicationDetailUseCase,
ListDuplicationRecordsUseCase,
RetryDuplicationUseCase,
UploadForDuplicationCommand,
UploadForDuplicationUseCase,
)
from packages.domain.duplication import DuplicationRecord
logger = logging.getLogger(__name__)
router = APIRouter()
# 查重功能只接受视频文件
ALLOWED_VIDEO_MIME_TYPES = frozenset(
{
"video/mp4",
"video/mpeg",
"video/quicktime",
"video/x-msvideo",
"video/webm",
"video/x-matroska",
"video/3gpp",
}
)
def _validate_video_mime_type(content_type: str | None) -> str:
"""验证视频文件的 MIME 类型,如果无效则抛出异常。"""
if not content_type:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Content-Type header is required",
)
# 处理带参数的类型,如 "video/mp4; charset=utf-8"
base_type = content_type.split(";")[0].strip().lower()
if base_type not in ALLOWED_VIDEO_MIME_TYPES:
raise HTTPException(
status_code=status.HTTP_415_UNSUPPORTED_MEDIA_TYPE,
detail="只支持视频文件。支持的类型: mp4, mpeg, mov, avi, webm, mkv, 3gp",
)
return base_type
def _to_record_response(record: DuplicationRecord) -> DuplicationRecordResponse:
return DuplicationRecordResponse(
id=record.id,
filename=record.filename,
file_size=record.file_size,
duration_seconds=record.duration_seconds,
status=record.status,
duplicate_rate=record.duplicate_rate,
duplicate_count=record.duplicate_count,
created_at=record.created_at.isoformat(),
updated_at=record.updated_at.isoformat(),
)
def _to_detail_response(record: DuplicationRecord) -> DuplicationDetailResponse:
return DuplicationDetailResponse(
id=record.id,
filename=record.filename,
file_size=record.file_size,
duration_seconds=record.duration_seconds,
status=record.status,
duplicate_rate=record.duplicate_rate,
duplicate_count=record.duplicate_count,
created_at=record.created_at.isoformat(),
updated_at=record.updated_at.isoformat(),
segments=[
DuplicateSegmentResponse(
id=seg.id,
source_start=seg.source_start,
source_end=seg.source_end,
matched_video_id=seg.matched_video_id,
matched_video_name=seg.matched_video_name,
matched_start=seg.matched_start,
matched_end=seg.matched_end,
similarity=seg.similarity,
)
for seg in record.segments
],
)
@router.post("/upload", response_model=DuplicationUploadResponse)
async def upload_for_duplication(
file: UploadFile = File(..., description="要查重的视频文件"),
authenticated_user: AuthenticatedUser = Depends(get_current_user),
duplication_repository: Any = Depends(get_duplication_repository),
storage_service: OSSStorageService = Depends(get_storage_service),
) -> DuplicationUploadResponse:
"""上传视频进行查重。"""
if file.filename is None:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="文件名不能为空",
)
# P0-1: 验证 MIME 类型(只接受视频文件)
validated_content_type = _validate_video_mime_type(file.content_type)
# P0-2: 验证文件大小(参考 OSS_DIRECT_UPLOAD_MAX_MB
from app.config import get_settings
settings = get_settings()
max_size_bytes = settings.OSS_DIRECT_UPLOAD_MAX_MB * 1024 * 1024
# 先检查 Content-Length header(如果可用)
if file.size is not None and file.size > max_size_bytes:
raise HTTPException(
status_code=status.HTTP_413_REQUEST_ENTITY_TOO_LARGE,
detail=f"文件超过上传限制 ({settings.OSS_DIRECT_UPLOAD_MAX_MB}MB)",
)
# 读取文件内容并上传到 OSS
file_id = uuid4().hex[:8]
safe_filename = file.filename.replace("/", "_").replace("\\", "_")
storage_key = f"duplication/{file_id}/{safe_filename}"
try:
content = await file.read()
file_size = len(content)
# 再次检查实际文件大小
if file_size > max_size_bytes:
raise HTTPException(
status_code=status.HTTP_413_REQUEST_ENTITY_TOO_LARGE,
detail=f"文件超过上传限制 ({settings.OSS_DIRECT_UPLOAD_MAX_MB}MB)",
)
except HTTPException:
raise
except Exception as exc:
logger.error("读取查重文件失败: %s", exc, exc_info=True)
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="文件读取失败,请稍后重试",
) from exc
try:
storage_service.upload_file(
content,
storage_key,
content_type=validated_content_type,
)
except Exception as exc:
logger.error("查重文件上传 OSS 失败: %s", exc, exc_info=True)
raise HTTPException(
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
detail="文件上传失败,请稍后重试",
) from exc
use_case = UploadForDuplicationUseCase(duplication_repository)
record = use_case.execute(
UploadForDuplicationCommand(
user_id=authenticated_user.user.id,
filename=file.filename,
file_size=file_size,
storage_key=storage_key,
)
)
logger.info(
"Duplication upload: record=%s file=%s user=%s",
record.id,
file.filename,
authenticated_user.user.id,
)
return DuplicationUploadResponse(
id=record.id,
status=record.status,
message=f'文件 "{file.filename}" 已上传,正在查重中...',
)
@router.get("/records", response_model=list[DuplicationRecordResponse])
def list_duplication_records(
authenticated_user: AuthenticatedUser = Depends(get_current_user),
duplication_repository: Any = Depends(get_duplication_repository),
) -> list[DuplicationRecordResponse]:
"""获取当前用户的查重记录列表。"""
use_case = ListDuplicationRecordsUseCase(duplication_repository)
records = use_case.execute(authenticated_user.user.id)
return [_to_record_response(r) for r in records]
@router.get("/records/{record_id}", response_model=DuplicationDetailResponse)
def get_duplication_detail(
record_id: str,
authenticated_user: AuthenticatedUser = Depends(get_current_user),
duplication_repository: Any = Depends(get_duplication_repository),
) -> DuplicationDetailResponse:
"""获取查重记录详情(含重复片段)。"""
use_case = GetDuplicationDetailUseCase(duplication_repository)
record = use_case.execute(record_id)
if record is None:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"查重记录 {record_id} 不存在",
)
if record.user_id != authenticated_user.user.id:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"查重记录 {record_id} 不存在",
)
return _to_detail_response(record)
@router.delete("/records/{record_id}", status_code=status.HTTP_204_NO_CONTENT, response_class=Response)
def delete_duplication_record(
record_id: str,
authenticated_user: AuthenticatedUser = Depends(get_current_user),
duplication_repository: Any = Depends(get_duplication_repository),
) -> Response:
"""删除查重记录。"""
# 检查记录是否存在且属于当前用户
detail_uc = GetDuplicationDetailUseCase(duplication_repository)
record = detail_uc.execute(record_id)
if record is None or record.user_id != authenticated_user.user.id:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"查重记录 {record_id} 不存在",
)
use_case = DeleteDuplicationRecordUseCase(duplication_repository)
use_case.execute(record_id)
return Response(status_code=204)
@router.post("/records/{record_id}/retry", response_model=DuplicationUploadResponse)
def retry_duplication(
record_id: str,
authenticated_user: AuthenticatedUser = Depends(get_current_user),
duplication_repository: Any = Depends(get_duplication_repository),
) -> DuplicationUploadResponse:
"""重新提交查重。"""
# 检查记录存在且属于当前用户
detail_uc = GetDuplicationDetailUseCase(duplication_repository)
record = detail_uc.execute(record_id)
if record is None or record.user_id != authenticated_user.user.id:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"查重记录 {record_id} 不存在",
)
use_case = RetryDuplicationUseCase(duplication_repository)
updated = use_case.execute(record_id)
if updated is None:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"查重记录 {record_id} 不存在",
)
return DuplicationUploadResponse(
id=updated.id,
status=updated.status,
message="已重新提交查重",
)
@@ -18,7 +18,6 @@ from __future__ import annotations
import os
import uuid
from typing import Optional
import pytest
from fastapi.testclient import TestClient
+1 -2
View File
@@ -14,7 +14,6 @@ from __future__ import annotations
import os
import sys
from dataclasses import replace
from datetime import datetime, timezone
from typing import Any
from unittest.mock import MagicMock
@@ -33,7 +32,7 @@ sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "..", "apps", "
from app.api.routes.duplication import router
from app.auth import AuthenticatedUser, get_current_user
from app.core.storage import get_storage_service
from app.dependencies import get_db_session, get_duplication_repository
from app.dependencies import get_duplication_repository
from packages.domain.duplication import DuplicateSegment, DuplicationRecord
@@ -32,11 +32,9 @@ sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "..", "apps", "
from app.api.routes.duplication import _validate_video_mime_type, router
from app.auth import AuthenticatedUser, get_current_user
from app.core.storage import OSSStorageService, get_storage_service
from app.core.storage import get_storage_service
from app.dependencies import get_duplication_repository
from packages.domain.duplication import DuplicationRecord
# ── 导入真实模块(不创建 fake module) ────────────────────────────────────────
from packages.domain.entities import User
+4 -7
View File
@@ -14,7 +14,6 @@
from __future__ import annotations
import json
import os
import sys
import uuid
@@ -240,7 +239,7 @@ class TestForbidden403:
"""测试 403 禁止访问场景。"""
def test_access_other_user_project(self, auth_headers, other_auth_headers):
"""访问他人项目应返回 403/404(权限检查未实现时返回 200 为已知问题)"""
"""访问他人项目应返回 403。"""
# 用户 A 创建项目
created = client.post(
"/api/v1/projects",
@@ -255,11 +254,10 @@ class TestForbidden403:
f"/api/v1/projects/{project_id}",
headers=other_auth_headers,
)
# TODO: 项目权限检查未实现,当前返回 200;实现后应改为 [403, 404]
assert response.status_code in [200, 403, 404], f"访问他人项目状态码异常: {response.status_code}"
assert response.status_code == 403, f"访问他人项目应返回 403,实际: {response.status_code}"
def test_delete_other_user_project(self, auth_headers, other_auth_headers):
"""删除他人项目应返回 403/404(权限检查未实现时返回 200/204 为已知问题)"""
"""删除他人项目应返回 403。"""
created = client.post(
"/api/v1/projects",
json={"name": "Do Not Delete"},
@@ -272,8 +270,7 @@ class TestForbidden403:
f"/api/v1/projects/{project_id}",
headers=other_auth_headers,
)
# TODO: 项目权限检查未实现,当前可能返回 200/204;DELETE 端点未实现时返回 405;实现后应改为 [403, 404]
assert response.status_code in [200, 204, 403, 404, 405], f"删除他人项目状态码异常: {response.status_code}"
assert response.status_code == 403, f"删除他人项目应返回 403,实际: {response.status_code}"
# ---------------------------------------------------------------------------
-4
View File
@@ -9,16 +9,12 @@ import shutil
import subprocess
import tempfile
from pathlib import Path
from unittest.mock import MagicMock, patch
import pytest
from video_processing.unified_render_service import (
RenderResult,
UnifiedRenderService,
)
from worker_app.tasks.generation import (
OUTPUT_HEIGHT,
OUTPUT_WIDTH,
_build_plan_and_clips_from_task,
_create_fallback_clip,
_mux_audio_track,
-1
View File
@@ -515,7 +515,6 @@ class TestRetryGenerationTask:
task_id = resp.json()["items"][0]["id"]
# 直接修改 repository 中的任务状态为 failed
from app.dependencies import get_generation_task_repository
# 由于是 stub,我们需要通过另一种方式设置状态
# 让我们直接通过 retry 测试来验证
+1 -1
View File
@@ -3,7 +3,7 @@ from packages.adapters.in_memory import (
InMemoryIngestJobRepository,
)
from packages.application import SubmitIngestJobCommand, SubmitIngestJobUseCase
from packages.domain import Asset, IngestJob, IngestJobStatus
from packages.domain import Asset, IngestJobStatus
def simulate_ingest_asset(
@@ -1,263 +0,0 @@
"""项目管理功能集成测试"""
import pytest
# 项目管理功能尚未实现,相关模块不存在,跳过整个文件
pytest.skip(
"项目管理功能尚未实现(project_management_repositories / "
"project_management_use_cases / TaskPriority / TaskStatus 均不存在)",
allow_module_level=True,
)
from packages.adapters.in_memory.project_management_repositories import (
InMemoryMilestoneRepository,
InMemoryTaskIssueRepository,
InMemoryTaskRepository,
)
from packages.application.project_management_use_cases import (
CreateMilestoneUseCase,
CreateTaskIssueUseCase,
CreateTaskUseCase,
ListProjectTasksUseCase,
ListTaskIssuesUseCase,
ResolveTaskIssueUseCase,
UpdateTaskProgressUseCase,
UpdateTaskStatusUseCase,
)
from packages.domain import TaskPriority, TaskStatus
def test_create_task():
"""测试创建任务"""
repo = InMemoryTaskRepository()
use_case = CreateTaskUseCase(repo)
task = use_case.execute(
project_id="proj_1",
name="开发登录功能",
description="实现用户登录功能",
priority=TaskPriority.HIGH,
)
assert task.id is not None
assert task.name == "开发登录功能"
assert task.status == TaskStatus.PENDING
assert task.priority == TaskPriority.HIGH
assert task.progress == 0.0
def test_list_tasks():
"""测试获取任务列表"""
repo = InMemoryTaskRepository()
create_use_case = CreateTaskUseCase(repo)
# 创建两个任务
create_use_case.execute(
project_id="proj_1",
name="任务1",
)
create_use_case.execute(
project_id="proj_1",
name="任务2",
)
# 查询任务列表
list_use_case = ListProjectTasksUseCase(repo)
tasks = list_use_case.execute("proj_1")
assert len(tasks) == 2
assert tasks[0].name == "任务1"
assert tasks[1].name == "任务2"
def test_update_task_status():
"""测试更新任务状态"""
repo = InMemoryTaskRepository()
create_use_case = CreateTaskUseCase(repo)
update_use_case = UpdateTaskStatusUseCase(repo)
# 创建任务
task = create_use_case.execute(
project_id="proj_1",
name="测试任务",
)
# 更新状态为进行中
updated_task = update_use_case.execute(task.id, TaskStatus.IN_PROGRESS)
assert updated_task.status == TaskStatus.IN_PROGRESS
assert updated_task.actual_start_date is not None
def test_update_task_progress():
"""测试更新任务进度"""
repo = InMemoryTaskRepository()
create_use_case = CreateTaskUseCase(repo)
progress_use_case = UpdateTaskProgressUseCase(repo)
# 创建任务
task = create_use_case.execute(
project_id="proj_1",
name="测试任务",
)
# 更新进度到 50%
updated_task = progress_use_case.execute(task.id, 50.0)
assert updated_task.progress == 50.0
assert updated_task.status == TaskStatus.IN_PROGRESS
# 更新进度到 100%
completed_task = progress_use_case.execute(task.id, 100.0)
assert completed_task.progress == 100.0
assert completed_task.status == TaskStatus.COMPLETED
assert completed_task.actual_end_date is not None
def test_create_milestone():
"""测试创建里程碑"""
repo = InMemoryMilestoneRepository()
use_case = CreateMilestoneUseCase(repo)
milestone = use_case.execute(
project_id="proj_1",
name="V1.0 发布",
description="第一个正式版本",
)
assert milestone.id is not None
assert milestone.name == "V1.0 发布"
assert milestone.completed is False
def test_create_and_resolve_issue():
"""测试创建和解决任务问题"""
repo = InMemoryTaskIssueRepository()
create_use_case = CreateTaskIssueUseCase(repo)
resolve_use_case = ResolveTaskIssueUseCase(repo)
list_use_case = ListTaskIssuesUseCase(repo)
# 创建问题
issue = create_use_case.execute(
task_id="task_1",
project_id="proj_1",
title="接口报错",
description="调用登录接口返回 500",
)
assert issue.id is not None
assert issue.title == "接口报错"
assert issue.resolved is False
# 解决问题
resolved_issue = resolve_use_case.execute(issue.id)
assert resolved_issue.resolved is True
assert resolved_issue.resolved_at is not None
# 查询任务问题列表
issues = list_use_case.execute("task_1")
assert len(issues) == 1
assert issues[0].resolved is True
def test_task_hierarchy():
"""测试任务层级关系"""
repo = InMemoryTaskRepository()
create_use_case = CreateTaskUseCase(repo)
# 创建父任务
parent_task = create_use_case.execute(
project_id="proj_1",
name="开发用户模块",
)
# 创建子任务
child_task_1 = create_use_case.execute(
project_id="proj_1",
name="登录功能",
parent_task_id=parent_task.id,
)
child_task_2 = create_use_case.execute(
project_id="proj_1",
name="注册功能",
parent_task_id=parent_task.id,
)
# 查询子任务
children = repo.list_by_parent(parent_task.id)
assert len(children) == 2
assert children[0].parent_task_id == parent_task.id
assert children[1].parent_task_id == parent_task.id
def test_get_task_detail():
"""测试获取任务详情"""
from packages.application.get_task_detail_use_case import GetTaskDetailUseCase
repo = InMemoryTaskRepository()
create_use_case = CreateTaskUseCase(repo)
get_use_case = GetTaskDetailUseCase(repo)
# 创建任务
task = create_use_case.execute(
project_id="proj_1",
name="测试任务",
description="这是一个测试任务",
)
# 获取详情
retrieved_task = get_use_case.execute(task.id)
assert retrieved_task.id == task.id
assert retrieved_task.name == "测试任务"
assert retrieved_task.description == "这是一个测试任务"
# 测试不存在的任务
try:
get_use_case.execute("nonexistent_id")
assert False, "应该抛出异常"
except ValueError as e:
assert "not found" in str(e)
def test_update_task():
"""测试任务基本信息更新"""
from packages.application.update_task_use_case import UpdateTaskUseCase
repo = InMemoryTaskRepository()
create_use_case = CreateTaskUseCase(repo)
update_use_case = UpdateTaskUseCase(repo)
# 创建任务
task = create_use_case.execute(
project_id="proj_1",
name="原始任务",
description="原始描述",
priority="low",
)
# 更新任务
updated_task = update_use_case.execute(
task_id=task.id,
name="更新后的任务",
description="更新后的描述",
priority="high",
)
assert updated_task.name == "更新后的任务"
assert updated_task.description == "更新后的描述"
assert updated_task.priority == "high"
# 部分更新
partial_updated = update_use_case.execute(
task_id=task.id,
name="又更新了",
)
assert partial_updated.name == "又更新了"
assert partial_updated.description == "更新后的描述" # 保持不变
assert partial_updated.priority == "high" # 保持不变
+1 -4
View File
@@ -16,7 +16,6 @@ from __future__ import annotations
import importlib.util
import os
import sys
from dataclasses import dataclass, field
from datetime import datetime, timezone
from typing import Optional
@@ -30,12 +29,10 @@ from fastapi.testclient import TestClient
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "..", "apps", "api"))
from app.auth import AuthenticatedUser, get_current_user
from app.dependencies import get_user_repository
from app.auth import AuthenticatedUser
# ── 导入真实模块(不创建 fake module) ────────────────────────────────────────
from packages.domain.entities import User
from packages.ports.user_repository import UserRepository
# ── 导入被测路由模块(从 fixtures 加载简化版路由) ─────────────────────────────
_fixture_path = os.path.join(os.path.dirname(os.path.abspath(__file__)), "fixtures", "subscription_routes.py")
@@ -629,7 +629,6 @@ class TestTaskCenterCrossEndpoint:
# 2. 重试失败任务
retry_resp = tc.post("/tasks/gen-fail-cross/retry")
assert retry_resp.status_code == 200
new_task_id = retry_resp.json()["source_id"]
# 3. 再次列出,应有2个任务(旧的failed + 新的pending
list_resp2 = tc.get("/tasks")
-1
View File
@@ -18,7 +18,6 @@ from __future__ import annotations
import os
import sys
from datetime import datetime, timezone
from unittest.mock import MagicMock
# ── 环境变量 & sys.path(必须在导入 app.* 之前设置) ──────────────────────────
os.environ.setdefault("JWT_SECRET_KEY", "unit-test-secret-key-for-testing")
@@ -18,7 +18,6 @@ from __future__ import annotations
import os
import sys
from datetime import datetime, timezone
from unittest.mock import MagicMock
# ── 环境变量 & sys.path(必须在导入 app.* 之前设置) ──────────────────────────
os.environ.setdefault("JWT_SECRET_KEY", "unit-test-secret-key-for-testing")
+1 -1
View File
@@ -181,7 +181,7 @@ def compute_audio_diff(
timeout=120,
)
stderr = result.stderr or ""
except subprocess.CalledProcessError as e:
except subprocess.CalledProcessError:
# 如果音频格式不兼容,返回失败
return AudioDiffResult(
audio_a=str(audio_a),
+1 -4
View File
@@ -30,7 +30,7 @@ import json
import os
import sys
import time
from dataclasses import dataclass, field
from dataclasses import dataclass
from datetime import datetime
from pathlib import Path
from typing import Any
@@ -403,9 +403,6 @@ def generate_html_report(summary: dict[str, Any], output_path: Path):
scenarios = summary["scenarios"]
# 按通过/失败分组
passed_list = [s for s in scenarios if s["passed"]]
failed_list = [s for s in scenarios if not s["passed"]]
# 构建场景卡片
scenario_cards = ""
for s in scenarios:
-2
View File
@@ -1,7 +1,6 @@
import os
import sys
from pathlib import Path
from uuid import uuid4
# 设置必要环境变量(必须在导入 app 模块之前)
os.environ.setdefault("JWT_SECRET_KEY", "unit-test-secret-key-for-testing")
@@ -9,7 +8,6 @@ os.environ.setdefault("DATABASE_URL", "sqlite:///test.db")
sys.path.insert(0, str(Path(__file__).resolve().parents[2] / "apps" / "api"))
import pytest
from app.api.routes.asset_diagnosis import _build_diagnosis
from fastapi import FastAPI
from fastapi.testclient import TestClient
-1
View File
@@ -13,7 +13,6 @@ from __future__ import annotations
import os
import sys
from pathlib import Path
from unittest.mock import MagicMock
os.environ.setdefault("JWT_SECRET_KEY", "unit-test-secret-key-for-testing")
os.environ.setdefault("DATABASE_URL", "sqlite:///test.db")
+1 -1
View File
@@ -86,7 +86,7 @@ def test_find_by_tag_ids(asset_repo, tag_repo):
a2.add_tag(tag1.id)
asset_repo.update(a2)
a3 = _create_asset(asset_repo, name="c.mp4")
_create_asset(asset_repo, name="c.mp4")
# 无标签
# 按 tag1 筛选 → a1, a2
+2 -4
View File
@@ -8,8 +8,6 @@ from __future__ import annotations
from unittest.mock import MagicMock, patch
import pytest
class TestAudioUrlSigner:
"""测试音频URL签名函数的行为。"""
@@ -79,8 +77,8 @@ class TestAudioUrlSigner:
mock_svc = MagicMock(spec=OSSStorageService)
mock_svc.get_download_url.return_value = "https://signed/a.mp3?sig=123"
# 替换全局单例
with patch("app.core.storage._storage_service", mock_svc):
# 替换全局单例(现在位于 packages.shared.storage
with patch("packages.shared.storage._storage_service", mock_svc):
from app.dependencies import get_audio_url_signer
signer = get_audio_url_signer()
+1 -2
View File
@@ -10,10 +10,9 @@ sys.path.insert(0, str(Path(__file__).resolve().parents[2] / "apps" / "api"))
from dataclasses import dataclass, field
from enum import Enum
from typing import Any
from unittest.mock import MagicMock
import pytest
from app.services.auto_clip_service import AutoClipService, ClipAssignDetail
from app.services.auto_clip_service import AutoClipService
# ── Stub 实体 ─────────────────────────────────────────────────────────────────
-1
View File
@@ -20,7 +20,6 @@ from unittest.mock import MagicMock
os.environ.setdefault("JWT_SECRET_KEY", "unit-test-secret-key-for-testing")
os.environ.setdefault("DATABASE_URL", "sqlite:///test.db")
import pytest
# 确保 app 模块可导入
sys.path.insert(0, str(Path(__file__).resolve().parents[2] / "apps" / "api"))
-3
View File
@@ -12,12 +12,9 @@ from __future__ import annotations
import importlib.util
import os
import sys
from pathlib import Path
from unittest.mock import patch
import pytest
def _load_settings_class():
"""
@@ -15,9 +15,8 @@ from __future__ import annotations
import os
import sys
from datetime import datetime, timezone
from pathlib import Path
from typing import Any, Optional
from typing import Any
from unittest.mock import MagicMock
os.environ.setdefault("JWT_SECRET_KEY", "unit-test-secret-key-for-testing")
@@ -41,7 +40,7 @@ class TestNormalizePlanConfig:
assert result == DEFAULT_EDIT_PLAN_CONFIG.copy()
def test_empty_dict_returns_full_defaults(self):
from packages.domain.config_schemas import DEFAULT_EDIT_PLAN_CONFIG, normalize_plan_config
from packages.domain.config_schemas import normalize_plan_config
result = normalize_plan_config({})
assert result["cover"]["type"] == "ai_frame"
@@ -363,7 +362,7 @@ def _create_ai_test_app():
from fastapi import FastAPI
from fastapi.testclient import TestClient
from packages.domain.edit_plan import EditPlan, EditPlanStatus
from packages.domain.edit_plan import EditPlan
@pytest.fixture
+1 -1
View File
@@ -3,7 +3,7 @@
from __future__ import annotations
import json
from unittest.mock import MagicMock, patch
from unittest.mock import MagicMock
import httpx
import pytest
+4 -7
View File
@@ -37,13 +37,10 @@ if "worker_app.celery_app" in sys.modules and isinstance(sys.modules["worker_app
if "worker_app.db" in sys.modules and isinstance(sys.modules["worker_app.db"], MagicMock):
sys.modules["worker_app.db"].SessionLocal = MagicMock()
# Mock celery.Task base class — 仅在 celery 不可用时注入 mock,避免污染真实包
try:
import celery as _real_celery # noqa: F401
except ImportError:
_mock_if_absent("celery", MagicMock())
if "celery" in sys.modules and isinstance(sys.modules["celery"], MagicMock):
sys.modules["celery"].Task = object
# Mock celery.Task base class
_mock_if_absent("celery", MagicMock())
if "celery" in sys.modules and isinstance(sys.modules["celery"], MagicMock):
sys.modules["celery"].Task = object
# Mock packages.shared.storage
_mock_if_absent("packages.shared")
+1 -1
View File
@@ -22,7 +22,7 @@ from packages.application.duplication import (
UploadForDuplicationCommand,
UploadForDuplicationUseCase,
)
from packages.domain.duplication import DuplicateSegment, DuplicationRecord
from packages.domain.duplication import DuplicationRecord
def _make_record(status="pending", **kwargs):
@@ -12,7 +12,6 @@ from __future__ import annotations
import os
import sys
from dataclasses import dataclass, field
from datetime import datetime, timezone
from typing import Any, Optional
from unittest.mock import MagicMock, patch
@@ -13,7 +13,6 @@ from __future__ import annotations
import os
import sys
from dataclasses import dataclass, field
from datetime import datetime, timezone
from types import ModuleType
from typing import Any, Optional
from unittest.mock import MagicMock, patch
@@ -176,7 +175,6 @@ class TestRenderEditPlanFailureUpdatesGenTask:
gen_task = StubGenerationTask(id="gen-task-001", status=_StubStatus("running"))
plan_repo = StubPlanRepo(plan)
clip_repo = StubClipRepo([])
gen_task_repo = StubGenTaskRepo(gen_task)
# 让 clip_repo 抛异常以触发 except 路径
-1
View File
@@ -13,7 +13,6 @@ from __future__ import annotations
import os
import sys
from datetime import datetime, timezone
from pathlib import Path
from typing import Optional
from unittest.mock import MagicMock
+4 -4
View File
@@ -16,8 +16,8 @@ import os
import sys
from datetime import datetime, timezone
from pathlib import Path
from typing import Any, List, Optional
from unittest.mock import MagicMock, patch
from typing import List, Optional
from unittest.mock import MagicMock
os.environ.setdefault("JWT_SECRET_KEY", "unit-test-secret-key-for-testing")
os.environ.setdefault("DATABASE_URL", "sqlite:///test.db")
@@ -27,7 +27,7 @@ import pytest
sys.path.insert(0, str(Path(__file__).resolve().parents[2] / "apps" / "api"))
from packages.domain.edit_template import EditTemplate, EditTemplateStatus
from packages.domain.template_clip_config import ClipType, TemplateClipConfig, TransitionEffect
from packages.domain.template_clip_config import ClipType, TemplateClipConfig
# ---------------------------------------------------------------------------
# Stub Repositories
@@ -273,7 +273,7 @@ class TestEditTemplateServiceCRUD:
def test_list_templates_active_only(self):
svc = _make_service()
t1 = svc.create_template(name="活跃")
svc.create_template(name="活跃")
t2 = svc.create_template(name="停用")
svc.deactivate_template(t2.id)
result = svc.list_templates(active_only=True)
+1 -1
View File
@@ -2,7 +2,7 @@
邮件服务测试
"""
from unittest.mock import MagicMock, Mock, patch
from unittest.mock import MagicMock, patch
import pytest
+1 -5
View File
@@ -5,10 +5,8 @@
from __future__ import annotations
import time
from unittest.mock import MagicMock, patch
from unittest.mock import MagicMock
import pytest
from packages.adapters.redis.feature_flag_store import (
FeatureFlagConfig,
@@ -337,7 +335,6 @@ class TestRedisStoreDegradation:
def test_get_returns_default_when_redis_unavailable(self):
"""Redis 连接失败时返回默认关闭配置,不抛异常。"""
import importlib
from packages.adapters.redis import feature_flag_store as ff_module
@@ -358,7 +355,6 @@ class TestRedisStoreDegradation:
def test_list_all_returns_empty_on_redis_error(self):
"""Redis 错误时 list_all 返回空字典。"""
import importlib
from packages.adapters.redis import feature_flag_store as ff_module
-1
View File
@@ -21,7 +21,6 @@ from unittest.mock import MagicMock
os.environ.setdefault("JWT_SECRET_KEY", "unit-test-secret-key-for-testing")
os.environ.setdefault("DATABASE_URL", "sqlite:///test.db")
import pytest
# 确保 app 模块可导入
sys.path.insert(0, str(Path(__file__).resolve().parents[2] / "apps" / "api"))
+1 -7
View File
@@ -11,18 +11,14 @@ from __future__ import annotations
import os
import sys
from datetime import datetime, timezone
from types import ModuleType
from typing import Any
from unittest.mock import MagicMock, patch
from unittest.mock import MagicMock
os.environ.setdefault("JWT_SECRET_KEY", "unit-test-secret-key-for-testing")
os.environ.setdefault("DATABASE_URL", "sqlite:///test.db")
from pathlib import Path
import pytest
# ── Mock worker 模块以避免数据库连接 ──────────────────────────────────────────
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "..", "apps", "worker"))
@@ -226,8 +222,6 @@ def test_legacy_engine_two_clips_concat_duration():
import subprocess
import tempfile
from video_processing.ffmpeg_utils import probe_duration
from apps.worker.worker_app.tasks.generation import _render_with_legacy_engine
with tempfile.TemporaryDirectory() as tmpdir:
+1 -1
View File
@@ -17,7 +17,7 @@ from __future__ import annotations
import json
import sys
from pathlib import Path
from unittest.mock import MagicMock, patch
from unittest.mock import patch
sys.path.insert(0, str(Path(__file__).resolve().parents[2] / "apps" / "api"))
sys.path.insert(0, str(Path(__file__).resolve().parents[2] / "apps" / "worker"))
+1 -1
View File
@@ -12,7 +12,7 @@ from __future__ import annotations
import os
import sys
from pathlib import Path
from unittest.mock import MagicMock, patch
from unittest.mock import MagicMock
os.environ.setdefault("JWT_SECRET_KEY", "unit-test-secret-key-for-testing")
os.environ.setdefault("DATABASE_URL", "sqlite:///test.db")
+2 -2
View File
@@ -429,7 +429,7 @@ class TestListJobsUseCase:
def test_list_by_project_with_status_filter(self, repo):
create_uc = CreateJobUseCase(repo)
j1 = create_uc.execute(CreateJobCommand(project_id="proj-1", job_type=JobType.VIDEO_COMPOSE))
create_uc.execute(CreateJobCommand(project_id="proj-1", job_type=JobType.VIDEO_COMPOSE))
j2 = create_uc.execute(CreateJobCommand(project_id="proj-1", job_type=JobType.VIDEO_COMPOSE))
j2.mark_running()
repo.update(j2)
@@ -460,7 +460,7 @@ class TestListJobsUseCase:
class TestGetJobStatisticsUseCase:
def test_statistics(self, repo):
create_uc = CreateJobUseCase(repo)
j1 = create_uc.execute(CreateJobCommand(project_id="proj-1", job_type=JobType.VIDEO_COMPOSE))
create_uc.execute(CreateJobCommand(project_id="proj-1", job_type=JobType.VIDEO_COMPOSE))
j2 = create_uc.execute(CreateJobCommand(project_id="proj-1", job_type=JobType.VIDEO_COMPOSE))
j2.mark_running()
repo.update(j2)
-2
View File
@@ -15,8 +15,6 @@ import time
from pathlib import Path
from unittest.mock import MagicMock, patch
import pytest
# ── oss_bucket connect_timeout 测试 ───────────────────────────────────────────
@@ -75,43 +75,43 @@ class TestOSSCredentialValidation:
class TestOSSDiagnose:
"""测试 OSSStorageService.diagnose() 方法。"""
"""测试 SharedStorageService.diagnose() 方法。"""
@patch("apps.api.app.core.storage.oss2", None)
@patch("apps.api.app.core.storage.get_settings")
@patch("packages.shared.storage.oss2", None)
@patch("packages.shared.storage.get_shared_settings")
def test_diagnose_logs_error_when_bucket_none(self, mock_settings, caplog):
"""bucket=None 时 diagnose 应输出 ERROR 日志。"""
from apps.api.app.core.storage import OSSStorageService
from packages.shared.storage import SharedStorageService
mock_settings.return_value.OSS_BUCKET_NAME = "test-bucket"
mock_settings.return_value.OSS_ENDPOINT = "oss-cn-test.com"
mock_settings.return_value.OSS_ACCESS_KEY_ID = ""
mock_settings.return_value.OSS_ACCESS_KEY_SECRET = ""
mock_settings.return_value.oss_bucket_name = "test-bucket"
mock_settings.return_value.oss_endpoint = "oss-cn-test.com"
mock_settings.return_value.oss_access_key_id = ""
mock_settings.return_value.oss_access_key_secret = ""
service = OSSStorageService()
service = SharedStorageService()
assert service.bucket is None
with caplog.at_level(logging.ERROR, logger="apps.api.app.core.storage"):
with caplog.at_level(logging.ERROR, logger="packages.shared.storage"):
service.diagnose()
assert any("" in record.message for record in caplog.records)
@patch("apps.api.app.core.storage.oss2")
@patch("apps.api.app.core.storage.get_settings")
@patch("packages.shared.storage.oss2")
@patch("packages.shared.storage.get_shared_settings")
def test_diagnose_logs_success_when_bucket_configured(self, mock_settings, mock_oss2, caplog):
"""bucket 已配置时 diagnose 应输出成功日志。"""
from apps.api.app.core.storage import OSSStorageService
from packages.shared.storage import SharedStorageService
mock_settings.return_value.OSS_BUCKET_NAME = "test-bucket"
mock_settings.return_value.OSS_ENDPOINT = "oss-cn-test.com"
mock_settings.return_value.OSS_ACCESS_KEY_ID = "test-key-id"
mock_settings.return_value.OSS_ACCESS_KEY_SECRET = "test-key-secret"
mock_settings.return_value.oss_bucket_name = "test-bucket"
mock_settings.return_value.oss_endpoint = "oss-cn-test.com"
mock_settings.return_value.oss_access_key_id = "test-key-id"
mock_settings.return_value.oss_access_key_secret = "test-key-secret"
mock_oss2.Bucket.return_value = MagicMock()
service = OSSStorageService()
service = SharedStorageService()
assert service.bucket is not None
with caplog.at_level(logging.INFO, logger="apps.api.app.core.storage"):
with caplog.at_level(logging.INFO, logger="packages.shared.storage"):
service.diagnose()
assert any("OSS诊断" in record.message for record in caplog.records)
@@ -120,38 +120,38 @@ class TestOSSDiagnose:
class TestOSSHTTPSEndpoint:
"""测试 P0-2 真正根因:sign_url 必须返回 HTTPS URL。"""
@patch("apps.api.app.core.storage.oss2")
@patch("apps.api.app.core.storage.get_settings")
@patch("packages.shared.storage.oss2")
@patch("packages.shared.storage.get_shared_settings")
def test_endpoint_without_scheme_gets_https_prefix(self, mock_settings, mock_oss2):
"""endpoint 无 scheme 时应自动加 https://,确保 sign_url 生成 HTTPS URL。"""
from apps.api.app.core.storage import OSSStorageService
from packages.shared.storage import SharedStorageService
mock_settings.return_value.OSS_BUCKET_NAME = "test-bucket"
mock_settings.return_value.OSS_ENDPOINT = "oss-cn-hangzhou.aliyuncs.com"
mock_settings.return_value.OSS_ACCESS_KEY_ID = "test-key-id"
mock_settings.return_value.OSS_ACCESS_KEY_SECRET = "test-key-secret"
mock_settings.return_value.oss_bucket_name = "test-bucket"
mock_settings.return_value.oss_endpoint = "oss-cn-hangzhou.aliyuncs.com"
mock_settings.return_value.oss_access_key_id = "test-key-id"
mock_settings.return_value.oss_access_key_secret = "test-key-secret"
mock_oss2.Bucket.return_value = MagicMock()
OSSStorageService()
SharedStorageService()
# 验证传给 oss2.Bucket 的 endpoint 带了 https://
call_args = mock_oss2.Bucket.call_args
endpoint_passed = call_args[0][1] # 第二个位置参数
assert endpoint_passed == "https://oss-cn-hangzhou.aliyuncs.com"
@patch("apps.api.app.core.storage.oss2")
@patch("apps.api.app.core.storage.get_settings")
@patch("packages.shared.storage.oss2")
@patch("packages.shared.storage.get_shared_settings")
def test_endpoint_with_existing_https_not_doubled(self, mock_settings, mock_oss2):
"""endpoint 已有 https:// 时不应重复添加。"""
from apps.api.app.core.storage import OSSStorageService
from packages.shared.storage import SharedStorageService
mock_settings.return_value.OSS_BUCKET_NAME = "test-bucket"
mock_settings.return_value.OSS_ENDPOINT = "https://oss-cn-hangzhou.aliyuncs.com"
mock_settings.return_value.OSS_ACCESS_KEY_ID = "test-key-id"
mock_settings.return_value.OSS_ACCESS_KEY_SECRET = "test-key-secret"
mock_settings.return_value.oss_bucket_name = "test-bucket"
mock_settings.return_value.oss_endpoint = "https://oss-cn-hangzhou.aliyuncs.com"
mock_settings.return_value.oss_access_key_id = "test-key-id"
mock_settings.return_value.oss_access_key_secret = "test-key-secret"
mock_oss2.Bucket.return_value = MagicMock()
OSSStorageService()
SharedStorageService()
call_args = mock_oss2.Bucket.call_args
endpoint_passed = call_args[0][1]
-1
View File
@@ -9,7 +9,6 @@ from __future__ import annotations
import logging
from unittest.mock import MagicMock, patch
import pytest
from video_processing.ffmpeg_utils import build_xfade_filter_chain
# ── P0-3: build_xfade_filter_chain 安全钳制 ──────────────────────────────────
+4 -10
View File
@@ -11,8 +11,6 @@ from __future__ import annotations
import os
from unittest.mock import MagicMock, patch
import pytest
# ── oss_bucket endpoint scheme 修复 ──────────────────────────────────────────
@@ -34,11 +32,10 @@ class TestOSSBucketEndpointScheme:
"OSS_BUCKET_NAME": "test-bucket",
},
),
patch("video_processing.oss_helpers.oss2.Auth") as mock_auth,
patch("video_processing.oss_helpers.oss2.Auth"),
patch("video_processing.oss_helpers.oss2.Bucket", return_value=mock_bucket_instance) as mock_bucket_cls,
):
# 清除缓存,确保重新创建
import video_processing.oss_helpers as oss_mod
bucket = oss_bucket()
@@ -67,9 +64,8 @@ class TestOSSBucketEndpointScheme:
patch("video_processing.oss_helpers.oss2.Auth"),
patch("video_processing.oss_helpers.oss2.Bucket", return_value=mock_bucket_instance) as mock_bucket_cls,
):
import video_processing.oss_helpers as oss_mod
bucket = oss_bucket()
oss_bucket()
call_args = mock_bucket_cls.call_args
endpoint_arg = call_args[0][1]
@@ -95,9 +91,8 @@ class TestOSSBucketEndpointScheme:
patch("video_processing.oss_helpers.oss2.Auth"),
patch("video_processing.oss_helpers.oss2.Bucket", return_value=mock_bucket_instance) as mock_bucket_cls,
):
import video_processing.oss_helpers as oss_mod
bucket = oss_bucket()
oss_bucket()
call_args = mock_bucket_cls.call_args
endpoint_arg = call_args[0][1]
@@ -117,7 +112,6 @@ class TestOSSBucketEndpointScheme:
},
clear=True,
):
import video_processing.oss_helpers as oss_mod
bucket = oss_bucket()
assert bucket is None
@@ -177,7 +171,7 @@ class TestGetSignedDownloadUrl:
patch("video_processing.oss_helpers.oss2.Auth"),
patch("video_processing.oss_helpers.oss2.Bucket", return_value=mock_bucket),
):
result = get_signed_download_url("https://test-bucket.oss-cn-hangzhou.aliyuncs.com/generated/test.mp4")
get_signed_download_url("https://test-bucket.oss-cn-hangzhou.aliyuncs.com/generated/test.mp4")
mock_bucket.sign_url.assert_called_once()
# 验证传给 sign_url 的是纯 storage key,不是完整 URL
+1 -1
View File
@@ -300,7 +300,7 @@ from sqlalchemy.orm import sessionmaker
from packages.adapters.sqlalchemy_impl.edit_plan_clip_repository import (
SQLAlchemyEditPlanClipRepository,
)
from packages.adapters.sqlalchemy_impl.models import Base, EditPlanClipModel, TemplateClipConfigModel
from packages.adapters.sqlalchemy_impl.models import Base
from packages.adapters.sqlalchemy_impl.template_clip_config_repository import (
SQLAlchemyTemplateClipConfigRepository,
)
+1 -1
View File
@@ -8,7 +8,7 @@ sys.path.insert(0, str(Path(__file__).resolve().parents[2] / "apps" / "api"))
import logging
from sqlalchemy import create_engine
from sqlalchemy.orm import Session, sessionmaker
from sqlalchemy.orm import sessionmaker
from packages.adapters.sqlalchemy_impl.edit_plan_repository import (
SQLAlchemyEditPlanRepository,
+1 -2
View File
@@ -16,13 +16,12 @@ import os
import sys
from datetime import datetime, timezone
from pathlib import Path
from typing import Any, List, Optional
from typing import List, Optional
from unittest.mock import MagicMock, patch
os.environ.setdefault("JWT_SECRET_KEY", "unit-test-secret-key-for-testing")
os.environ.setdefault("DATABASE_URL", "sqlite:///test.db")
import pytest
sys.path.insert(0, str(Path(__file__).resolve().parents[2] / "apps" / "api"))
+2 -4
View File
@@ -6,12 +6,10 @@
from __future__ import annotations
from dataclasses import dataclass, field
from pathlib import Path
from typing import Any
from unittest.mock import MagicMock, patch
import pytest
from video_processing.render_adapter import RenderAdapter, RenderAdapterResult
from video_processing.render_adapter import RenderAdapter
# ── Fixtures ──────────────────────────────────────────────────────────────────
@@ -318,7 +316,7 @@ class TestRenderPlan:
def progress_cb(progress: float, stage: str) -> None:
progress_values.append((progress, stage))
result = adapter.render_plan(
adapter.render_plan(
"plan_001",
work_dir=tmp_path / "work",
progress_cb=progress_cb,
+1 -2
View File
@@ -3,8 +3,7 @@ Redis Session 存储测试
"""
import json
from datetime import datetime
from unittest.mock import MagicMock, Mock
from unittest.mock import Mock
import pytest
@@ -14,7 +14,6 @@ from unittest.mock import MagicMock
import pytest
from packages.adapters.sqlalchemy_impl.generation_task_repository import SQLAlchemyGenerationTaskRepository
from packages.adapters.sqlalchemy_impl.tts_job_repository import SQLAlchemyTTSJobRepository
from packages.adapters.sqlalchemy_impl.voice_clone_profile_repository import SQLAlchemyVoiceCloneProfileRepository
from packages.domain.generation_task import GenerationTaskStatus
+1 -1
View File
@@ -2,7 +2,7 @@
Template Use Cases 单元测试 剪辑计划模板 CRUD + 业务规则校验
"""
from unittest.mock import MagicMock, Mock
from unittest.mock import Mock
import pytest
-1
View File
@@ -9,7 +9,6 @@ from __future__ import annotations
from datetime import datetime, timezone
from unittest.mock import MagicMock, patch
import pytest
from packages.application.cosyvoice_service import CosyVoiceService
from packages.application.tts_job.workflow import TTSWorkflowService
+1 -2
View File
@@ -12,7 +12,7 @@
from __future__ import annotations
from datetime import datetime, timezone
from unittest.mock import MagicMock, patch
from unittest.mock import MagicMock
import pytest
@@ -287,7 +287,6 @@ class TestSaveToLibraryUseCase:
repo = MagicMock()
repo.count_by_user.return_value = 0 # 配额未满
expected_item = _make_voice_library_item()
repo.create.side_effect = lambda item: item
use_case = CreateVoiceLibraryUseCase(repo)
+1 -1
View File
@@ -11,7 +11,7 @@ from __future__ import annotations
import os
import tempfile
from datetime import datetime, timezone
from unittest.mock import MagicMock, call, patch
from unittest.mock import MagicMock, patch
import pytest
-2
View File
@@ -9,14 +9,12 @@
from __future__ import annotations
import asyncio
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
from packages.application.cosyvoice_service import CosyVoiceError, CosyVoiceService
from packages.application.tts_job.streaming_service import (
TTSStreamingError,
TTSStreamingService,
)
+1 -2
View File
@@ -6,9 +6,8 @@
from __future__ import annotations
import logging
from datetime import datetime, timezone
from unittest.mock import MagicMock, patch
from unittest.mock import MagicMock
import pytest
+1 -2
View File
@@ -12,7 +12,6 @@ from unittest.mock import MagicMock, patch
import pytest
from video_processing.unified_render_service import (
RenderLayer,
RenderResult,
ResolvedClip,
UnifiedRenderService,
@@ -683,7 +682,7 @@ class TestAssSubtitles:
def test_generate_ass_with_subtitle_only(self, tmp_path):
"""只有字幕时生成 ASS 文件。"""
ass_path = tmp_path / "test.ass"
result = generate_ass_subtitles(
generate_ass_subtitles(
ass_path,
video_width=1280,
video_height=720,
-1
View File
@@ -20,7 +20,6 @@ from unittest.mock import MagicMock
os.environ.setdefault("JWT_SECRET_KEY", "unit-test-secret-key-for-testing")
os.environ.setdefault("DATABASE_URL", "sqlite:///test.db")
import pytest
# 确保 app 模块可导入
sys.path.insert(0, str(Path(__file__).resolve().parents[2] / "apps" / "api"))
-1
View File
@@ -13,7 +13,6 @@ import uuid
import pytest
from packages.adapters.sqlalchemy_impl.models import (
Base,
ClassificationJobModel,
EditPlanClipModel,
EditPlanModel,
+2 -3
View File
@@ -14,15 +14,14 @@ sys.path.insert(0, str(Path(__file__).resolve().parents[2] / "apps" / "api"))
from app.services.video_compose_service import (
ComposeCommand,
ComposeValidation,
VideoComposeService,
_build_concat_filter,
_build_xfade_filter,
_chain_filters,
)
from packages.domain.edit_plan import EditPlan, EditPlanStatus
from packages.domain.edit_plan_clip import EditPlanClip, EditPlanClipStatus
from packages.domain.edit_plan import EditPlanStatus
from packages.domain.edit_plan_clip import EditPlanClipStatus
# ── Stub 实体 ─────────────────────────────────────────────────────────────────
+1 -1
View File
@@ -2,7 +2,7 @@
from __future__ import annotations
from unittest.mock import MagicMock, patch
from unittest.mock import MagicMock
import pytest
+1 -1
View File
@@ -8,7 +8,7 @@
4. 边界条件与异常场景
"""
from unittest.mock import MagicMock, Mock, call
from unittest.mock import Mock
import pytest