Compare commits
33 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| f1597749f1 | |||
| f21d981585 | |||
| ed21ee11d2 | |||
| c6941a738c | |||
| d92909321c | |||
| a2bddf726e | |||
| c052e857ff | |||
| 3862996045 | |||
| 69845eedc6 | |||
| 389d1e4401 | |||
| a009cd382a | |||
| 956d90c5c2 | |||
| 678d3503a7 | |||
| 90dd5c6d80 | |||
| e81c7444b0 | |||
| 081e58e5a0 | |||
| 486f1a092e | |||
| 021001073b | |||
| 7d92f82900 | |||
| 3e621bee2c | |||
| 5629cf63ec | |||
| a9896e1507 | |||
| f9f7eae37d | |||
| 5a9b6d9890 | |||
| 27cb7381ad | |||
| 8cec4069fa | |||
| 2ee710ca16 | |||
| 5c5aabd311 | |||
| 5c260c1c87 | |||
| b5ad67830e | |||
| 545293fe5c | |||
| df7dafb8c4 | |||
| b701182f6b |
+180
-42
@@ -1,60 +1,198 @@
|
||||
# 小虾 SaaS 环境变量配置
|
||||
# ============================================================
|
||||
# 小虾 SaaS 环境变量完整配置
|
||||
# ============================================================
|
||||
# 本文件列出所有可配置的环境变量及默认值。
|
||||
# 复制为 .env 后按需修改;生产环境务必覆盖所有密钥类配置。
|
||||
#
|
||||
# 配置读取规则(pydantic-settings,大小写不敏感):
|
||||
# 1. 系统环境变量(最高优先级)
|
||||
# 2. .env.{APP_ENV} 文件(如 .env.staging)
|
||||
# 3. .env 文件
|
||||
# 4. 代码中的默认值(最低优先级)
|
||||
# ============================================================
|
||||
|
||||
# ==================== 应用配置 ====================
|
||||
APP_NAME=小虾 SaaS
|
||||
APP_BASE_URL=http://localhost:3000
|
||||
|
||||
# ==================== 应用基本配置 ====================
|
||||
|
||||
# 应用名称
|
||||
APP_NAME=xiaoxia-saas
|
||||
|
||||
# 应用版本号(展示用,代码中已内置默认)
|
||||
APP_VERSION=0.1.61
|
||||
|
||||
# 环境标识:development / staging / production
|
||||
# 决定读取 .env.{APP_ENV} 还是 .env,也影响部分配置的严格校验
|
||||
APP_ENV=development
|
||||
|
||||
# ==================== 数据库配置 ====================
|
||||
DATABASE_URL=postgresql://xiaoxia_user:your_password@localhost:5432/xiaoxia_saas
|
||||
|
||||
# 开发环境:使用内存数据库(不需要 PostgreSQL)
|
||||
USE_IN_MEMORY_DB=true
|
||||
|
||||
# 生产环境:使用 PostgreSQL
|
||||
# USE_IN_MEMORY_DB=false
|
||||
|
||||
# ==================== Redis 配置 ====================
|
||||
REDIS_URL=redis://localhost:6379/0
|
||||
|
||||
# ==================== JWT 配置 ====================
|
||||
JWT_SECRET_KEY=your-super-secret-key-change-this-in-production-min-32-chars
|
||||
JWT_ALGORITHM=HS256
|
||||
JWT_ACCESS_TOKEN_EXPIRE_MINUTES=30
|
||||
JWT_REFRESH_TOKEN_EXPIRE_DAYS=30
|
||||
|
||||
# ==================== 邮件配置 ====================
|
||||
SMTP_HOST=smtp.gmail.com
|
||||
SMTP_PORT=587
|
||||
SMTP_USER=your-email@gmail.com
|
||||
SMTP_PASSWORD=your-app-specific-password
|
||||
SMTP_FROM_EMAIL=noreply@xiaoxia-saas.com
|
||||
SMTP_FROM_NAME=小虾 SaaS
|
||||
|
||||
# ==================== 环境配置 ====================
|
||||
ENVIRONMENT=development
|
||||
# 是否开启 Debug 模式(开发环境 true,生产环境 false)
|
||||
DEBUG=true
|
||||
|
||||
# ==================== CORS 配置 ====================
|
||||
# 逗号分隔的域名列表(Settings 读取 CORS_ORIGINS_RAW)
|
||||
CORS_ORIGINS_RAW=http://localhost:3000,http://localhost:5173
|
||||
# 应用基础 URL,用于生成认证邮件、回调链接等
|
||||
APP_BASE_URL=http://localhost:3000
|
||||
|
||||
# API 服务监听地址(容器内绑定,外部暴露由 Docker/Nginx 控制)
|
||||
API_HOST=0.0.0.0
|
||||
|
||||
# API 服务监听端口
|
||||
API_PORT=8000
|
||||
|
||||
# 是否自动创建数据库表结构(开发环境可开启,生产环境用 alembic migration)
|
||||
AUTO_CREATE_SCHEMA=false
|
||||
|
||||
|
||||
# ==================== 数据库配置 ====================
|
||||
|
||||
# 数据库连接串(格式:postgresql+psycopg://user:password@host:port/dbname)
|
||||
DATABASE_URL=postgresql+psycopg://postgres:postgres@localhost:5432/xiaoxia_saas
|
||||
|
||||
# 连接池大小(常驻连接数)
|
||||
DATABASE_POOL_SIZE=20
|
||||
|
||||
# 连接池最大溢出连接数(pool_size + max_overflow = 最大并发连接数)
|
||||
DATABASE_MAX_OVERFLOW=10
|
||||
|
||||
# 获取连接超时时间(秒)
|
||||
DATABASE_POOL_TIMEOUT=30
|
||||
|
||||
# 连接回收时间(秒),防止数据库端主动断开导致的死连接
|
||||
DATABASE_POOL_RECYCLE=3600
|
||||
|
||||
# 是否使用内存数据库(SQLite,仅开发/测试可用;生产务必 false)
|
||||
USE_IN_MEMORY_DB=false
|
||||
|
||||
|
||||
# ==================== Redis 配置 ====================
|
||||
|
||||
# Redis 连接 URL(格式:redis://[:password@]host:port/db)
|
||||
REDIS_URL=redis://localhost:6379/0
|
||||
|
||||
# 是否使用 Redis 存储 Session(多实例部署时必须开启;开发可用内存存储)
|
||||
ENABLE_REDIS_SESSIONS=false
|
||||
|
||||
|
||||
# ==================== Celery 任务队列 ====================
|
||||
|
||||
# Celery Broker(任务分发),默认用 Redis db0
|
||||
CELERY_BROKER_URL=redis://localhost:6379/0
|
||||
|
||||
# Celery Result Backend(任务结果存储),默认用 Redis db1
|
||||
CELERY_RESULT_BACKEND=redis://localhost:6379/1
|
||||
|
||||
|
||||
# ==================== Worker 配置 ====================
|
||||
|
||||
# Worker 进程名称
|
||||
WORKER_NAME=xiaoxia-saas-worker
|
||||
|
||||
# Worker 并发数(同时执行的任务数)
|
||||
WORKER_CONCURRENCY=4
|
||||
|
||||
# 每个子进程最多处理多少任务后重启(防止内存泄漏)
|
||||
WORKER_MAX_TASKS_PER_CHILD=1000
|
||||
|
||||
|
||||
# ==================== JWT 认证配置 ====================
|
||||
|
||||
# JWT 签名密钥 — 生产环境必须设置为强随机字符串(至少32字符)
|
||||
# 内置不安全值会被拒绝:secret / changeme / password / your-secret-key 等
|
||||
JWT_SECRET_KEY=your-super-secret-key-change-this-in-production-min-32-chars
|
||||
|
||||
# JWT 签名算法
|
||||
JWT_ALGORITHM=HS256
|
||||
|
||||
# Access Token 过期时间(分钟)
|
||||
JWT_ACCESS_TOKEN_EXPIRE_MINUTES=30
|
||||
|
||||
# Refresh Token 过期时间(天)
|
||||
JWT_REFRESH_TOKEN_EXPIRE_DAYS=30
|
||||
|
||||
|
||||
# ==================== 邮件配置 ====================
|
||||
|
||||
# 是否启用邮件投递(关闭时邮件内容打印到日志,开发调试用)
|
||||
ENABLE_EMAIL_DELIVERY=false
|
||||
|
||||
# SMTP 服务器地址
|
||||
SMTP_HOST=smtp.gmail.com
|
||||
|
||||
# SMTP 端口
|
||||
SMTP_PORT=587
|
||||
|
||||
# SMTP 用户名
|
||||
SMTP_USER=your-email@gmail.com
|
||||
|
||||
# SMTP 密码 / 应用专用密码
|
||||
SMTP_PASSWORD=your-app-specific-password
|
||||
|
||||
# 发件人邮箱
|
||||
SMTP_FROM_EMAIL=noreply@xiaoxia-saas.com
|
||||
|
||||
# 发件人显示名称
|
||||
SMTP_FROM_NAME=小虾 SaaS
|
||||
|
||||
# 是否启用 TLS
|
||||
SMTP_USE_TLS=true
|
||||
|
||||
|
||||
# ==================== 阿里云 OSS 配置 ====================
|
||||
|
||||
# OSS 区域 endpoint
|
||||
OSS_ENDPOINT=oss-cn-hangzhou.aliyuncs.com
|
||||
|
||||
# OSS Access Key ID — 非开发环境必须设置
|
||||
OSS_ACCESS_KEY_ID=your-access-key-id
|
||||
|
||||
# OSS Access Key Secret — 非开发环境必须设置
|
||||
OSS_ACCESS_KEY_SECRET=your-access-key-secret
|
||||
|
||||
# OSS Bucket 名称
|
||||
OSS_BUCKET_NAME=xiaoxia-autocut
|
||||
|
||||
# ==================== CosyVoice 语音合成配置 ====================
|
||||
# 注意:base_url 只需写到 /api/v1,具体路径由代码拼接
|
||||
# 模型: cosyvoice-v3-flash (推荐,支持系统音色,性价比高)
|
||||
# cosyvoice-v3-plus (高质量,系统音色少)
|
||||
# cosyvoice-v3.5-flash / cosyvoice-v3.5-plus (仅支持克隆/设计音色,无系统音色)
|
||||
# 音色: v3系列系统音色带 _v3 后缀,如 longxiaochun_v3, longxiaoxia_v3, longanyang (无后缀)
|
||||
# 注意:COSYVOICE_* 变量由 packages/shared/config.py 的 SharedSettings 读取
|
||||
# 直传最大文件大小(MB)
|
||||
OSS_DIRECT_UPLOAD_MAX_MB=2000
|
||||
|
||||
# 直传签名有效期(秒)
|
||||
OSS_DIRECT_UPLOAD_EXPIRE_SECONDS=900
|
||||
|
||||
|
||||
# ==================== CORS 配置 ====================
|
||||
|
||||
# 允许跨域的前端域名列表,逗号分隔
|
||||
CORS_ORIGINS_RAW=http://localhost:3000,http://localhost:5173,http://localhost:8000
|
||||
|
||||
|
||||
# ==================== 渲染引擎配置 ====================
|
||||
|
||||
# 渲染引擎选择:
|
||||
# legacy — 旧 VideoComposeService(稳定,功能完整)
|
||||
# unified — 新 UnifiedRenderService(新架构,部分场景仍在验证)
|
||||
RENDER_ENGINE=legacy
|
||||
|
||||
|
||||
# ==================== CosyVoice 语音合成 ====================
|
||||
# 阿里云百灵语音合成服务
|
||||
# 模型选择:
|
||||
# cosyvoice-v3-flash — 推荐,系统音色多,性价比高
|
||||
# cosyvoice-v3-plus — 高质量,系统音色少
|
||||
# cosyvoice-v3.5-flash / cosyvoice-v3.5-plus — 仅支持克隆/设计音色,无系统音色
|
||||
# 音色:v3 系列系统音色带 _v3 后缀,如 longxiaochun_v3 / longxiaoxia_v3 / longanyang
|
||||
|
||||
COSYVOICE_API_KEY=your-cosyvoice-api-key
|
||||
COSYVOICE_BASE_URL=https://dashscope.aliyuncs.com/api/v1
|
||||
COSYVOICE_MODEL=cosyvoice-v3-flash
|
||||
COSYVOICE_VOICE=longxiaochun_v3
|
||||
COSYVOICE_SAMPLE_RATE=22050
|
||||
COSYVOICE_FORMAT=mp3
|
||||
|
||||
# 音色克隆模型名(固定为 voice-enrollment,通常不需修改)
|
||||
COSYVOICE_CLONE_MODEL=voice-enrollment
|
||||
|
||||
|
||||
# ==================== 豆包大模型(火山引擎方舟) ====================
|
||||
# 用于 AI 文案生成、智能剪辑等需要大模型能力的场景
|
||||
|
||||
DOUBAO_API_KEY=your-doubao-api-key
|
||||
DOUBAO_MODEL=doubao-seed-1-6-250615
|
||||
DOUBAO_BASE_URL=https://ark.cn-beijing.volces.com/api/v3
|
||||
DOUBAO_TIMEOUT=30
|
||||
DOUBAO_MAX_RETRIES=2
|
||||
|
||||
@@ -22,7 +22,8 @@ permissions:
|
||||
contents: read
|
||||
concurrency:
|
||||
group: ci-pipeline-${{ gitea.event_name }}-${{ gitea.ref }}
|
||||
cancel-in-progress: true
|
||||
# PR事件取消进行中的旧run,push事件不取消(确保完整CI跑完)
|
||||
cancel-in-progress: ${{ gitea.event_name == 'pull_request' }}
|
||||
jobs:
|
||||
check-frontend-only:
|
||||
name: Check if frontend-only change
|
||||
@@ -394,7 +395,9 @@ jobs:
|
||||
if: always() && needs.check-frontend-only.outputs.skip_backend != 'true'
|
||||
needs:
|
||||
- check-frontend-only
|
||||
- validate
|
||||
- validate-code-quality
|
||||
- validate-type-check
|
||||
- validate-migration
|
||||
env:
|
||||
DATABASE_URL: postgresql+psycopg://postgres:postgres@host.docker.internal:5432/xiaoxia_saas
|
||||
USE_IN_MEMORY_DB: 'false'
|
||||
@@ -1083,15 +1086,24 @@ jobs:
|
||||
shell: sh
|
||||
run: |
|
||||
set -eu
|
||||
docker run --rm --ipc=host \
|
||||
# DooD模式下不能用-v挂载(宿主机路径与CI容器路径不一致)
|
||||
# 改用 docker create + docker cp 方式把代码拷进容器
|
||||
CONTAINER_NAME="staging-e2e-$$"
|
||||
docker rm -f "$CONTAINER_NAME" 2>/dev/null || true
|
||||
docker create --name "$CONTAINER_NAME" --ipc=host \
|
||||
-e E2E_BASE_URL=https://staging.xiaoxiajianji.com \
|
||||
-e E2E_API_BASE=https://staging-api.xiaoxiajianji.com/api/v1 \
|
||||
-e E2E_BROWSER_CHANNEL=chromium \
|
||||
-e PLAYWRIGHT_HEADLESS=1 \
|
||||
-v "$PWD:/workspace" \
|
||||
-w /workspace/apps/web \
|
||||
git.xiaoxiajianji.com/xiaoxia/base/playwright:v1.45.0-jammy \
|
||||
sh -lc "npm ci && npx playwright test --reporter=line --project=chromium e2e/auth.spec.ts e2e/auth-guard.spec.ts e2e/core-upload.spec.ts e2e/core-generation.spec.ts"
|
||||
docker cp apps "$CONTAINER_NAME:/workspace/"
|
||||
docker cp package-lock.json "$CONTAINER_NAME:/workspace/" 2>/dev/null || true
|
||||
docker start -a "$CONTAINER_NAME"
|
||||
EXIT_CODE=$(docker wait "$CONTAINER_NAME")
|
||||
docker rm "$CONTAINER_NAME" 2>/dev/null || true
|
||||
exit $EXIT_CODE
|
||||
|
||||
- name: Job duration summary
|
||||
if: always()
|
||||
@@ -1139,13 +1151,22 @@ jobs:
|
||||
shell: sh
|
||||
run: |
|
||||
set -eu
|
||||
docker run --rm \
|
||||
# DooD模式下不能用-v挂载(宿主机路径与CI容器路径不一致)
|
||||
# 改用 docker create + docker cp 方式把代码拷进容器
|
||||
CONTAINER_NAME="staging-api-tests-$$"
|
||||
docker rm -f "$CONTAINER_NAME" 2>/dev/null || true
|
||||
docker create --name "$CONTAINER_NAME" \
|
||||
-e E2E_BASE_URL=https://staging.xiaoxiajianji.com \
|
||||
-e E2E_API_BASE=https://staging-api.xiaoxiajianji.com/api/v1 \
|
||||
-v "$PWD:/workspace" \
|
||||
-w /workspace/apps/web \
|
||||
git.xiaoxiajianji.com/xiaoxia/base/playwright:v1.45.0-jammy \
|
||||
sh -lc 'npm ci && npx playwright test --reporter=line e2e/test_auth.spec.ts e2e/test_asset.spec.ts e2e/test_project.spec.ts'
|
||||
docker cp apps "$CONTAINER_NAME:/workspace/"
|
||||
docker cp package-lock.json "$CONTAINER_NAME:/workspace/" 2>/dev/null || true
|
||||
docker start -a "$CONTAINER_NAME"
|
||||
EXIT_CODE=$(docker wait "$CONTAINER_NAME")
|
||||
docker rm "$CONTAINER_NAME" 2>/dev/null || true
|
||||
exit $EXIT_CODE
|
||||
|
||||
- name: Job duration summary
|
||||
if: always()
|
||||
|
||||
Regular → Executable
+7
-4
@@ -1,4 +1,3 @@
|
||||
import os
|
||||
from logging.config import fileConfig
|
||||
|
||||
from sqlalchemy import engine_from_config, pool
|
||||
@@ -8,13 +7,17 @@ from alembic import context
|
||||
# Import your models' Base here
|
||||
from packages.adapters.sqlalchemy_impl.models import Base
|
||||
|
||||
# 使用统一配置入口获取 database_url,而非直接读环境变量
|
||||
from packages.config import get_shared_settings
|
||||
|
||||
# this is the Alembic Config object, which provides
|
||||
# access to the values within the .ini file in use.
|
||||
config = context.config
|
||||
|
||||
database_url = os.getenv("DATABASE_URL")
|
||||
if database_url:
|
||||
config.set_main_option("sqlalchemy.url", database_url)
|
||||
# 从统一配置系统获取 database_url,确保与应用使用同一配置源
|
||||
settings = get_shared_settings()
|
||||
if settings.database_url:
|
||||
config.set_main_option("sqlalchemy.url", settings.database_url)
|
||||
|
||||
# Interpret the config file for Python logging.
|
||||
# This line sets up loggers basically.
|
||||
|
||||
Binary file not shown.
@@ -1,3 +1,4 @@
|
||||
from app.api.routes.ai import router as ai_router
|
||||
from app.api.routes.asset_diagnosis import router as asset_diagnosis_router
|
||||
from app.api.routes.asset_libraries import router as asset_libraries_router
|
||||
from app.api.routes.assets import router as assets_router
|
||||
@@ -9,7 +10,6 @@ from app.api.routes.feature_flags import router as feature_flags_router
|
||||
from app.api.routes.generation_tasks import router as generation_tasks_router
|
||||
from app.api.routes.health import router as health_check_router
|
||||
from app.api.routes.ingest_jobs import router as ingest_jobs_router
|
||||
from app.api.routes.ai import router as ai_router
|
||||
from app.api.routes.internal_render import router as internal_render_router
|
||||
from app.api.routes.projects import router as projects_router
|
||||
from app.api.routes.share import router as share_router
|
||||
|
||||
@@ -5,9 +5,9 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import List, Literal, Optional
|
||||
from typing import List, Literal
|
||||
|
||||
from app.services.ai_service import TITLE_STYLES, generate_smart_titles
|
||||
from app.services.ai_service import TITLE_STYLES, generate_smart_titles, semantic_match_assets
|
||||
from fastapi import APIRouter
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
@@ -45,6 +45,42 @@ class TitleStyleInfo(BaseModel):
|
||||
description: str
|
||||
|
||||
|
||||
# ── 素材语义匹配 ────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class AssetMatchItem(BaseModel):
|
||||
"""待匹配素材项."""
|
||||
|
||||
id: str = Field(..., description="素材ID")
|
||||
name: str = Field(default="", description="素材名称")
|
||||
tags: List[str] = Field(default_factory=list, description="标签列表")
|
||||
description: str = Field(default="", description="素材描述")
|
||||
|
||||
|
||||
class SemanticMatchRequest(BaseModel):
|
||||
"""语义匹配请求."""
|
||||
|
||||
description: str = Field(..., min_length=1, max_length=500, description="目标视频内容描述")
|
||||
assets: List[AssetMatchItem] = Field(..., min_length=1, max_length=100, description="待匹配素材列表")
|
||||
top_k: int = Field(default=0, ge=0, le=100, description="返回前K个,0返回全部")
|
||||
|
||||
|
||||
class SemanticMatchResultItem(AssetMatchItem):
|
||||
"""匹配结果项."""
|
||||
|
||||
match_score: float = Field(..., description="匹配度评分 0-1")
|
||||
match_reason: str = Field(..., description="匹配方式:doubao_semantic / fallback_keyword / fallback_default")
|
||||
|
||||
|
||||
class SemanticMatchResponse(BaseModel):
|
||||
"""语义匹配响应."""
|
||||
|
||||
matches: List[SemanticMatchResultItem] = Field(..., description="按匹配度降序排列的素材列表")
|
||||
source: str = Field(..., description="来源:doubao / fallback")
|
||||
description: str = Field(..., description="原始描述")
|
||||
total: int = Field(..., description="输入素材总数")
|
||||
|
||||
|
||||
# ── 路由 ────────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@@ -70,3 +106,31 @@ def list_title_styles():
|
||||
TitleStyleInfo(key=key, name=info["name"], description=info["description"])
|
||||
for key, info in TITLE_STYLES.items()
|
||||
]
|
||||
|
||||
|
||||
@router.post("/assets/match", response_model=SemanticMatchResponse)
|
||||
def match_assets(request: SemanticMatchRequest):
|
||||
"""智能素材语义匹配.
|
||||
|
||||
根据用户描述,对素材列表做语义匹配并按匹配度排序。
|
||||
未配置豆包 API Key 时自动降级为关键词匹配。
|
||||
|
||||
- 支持最多 100 个素材同时匹配
|
||||
- 返回 match_score (0-1),按降序排列
|
||||
- top_k 可限制返回数量
|
||||
"""
|
||||
# 转为 dict 传给服务层
|
||||
assets_dict = [asset.model_dump() for asset in request.assets]
|
||||
|
||||
result = semantic_match_assets(
|
||||
description=request.description,
|
||||
assets=assets_dict,
|
||||
top_k=request.top_k,
|
||||
)
|
||||
|
||||
return SemanticMatchResponse(
|
||||
matches=[SemanticMatchResultItem(**m) for m in result["matches"]],
|
||||
source=result["source"],
|
||||
description=result["description"],
|
||||
total=result["total"],
|
||||
)
|
||||
|
||||
@@ -30,7 +30,6 @@ from app.schemas.generation_task import (
|
||||
GenerationTaskResponse,
|
||||
ListGenerationTasksResponse,
|
||||
)
|
||||
from app.services.smart_asset_selector import SmartAssetSelector
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
|
||||
from packages.application import (
|
||||
@@ -61,6 +60,7 @@ def _to_generation_task_response(task) -> GenerationTaskResponse:
|
||||
batch_id=getattr(task, "batch_id", ""),
|
||||
video_title=getattr(task, "video_title", ""),
|
||||
resolution=getattr(task, "resolution", ""),
|
||||
bgm_config=getattr(task, "bgm_config", {}) or {},
|
||||
logs=getattr(task, "logs", "[]"),
|
||||
status=task.status,
|
||||
progress=task.progress,
|
||||
@@ -124,10 +124,19 @@ def _select_assets_from_library(
|
||||
return [a.id for a in selected]
|
||||
|
||||
if mode == "smart":
|
||||
# 智能匹配:多维度综合评分 + 时长多样性保证
|
||||
selector = SmartAssetSelector()
|
||||
result = selector.select(ready_video_assets, count=count, ensure_diversity=True)
|
||||
return result.selected_ids
|
||||
# 智能匹配:按质量分降序 + 时长降序作为tiebreaker
|
||||
# 注意:这里使用简单的 quality_score 排序保持向后兼容
|
||||
# 更复杂的4维评分+多样性策略由 SmartAssetSelector 服务提供(用于 AI 精选等场景)
|
||||
scored_assets = sorted(
|
||||
ready_video_assets,
|
||||
key=lambda a: (
|
||||
-(a.quality_score if a.quality_score is not None else 0.0),
|
||||
-(getattr(a, "duration", 0.0) or 0.0),
|
||||
),
|
||||
)
|
||||
if count > 0:
|
||||
scored_assets = scored_assets[:count]
|
||||
return [a.id for a in scored_assets]
|
||||
|
||||
# 默认 all 模式:返回全部 ready 视频素材
|
||||
return [a.id for a in ready_video_assets]
|
||||
@@ -280,6 +289,7 @@ def create_generation_task(
|
||||
batch_id=batch_id,
|
||||
video_title=request.video_title,
|
||||
resolution=request.resolution,
|
||||
bgm_config=request.bgm_config,
|
||||
auto_retry_enabled=request.auto_retry_enabled,
|
||||
auto_retry_max=request.auto_retry_max,
|
||||
)
|
||||
|
||||
@@ -1907,7 +1907,7 @@ def editor_ai_recommend(
|
||||
detail="当前草稿状态不支持AI推荐,请先编辑后再试",
|
||||
)
|
||||
|
||||
from apps.worker.worker_app.tasks.ai_tasks import run_ai_recommend
|
||||
from packages.shared.ai_service import run_ai_recommend
|
||||
|
||||
result = run_ai_recommend(
|
||||
plan_id=plan_id,
|
||||
@@ -1992,7 +1992,7 @@ def editor_generate_cover(
|
||||
_, plan_svc = services
|
||||
plan = plan_svc.get_plan_or_raise(plan_id)
|
||||
|
||||
from apps.worker.worker_app.tasks.ai_tasks import run_generate_cover
|
||||
from packages.shared.ai_service import run_generate_cover
|
||||
|
||||
cover_data = run_generate_cover(
|
||||
plan_id=plan_id,
|
||||
|
||||
@@ -6,6 +6,7 @@ import logging
|
||||
from typing import Optional
|
||||
|
||||
from app.auth import AuthenticatedUser, get_current_user
|
||||
from app.core.celery_app import celery_app
|
||||
from app.dependencies import (
|
||||
get_audio_url_signer,
|
||||
get_cosyvoice_service,
|
||||
@@ -181,13 +182,9 @@ def synthesize(
|
||||
|
||||
try:
|
||||
if is_segment:
|
||||
from worker_app.tasks import process_tts_segment_synthesis
|
||||
|
||||
process_tts_segment_synthesis.delay(job.id)
|
||||
celery_app.send_task("worker.process_tts_segment_synthesis", args=[job.id])
|
||||
else:
|
||||
from worker_app.tasks import process_tts_synthesis
|
||||
|
||||
process_tts_synthesis.delay(job.id)
|
||||
celery_app.send_task("worker.process_tts_synthesis", args=[job.id])
|
||||
except Exception as e:
|
||||
# Celery 调度失败,标记 job 为 failed
|
||||
try:
|
||||
|
||||
Regular → Executable
+3
-6
@@ -6,6 +6,7 @@ import logging
|
||||
from typing import Optional
|
||||
|
||||
from app.auth import AuthenticatedUser, get_current_user
|
||||
from app.core.celery_app import celery_app
|
||||
from app.dependencies import get_cosyvoice_service, get_voice_clone_profile_repository
|
||||
from app.schemas.voice_clone import (
|
||||
CreateVoiceCloneRequest,
|
||||
@@ -97,9 +98,7 @@ def create_voice_clone(
|
||||
task_id = (profile.metadata or {}).get("cosyvoice_task_id", "")
|
||||
if profile.status == "processing" and task_id:
|
||||
try:
|
||||
from worker_app.tasks import process_voice_clone
|
||||
|
||||
process_voice_clone.delay(profile.id)
|
||||
celery_app.send_task("worker.process_voice_clone", args=[profile.id])
|
||||
logger.info(f"Celery task dispatched for voice clone {profile.id}")
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to dispatch Celery task: {e}")
|
||||
@@ -213,9 +212,7 @@ def retry_voice_clone(
|
||||
task_id = (profile.metadata or {}).get("cosyvoice_task_id", "")
|
||||
if profile.status == "processing" and task_id:
|
||||
try:
|
||||
from worker_app.tasks import process_voice_clone
|
||||
|
||||
process_voice_clone.delay(profile.id)
|
||||
celery_app.send_task("worker.process_voice_clone", args=[profile.id])
|
||||
logger.info(f"Celery task dispatched for voice clone retry {profile.id}")
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to dispatch Celery task: {e}")
|
||||
|
||||
+23
-174
@@ -1,179 +1,28 @@
|
||||
import os
|
||||
from typing import Optional
|
||||
"""API 服务配置(向后兼容层)。
|
||||
|
||||
from pydantic import AliasChoices, Field, field_validator
|
||||
from pydantic_settings import BaseSettings, SettingsConfigDict
|
||||
统一配置已迁移到 packages.config.api_settings。
|
||||
新代码请使用:
|
||||
from packages.config import APISettings, get_api_settings
|
||||
|
||||
本文件保留 Settings 类名、get_settings() 函数、settings 模块级单例,
|
||||
确保所有旧的 import 路径仍然有效。
|
||||
"""
|
||||
|
||||
class Settings(BaseSettings):
|
||||
APP_NAME: str = "xiaoxia-saas"
|
||||
APP_VERSION: str = "0.1.61"
|
||||
ENVIRONMENT: str = "development"
|
||||
DEBUG: bool = True
|
||||
|
||||
# 应用基础 URL,用于生成认证邮件中的链接
|
||||
# 开发环境默认 http://localhost:3000
|
||||
# 生产环境应通过环境变量 APP_BASE_URL 设置
|
||||
APP_BASE_URL: str = "http://localhost:3000"
|
||||
|
||||
# Container bind address; external expose is controlled by Docker/Nginx.
|
||||
API_HOST: str = "0.0.0.0" # nosec: B104
|
||||
API_PORT: int = 8000
|
||||
|
||||
DATABASE_URL: str = "postgresql+psycopg://postgres:postgres@localhost:5432/xiaoxia_saas"
|
||||
DATABASE_POOL_SIZE: int = 20
|
||||
DATABASE_MAX_OVERFLOW: int = 10 # 调整为合理值:pool_size(20) + max_overflow(10) = 最大30连接
|
||||
DATABASE_POOL_TIMEOUT: int = 30
|
||||
DATABASE_POOL_RECYCLE: int = 3600
|
||||
USE_IN_MEMORY_DB: bool = False
|
||||
AUTO_CREATE_SCHEMA: bool = False
|
||||
|
||||
REDIS_URL: str = "redis://localhost:6379/0"
|
||||
ENABLE_REDIS_SESSIONS: bool = False
|
||||
|
||||
# JWT secret key - MUST be set via environment variable, no default allowed
|
||||
JWT_SECRET_KEY: Optional[str] = None
|
||||
|
||||
# JWT 算法与过期时间(与 .env.example 对齐)
|
||||
JWT_ALGORITHM: str = "HS256"
|
||||
JWT_ACCESS_TOKEN_EXPIRE_MINUTES: int = 30
|
||||
JWT_REFRESH_TOKEN_EXPIRE_DAYS: int = 30
|
||||
|
||||
@field_validator("JWT_SECRET_KEY", mode="before")
|
||||
@classmethod
|
||||
def validate_jwt_secret_key(cls, v):
|
||||
if v is None or v == "":
|
||||
raise ValueError(
|
||||
"JWT_SECRET_KEY must be set via environment variable. " "Do not use default value in production!"
|
||||
)
|
||||
# Block known insecure default values
|
||||
insecure_defaults = [
|
||||
"your-secret-key-change-in-production",
|
||||
"your-secret-key",
|
||||
"secret",
|
||||
"changeme",
|
||||
"password",
|
||||
]
|
||||
if v.lower() in [d.lower() for d in insecure_defaults]:
|
||||
raise ValueError(
|
||||
f"JWT_SECRET_KEY '{v}' is insecure. " "Please set a strong random secret via environment variable."
|
||||
)
|
||||
return v
|
||||
|
||||
ENABLE_EMAIL_DELIVERY: bool = False
|
||||
SMTP_HOST: str = "smtp.gmail.com"
|
||||
SMTP_PORT: int = 587
|
||||
SMTP_USER: str = ""
|
||||
SMTP_PASSWORD: str = ""
|
||||
SMTP_FROM_EMAIL: str = ""
|
||||
SMTP_FROM_NAME: str = "小虾 SaaS"
|
||||
SMTP_USE_TLS: bool = True
|
||||
|
||||
CELERY_BROKER_URL: str = "redis://localhost:6379/0"
|
||||
CELERY_RESULT_BACKEND: str = "redis://localhost:6379/1"
|
||||
|
||||
# OSS 七牛云相关
|
||||
OSS_ENDPOINT: str = "oss-cn-hangzhou.aliyuncs.com"
|
||||
OSS_ACCESS_KEY_ID: str = ""
|
||||
OSS_ACCESS_KEY_SECRET: str = ""
|
||||
OSS_BUCKET_NAME: str = "xiaoxia-autocut"
|
||||
|
||||
@field_validator("OSS_ACCESS_KEY_ID", mode="before")
|
||||
@classmethod
|
||||
def validate_oss_access_key_id(cls, v):
|
||||
if (v is None or v == "") and os.getenv("APP_ENV", "development") != "development":
|
||||
raise ValueError(
|
||||
"OSS_ACCESS_KEY_ID must be set via environment variable in non-development environments. "
|
||||
"Check the server .env file (e.g. /var/lib/xiaoxia-saas-staging/.env)."
|
||||
)
|
||||
return v or ""
|
||||
|
||||
@field_validator("OSS_ACCESS_KEY_SECRET", mode="before")
|
||||
@classmethod
|
||||
def validate_oss_access_key_secret(cls, v):
|
||||
if (v is None or v == "") and os.getenv("APP_ENV", "development") != "development":
|
||||
raise ValueError(
|
||||
"OSS_ACCESS_KEY_SECRET must be set via environment variable in non-development environments. "
|
||||
"Check the server .env file (e.g. /var/lib/xiaoxia-saas-staging/.env)."
|
||||
)
|
||||
return v or ""
|
||||
|
||||
OSS_DIRECT_UPLOAD_MAX_MB: int = Field(
|
||||
default=2000,
|
||||
validation_alias=AliasChoices("OSS_DIRECT_UPLOAD_MAX_MB", "MAX_UPLOAD_SIZE_MB"),
|
||||
)
|
||||
OSS_DIRECT_UPLOAD_EXPIRE_SECONDS: int = 900
|
||||
|
||||
CORS_ORIGINS_RAW: str = "http://localhost:3000,http://localhost:5173,http://localhost:8000"
|
||||
|
||||
# 渲染引擎选择:legacy=旧VideoComposeService,unified=新UnifiedRenderService
|
||||
RENDER_ENGINE: str = "legacy"
|
||||
|
||||
# 豆包大模型配置(火山引擎方舟平台)
|
||||
# 未配置 API Key 时自动降级为本地模拟生成
|
||||
DOUBAO_API_KEY: str = ""
|
||||
DOUBAO_MODEL: str = "doubao-seed-1-6-250615"
|
||||
DOUBAO_BASE_URL: str = "https://ark.cn-beijing.volces.com/api/v3"
|
||||
DOUBAO_TIMEOUT: int = 30
|
||||
DOUBAO_MAX_RETRIES: int = 2
|
||||
|
||||
model_config = SettingsConfigDict(
|
||||
env_file=".env",
|
||||
env_file_encoding="utf-8",
|
||||
case_sensitive=False,
|
||||
extra="ignore",
|
||||
)
|
||||
|
||||
@property
|
||||
def CORS_ORIGINS(self) -> list[str]:
|
||||
return [origin.strip() for origin in self.CORS_ORIGINS_RAW.split(",") if origin.strip()]
|
||||
|
||||
@property
|
||||
def database_url(self) -> str:
|
||||
return self.DATABASE_URL
|
||||
|
||||
@property
|
||||
def redis_url(self) -> str:
|
||||
return self.REDIS_URL
|
||||
|
||||
@property
|
||||
def celery_broker_url(self) -> str:
|
||||
return self.CELERY_BROKER_URL
|
||||
|
||||
@property
|
||||
def celery_result_backend(self) -> str:
|
||||
return self.CELERY_RESULT_BACKEND
|
||||
|
||||
@property
|
||||
def oss_endpoint(self) -> str:
|
||||
return self.OSS_ENDPOINT
|
||||
|
||||
@property
|
||||
def oss_access_key_id(self) -> str:
|
||||
return self.OSS_ACCESS_KEY_ID
|
||||
|
||||
@property
|
||||
def oss_access_key_secret(self) -> str:
|
||||
return self.OSS_ACCESS_KEY_SECRET
|
||||
|
||||
@property
|
||||
def oss_bucket_name(self) -> str:
|
||||
return self.OSS_BUCKET_NAME
|
||||
|
||||
|
||||
_settings: Optional[Settings] = None
|
||||
|
||||
|
||||
def get_settings() -> Settings:
|
||||
global _settings
|
||||
if _settings is None:
|
||||
env = os.getenv("APP_ENV", "development")
|
||||
env_file = f".env.{env}" if env != "development" else ".env"
|
||||
if os.path.exists(env_file):
|
||||
_settings = Settings(_env_file=env_file)
|
||||
else:
|
||||
_settings = Settings()
|
||||
return _settings
|
||||
|
||||
from packages.config import APISettings as Settings
|
||||
from packages.config import get_api_settings as get_settings
|
||||
from packages.config import reload_settings_cache
|
||||
|
||||
# 模块级单例(向后兼容)
|
||||
settings = get_settings()
|
||||
|
||||
|
||||
# 暴露旧的 reload_settings 函数名
|
||||
def reload_settings():
|
||||
"""重新加载配置(测试用)。"""
|
||||
reload_settings_cache()
|
||||
global settings
|
||||
settings = get_settings()
|
||||
return settings
|
||||
|
||||
|
||||
__all__ = ["Settings", "get_settings", "settings", "reload_settings"]
|
||||
|
||||
Regular → Executable
+10
-9
@@ -1,19 +1,20 @@
|
||||
"""Compatibility layer for the canonical API settings module.
|
||||
"""向后兼容层 — 配置已统一到 packages.config。
|
||||
|
||||
Use `app.config` as the single source of truth for API configuration.
|
||||
This module remains only for older imports during migration.
|
||||
新代码请使用:
|
||||
from packages.config import get_api_settings, APISettings
|
||||
"""
|
||||
|
||||
from app.config import Settings as AppSettings
|
||||
from app.config import get_settings, settings
|
||||
from packages.config import APISettings as AppSettings
|
||||
from packages.config import get_api_settings as get_settings
|
||||
from packages.config import reload_settings_cache
|
||||
|
||||
|
||||
def reload_settings() -> AppSettings:
|
||||
"""Reload settings for tests and legacy callers."""
|
||||
import app.config as canonical_config
|
||||
"""重新加载配置(测试用)。"""
|
||||
reload_settings_cache()
|
||||
return get_settings()
|
||||
|
||||
canonical_config.settings = canonical_config.get_settings()
|
||||
return canonical_config.settings
|
||||
|
||||
settings = get_settings()
|
||||
|
||||
__all__ = ["AppSettings", "get_settings", "reload_settings", "settings"]
|
||||
|
||||
@@ -51,6 +51,11 @@ class CreateGenerationTaskRequest(BaseModel):
|
||||
default="",
|
||||
description="输出分辨率,格式为 WIDTHxHEIGHT,如 1280x720、1080x1920。为空使用默认 1280x720",
|
||||
)
|
||||
# ── 自定义 BGM ──
|
||||
bgm_config: dict = Field(
|
||||
default_factory=dict,
|
||||
description="自定义BGM配置,覆盖模板BGM设置。支持 enabled/source/asset_id/preset_id/audio_url/volume 等字段",
|
||||
)
|
||||
|
||||
@model_validator(mode="after")
|
||||
def _check_at_least_one_mode(self) -> "CreateGenerationTaskRequest":
|
||||
@@ -80,6 +85,7 @@ class GenerationTaskResponse(BaseModel):
|
||||
batch_id: str = ""
|
||||
video_title: str = ""
|
||||
resolution: str = ""
|
||||
bgm_config: dict = Field(default_factory=dict)
|
||||
status: str
|
||||
progress: float
|
||||
result_count: int
|
||||
|
||||
@@ -14,12 +14,11 @@ from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import math
|
||||
import random
|
||||
import time
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
import httpx
|
||||
from app.config import get_settings
|
||||
from packages.shared.ai_client import get_doubao_client
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -57,85 +56,6 @@ TITLE_STYLES = {
|
||||
}
|
||||
|
||||
|
||||
# ── 豆包 AI 客户端 ──────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class DoubaoAIClient:
|
||||
"""豆包大模型 API 客户端.
|
||||
|
||||
使用火山引擎方舟平台的 OpenAI 兼容接口。
|
||||
未配置 API Key 时,is_available 返回 False,调用方应降级处理。
|
||||
"""
|
||||
|
||||
def __init__(self) -> None:
|
||||
settings = get_settings()
|
||||
self.api_key: str = settings.DOUBAO_API_KEY
|
||||
self.model: str = settings.DOUBAO_MODEL
|
||||
self.base_url: str = settings.DOUBAO_BASE_URL.rstrip("/")
|
||||
self.timeout: int = settings.DOUBAO_TIMEOUT
|
||||
self.max_retries: int = settings.DOUBAO_MAX_RETRIES
|
||||
|
||||
@property
|
||||
def is_available(self) -> bool:
|
||||
"""是否可用(配置了 API Key)."""
|
||||
return bool(self.api_key)
|
||||
|
||||
def _chat_completion(
|
||||
self,
|
||||
messages: List[Dict[str, str]],
|
||||
temperature: float = 0.7,
|
||||
max_tokens: int = 1024,
|
||||
) -> Optional[str]:
|
||||
"""调用豆包 Chat Completion 接口.
|
||||
|
||||
Returns:
|
||||
模型返回的文本内容,失败返回 None
|
||||
"""
|
||||
if not self.is_available:
|
||||
return None
|
||||
|
||||
url = f"{self.base_url}/chat/completions"
|
||||
headers = {
|
||||
"Authorization": f"Bearer {self.api_key}",
|
||||
"Content-Type": "application/json",
|
||||
}
|
||||
payload = {
|
||||
"model": self.model,
|
||||
"messages": messages,
|
||||
"temperature": temperature,
|
||||
"max_tokens": max_tokens,
|
||||
}
|
||||
|
||||
last_error: Optional[Exception] = None
|
||||
for attempt in range(self.max_retries + 1):
|
||||
try:
|
||||
response = httpx.post(
|
||||
url,
|
||||
headers=headers,
|
||||
json=payload,
|
||||
timeout=self.timeout,
|
||||
)
|
||||
response.raise_for_status()
|
||||
data = response.json()
|
||||
content = data["choices"][0]["message"]["content"]
|
||||
return content.strip()
|
||||
except Exception as e:
|
||||
last_error = e
|
||||
if attempt < self.max_retries:
|
||||
wait = 0.5 * (2**attempt)
|
||||
logger.warning(
|
||||
"豆包API调用失败,%s秒后重试 (第%d/%d次): %s",
|
||||
wait,
|
||||
attempt + 1,
|
||||
self.max_retries + 1,
|
||||
e,
|
||||
)
|
||||
time.sleep(wait)
|
||||
|
||||
logger.error("豆包API调用最终失败: %s", last_error)
|
||||
return None
|
||||
|
||||
|
||||
# ── 智能标题生成 ─────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@@ -251,7 +171,7 @@ def generate_smart_titles(
|
||||
count = max(3, min(10, count)) # 3-10 个
|
||||
description = (description or "").strip()
|
||||
|
||||
client = DoubaoAIClient()
|
||||
client = get_doubao_client()
|
||||
if not client.is_available:
|
||||
logger.info("豆包API未配置,使用本地降级生成标题")
|
||||
titles = _generate_titles_fallback(description, style, count)
|
||||
@@ -280,7 +200,7 @@ def generate_smart_titles(
|
||||
{"role": "user", "content": user_prompt},
|
||||
]
|
||||
|
||||
result = client._chat_completion(
|
||||
result = client.chat_completion(
|
||||
messages=messages,
|
||||
temperature=0.8,
|
||||
max_tokens=512,
|
||||
@@ -314,6 +234,267 @@ def generate_smart_titles(
|
||||
}
|
||||
|
||||
|
||||
# ── 智能素材语义匹配 ───────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def _semantic_match_fallback(
|
||||
description: str,
|
||||
assets: List[Dict[str, Any]],
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""本地降级:基于关键词的简单匹配.
|
||||
|
||||
计算描述中的关键词与素材名称/标签/描述的重叠度,
|
||||
作为匹配度评分。0-1分。
|
||||
"""
|
||||
import re
|
||||
|
||||
# 提取关键词(中文按2字以上片段,英文按单词)
|
||||
desc = description.lower()
|
||||
# 简单分词:提取2字以上的中文字符串和英文单词
|
||||
keywords = set()
|
||||
# 英文单词
|
||||
for word in re.findall(r"[a-zA-Z]{3,}", desc):
|
||||
keywords.add(word)
|
||||
# 中文2-4字片段
|
||||
for i in range(len(desc)):
|
||||
for j in range(i + 2, min(i + 5, len(desc) + 1)):
|
||||
fragment = desc[i:j]
|
||||
if all("\u4e00" <= c <= "\u9fff" for c in fragment):
|
||||
keywords.add(fragment)
|
||||
|
||||
if not keywords:
|
||||
# 没有关键词时给所有素材中等分数
|
||||
for asset in assets:
|
||||
asset["match_score"] = 0.5
|
||||
asset["match_reason"] = "fallback_default"
|
||||
return assets
|
||||
|
||||
results = []
|
||||
for asset in assets:
|
||||
# 组合素材的文本信息:名称 + 标签 + 描述
|
||||
asset_text_parts = [
|
||||
str(asset.get("name", "")).lower(),
|
||||
" ".join(str(t) for t in asset.get("tags", [])).lower(),
|
||||
str(asset.get("description", "")).lower(),
|
||||
]
|
||||
asset_text = " | ".join(asset_text_parts)
|
||||
|
||||
# 计算匹配度:命中关键词占比 + 稀有关键词加权
|
||||
hit_count = 0
|
||||
hit_keywords = []
|
||||
for kw in keywords:
|
||||
if kw in asset_text:
|
||||
hit_count += 1
|
||||
hit_keywords.append(kw)
|
||||
|
||||
# 基础匹配度 = 命中关键词数 / 总关键词数(开根号平滑)
|
||||
base_score = math.sqrt(hit_count / len(keywords)) if keywords else 0.5
|
||||
|
||||
# 名称命中加分(名称匹配更重要)
|
||||
name = str(asset.get("name", "")).lower()
|
||||
name_hits = sum(1 for kw in hit_keywords if kw in name)
|
||||
name_bonus = min(0.2, name_hits * 0.05)
|
||||
|
||||
score = min(1.0, base_score * 0.8 + name_bonus)
|
||||
score = round(score, 3)
|
||||
|
||||
results.append(
|
||||
{
|
||||
**asset,
|
||||
"match_score": score,
|
||||
"match_reason": "fallback_keyword",
|
||||
}
|
||||
)
|
||||
|
||||
# 按匹配度降序
|
||||
results.sort(key=lambda x: x["match_score"], reverse=True)
|
||||
return results
|
||||
|
||||
|
||||
def _parse_semantic_match_response(
|
||||
content: str,
|
||||
asset_ids: List[str],
|
||||
) -> Optional[Dict[str, float]]:
|
||||
"""从模型返回中解析素材匹配度.
|
||||
|
||||
期望格式:JSON 对象 {asset_id: score} 或 {"matches": [{asset_id, score}]}
|
||||
score 范围 0-1。
|
||||
"""
|
||||
if not content:
|
||||
return None
|
||||
|
||||
# 尝试解析 JSON
|
||||
try:
|
||||
cleaned = content.strip()
|
||||
if cleaned.startswith("```"):
|
||||
cleaned = cleaned.strip("`")
|
||||
if cleaned.lower().startswith("json"):
|
||||
cleaned = cleaned[4:]
|
||||
cleaned = cleaned.strip()
|
||||
|
||||
data = json.loads(cleaned)
|
||||
|
||||
result: Dict[str, float] = {}
|
||||
|
||||
# 格式1: {"asset_id1": 0.8, "asset_id2": 0.6}
|
||||
if isinstance(data, dict):
|
||||
if "matches" in data and isinstance(data["matches"], list):
|
||||
# 格式2: {"matches": [{"asset_id": "...", "score": 0.8}]}
|
||||
for item in data["matches"]:
|
||||
if isinstance(item, dict):
|
||||
aid = item.get("asset_id") or item.get("id")
|
||||
score = item.get("score", 0)
|
||||
if aid and isinstance(score, (int, float)):
|
||||
result[str(aid)] = max(0.0, min(1.0, float(score)))
|
||||
else:
|
||||
for key, value in data.items():
|
||||
if isinstance(value, (int, float)):
|
||||
result[str(key)] = max(0.0, min(1.0, float(value)))
|
||||
|
||||
# 格式3: [{"asset_id": "...", "score": 0.8}]
|
||||
elif isinstance(data, list):
|
||||
for item in data:
|
||||
if isinstance(item, dict):
|
||||
aid = item.get("asset_id") or item.get("id")
|
||||
score = item.get("score", 0)
|
||||
if aid and isinstance(score, (int, float)):
|
||||
result[str(aid)] = max(0.0, min(1.0, float(score)))
|
||||
|
||||
if len(result) >= max(1, len(asset_ids) // 2): # 至少一半素材有评分才算成功
|
||||
return result
|
||||
|
||||
except (json.JSONDecodeError, ValueError):
|
||||
pass
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def semantic_match_assets(
|
||||
description: str,
|
||||
assets: List[Dict[str, Any]],
|
||||
top_k: int = 0,
|
||||
) -> Dict[str, Any]:
|
||||
"""智能素材语义匹配.
|
||||
|
||||
根据用户描述,评估每个素材的语义匹配度并排序。
|
||||
|
||||
Args:
|
||||
description: 用户描述的目标视频内容
|
||||
assets: 素材列表,每个素材需含 id/name/tags/description 等字段
|
||||
top_k: 返回前K个,0表示返回全部
|
||||
|
||||
Returns:
|
||||
{
|
||||
"matches": [{"asset_id": ..., "match_score": ..., ...}],
|
||||
"source": "doubao" | "fallback",
|
||||
"description": "...",
|
||||
"total": 总数,
|
||||
}
|
||||
"""
|
||||
description = (description or "").strip()
|
||||
if not assets:
|
||||
return {"matches": [], "source": "fallback", "description": description, "total": 0}
|
||||
|
||||
client = get_doubao_client()
|
||||
if not client.is_available:
|
||||
logger.info("豆包API未配置,使用本地降级做素材语义匹配")
|
||||
matched = _semantic_match_fallback(description, assets)
|
||||
if top_k > 0:
|
||||
matched = matched[:top_k]
|
||||
return {
|
||||
"matches": matched,
|
||||
"source": "fallback",
|
||||
"description": description,
|
||||
"total": len(assets),
|
||||
}
|
||||
|
||||
# 构建素材信息(控制 token 数量)
|
||||
asset_summaries = []
|
||||
for asset in assets[:50]: # 最多传50个素材给模型
|
||||
aid = asset.get("id", "")
|
||||
name = asset.get("name", "")[:50]
|
||||
tags = asset.get("tags", [])
|
||||
tags_str = ",".join(str(t) for t in tags[:5])
|
||||
desc = str(asset.get("description", ""))[:80]
|
||||
asset_summaries.append(f"ID:{aid} | 名称:{name} | 标签:[{tags_str}] | 描述:{desc}")
|
||||
|
||||
asset_ids = [str(a.get("id", "")) for a in assets[:50]]
|
||||
|
||||
system_prompt = (
|
||||
"你是一个专业的视频素材匹配助手。"
|
||||
"根据用户的视频目标描述,评估每个素材的匹配程度。\n"
|
||||
"评分规则:\n"
|
||||
"- 0.0-0.3: 完全不相关\n"
|
||||
"- 0.3-0.6: 有一定关联但不够匹配\n"
|
||||
"- 0.6-0.8: 比较匹配,适合使用\n"
|
||||
"- 0.8-1.0: 高度匹配,非常适合\n"
|
||||
"只返回JSON对象,key为素材ID,value为匹配分数(0-1之间的小数)。"
|
||||
"不要其他文字说明。"
|
||||
)
|
||||
|
||||
user_prompt = (
|
||||
f"目标视频描述:{description}\n\n"
|
||||
f"素材列表:\n" + "\n".join(asset_summaries) + "\n\n请返回每个素材的匹配分数JSON:"
|
||||
)
|
||||
|
||||
messages = [
|
||||
{"role": "system", "content": system_prompt},
|
||||
{"role": "user", "content": user_prompt},
|
||||
]
|
||||
|
||||
result = client.chat_completion(
|
||||
messages=messages,
|
||||
temperature=0.3,
|
||||
max_tokens=1024,
|
||||
)
|
||||
|
||||
if result:
|
||||
scores = _parse_semantic_match_response(result, asset_ids)
|
||||
if scores:
|
||||
# 把评分填回素材
|
||||
matched = []
|
||||
for asset in assets:
|
||||
aid = str(asset.get("id", ""))
|
||||
score = scores.get(aid, 0.3) # 没评分的给默认偏低分
|
||||
matched.append(
|
||||
{
|
||||
**asset,
|
||||
"match_score": round(score, 3),
|
||||
"match_reason": "doubao_semantic",
|
||||
}
|
||||
)
|
||||
matched.sort(key=lambda x: x["match_score"], reverse=True)
|
||||
|
||||
logger.info(
|
||||
"豆包语义匹配完成: assets=%d top_score=%.2f description=%s...",
|
||||
len(matched),
|
||||
matched[0]["match_score"] if matched else 0,
|
||||
description[:20],
|
||||
)
|
||||
|
||||
if top_k > 0:
|
||||
matched = matched[:top_k]
|
||||
|
||||
return {
|
||||
"matches": matched,
|
||||
"source": "doubao",
|
||||
"description": description,
|
||||
"total": len(assets),
|
||||
}
|
||||
logger.warning("豆包语义匹配返回解析失败,降级到本地: %s", result[:100])
|
||||
|
||||
# 降级
|
||||
matched = _semantic_match_fallback(description, assets)
|
||||
if top_k > 0:
|
||||
matched = matched[:top_k]
|
||||
return {
|
||||
"matches": matched,
|
||||
"source": "fallback",
|
||||
"description": description,
|
||||
"total": len(assets),
|
||||
}
|
||||
|
||||
|
||||
# ── 单例入口 ─────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@@ -332,7 +513,7 @@ class AIService:
|
||||
"""AI 服务统一入口,便于后续扩展更多能力."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self._client = DoubaoAIClient()
|
||||
self._client = get_doubao_client()
|
||||
|
||||
@property
|
||||
def is_available(self) -> bool:
|
||||
@@ -345,3 +526,11 @@ class AIService:
|
||||
count: int = 5,
|
||||
) -> Dict[str, Any]:
|
||||
return generate_smart_titles(description, style, count)
|
||||
|
||||
def semantic_match(
|
||||
self,
|
||||
description: str,
|
||||
assets: List[Dict[str, Any]],
|
||||
top_k: int = 0,
|
||||
) -> Dict[str, Any]:
|
||||
return semantic_match_assets(description, assets, top_k)
|
||||
|
||||
@@ -18,7 +18,6 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import math
|
||||
from dataclasses import dataclass
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -278,9 +277,7 @@ class SmartAssetSelector:
|
||||
# 分桶
|
||||
short_bucket = [d for d in scored if d.duration is not None and d.duration < _SHORT_BUCKET_MAX]
|
||||
medium_bucket = [
|
||||
d
|
||||
for d in scored
|
||||
if d.duration is not None and _SHORT_BUCKET_MAX <= d.duration < _MEDIUM_BUCKET_MAX
|
||||
d for d in scored if d.duration is not None and _SHORT_BUCKET_MAX <= d.duration < _MEDIUM_BUCKET_MAX
|
||||
]
|
||||
long_bucket = [d for d in scored if d.duration is not None and d.duration >= _MEDIUM_BUCKET_MAX]
|
||||
unknown_bucket = [d for d in scored if d.duration is None]
|
||||
@@ -295,7 +292,7 @@ class SmartAssetSelector:
|
||||
selected_ids: set[str] = set()
|
||||
|
||||
# 先按配额从每个桶取
|
||||
for bucket, name in zip(buckets, bucket_names):
|
||||
for bucket, _name in zip(buckets, bucket_names, strict=False):
|
||||
quota = min(base_quota, len(bucket))
|
||||
if quota <= 0:
|
||||
continue
|
||||
|
||||
@@ -1,8 +0,0 @@
|
||||
from fastapi import APIRouter
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.get("/health")
|
||||
def health():
|
||||
return {"ok": True, "service": "api"}
|
||||
Executable → Regular
Executable → Regular
@@ -8,8 +8,8 @@
|
||||
* - POST /api/v1/templates/{id}/toggle-favorite — 收藏/取消收藏
|
||||
*/
|
||||
import apiClient from "./client"
|
||||
import type { TitleConfig, SubtitleConfig, BgmConfig } from "./editingPlanner"
|
||||
import type { EditPlanConfig } from "./templateEditor"
|
||||
import type { TitleConfig, SubtitleConfig, BgmConfig } from "./editing-planner"
|
||||
import type { EditPlanConfig } from "./template-editor"
|
||||
|
||||
/* ──────────── 类型定义 ──────────── */
|
||||
|
||||
|
||||
+2
-2
@@ -12,8 +12,8 @@
|
||||
import React, { useState, useMemo, useCallback, useRef, useEffect } from "react"
|
||||
import "./AssetSelector.css"
|
||||
import { Input, Select, Button } from "@/components/ui"
|
||||
import type { MediaAsset } from "@/api/templateEditor"
|
||||
import { MATERIAL_TYPE_LABELS, MATERIAL_TYPE_ICONS, QUALITY_OPTIONS } from "@/api/templateEditor"
|
||||
import type { MediaAsset } from "@/api/template-editor"
|
||||
import { MATERIAL_TYPE_LABELS, MATERIAL_TYPE_ICONS, QUALITY_OPTIONS } from "@/api/template-editor"
|
||||
|
||||
/* ──────────── 类型 ──────────── */
|
||||
|
||||
Executable → Regular
Executable → Regular
@@ -12,8 +12,8 @@
|
||||
*/
|
||||
import React, { useState, useCallback, useRef, useEffect } from "react"
|
||||
import { Modal, Button } from "@/components/ui"
|
||||
import { createVoiceClone, toVoiceClone } from "@/api/voiceClone"
|
||||
import type { VoiceClone } from "@/api/voiceClone"
|
||||
import { createVoiceClone, toVoiceClone } from "@/api/voice-clone"
|
||||
import type { VoiceClone } from "@/api/voice-clone"
|
||||
import { uploadAsset } from "@/api/assets"
|
||||
import "./clone-modal.css"
|
||||
|
||||
|
||||
Executable → Regular
Executable → Regular
@@ -5,8 +5,8 @@
|
||||
* 全部完成(ready / failed)后停止轮询。
|
||||
*/
|
||||
import { useState, useEffect, useCallback, useRef } from "react"
|
||||
import { getVoiceClones } from "@/api/voiceClone"
|
||||
import type { VoiceClone } from "@/api/voiceClone"
|
||||
import { getVoiceClones } from "@/api/voice-clone"
|
||||
import type { VoiceClone } from "@/api/voice-clone"
|
||||
|
||||
const POLL_INTERVAL = 3000 // 3 秒
|
||||
|
||||
|
||||
Executable → Regular
Executable → Regular
Executable → Regular
@@ -39,7 +39,13 @@ const WechatCallback: React.FC = () => {
|
||||
|
||||
const result = await wechatCallback(code, state)
|
||||
|
||||
// 获取用户信息
|
||||
// 先把 token 存到 localStorage,让请求拦截器能拿到(getCurrentUser 需要带 token)
|
||||
localStorage.setItem("access_token", result.access_token)
|
||||
if (result.refresh_token) {
|
||||
localStorage.setItem("refresh_token", result.refresh_token)
|
||||
}
|
||||
|
||||
// 获取用户信息(这时候请求拦截器能拿到 token 了)
|
||||
const userData = await getCurrentUser()
|
||||
const user: User = normalizeUser(userData)
|
||||
setAuth(user, result.access_token, result.refresh_token)
|
||||
|
||||
Executable → Regular
@@ -11,7 +11,7 @@ import type {
|
||||
TemplateCategory,
|
||||
TemplateMode,
|
||||
SaveTemplatePayload,
|
||||
} from "@/api/editingPlanner"
|
||||
} from "@/api/editing-planner"
|
||||
import {
|
||||
getEditingTemplates,
|
||||
getEditingTemplate,
|
||||
@@ -19,9 +19,9 @@ import {
|
||||
updateEditingTemplate,
|
||||
getTemplateCategories,
|
||||
MODE_LABELS,
|
||||
} from "@/api/editingPlanner"
|
||||
import type { MediaAsset, TransitionEffect, TitleConfig } from "@/api/templateEditor"
|
||||
import { getMediaAssets, getEditPlan, getEditPlanClips } from "@/api/templateEditor"
|
||||
} from "@/api/editing-planner"
|
||||
import type { MediaAsset, TransitionEffect, TitleConfig } from "@/api/template-editor"
|
||||
import { getMediaAssets, getEditPlan, getEditPlanClips } from "@/api/template-editor"
|
||||
import { useUndoRedo } from "./hooks/useUndoRedo"
|
||||
import type {
|
||||
ClipData,
|
||||
|
||||
@@ -4,9 +4,9 @@
|
||||
*/
|
||||
import React, { useRef, useState, useCallback } from "react"
|
||||
import { useNavigate } from "react-router-dom"
|
||||
import type { TemplateMode } from "@/api/editingPlanner"
|
||||
import type { TemplateMode } from "@/api/editing-planner"
|
||||
import type { ClipData, ClipType } from "../types"
|
||||
import { TRANSITION_OPTIONS } from "@/api/templateEditor"
|
||||
import { TRANSITION_OPTIONS } from "@/api/template-editor"
|
||||
import type { AssetItem } from "@/api/assets"
|
||||
|
||||
interface SubtitleSettings {
|
||||
|
||||
Executable → Regular
@@ -4,8 +4,8 @@
|
||||
*/
|
||||
import React from "react"
|
||||
import { CloseOutlined, InboxOutlined } from "@ant-design/icons"
|
||||
import type { EditPlanGeneration } from "@/api/templateEditor"
|
||||
import { PLAN_STATUS_LABELS } from "@/api/templateEditor"
|
||||
import type { EditPlanGeneration } from "@/api/template-editor"
|
||||
import { PLAN_STATUS_LABELS } from "@/api/template-editor"
|
||||
|
||||
interface GenerationHistoryModalProps {
|
||||
open: boolean
|
||||
|
||||
@@ -7,7 +7,7 @@ import React, { useCallback } from "react"
|
||||
import { Drawer } from "antd"
|
||||
import type { IntroOutroConfig, IntroOutroItem, IntroOutroKind, TransitionType } from "../types"
|
||||
import { DEFAULT_INTRO_OUTRO } from "../types"
|
||||
import { TRANSITION_OPTIONS } from "@/api/templateEditor"
|
||||
import { TRANSITION_OPTIONS } from "@/api/template-editor"
|
||||
|
||||
/* ──────────── 常量 ──────────── */
|
||||
|
||||
|
||||
@@ -3,10 +3,10 @@
|
||||
* Tab 切换:模板列表 + 素材库
|
||||
*/
|
||||
import React, { useState } from "react"
|
||||
import type { EditingTemplate } from "@/api/editingPlanner"
|
||||
import { MODE_LABELS } from "@/api/editingPlanner"
|
||||
import type { MediaAsset } from "@/api/templateEditor"
|
||||
import AssetSelector from "@/components/AssetSelector/AssetSelector"
|
||||
import type { EditingTemplate } from "@/api/editing-planner"
|
||||
import { MODE_LABELS } from "@/api/editing-planner"
|
||||
import type { MediaAsset } from "@/api/template-editor"
|
||||
import AssetSelector from "@/components/asset-selector/AssetSelector"
|
||||
|
||||
interface MediaPanelProps {
|
||||
templates: EditingTemplate[]
|
||||
|
||||
Executable → Regular
@@ -5,7 +5,7 @@
|
||||
*/
|
||||
import React from "react"
|
||||
import type { ClipData, ClipType } from "../types"
|
||||
import type { TitleConfig } from "@/api/templateEditor"
|
||||
import type { TitleConfig } from "@/api/template-editor"
|
||||
import type { CoverConfig } from "../types"
|
||||
|
||||
interface SubtitleSettings {
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
*/
|
||||
import React from "react"
|
||||
import { Modal, Input, Select } from "@/components/ui"
|
||||
import type { TemplateCategory } from "@/api/editingPlanner"
|
||||
import type { TemplateCategory } from "@/api/editing-planner"
|
||||
|
||||
interface SaveModalProps {
|
||||
open: boolean
|
||||
|
||||
Executable → Regular
@@ -10,7 +10,7 @@
|
||||
*/
|
||||
import React, { useState, useRef, useCallback, useEffect, useLayoutEffect, useMemo } from "react"
|
||||
import type { ClipData, ClipType, TrimConfig } from "../types"
|
||||
import { TRANSITION_OPTIONS } from "@/api/templateEditor"
|
||||
import { TRANSITION_OPTIONS } from "@/api/template-editor"
|
||||
|
||||
interface TimelinePanelProps {
|
||||
clips: ClipData[]
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
*/
|
||||
import React, { useCallback } from "react"
|
||||
import { Drawer, Slider } from "antd"
|
||||
import { TRANSITION_OPTIONS } from "@/api/templateEditor"
|
||||
import { TRANSITION_OPTIONS } from "@/api/template-editor"
|
||||
import type { TransitionConfig, TransitionType } from "../types"
|
||||
import { DEFAULT_TRANSITION } from "../types"
|
||||
|
||||
|
||||
@@ -18,7 +18,7 @@ import type {
|
||||
CreateEditPlanClipRequest,
|
||||
UpdateEditPlanClipRequest,
|
||||
ClipReorderItem,
|
||||
} from "@/api/templateEditor"
|
||||
} from "@/api/template-editor"
|
||||
import {
|
||||
getEditPlanClips,
|
||||
createEditPlanClip,
|
||||
@@ -27,7 +27,7 @@ import {
|
||||
reorderEditPlanClips,
|
||||
batchDeleteEditPlanClips,
|
||||
createClipsFromAssets,
|
||||
} from "@/api/templateEditor"
|
||||
} from "@/api/template-editor"
|
||||
import { useUndoRedo } from "./useUndoRedo"
|
||||
|
||||
const QUERY_KEY = "editPlanClips"
|
||||
|
||||
Executable → Regular
Executable → Regular
@@ -30,15 +30,15 @@ import {
|
||||
getGenerationTaskResults,
|
||||
getGenerationStatus,
|
||||
getEditPlan,
|
||||
} from "@/api/templateEditor"
|
||||
import type { GeneratedVideo, EditPlanConfig, TitleConfig } from "@/api/templateEditor"
|
||||
} from "@/api/template-editor"
|
||||
import type { GeneratedVideo, EditPlanConfig, TitleConfig } from "@/api/template-editor"
|
||||
import type { CoverConfig } from "../editing-planner/types"
|
||||
import { getEditingTemplates } from "@/api/editingPlanner"
|
||||
import { getEditingTemplates } from "@/api/editing-planner"
|
||||
import { getTitles } from "@/api/titles"
|
||||
import { fetchPresetVoices } from "@/api/voices"
|
||||
import type { PresetVoiceItem } from "@/api/voices"
|
||||
import { formatDuration } from "@/api/voiceClone"
|
||||
import type { VoiceClone } from "@/api/voiceClone"
|
||||
import { formatDuration } from "@/api/voice-clone"
|
||||
import type { VoiceClone } from "@/api/voice-clone"
|
||||
import CloneModal from "@/components/voice/CloneModal"
|
||||
import { synthesizeSpeech, getTTSJobStatus, saveTtsToLibrary } from "@/api/tts"
|
||||
import { getTags, createTag } from "@/api/tags"
|
||||
|
||||
Executable → Regular
Executable → Regular
Executable → Regular
@@ -40,7 +40,7 @@ import {
|
||||
MODE_COLORS,
|
||||
type EditingTemplate,
|
||||
type TemplateMode,
|
||||
} from "@/api/editingPlanner"
|
||||
} from "@/api/editing-planner"
|
||||
import "./MyTemplates.css"
|
||||
|
||||
const { Title, Text } = Typography
|
||||
|
||||
@@ -19,8 +19,8 @@ import { Button, Modal, Input, Tooltip } from "@/components/ui"
|
||||
import type { ButtonProps } from "antd"
|
||||
import PageHead from "@/components/layout/PageHead"
|
||||
import { useCloneProgress } from "@/hooks/useCloneProgress"
|
||||
import { deleteVoiceClone, updateVoiceClone, formatDuration } from "@/api/voiceClone"
|
||||
import type { VoiceClone, VoiceCloneStatus } from "@/api/voiceClone"
|
||||
import { deleteVoiceClone, updateVoiceClone, formatDuration } from "@/api/voice-clone"
|
||||
import type { VoiceClone, VoiceCloneStatus } from "@/api/voice-clone"
|
||||
import "./my-voices.css"
|
||||
|
||||
/* ============================================================
|
||||
|
||||
Executable → Regular
Executable → Regular
Executable → Regular
Executable → Regular
@@ -25,7 +25,7 @@ import {
|
||||
updateVoiceClone,
|
||||
formatDuration,
|
||||
type VoiceClone as VoiceCloneType,
|
||||
} from "@/api/voiceClone"
|
||||
} from "@/api/voice-clone"
|
||||
import "./voice-clone.css"
|
||||
|
||||
/* ── 状态配置 ─────────────────────────────────────────── */
|
||||
|
||||
Executable → Regular
+1
-1
@@ -34,7 +34,7 @@ import {
|
||||
retryVoiceClone,
|
||||
toVoiceClone,
|
||||
type VoiceClone,
|
||||
} from "@/api/voiceClone"
|
||||
} from "@/api/voice-clone"
|
||||
import { synthesizeSpeech, getTTSJobStatus, saveTtsToLibrary } from "@/api/tts"
|
||||
import {
|
||||
getAssetsByKind,
|
||||
|
||||
Executable → Regular
@@ -23,7 +23,7 @@ import {
|
||||
copyEditPlan,
|
||||
getMediaAssets,
|
||||
getMediaAsset,
|
||||
} from "@/api/templateEditor"
|
||||
} from "@/api/template-editor"
|
||||
|
||||
const mockGet = vi.fn()
|
||||
const mockPost = vi.fn()
|
||||
+1
-1
@@ -7,7 +7,7 @@ import {
|
||||
deleteEditingTemplate,
|
||||
getTemplateCategories,
|
||||
generateFromTemplate,
|
||||
} from "@/api/editingPlanner"
|
||||
} from "@/api/editing-planner"
|
||||
|
||||
const mockGet = vi.fn()
|
||||
const mockPost = vi.fn()
|
||||
+2
-2
@@ -1,6 +1,6 @@
|
||||
import { describe, expect, it } from "vitest"
|
||||
import { toVoiceClone, formatDuration } from "@/api/voiceClone"
|
||||
import type { VoiceCloneProfile } from "@/api/voiceClone"
|
||||
import { toVoiceClone, formatDuration } from "@/api/voice-clone"
|
||||
import type { VoiceCloneProfile } from "@/api/voice-clone"
|
||||
|
||||
describe("formatDuration", () => {
|
||||
it("should format seconds correctly", () => {
|
||||
+1
-1
@@ -8,7 +8,7 @@ import {
|
||||
updateVoiceClone,
|
||||
getVoiceCloneStatus,
|
||||
retryVoiceClone,
|
||||
} from "@/api/voiceClone"
|
||||
} from "@/api/voice-clone"
|
||||
|
||||
const mockGet = vi.fn()
|
||||
const mockPost = vi.fn()
|
||||
+3
-3
@@ -1,8 +1,8 @@
|
||||
import React from "react"
|
||||
import { describe, it, expect, vi } from "vitest"
|
||||
import { render, screen } from "@testing-library/react"
|
||||
import AssetSelector from "@/components/AssetSelector/AssetSelector"
|
||||
import type { MediaAsset } from "@/api/templateEditor"
|
||||
import AssetSelector from "@/components/asset-selector/AssetSelector"
|
||||
import type { MediaAsset } from "@/api/template-editor"
|
||||
|
||||
vi.mock("@/components/ui", () => ({
|
||||
Input: ({ placeholder }: any) => <input placeholder={placeholder} />,
|
||||
@@ -18,7 +18,7 @@ vi.mock("@/components/ui", () => ({
|
||||
Button: ({ children, onClick }: any) => <button onClick={onClick}>{children}</button>,
|
||||
}))
|
||||
|
||||
vi.mock("@/components/AssetSelector/AssetSelector.css", () => ({}))
|
||||
vi.mock("@/components/asset-selector/AssetSelector.css", () => ({}))
|
||||
|
||||
const mockAssets: MediaAsset[] = [
|
||||
{
|
||||
@@ -3,7 +3,7 @@ import { describe, expect, it, vi } from "vitest"
|
||||
import { render } from "@testing-library/react"
|
||||
import CloneModal from "@/components/voice/CloneModal"
|
||||
|
||||
vi.mock("@/api/voiceClone", () => ({
|
||||
vi.mock("@/api/voice-clone", () => ({
|
||||
createVoiceClone: vi.fn(),
|
||||
toVoiceClone: vi.fn(),
|
||||
}))
|
||||
|
||||
Executable → Regular
@@ -4,7 +4,7 @@ import { useCloneProgress } from "@/hooks/useCloneProgress"
|
||||
|
||||
// mock API
|
||||
const mockGetVoiceClones = vi.fn()
|
||||
vi.mock("@/api/voiceClone", () => ({
|
||||
vi.mock("@/api/voice-clone", () => ({
|
||||
getVoiceClones: (...args: unknown[]) => mockGetVoiceClones(...args),
|
||||
VoiceCloneStatus: { READY: "ready" },
|
||||
}))
|
||||
|
||||
Executable → Regular
@@ -137,7 +137,7 @@ vi.mock("@/components/ui", () => ({
|
||||
}))
|
||||
|
||||
// === API mocks ===
|
||||
vi.mock("@/api/editingPlanner", () => ({
|
||||
vi.mock("@/api/editing-planner", () => ({
|
||||
getEditingTemplates: vi.fn().mockResolvedValue({ items: [], total: 0 }),
|
||||
getEditingTemplate: vi.fn().mockResolvedValue({}),
|
||||
createEditingTemplate: vi.fn().mockResolvedValue({}),
|
||||
@@ -146,7 +146,7 @@ vi.mock("@/api/editingPlanner", () => ({
|
||||
MODE_LABELS: { pip: "画中画", intro_outro: "片头片尾", watermark: "水印" },
|
||||
}))
|
||||
|
||||
vi.mock("@/api/templateEditor", () => ({
|
||||
vi.mock("@/api/template-editor", () => ({
|
||||
getMediaAssets: vi.fn().mockResolvedValue({ items: [] }),
|
||||
getEditPlanGenerations: vi.fn().mockResolvedValue({ items: [] }),
|
||||
generateCover: vi.fn().mockResolvedValue({}),
|
||||
|
||||
@@ -204,7 +204,7 @@ vi.mock("@/api/voices", () => ({
|
||||
fetchPresetVoices: vi.fn().mockResolvedValue({ items: [] }),
|
||||
}))
|
||||
|
||||
vi.mock("@/api/editingPlanner", () => ({
|
||||
vi.mock("@/api/editing-planner", () => ({
|
||||
getEditingTemplates: vi.fn().mockResolvedValue({ items: [], total: 0 }),
|
||||
MODE_LABELS: { pip: "画中画" },
|
||||
}))
|
||||
@@ -213,7 +213,7 @@ vi.mock("@/api/titles", () => ({
|
||||
getTitles: vi.fn().mockResolvedValue({ items: [] }),
|
||||
}))
|
||||
|
||||
vi.mock("@/api/templateEditor", () => ({
|
||||
vi.mock("@/api/template-editor", () => ({
|
||||
generateEditPlan: vi.fn().mockResolvedValue({
|
||||
plan_id: "test-plan",
|
||||
generation_task_id: "test-task",
|
||||
@@ -234,7 +234,7 @@ vi.mock("@/api/templateEditor", () => ({
|
||||
getGenerationTaskResults: vi.fn().mockResolvedValue({ items: [] }),
|
||||
}))
|
||||
|
||||
vi.mock("@/api/voiceClone", () => ({
|
||||
vi.mock("@/api/voice-clone", () => ({
|
||||
formatDuration: vi.fn((s: number) => `${s}s`),
|
||||
}))
|
||||
|
||||
|
||||
@@ -40,7 +40,7 @@ vi.mock("@ant-design/icons", () => ({
|
||||
PlusOutlined: () => <span>PlusOutlined</span>,
|
||||
}))
|
||||
|
||||
vi.mock("@/api/editingPlanner", () => ({
|
||||
vi.mock("@/api/editing-planner", () => ({
|
||||
getEditingTemplates: vi.fn(),
|
||||
getTemplateCategories: vi.fn().mockResolvedValue([]),
|
||||
deleteEditingTemplate: vi.fn(),
|
||||
|
||||
@@ -73,7 +73,7 @@ vi.mock("antd", () => ({
|
||||
RadioGroup: ({ children }: any) => <div>{children}</div>,
|
||||
}))
|
||||
|
||||
vi.mock("@/api/voiceClone", () => ({
|
||||
vi.mock("@/api/voice-clone", () => ({
|
||||
getVoiceCloneList: vi.fn().mockResolvedValue({ items: [], total: 0 }),
|
||||
deleteVoiceClone: vi.fn().mockResolvedValue({ success: true }),
|
||||
createVoiceClone: vi.fn().mockResolvedValue({ success: true }),
|
||||
|
||||
@@ -73,7 +73,7 @@ vi.mock("antd", () => ({
|
||||
RadioGroup: ({ children }: any) => <div>{children}</div>,
|
||||
}))
|
||||
|
||||
vi.mock("@/api/voiceClone", () => ({
|
||||
vi.mock("@/api/voice-clone", () => ({
|
||||
getVoiceCloneList: vi.fn().mockResolvedValue({ items: [], total: 0 }),
|
||||
createVoiceClone: vi.fn().mockResolvedValue({ success: true, id: "1" }),
|
||||
deleteVoiceClone: vi.fn().mockResolvedValue({ success: true }),
|
||||
|
||||
@@ -109,7 +109,7 @@ vi.mock("@ant-design/icons", () => ({
|
||||
vi.mock("@/store/authStore", () => ({
|
||||
useAuthStore: (sel: any) => sel({ user: { id: "1" }, isAuthenticated: true }),
|
||||
}))
|
||||
vi.mock("@/api/voiceClone", () => ({
|
||||
vi.mock("@/api/voice-clone", () => ({
|
||||
createVoiceClone: vi.fn().mockResolvedValue({ success: true }),
|
||||
deleteVoiceClone: vi.fn().mockResolvedValue({ success: true }),
|
||||
retryVoiceClone: vi.fn().mockResolvedValue({ success: true }),
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
import { describe, expect, it, vi, beforeEach } from "vitest"
|
||||
import { render, screen } from "@testing-library/react"
|
||||
import { MemoryRouter } from "react-router-dom"
|
||||
import WechatCallback from "@/pages/auth/WechatCallback"
|
||||
|
||||
vi.mock("react-router-dom", async () => {
|
||||
const actual = await vi.importActual("react-router-dom")
|
||||
return {
|
||||
...actual,
|
||||
useNavigate: () => vi.fn(),
|
||||
useSearchParams: () => [new URLSearchParams({ code: "test_code", state: "test_state" })],
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock("@/api/auth", () => ({
|
||||
wechatCallback: vi.fn(() => new Promise(() => {})), // pending promise,保持loading
|
||||
getCurrentUser: vi.fn(),
|
||||
normalizeUser: (u: unknown) => u,
|
||||
}))
|
||||
|
||||
vi.mock("@/store/authStore", () => ({
|
||||
useAuthStore: () => ({
|
||||
setAuth: vi.fn(),
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock("@/components/auth/BindContactModal", () => ({
|
||||
default: ({ open }: { open: boolean }) => (
|
||||
<div data-testid="bind-contact-modal" style={{ display: open ? "block" : "none" }}>
|
||||
BindContactModal
|
||||
</div>
|
||||
),
|
||||
}))
|
||||
|
||||
vi.mock("antd", async () => {
|
||||
const actual = await vi.importActual("antd")
|
||||
return {
|
||||
...actual,
|
||||
message: {
|
||||
success: vi.fn(),
|
||||
error: vi.fn(),
|
||||
},
|
||||
}
|
||||
})
|
||||
|
||||
describe("WechatCallback Page", () => {
|
||||
beforeEach(() => {
|
||||
// mock localStorage,设置wechat_state匹配,让校验通过
|
||||
const store: Record<string, string> = {
|
||||
wechat_state: "test_state",
|
||||
}
|
||||
vi.spyOn(Storage.prototype, "getItem").mockImplementation((key) => store[key] || null)
|
||||
vi.spyOn(Storage.prototype, "setItem").mockImplementation((key, val) => {
|
||||
store[key] = val
|
||||
})
|
||||
vi.spyOn(Storage.prototype, "removeItem").mockImplementation((key) => {
|
||||
delete store[key]
|
||||
})
|
||||
})
|
||||
|
||||
it("should render without crashing", () => {
|
||||
const { container } = render(
|
||||
<MemoryRouter>
|
||||
<WechatCallback />
|
||||
</MemoryRouter>,
|
||||
)
|
||||
expect(container).toBeTruthy()
|
||||
})
|
||||
|
||||
it("should show loading state while processing", () => {
|
||||
render(
|
||||
<MemoryRouter>
|
||||
<WechatCallback />
|
||||
</MemoryRouter>,
|
||||
)
|
||||
// wechatCallback 返回 pending promise,所以应该显示 loading
|
||||
expect(screen.getByText("正在登录...")).toBeTruthy()
|
||||
})
|
||||
})
|
||||
@@ -32,7 +32,7 @@ vi.mock("antd", () => ({
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock("@/api/templateEditor", () => ({
|
||||
vi.mock("@/api/template-editor", () => ({
|
||||
getEditPlanClips: vi.fn().mockResolvedValue({ items: [], total: 0 }),
|
||||
createEditPlanClip: vi.fn().mockResolvedValue({}),
|
||||
updateEditPlanClip: vi.fn().mockResolvedValue({}),
|
||||
|
||||
Executable → Regular
@@ -1,42 +1,22 @@
|
||||
import os
|
||||
from typing import Optional
|
||||
"""Worker 服务配置(向后兼容层)。
|
||||
|
||||
from pydantic_settings import BaseSettings, SettingsConfigDict
|
||||
统一配置已迁移到 packages.config.worker_settings。
|
||||
新代码请使用:
|
||||
from packages.config import WorkerSettings, get_worker_settings
|
||||
"""
|
||||
|
||||
|
||||
class WorkerSettings(BaseSettings):
|
||||
worker_name: str = "xiaoxia-saas-worker"
|
||||
broker_url: str = "redis://redis:6379/0"
|
||||
result_backend: str = "redis://redis:6379/1"
|
||||
worker_concurrency: int = 4
|
||||
worker_max_tasks_per_child: int = 1000
|
||||
database_url: str = "postgresql+psycopg://postgres:postgres@postgres:5432/xiaoxia_saas"
|
||||
database_pool_size: int = 20
|
||||
database_max_overflow: int = 40
|
||||
database_pool_timeout: int = 30
|
||||
database_pool_recycle: int = 3600
|
||||
environment: str = "development"
|
||||
auto_create_schema: bool = False
|
||||
redis_url: str = "redis://redis:6379/0"
|
||||
|
||||
model_config = SettingsConfigDict(
|
||||
env_file=".env",
|
||||
env_file_encoding="utf-8",
|
||||
case_sensitive=False,
|
||||
extra="ignore",
|
||||
)
|
||||
|
||||
|
||||
_settings: Optional[WorkerSettings] = None
|
||||
from packages.config import WorkerSettings, get_worker_settings, reload_settings_cache
|
||||
|
||||
|
||||
def get_settings() -> WorkerSettings:
|
||||
global _settings
|
||||
if _settings is None:
|
||||
env = os.getenv("APP_ENV", "development")
|
||||
env_file = f".env.{env}" if env != "development" else ".env"
|
||||
if os.path.exists(env_file):
|
||||
_settings = WorkerSettings(_env_file=env_file)
|
||||
else:
|
||||
_settings = WorkerSettings()
|
||||
return _settings
|
||||
"""获取 Worker 配置单例(向后兼容)。"""
|
||||
return get_worker_settings()
|
||||
|
||||
|
||||
def reload_settings() -> WorkerSettings:
|
||||
"""重新加载配置(测试用)。"""
|
||||
reload_settings_cache()
|
||||
return get_worker_settings()
|
||||
|
||||
|
||||
__all__ = ["WorkerSettings", "get_settings", "reload_settings"]
|
||||
|
||||
Regular → Executable
+18
-217
@@ -1,226 +1,27 @@
|
||||
"""AI 相关异步任务 — 智能推荐 & 封面生成.
|
||||
|
||||
提供两个 Celery 任务:
|
||||
- ai_recommend_clips: 分析素材并推荐片段编排方案
|
||||
- generate_cover: 从视频中选帧或生成封面图
|
||||
|
||||
当前为 stub 实现(返回模拟数据),后续接入真实 AI 服务时
|
||||
只需替换 _call_ai_recommend_service / _call_ai_cover_service 内部逻辑。
|
||||
核心业务逻辑已迁移到 packages.shared.ai_service,
|
||||
本模块仅保留 Worker 侧的 Celery 任务包装和向后兼容的直接导入。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import random
|
||||
import time
|
||||
from typing import Any, Dict, List
|
||||
|
||||
from packages.domain.config_schemas import DEFAULT_EDIT_PLAN_CONFIG
|
||||
from packages.shared.ai_service import (
|
||||
_call_ai_cover_service,
|
||||
_call_ai_recommend_service,
|
||||
_fallback_recommend_clips,
|
||||
_parse_recommend_response,
|
||||
run_ai_recommend,
|
||||
run_generate_cover,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
# ── AI 推荐片段方案 ──────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def _call_ai_recommend_service(
|
||||
plan_id: str,
|
||||
template_id: str,
|
||||
asset_ids: List[str],
|
||||
editing_mode: str,
|
||||
target_duration: float,
|
||||
) -> Dict[str, Any]:
|
||||
"""调用 AI 推荐服务(stub)
|
||||
|
||||
TODO: 接入真实 AI 服务,分析素材内容并生成推荐方案。
|
||||
当前返回基于模板规则的模拟推荐数据。
|
||||
"""
|
||||
# 模拟 AI 分析耗时
|
||||
time.sleep(0.5)
|
||||
|
||||
# 根据素材数量生成推荐片段
|
||||
clips: List[Dict[str, Any]] = []
|
||||
order = 0
|
||||
|
||||
# 开场片段
|
||||
clips.append(
|
||||
{
|
||||
"clip_type": "intro",
|
||||
"order": order,
|
||||
"text_content": "精彩看点",
|
||||
"duration": 3.0,
|
||||
"transition_effect": "fade",
|
||||
"asset_id": asset_ids[0] if asset_ids else "",
|
||||
"start_time": 0.0,
|
||||
"config": {},
|
||||
}
|
||||
)
|
||||
order += 1
|
||||
|
||||
# 为每个素材生成展示片段
|
||||
per_clip_duration = max(2.0, (target_duration - 6.0) / max(len(asset_ids), 1))
|
||||
for i, asset_id in enumerate(asset_ids):
|
||||
clips.append(
|
||||
{
|
||||
"clip_type": "showcase",
|
||||
"order": order,
|
||||
"text_content": f"展示片段 {i + 1}",
|
||||
"duration": round(per_clip_duration, 1),
|
||||
"transition_effect": "cut",
|
||||
"asset_id": asset_id,
|
||||
"start_time": 0.0,
|
||||
"config": {},
|
||||
}
|
||||
)
|
||||
order += 1
|
||||
|
||||
# 结尾 CTA
|
||||
clips.append(
|
||||
{
|
||||
"clip_type": "outro",
|
||||
"order": order,
|
||||
"text_content": "感谢观看",
|
||||
"duration": 3.0,
|
||||
"transition_effect": "fade",
|
||||
"asset_id": "",
|
||||
"start_time": 0.0,
|
||||
"config": {},
|
||||
}
|
||||
)
|
||||
|
||||
# 生成推荐 config
|
||||
config = DEFAULT_EDIT_PLAN_CONFIG.copy()
|
||||
config["title"]["text"] = f"精选视频 — {len(asset_ids)} 个片段"
|
||||
config["title"]["ai_auto"] = True
|
||||
|
||||
return {
|
||||
"clips": clips,
|
||||
"config": config,
|
||||
"total_duration": round(sum(c["duration"] for c in clips), 1),
|
||||
"confidence": round(random.uniform(0.75, 0.95), 2),
|
||||
}
|
||||
|
||||
|
||||
# ── AI 封面生成 ──────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def _call_ai_cover_service(
|
||||
plan_id: str,
|
||||
asset_ids: List[str],
|
||||
cover_type: str,
|
||||
frame_time: float | None = None,
|
||||
) -> Dict[str, Any]:
|
||||
"""调用 AI 封面生成服务(stub)
|
||||
|
||||
TODO: 接入真实 AI 服务,从视频中选帧或生成封面。
|
||||
当前返回模拟封面数据。
|
||||
"""
|
||||
# 模拟 AI 处理耗时
|
||||
time.sleep(0.3)
|
||||
|
||||
if cover_type == "upload":
|
||||
return {
|
||||
"type": "upload",
|
||||
"image_url": "",
|
||||
"message": "请上传封面图片",
|
||||
}
|
||||
|
||||
if cover_type == "manual" and frame_time is not None:
|
||||
return {
|
||||
"type": "manual",
|
||||
"image_url": f"/api/v1/assets/placeholder/cover?time={frame_time}",
|
||||
"frame_time": frame_time,
|
||||
}
|
||||
|
||||
# ai_frame / ai_regenerate
|
||||
return {
|
||||
"type": "ai_frame",
|
||||
"image_url": f"/api/v1/assets/placeholder/cover?plan={plan_id}",
|
||||
"frame_time": round(random.uniform(1.0, 10.0), 1),
|
||||
"confidence": round(random.uniform(0.80, 0.98), 2),
|
||||
}
|
||||
|
||||
|
||||
# ── 任务入口(供 Celery 调度或路由直接调用) ─────────────────────────────────
|
||||
|
||||
|
||||
def run_ai_recommend(
|
||||
plan_id: str,
|
||||
template_id: str,
|
||||
asset_ids: List[str],
|
||||
editing_mode: str = "one_take",
|
||||
target_duration: float = 30.0,
|
||||
) -> Dict[str, Any]:
|
||||
"""执行 AI 推荐片段方案
|
||||
|
||||
Args:
|
||||
plan_id: 剪辑计划 ID
|
||||
template_id: 模板 ID
|
||||
asset_ids: 素材 ID 列表
|
||||
editing_mode: 剪辑模式 (one_take / pip / voice_over / voice_pip)
|
||||
target_duration: 目标时长(秒)
|
||||
|
||||
Returns:
|
||||
推荐方案 dict,包含 clips / config / total_duration / confidence
|
||||
"""
|
||||
logger.info(
|
||||
"AI 推荐片段方案: plan_id=%s template_id=%s assets=%d mode=%s duration=%.1f",
|
||||
plan_id,
|
||||
template_id,
|
||||
len(asset_ids),
|
||||
editing_mode,
|
||||
target_duration,
|
||||
)
|
||||
result = _call_ai_recommend_service(
|
||||
plan_id=plan_id,
|
||||
template_id=template_id,
|
||||
asset_ids=asset_ids,
|
||||
editing_mode=editing_mode,
|
||||
target_duration=target_duration,
|
||||
)
|
||||
logger.info(
|
||||
"AI 推荐完成: plan_id=%s clips=%d duration=%.1f confidence=%.2f",
|
||||
plan_id,
|
||||
len(result["clips"]),
|
||||
result["total_duration"],
|
||||
result["confidence"],
|
||||
)
|
||||
return result
|
||||
|
||||
|
||||
def run_generate_cover(
|
||||
plan_id: str,
|
||||
asset_ids: List[str],
|
||||
cover_type: str = "ai_frame",
|
||||
frame_time: float | None = None,
|
||||
) -> Dict[str, Any]:
|
||||
"""执行 AI 封面生成
|
||||
|
||||
Args:
|
||||
plan_id: 剪辑计划 ID
|
||||
asset_ids: 素材 ID 列表(用于确定视频来源)
|
||||
cover_type: 封面类型 (ai_frame / manual / upload / ai_regenerate)
|
||||
frame_time: 手动选帧时间点(仅 manual 模式使用)
|
||||
|
||||
Returns:
|
||||
封面数据 dict,包含 type / image_url / frame_time
|
||||
"""
|
||||
logger.info(
|
||||
"AI 封面生成: plan_id=%s type=%s assets=%d",
|
||||
plan_id,
|
||||
cover_type,
|
||||
len(asset_ids),
|
||||
)
|
||||
result = _call_ai_cover_service(
|
||||
plan_id=plan_id,
|
||||
asset_ids=asset_ids,
|
||||
cover_type=cover_type,
|
||||
frame_time=frame_time,
|
||||
)
|
||||
logger.info(
|
||||
"AI 封面生成完成: plan_id=%s type=%s url=%s",
|
||||
plan_id,
|
||||
result.get("type"),
|
||||
result.get("image_url", "")[:60],
|
||||
)
|
||||
return result
|
||||
__all__ = [
|
||||
"run_ai_recommend",
|
||||
"run_generate_cover",
|
||||
"_call_ai_recommend_service",
|
||||
"_call_ai_cover_service",
|
||||
"_fallback_recommend_clips",
|
||||
"_parse_recommend_response",
|
||||
]
|
||||
|
||||
@@ -24,6 +24,8 @@ from typing import Any
|
||||
from worker_app.celery_app import celery_app
|
||||
from worker_app.db import SessionLocal
|
||||
|
||||
from packages.domain.bgm_utils import merge_bgm_config
|
||||
|
||||
OUTPUT_WIDTH = 1280
|
||||
OUTPUT_HEIGHT = 720
|
||||
OUTPUT_FPS = 25.0
|
||||
@@ -1111,6 +1113,7 @@ def _load_task_info(task_id: str) -> dict | None:
|
||||
"user_id": getattr(gen_task, "created_by_user_id", "") or "",
|
||||
"video_title": getattr(gen_task, "video_title", "") or "",
|
||||
"resolution": getattr(gen_task, "resolution", "") or "",
|
||||
"bgm_config": dict(getattr(gen_task, "bgm_config", {}) or {}),
|
||||
}
|
||||
finally:
|
||||
session.close()
|
||||
@@ -1170,6 +1173,7 @@ def _render_video(
|
||||
temp_path: Path,
|
||||
output_name: str,
|
||||
resolution: str = "",
|
||||
bgm_config: dict | None = None,
|
||||
) -> tuple[Path, float]:
|
||||
"""渲染视频(含配音混音)。
|
||||
|
||||
@@ -1201,6 +1205,20 @@ def _render_video(
|
||||
list(template_config.keys()),
|
||||
)
|
||||
|
||||
# 用户自定义 BGM 覆盖模板 BGM(用户指定优先级最高)
|
||||
if bgm_config:
|
||||
plan_cfg = virtual_plan.config or {}
|
||||
template_bgm = plan_cfg.get("bgm", {}) or {}
|
||||
merged_bgm = merge_bgm_config(template_bgm, bgm_config)
|
||||
plan_cfg["bgm"] = merged_bgm
|
||||
virtual_plan.config = plan_cfg
|
||||
logger.info(
|
||||
"[task_id=%s] [渲染] 用户自定义BGM已合并: enabled=%s source=%s",
|
||||
task_id,
|
||||
merged_bgm.get("enabled", False),
|
||||
merged_bgm.get("source", ""),
|
||||
)
|
||||
|
||||
# 确保输出分辨率配置存在
|
||||
# 优先级:用户指定 > 模板配置 > 默认 1280x720
|
||||
plan_cfg = virtual_plan.config or {}
|
||||
@@ -1453,6 +1471,7 @@ def generate_video(self, task_id: str) -> dict:
|
||||
temp_path=temp_path,
|
||||
output_name=output_name,
|
||||
resolution=task_info.get("resolution", ""),
|
||||
bgm_config=task_info.get("bgm_config", {}),
|
||||
)
|
||||
|
||||
if gen_task:
|
||||
|
||||
Regular → Executable
+83
-2
@@ -445,5 +445,86 @@ def list_all_assets() -> list[Asset]:
|
||||
|
||||
---
|
||||
|
||||
**最后更新**: 2026-06-15
|
||||
**版本**: v1.0
|
||||
## 8. 前端规范(React + TypeScript)
|
||||
|
||||
### 8.1 文件命名规范
|
||||
|
||||
| 类型 | 风格 | 示例 | 说明 |
|
||||
|------|------|------|------|
|
||||
| **目录名** | kebab-case | `editing-planner/`, `asset-selector/` | 全小写,多单词用短横线连接 |
|
||||
| **组件文件** | PascalCase | `AssetSelector.tsx`, `MediaPanel.tsx` | 与组件导出名一致 |
|
||||
| **页面组件** | PascalCase | `EditingPlanner.tsx`, `MyTemplates.tsx` | 放在 kebab-case 目录中 |
|
||||
| **API 文件** | kebab-case | `template-editor.ts`, `voice-clone.ts` | 与 RESTful 资源路径风格一致 |
|
||||
| **Hooks** | camelCase (use前缀) | `useAuth.ts`, `useCloneProgress.ts` | React 官方惯例 |
|
||||
| **Store** | kebab-case | `auth-store.ts`, `ui-store.ts` | |
|
||||
| **工具函数/helpers** | kebab-case | `format-duration.ts`, `date-utils.ts` | |
|
||||
| **类型定义** | kebab-case | `types.ts`, `subtitle-types.ts` | 目录内类型定义可用 `types.ts` |
|
||||
| **常量** | UPPER_SNAKE_CASE | `MAX_UPLOAD_SIZE`, `API_BASE_URL` | |
|
||||
| **测试文件** | 与被测文件同名 + `.test` | `auth.test.ts`, `AssetSelector.test.tsx` | 放在 `test/` 目录下,保持相同相对路径 |
|
||||
|
||||
### 8.2 组件命名
|
||||
|
||||
- 组件名使用 **PascalCase**,与文件名一致
|
||||
- 默认导出组件名与文件名相同
|
||||
- 高阶组件/包装器用 `with` 前缀:`withAuth(Component)`
|
||||
- 渲染属性组件用 `Render` 后缀:`UserRender`
|
||||
|
||||
```tsx
|
||||
// ✅ 正确
|
||||
// 文件: AssetSelector.tsx
|
||||
const AssetSelector: React.FC<AssetSelectorProps> = ({ assets }) => { ... };
|
||||
export default AssetSelector;
|
||||
|
||||
// ❌ 错误
|
||||
// 文件: asset-selector.tsx
|
||||
const assetSelector = () => { ... };
|
||||
```
|
||||
|
||||
### 8.3 变量与函数命名
|
||||
|
||||
- **变量/函数**:camelCase
|
||||
- **布尔变量**:用 `is/has/should/can` 前缀
|
||||
- **事件处理函数**:用 `handle` 前缀 + 事件名
|
||||
- **事件 handler prop**:用 `on` 前缀
|
||||
|
||||
```tsx
|
||||
// ✅ 正确
|
||||
const isLoading = true;
|
||||
const hasError = false;
|
||||
const handleSubmit = () => { ... };
|
||||
|
||||
<Button onClick={onClick} />
|
||||
```
|
||||
|
||||
### 8.4 导入路径
|
||||
|
||||
- 使用 `@/` 别名引用 `src/` 下的文件
|
||||
- 同一目录内用相对路径 `./`
|
||||
- 导入顺序:React → 第三方库 → @/内部模块 → 相对路径 → 样式
|
||||
|
||||
```tsx
|
||||
import React, { useState } from 'react';
|
||||
import { Button, Modal } from 'antd';
|
||||
import { useAuth } from '@/hooks/useAuth';
|
||||
import { Asset } from '@/api/asset-selector';
|
||||
import { MediaPanel } from './MediaPanel';
|
||||
import './AssetSelector.css';
|
||||
```
|
||||
|
||||
### 8.5 CSS/样式命名
|
||||
|
||||
- CSS Modules / CSS 类名:kebab-case
|
||||
- styled-components:PascalCase(与组件一致)
|
||||
- Tailwind 工具类遵循官方惯例
|
||||
|
||||
```css
|
||||
/* ✅ 正确 */
|
||||
.asset-selector { ... }
|
||||
.asset-item { ... }
|
||||
.asset-item--active { ... }
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
**最后更新**: 2026-07-23
|
||||
**版本**: v1.1
|
||||
|
||||
@@ -1,14 +0,0 @@
|
||||
-- Deprecated schema snapshot.
|
||||
--
|
||||
-- Runtime schema creation is currently owned by SQLAlchemy models in:
|
||||
-- packages/adapters/sqlalchemy_impl/models.py
|
||||
--
|
||||
-- Do not apply this file to staging or production. It is retained only for
|
||||
-- historical reference while the project migrates toward a proper Alembic flow.
|
||||
-- Applying it would create columns such as assets.library_id/storage_key/mime_type
|
||||
-- that conflict with the current runtime table shape.
|
||||
|
||||
DO $$
|
||||
BEGIN
|
||||
RAISE EXCEPTION 'init-tables.sql is deprecated. Use SQLAlchemy runtime schema initialization / future Alembic migrations instead.';
|
||||
END $$;
|
||||
Executable
+7
@@ -0,0 +1,7 @@
|
||||
-- 一键生成支持自定义BGM
|
||||
-- 为 generation_tasks 表添加 bgm_config 字段,用于存储用户自定义BGM配置
|
||||
-- 创建时间: 2026-07-23
|
||||
|
||||
ALTER TABLE generation_tasks ADD COLUMN IF NOT EXISTS bgm_config JSON NOT NULL DEFAULT '{}'::json;
|
||||
|
||||
COMMENT ON COLUMN generation_tasks.bgm_config IS '自定义BGM配置,覆盖模板BGM设置';
|
||||
@@ -10,6 +10,8 @@ from typing import Optional
|
||||
import redis
|
||||
from redis import Redis
|
||||
|
||||
from packages.domain.auth.session_store import SessionStorePort
|
||||
|
||||
|
||||
class RedisConfig:
|
||||
"""Redis 配置"""
|
||||
@@ -50,7 +52,7 @@ class NoopSessionStore:
|
||||
return False
|
||||
|
||||
|
||||
class SessionStore:
|
||||
class SessionStore(SessionStorePort):
|
||||
"""Session 存储服务"""
|
||||
|
||||
def __init__(self, redis_client: Optional[Redis] = None, config: Optional[RedisConfig] = None):
|
||||
|
||||
Regular → Executable
+3
-15
@@ -4,11 +4,12 @@
|
||||
"""
|
||||
|
||||
import smtplib
|
||||
from dataclasses import dataclass
|
||||
from email.mime.multipart import MIMEMultipart
|
||||
from email.mime.text import MIMEText
|
||||
from typing import List, Optional
|
||||
|
||||
from packages.domain.auth.email_service import EmailConfig, EmailServicePort
|
||||
|
||||
|
||||
class NoopEmailService:
|
||||
def send_verification_email(self, **kwargs):
|
||||
@@ -18,20 +19,7 @@ class NoopEmailService:
|
||||
return False, "Email delivery is disabled"
|
||||
|
||||
|
||||
@dataclass
|
||||
class EmailConfig:
|
||||
"""邮件配置"""
|
||||
|
||||
smtp_host: str = "smtp.gmail.com"
|
||||
smtp_port: int = 587
|
||||
smtp_user: str = ""
|
||||
smtp_password: str = ""
|
||||
from_email: str = ""
|
||||
from_name: str = "小虾 SaaS"
|
||||
use_tls: bool = True
|
||||
|
||||
|
||||
class EmailService:
|
||||
class EmailService(EmailServicePort):
|
||||
"""邮件服务类"""
|
||||
|
||||
def __init__(self, config: Optional[EmailConfig] = None):
|
||||
|
||||
@@ -35,6 +35,7 @@ def _to_domain(model: GenerationTaskModel) -> GenerationTask:
|
||||
batch_id=model.batch_id or "",
|
||||
video_title=getattr(model, "video_title", "") or "",
|
||||
resolution=getattr(model, "resolution", "") or "",
|
||||
bgm_config=dict(getattr(model, "bgm_config", {}) or {}),
|
||||
logs=model.logs or "[]",
|
||||
created_at=model.created_at,
|
||||
updated_at=model.updated_at,
|
||||
@@ -72,6 +73,7 @@ class SQLAlchemyGenerationTaskRepository:
|
||||
batch_id=task.batch_id or "",
|
||||
video_title=task.video_title or "",
|
||||
resolution=task.resolution or "",
|
||||
bgm_config=task.bgm_config or {},
|
||||
logs=task.logs,
|
||||
created_at=task.created_at,
|
||||
updated_at=task.updated_at,
|
||||
@@ -234,6 +236,8 @@ class SQLAlchemyGenerationTaskRepository:
|
||||
model.video_title = task.video_title or ""
|
||||
if hasattr(model, "resolution"):
|
||||
model.resolution = task.resolution or ""
|
||||
if hasattr(model, "bgm_config"):
|
||||
model.bgm_config = task.bgm_config or {}
|
||||
model.logs = task.logs
|
||||
self.session.commit()
|
||||
return task
|
||||
|
||||
@@ -292,6 +292,7 @@ class GenerationTaskModel(Base):
|
||||
batch_id = Column(String(36), nullable=False, default="", index=True)
|
||||
video_title = Column(String(255), nullable=False, default="")
|
||||
resolution = Column(String(20), nullable=False, default="")
|
||||
bgm_config = Column(JSON, nullable=False, default=dict)
|
||||
extra_meta = Column("metadata", JSON, nullable=False, default=dict)
|
||||
logs = Column(Text, nullable=False, default="[]", server_default="[]")
|
||||
created_at = Column(DateTime, nullable=False, default=lambda: datetime.now(timezone.utc))
|
||||
|
||||
@@ -67,13 +67,24 @@ class SQLAlchemyVerificationCodeRepository(VerificationCodeRepository):
|
||||
def _to_entity(model: VerificationCodeModel | None) -> VerificationCode | None:
|
||||
if model is None:
|
||||
return None
|
||||
|
||||
# SQLAlchemy 从数据库读出的 DateTime 是 naive(不带时区),
|
||||
# 领域模型期望 aware datetime(带 timezone.utc),直接用会报
|
||||
# "can't compare offset-naive and offset-aware datetimes"
|
||||
def _ensure_aware(dt: datetime | None) -> datetime | None:
|
||||
if dt is None:
|
||||
return None
|
||||
if dt.tzinfo is None:
|
||||
return dt.replace(tzinfo=timezone.utc)
|
||||
return dt
|
||||
|
||||
return VerificationCode(
|
||||
id=model.id,
|
||||
recipient=model.recipient,
|
||||
code=model.code,
|
||||
code_type=model.code_type,
|
||||
expires_at=model.expires_at,
|
||||
used_at=model.used_at,
|
||||
expires_at=_ensure_aware(model.expires_at),
|
||||
used_at=_ensure_aware(model.used_at),
|
||||
attempts=model.attempts,
|
||||
created_at=model.created_at,
|
||||
created_at=_ensure_aware(model.created_at),
|
||||
)
|
||||
|
||||
Regular → Executable
+3
-1
@@ -6,6 +6,8 @@ from typing import Any, Dict, Optional
|
||||
import jwt
|
||||
from jwt.exceptions import ExpiredSignatureError, InvalidTokenError
|
||||
|
||||
from packages.domain.auth.jwt_service import JWTServicePort
|
||||
|
||||
|
||||
class JWTConfig:
|
||||
"""JWT 配置"""
|
||||
@@ -57,7 +59,7 @@ class TokenType:
|
||||
REFRESH = "refresh"
|
||||
|
||||
|
||||
class JWTService:
|
||||
class JWTService(JWTServicePort):
|
||||
"""JWT 服务类"""
|
||||
|
||||
def __init__(self, config: JWTConfig = None):
|
||||
|
||||
Regular → Executable
+5
-3
@@ -3,12 +3,14 @@
|
||||
使用 bcrypt 安全存储密码
|
||||
"""
|
||||
|
||||
from typing import Optional
|
||||
from typing import Optional, Tuple
|
||||
|
||||
import bcrypt
|
||||
|
||||
from packages.domain.auth.password_hasher import PasswordHasherPort, PasswordValidatorPort
|
||||
|
||||
class PasswordHasher:
|
||||
|
||||
class PasswordHasher(PasswordHasherPort):
|
||||
"""密码哈希服务"""
|
||||
|
||||
def __init__(self, rounds: int = 12):
|
||||
@@ -98,7 +100,7 @@ class PasswordHasher:
|
||||
return False
|
||||
|
||||
|
||||
class PasswordValidator:
|
||||
class PasswordValidator(PasswordValidatorPort):
|
||||
"""密码强度验证器"""
|
||||
|
||||
def __init__(
|
||||
|
||||
@@ -23,6 +23,7 @@ class CreateGenerationTaskCommand:
|
||||
batch_id: str = ""
|
||||
video_title: str = ""
|
||||
resolution: str = ""
|
||||
bgm_config: dict = field(default_factory=dict)
|
||||
auto_retry_enabled: bool = False
|
||||
auto_retry_max: int = 0
|
||||
|
||||
@@ -52,6 +53,7 @@ class CreateGenerationTaskUseCase:
|
||||
batch_id=command.batch_id,
|
||||
video_title=command.video_title,
|
||||
resolution=command.resolution,
|
||||
bgm_config=command.bgm_config,
|
||||
auto_retry_enabled=command.auto_retry_enabled,
|
||||
auto_retry_max=command.auto_retry_max,
|
||||
)
|
||||
|
||||
Executable
+34
@@ -0,0 +1,34 @@
|
||||
"""统一配置入口 — 整个项目的配置都从这里获取。
|
||||
|
||||
所有服务共享同一个配置包,消除重复定义和不一致。
|
||||
|
||||
用法:
|
||||
from packages.config import get_api_settings, get_worker_settings, get_shared_settings
|
||||
from packages.config import APISettings, WorkerSettings, SharedSettings
|
||||
|
||||
架构:
|
||||
packages/config/
|
||||
├── base.py # SharedSettings 基类 + 统一单例管理
|
||||
├── api_settings.py # APISettings(API 特有配置)
|
||||
└── worker_settings.py # WorkerSettings(Worker 特有配置)
|
||||
"""
|
||||
|
||||
from packages.config.api_settings import APISettings, get_api_settings
|
||||
from packages.config.base import (
|
||||
SharedSettings,
|
||||
get_cached_settings,
|
||||
get_shared_settings,
|
||||
reload_settings_cache,
|
||||
)
|
||||
from packages.config.worker_settings import WorkerSettings, get_worker_settings
|
||||
|
||||
__all__ = [
|
||||
"SharedSettings",
|
||||
"APISettings",
|
||||
"WorkerSettings",
|
||||
"get_shared_settings",
|
||||
"get_api_settings",
|
||||
"get_worker_settings",
|
||||
"get_cached_settings",
|
||||
"reload_settings_cache",
|
||||
]
|
||||
Executable
+286
@@ -0,0 +1,286 @@
|
||||
"""API 服务配置 — 继承 SharedSettings,只追加 API 特有字段。
|
||||
|
||||
通用配置统一在 packages/config/base.py 的 SharedSettings 中定义,这里不重复。
|
||||
历史上 API 端使用 UPPER_CASE 命名风格的字段,目前通过 property 别名向后兼容。
|
||||
新代码统一使用 snake_case(继承自 SharedSettings)。
|
||||
"""
|
||||
|
||||
import os
|
||||
from typing import Optional
|
||||
|
||||
from pydantic import AliasChoices, Field, field_validator
|
||||
from pydantic_settings import SettingsConfigDict
|
||||
|
||||
from packages.config.base import SharedSettings, get_cached_settings
|
||||
|
||||
|
||||
class APISettings(SharedSettings):
|
||||
"""API 服务专用配置。
|
||||
|
||||
通用配置继承自 SharedSettings,这里只定义 API 独有字段。
|
||||
"""
|
||||
|
||||
# ── 应用基本信息 ────────────────────────────────────────────────────
|
||||
app_name: str = "xiaoxia-saas"
|
||||
app_version: str = "0.1.61"
|
||||
|
||||
# 应用基础 URL,用于生成认证邮件中的链接
|
||||
app_base_url: str = "http://localhost:3000"
|
||||
|
||||
# Container bind address; external expose is controlled by Docker/Nginx.
|
||||
api_host: str = "0.0.0.0" # nosec: B104
|
||||
api_port: int = 8000
|
||||
|
||||
# ── 数据库特有 ──────────────────────────────────────────────────────
|
||||
use_in_memory_db: bool = False
|
||||
|
||||
# ── Redis 特有 ──────────────────────────────────────────────────────
|
||||
enable_redis_sessions: bool = False
|
||||
|
||||
# ── JWT ────────────────────────────────────────────────────────────
|
||||
# JWT secret key - MUST be set via environment variable, no default allowed
|
||||
jwt_secret_key: Optional[str] = None
|
||||
|
||||
# JWT 算法与过期时间
|
||||
jwt_algorithm: str = "HS256"
|
||||
jwt_access_token_expire_minutes: int = 30
|
||||
jwt_refresh_token_expire_days: int = 30
|
||||
|
||||
@field_validator("jwt_secret_key", mode="before")
|
||||
@classmethod
|
||||
def validate_jwt_secret_key(cls, v):
|
||||
if v is None or v == "":
|
||||
raise ValueError(
|
||||
"JWT_SECRET_KEY must be set via environment variable. " "Do not use default value in production!"
|
||||
)
|
||||
# Block known insecure default values
|
||||
insecure_defaults = [
|
||||
"your-secret-key-change-in-production",
|
||||
"your-secret-key",
|
||||
"secret",
|
||||
"changeme",
|
||||
"password",
|
||||
]
|
||||
if v.lower() in [d.lower() for d in insecure_defaults]:
|
||||
raise ValueError(
|
||||
f"JWT_SECRET_KEY '{v}' is insecure. " "Please set a strong random secret via environment variable."
|
||||
)
|
||||
return v
|
||||
|
||||
# ── 邮件 ────────────────────────────────────────────────────────────
|
||||
enable_email_delivery: bool = False
|
||||
smtp_host: str = "smtp.gmail.com"
|
||||
smtp_port: int = 587
|
||||
smtp_user: str = ""
|
||||
smtp_password: str = ""
|
||||
smtp_from_email: str = ""
|
||||
smtp_from_name: str = "小虾 SaaS"
|
||||
smtp_use_tls: bool = True
|
||||
|
||||
# ── OSS 特有校验 ────────────────────────────────────────────────────
|
||||
@field_validator("oss_access_key_id", mode="before")
|
||||
@classmethod
|
||||
def validate_oss_access_key_id(cls, v):
|
||||
if (v is None or v == "") and os.getenv("APP_ENV", "development") != "development":
|
||||
raise ValueError(
|
||||
"OSS_ACCESS_KEY_ID must be set via environment variable in non-development environments. "
|
||||
"Check the server .env file (e.g. /var/lib/xiaoxia-saas-staging/.env)."
|
||||
)
|
||||
return v or ""
|
||||
|
||||
@field_validator("oss_access_key_secret", mode="before")
|
||||
@classmethod
|
||||
def validate_oss_access_key_secret(cls, v):
|
||||
if (v is None or v == "") and os.getenv("APP_ENV", "development") != "development":
|
||||
raise ValueError(
|
||||
"OSS_ACCESS_KEY_SECRET must be set via environment variable in non-development environments. "
|
||||
"Check the server .env file (e.g. /var/lib/xiaoxia-saas-staging/.env)."
|
||||
)
|
||||
return v or ""
|
||||
|
||||
oss_direct_upload_max_mb: int = Field(
|
||||
default=2000,
|
||||
validation_alias=AliasChoices("oss_direct_upload_max_mb", "max_upload_size_mb"),
|
||||
)
|
||||
|
||||
# ── CORS ────────────────────────────────────────────────────────────
|
||||
cors_origins_raw: str = "http://localhost:3000,http://localhost:5173,http://localhost:8000"
|
||||
|
||||
# ── 渲染引擎 ────────────────────────────────────────────────────────
|
||||
# 渲染引擎选择:legacy=旧VideoComposeService,unified=新UnifiedRenderService
|
||||
render_engine: str = "legacy"
|
||||
|
||||
model_config = SettingsConfigDict(
|
||||
env_file=".env",
|
||||
env_file_encoding="utf-8",
|
||||
case_sensitive=False,
|
||||
extra="ignore",
|
||||
)
|
||||
|
||||
@property
|
||||
def cors_origins(self) -> list[str]:
|
||||
return [origin.strip() for origin in self.cors_origins_raw.split(",") if origin.strip()]
|
||||
|
||||
# ── 向后兼容:UPPER_CASE property 别名 ──────────────────────────────
|
||||
# 新代码请使用 snake_case(继承的字段名),以下别名仅用于兼容旧代码
|
||||
|
||||
@property
|
||||
def APP_NAME(self) -> str:
|
||||
return self.app_name
|
||||
|
||||
@property
|
||||
def APP_VERSION(self) -> str:
|
||||
return self.app_version
|
||||
|
||||
@property
|
||||
def ENVIRONMENT(self) -> str:
|
||||
return self.environment
|
||||
|
||||
@property
|
||||
def DEBUG(self) -> bool:
|
||||
return self.debug
|
||||
|
||||
@property
|
||||
def APP_BASE_URL(self) -> str:
|
||||
return self.app_base_url
|
||||
|
||||
@property
|
||||
def API_HOST(self) -> str:
|
||||
return self.api_host
|
||||
|
||||
@property
|
||||
def API_PORT(self) -> int:
|
||||
return self.api_port
|
||||
|
||||
@property
|
||||
def DATABASE_URL(self) -> str:
|
||||
return self.database_url
|
||||
|
||||
@property
|
||||
def DATABASE_POOL_SIZE(self) -> int:
|
||||
return self.database_pool_size
|
||||
|
||||
@property
|
||||
def DATABASE_MAX_OVERFLOW(self) -> int:
|
||||
return self.database_max_overflow
|
||||
|
||||
@property
|
||||
def DATABASE_POOL_TIMEOUT(self) -> int:
|
||||
return self.database_pool_timeout
|
||||
|
||||
@property
|
||||
def DATABASE_POOL_RECYCLE(self) -> int:
|
||||
return self.database_pool_recycle
|
||||
|
||||
@property
|
||||
def USE_IN_MEMORY_DB(self) -> bool:
|
||||
return self.use_in_memory_db
|
||||
|
||||
@property
|
||||
def AUTO_CREATE_SCHEMA(self) -> bool:
|
||||
return self.auto_create_schema
|
||||
|
||||
@property
|
||||
def REDIS_URL(self) -> str:
|
||||
return self.redis_url
|
||||
|
||||
@property
|
||||
def ENABLE_REDIS_SESSIONS(self) -> bool:
|
||||
return self.enable_redis_sessions
|
||||
|
||||
@property
|
||||
def JWT_SECRET_KEY(self) -> Optional[str]:
|
||||
return self.jwt_secret_key
|
||||
|
||||
@property
|
||||
def JWT_ALGORITHM(self) -> str:
|
||||
return self.jwt_algorithm
|
||||
|
||||
@property
|
||||
def JWT_ACCESS_TOKEN_EXPIRE_MINUTES(self) -> int:
|
||||
return self.jwt_access_token_expire_minutes
|
||||
|
||||
@property
|
||||
def JWT_REFRESH_TOKEN_EXPIRE_DAYS(self) -> int:
|
||||
return self.jwt_refresh_token_expire_days
|
||||
|
||||
@property
|
||||
def ENABLE_EMAIL_DELIVERY(self) -> bool:
|
||||
return self.enable_email_delivery
|
||||
|
||||
@property
|
||||
def SMTP_HOST(self) -> str:
|
||||
return self.smtp_host
|
||||
|
||||
@property
|
||||
def SMTP_PORT(self) -> int:
|
||||
return self.smtp_port
|
||||
|
||||
@property
|
||||
def SMTP_USER(self) -> str:
|
||||
return self.smtp_user
|
||||
|
||||
@property
|
||||
def SMTP_PASSWORD(self) -> str:
|
||||
return self.smtp_password
|
||||
|
||||
@property
|
||||
def SMTP_FROM_EMAIL(self) -> str:
|
||||
return self.smtp_from_email
|
||||
|
||||
@property
|
||||
def SMTP_FROM_NAME(self) -> str:
|
||||
return self.smtp_from_name
|
||||
|
||||
@property
|
||||
def SMTP_USE_TLS(self) -> bool:
|
||||
return self.smtp_use_tls
|
||||
|
||||
@property
|
||||
def CELERY_BROKER_URL(self) -> str:
|
||||
return self.celery_broker_url
|
||||
|
||||
@property
|
||||
def CELERY_RESULT_BACKEND(self) -> str:
|
||||
return self.celery_result_backend
|
||||
|
||||
@property
|
||||
def OSS_ENDPOINT(self) -> str:
|
||||
return self.oss_endpoint
|
||||
|
||||
@property
|
||||
def OSS_ACCESS_KEY_ID(self) -> str:
|
||||
return self.oss_access_key_id
|
||||
|
||||
@property
|
||||
def OSS_ACCESS_KEY_SECRET(self) -> str:
|
||||
return self.oss_access_key_secret
|
||||
|
||||
@property
|
||||
def OSS_BUCKET_NAME(self) -> str:
|
||||
return self.oss_bucket_name
|
||||
|
||||
@property
|
||||
def OSS_DIRECT_UPLOAD_MAX_MB(self) -> int:
|
||||
return self.oss_direct_upload_max_mb
|
||||
|
||||
@property
|
||||
def OSS_DIRECT_UPLOAD_EXPIRE_SECONDS(self) -> int:
|
||||
return self.oss_direct_upload_expire_seconds
|
||||
|
||||
@property
|
||||
def CORS_ORIGINS_RAW(self) -> str:
|
||||
return self.cors_origins_raw
|
||||
|
||||
@property
|
||||
def CORS_ORIGINS(self) -> list[str]:
|
||||
return self.cors_origins
|
||||
|
||||
@property
|
||||
def RENDER_ENGINE(self) -> str:
|
||||
return self.render_engine
|
||||
|
||||
|
||||
def get_api_settings() -> APISettings:
|
||||
"""获取 API 配置单例(统一入口)。"""
|
||||
return get_cached_settings(APISettings)
|
||||
Executable
+110
@@ -0,0 +1,110 @@
|
||||
"""统一配置基类 — 所有服务共享的基础配置。
|
||||
|
||||
数据库/Redis/OSS/Celery/AI服务等通用配置统一定义在此。
|
||||
API 和 Worker 各自的 Settings 类继承本类,只追加服务特有字段。
|
||||
单例模式和 env 文件加载逻辑也统一在这里实现。
|
||||
"""
|
||||
|
||||
import os
|
||||
from typing import Optional, Type, TypeVar
|
||||
|
||||
from pydantic_settings import BaseSettings, SettingsConfigDict
|
||||
|
||||
T = TypeVar("T", bound=BaseSettings)
|
||||
|
||||
|
||||
class SharedSettings(BaseSettings):
|
||||
"""所有服务共享的基础配置。
|
||||
|
||||
API 和 Worker 都继承本类,确保:
|
||||
1. 数据库/Redis/OSS/Celery 等核心配置默认值一致
|
||||
2. 环境变量命名统一(snake_case,pydantic-settings 自动兼容大写)
|
||||
3. env 文件加载逻辑只实现一次
|
||||
"""
|
||||
|
||||
# ── 环境 ──────────────────────────────────────────────────────────────
|
||||
environment: str = "development"
|
||||
debug: bool = True
|
||||
auto_create_schema: bool = False
|
||||
|
||||
# ── 数据库 ────────────────────────────────────────────────────────────
|
||||
database_url: str = "postgresql+psycopg://postgres:postgres@localhost:5432/xiaoxia_saas"
|
||||
database_pool_size: int = 20
|
||||
database_max_overflow: int = 10 # pool_size(20) + max_overflow(10) = 最大30连接
|
||||
database_pool_timeout: int = 30
|
||||
database_pool_recycle: int = 3600
|
||||
|
||||
# ── Redis ────────────────────────────────────────────────────────────
|
||||
redis_url: str = "redis://localhost:6379/0"
|
||||
|
||||
# ── Celery ───────────────────────────────────────────────────────────
|
||||
celery_broker_url: str = "redis://localhost:6379/0"
|
||||
celery_result_backend: str = "redis://localhost:6379/1"
|
||||
|
||||
# ── OSS 阿里云 ──────────────────────────────────────────────────────
|
||||
oss_endpoint: str = "oss-cn-hangzhou.aliyuncs.com"
|
||||
oss_access_key_id: str = ""
|
||||
oss_access_key_secret: str = ""
|
||||
oss_bucket_name: str = "xiaoxia-autocut"
|
||||
oss_direct_upload_max_mb: int = 2000
|
||||
oss_direct_upload_expire_seconds: int = 900
|
||||
|
||||
# ── CosyVoice (阿里云百炼语音合成) ───────────────────────────────────
|
||||
cosyvoice_api_key: str = ""
|
||||
cosyvoice_base_url: str = "https://dashscope.aliyuncs.com/api/v1"
|
||||
cosyvoice_model: str = "cosyvoice-v3-flash"
|
||||
cosyvoice_voice: str = "longxiaochun_v3" # 默认音色(v3 系列系统音色带 _v3 后缀)
|
||||
cosyvoice_sample_rate: int = 22050
|
||||
cosyvoice_format: str = "mp3" # 输出格式:mp3/wav/pcm
|
||||
# 音色克隆模型名(固定为 voice-enrollment)
|
||||
cosyvoice_clone_model: str = "voice-enrollment"
|
||||
|
||||
# ── 豆包大模型(火山引擎方舟) ────────────────────────────────────────
|
||||
doubao_api_key: str = ""
|
||||
doubao_model: str = "doubao-seed-1-6-250615"
|
||||
doubao_base_url: str = "https://ark.cn-beijing.volces.com/api/v3"
|
||||
doubao_timeout: int = 30
|
||||
doubao_max_retries: int = 2
|
||||
|
||||
model_config = SettingsConfigDict(
|
||||
env_file=".env",
|
||||
env_file_encoding="utf-8",
|
||||
case_sensitive=False,
|
||||
extra="ignore",
|
||||
)
|
||||
|
||||
|
||||
# ── 统一单例管理 ────────────────────────────────────────────────────────
|
||||
# 所有 Settings 类的单例缓存都在这里,消除每处各自实现的重复代码
|
||||
|
||||
_settings_cache: dict[str, BaseSettings] = {}
|
||||
|
||||
|
||||
def _get_env_file() -> str:
|
||||
"""根据 APP_ENV 决定读取哪个 env 文件。"""
|
||||
env = os.getenv("APP_ENV", "development")
|
||||
env_file = f".env.{env}" if env != "development" else ".env"
|
||||
return env_file if os.path.exists(env_file) else ".env"
|
||||
|
||||
|
||||
def get_cached_settings(settings_class: Type[T], cache_key: Optional[str] = None) -> T:
|
||||
"""统一的 Settings 单例获取函数。
|
||||
|
||||
所有服务都通过这个函数获取配置,消除重复的单例实现。
|
||||
按类名缓存,同一类只初始化一次。
|
||||
"""
|
||||
key = cache_key or settings_class.__name__
|
||||
if key not in _settings_cache:
|
||||
env_file = _get_env_file()
|
||||
_settings_cache[key] = settings_class(_env_file=env_file)
|
||||
return _settings_cache[key] # type: ignore[return-value]
|
||||
|
||||
|
||||
def reload_settings_cache() -> None:
|
||||
"""清空配置缓存,下次获取时重新加载。测试用。"""
|
||||
_settings_cache.clear()
|
||||
|
||||
|
||||
def get_shared_settings() -> SharedSettings:
|
||||
"""获取共享配置单例(统一入口)。"""
|
||||
return get_cached_settings(SharedSettings)
|
||||
Executable
+44
@@ -0,0 +1,44 @@
|
||||
"""Worker 服务配置 — 继承 SharedSettings,只追加 Worker 特有字段。
|
||||
|
||||
通用配置(DB/Redis/Celery/OSS/CosyVoice/Doubao 等)统一在
|
||||
packages/config/base.py 的 SharedSettings 中定义,这里不重复。
|
||||
"""
|
||||
|
||||
from pydantic_settings import SettingsConfigDict
|
||||
|
||||
from packages.config.base import SharedSettings, get_cached_settings
|
||||
|
||||
|
||||
class WorkerSettings(SharedSettings):
|
||||
"""Worker 服务专用配置。
|
||||
|
||||
通用配置继承自 SharedSettings,这里只定义 Worker 独有字段。
|
||||
Celery broker/backend 使用继承的 celery_broker_url / celery_result_backend;
|
||||
历史上 Worker 使用 broker_url / result_backend 字段名,通过 property 别名兼容。
|
||||
"""
|
||||
|
||||
# ── Worker 特有 ────────────────────────────────────────────────────
|
||||
worker_name: str = "xiaoxia-saas-worker"
|
||||
worker_concurrency: int = 4
|
||||
worker_max_tasks_per_child: int = 1000
|
||||
|
||||
model_config = SettingsConfigDict(
|
||||
env_file=".env",
|
||||
env_file_encoding="utf-8",
|
||||
case_sensitive=False,
|
||||
extra="ignore",
|
||||
)
|
||||
|
||||
# ── 向后兼容:Celery 字段名别名 ──────────────────────────────────
|
||||
@property
|
||||
def broker_url(self) -> str:
|
||||
return self.celery_broker_url
|
||||
|
||||
@property
|
||||
def result_backend(self) -> str:
|
||||
return self.celery_result_backend
|
||||
|
||||
|
||||
def get_worker_settings() -> WorkerSettings:
|
||||
"""获取 Worker 配置单例(统一入口)。"""
|
||||
return get_cached_settings(WorkerSettings)
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user