Files
meijiaka-zy/python-api/app/services/material_service.py
T
小鱼开发 b597d715c8 fix: 认证流程修复 + alembic 迁移补全 + 前端僵尸代码清理
后端:
- 修复 get_current_user 未校验 is_active,被封禁用户仍可用旧 Token
- auth.py 捕获 ValueError 转 HTTPException(验证码错误、账号被封、Token 无效等不再返回 500)
- 修正 SMS 每日上限注释(3次 → 10次)
- 修复迁移脚本外键引用错误:users.id → mjk_users.id
- 新建积分系统 4 张表的迁移(mjk_user_points/batches/transactions/recharge_orders)
- pyproject.toml 补充 alembic + psycopg2-binary 依赖
- ruff 格式修复(import 排序等)

前端:
- 修复 doRefreshToken 成功后不持久化新 Token 的严重 bug
- 修复应用重启后 SSE 不自动重连(收不到踢人通知)
- 修复 App.tsx handleLogout 未 await
- client.ts 统一从 utils/env 导入 isTauri,默认 base URL 兜底 localhost:8000
- 清理 ~20 个未使用的 hooks/utils/api 模块/组件导出
- 修复所有 ESLint 警告(206 → 0)和 TSC 错误
- 测试通过(5/5)

其他:
- 更新 requirements.lock 和 uv.lock
2026-05-08 11:10:48 +08:00

165 lines
5.0 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
空镜素材服务
============
优先从远程 CDN 加载素材配置,失败时 fallback 到本地 JSON 文件。
支持手动 reload,新增素材只需更新远程配置文件即可生效。
duration 从文件名 `_{N}s_` 中自动解析。
"""
import json
import logging
import random
import re
from pathlib import Path
import httpx
from pydantic import ValidationError
from app.schemas.materials import MaterialsConfig
# 正则:从文件名中提取时长,如 plumbing_10s_a23f8fcb.mp4 → 10
_DURATION_RE = re.compile(r"_(\d+)s_")
logger = logging.getLogger(__name__)
# 远程配置 URL(七牛云 CDN
REMOTE_CONFIG_URL = "https://media.liche.cn/meijiaka-zy/materials.json"
# 配置缓存(启动时加载,运行时 reload 更新)
_keywords: dict[str, str] = {}
_materials: dict[str, list[dict]] = {}
def _get_config_path() -> Path:
"""获取本地配置文件绝对路径"""
return Path(__file__).resolve().parent.parent.parent / "config" / "materials.json"
def _parse_duration(url: str) -> float:
"""从 URL 文件名中解析时长(秒)"""
filename = url.split("/")[-1]
match = _DURATION_RE.search(filename)
if not match:
raise ValueError(f"无法从文件名解析时长: {filename}")
return float(match.group(1))
def _apply_config(data: dict) -> None:
"""将原始配置数据解析到内存缓存"""
global _keywords, _materials
_keywords = data.get("keywords", {})
raw_materials = data.get("materials", {})
_materials = {}
for slug, entries in raw_materials.items():
parsed = []
for entry in entries:
url = entry["url"]
duration = _parse_duration(url)
parsed.append({"url": url, "duration": duration})
_materials[slug] = parsed
def _validate_config(data: dict) -> dict:
"""使用 Pydantic Schema 校验配置结构,失败时抛出 ValidationError"""
try:
validated = MaterialsConfig.model_validate(data)
return validated.model_dump()
except ValidationError as e:
logger.error(f"素材配置格式校验失败: {e}")
raise ValueError(f"素材配置格式错误: {e}") from e
def load_config() -> None:
"""加载素材配置:优先远程 CDN,fallback 本地文件"""
global _keywords, _materials
# 1. 尝试从远程 CDN 加载
try:
with httpx.Client(timeout=10.0) as client:
response = client.get(REMOTE_CONFIG_URL)
response.raise_for_status()
data = response.json()
_validate_config(data)
_apply_config(data)
return
except Exception as e:
# 远程加载失败,记录日志后继续尝试本地
logger.warning(f"远程素材配置加载失败: {e},尝试本地 fallback")
# 2. Fallback 到本地文件
config_path = _get_config_path()
if not config_path.exists():
raise FileNotFoundError(f"素材配置文件不存在: {config_path}")
with open(config_path, encoding="utf-8") as f:
data = json.load(f)
_validate_config(data)
_apply_config(data)
def reload_config() -> tuple[bool, str]:
"""手动重新加载配置(用于更新素材后即时生效)
Returns:
(是否成功, 错误信息)。成功时错误信息为空字符串。
"""
try:
load_config()
return True, ""
except Exception as e:
logger.warning(f"素材配置重载失败: {e}")
return False, str(e)
def match_material(
scene: str,
required_duration: float,
exclude_urls: list[str] | None = None,
strict: bool = False,
) -> dict | None:
"""
根据场景描述和所需时长匹配空镜素材
策略:
1. 严格匹配分类(scene 必须完全匹配 keywords 中的关键词)
2. 过滤掉时长小于 required_duration 的素材
3. 从剩余素材中排除已使用的,随机选取
Args:
scene: 分镜场景描述
required_duration: 所需时长(秒)
exclude_urls: 已使用的素材 URL 列表(避免重复)
Returns:
匹配到的素材 {url, duration},无匹配返回 None
"""
exclude_urls = exclude_urls or []
for keyword, slug in _keywords.items():
if keyword == scene:
all_materials = _materials.get(slug, [])
if not all_materials:
return None
# 1. 过滤掉时长小于 required_duration 的素材
matching = [m for m in all_materials if m["duration"] >= required_duration]
if not matching:
return None
# 2. 排除已使用的,从中随机选
unused = [m for m in matching if m["url"] not in exclude_urls]
if unused:
return random.choice(unused)
# 3. 严格模式下不允许返回已排除的素材
if strict:
return None
# 4. 非严格模式:全部用完则允许重复
return random.choice(matching)
return None