This commit is contained in:
2026-07-16 15:02:14 +08:00
27 changed files with 1689 additions and 1155 deletions
+81
View File
@@ -29,6 +29,7 @@ from app.schemas.admin import (
CreditAdjustRequest,
ModelConfigCreate,
ModelConfigOut,
SystemConfigCreate,
SystemConfigUpdate,
SystemConfigOut,
AdminUserOut,
@@ -1594,6 +1595,34 @@ async def list_system_configs(
return result.scalars().all()
@router.post("/system-configs", response_model=SystemConfigOut)
async def create_system_config(
req: SystemConfigCreate,
admin: User = Depends(get_admin_user),
db: AsyncSession = Depends(get_db),
):
from app.utils.id_gen import generate_id
config = SystemConfig(
id=generate_id(),
key=req.key,
value=str(req.value),
description=req.description or "",
)
db.add(config)
await db.flush()
await log_operation(
db,
admin.id,
admin.username,
f"创建系统配置: {config.key}",
"POST",
"/admin/system-configs",
detail=json.dumps({"key": req.key, "value": req.value}, ensure_ascii=False),
)
await db.commit()
return config
@router.put("/system-configs/{config_id}", response_model=SystemConfigOut)
async def update_system_config(
config_id: str,
@@ -2310,6 +2339,58 @@ async def upload_logo(
return {"url": url}
@router.post("/upload-login-video")
async def upload_login_video(
file: UploadFile = File(...),
admin: User = Depends(get_admin_user),
db: AsyncSession = Depends(get_db),
):
"""上传登录页背景视频/动图,保存 URL 到 system config login_bg_video。"""
from app.config import settings
if not file.filename:
raise HTTPException(status_code=400, detail="请选择文件")
content = await file.read()
if len(content) > 50 * 1024 * 1024:
raise HTTPException(status_code=400, detail="文件大小不能超过50MB")
ext = os.path.splitext(file.filename)[1].lower()
safe_name = f"login_bg_{generate_id()}{ext}"
file_path = os.path.join(settings.UPLOAD_LOCAL_PATH, safe_name)
with open(file_path, "wb") as f:
f.write(content)
url = f"/uploads/{safe_name}"
result = await db.execute(
select(SystemConfig).where(SystemConfig.key == "login_bg_video").limit(1)
)
config = result.scalar_one_or_none()
if config:
config.value = url
else:
db.add(SystemConfig(
id=generate_id(),
key="login_bg_video",
value=url,
description="登录页背景视频",
))
await db.flush()
await log_operation(
db,
admin.id,
admin.username,
f"上传登录背景视频: {file.filename}",
"POST",
"/admin/upload-login-video",
detail=json.dumps({"filename": file.filename, "url": url}, ensure_ascii=False),
)
await db.commit()
return {"url": url}
# ── Payment Stats ────────────────────────────────────────
+2 -1
View File
@@ -342,7 +342,7 @@ async def get_site_info(db: AsyncSession = Depends(get_db)):
"""Public endpoint returning site name, logo, agreement and copyright info."""
result = await db.execute(
select(SystemConfig).where(SystemConfig.key.in_([
"site_name", "site_logo", "user_agreement_privacy_url", "site_copyright", "operation_manual"
"site_name", "site_logo", "user_agreement_privacy_url", "site_copyright", "operation_manual", "login_bg_video"
]))
)
configs = result.scalars().all()
@@ -365,6 +365,7 @@ async def get_site_info(db: AsyncSession = Depends(get_db)):
"user_agreement_privacy_url": to_full_url(info.get("user_agreement_privacy_url")),
"site_copyright": info.get("site_copyright", "© 2024 民众智创 版权所有"),
"operation_manual": info.get("operation_manual", ""),
"login_bg_video": to_full_url(info.get("login_bg_video")) if info.get("login_bg_video") else "",
}
+1
View File
@@ -25,6 +25,7 @@ class Settings(BaseSettings):
LLM_API_KEY: str = ""
LLM_MODEL: str = "gpt-4o"
LLM_MOCK: bool = True
LLM_MEDIA_AS_BASE64: bool = True
ENCRYPTION_KEY: str = "changeme-32bytes-base64-key-here!!"
+14
View File
@@ -293,6 +293,20 @@ async def _seed_data():
)
)
# 文字模型媒体使用 base64 开关
existing_media_format = await db.execute(
select(SystemConfig).where(SystemConfig.key == "llm_media_as_base64").limit(1)
)
if not existing_media_format.scalar_one_or_none():
db.add(
SystemConfig(
id=generate_id(),
key="llm_media_as_base64",
value="true",
description="文字模型请求时图片/视频使用 base64 编码(而非 URL 链接)",
)
)
# Seed credit ratios - model_config_id is kept as a compatible field name,
# but now stores the actual engine id:
# - gen_type=video -> video_engines.id
+6
View File
@@ -29,6 +29,12 @@ class ModelConfigOut(ModelConfigCreate):
model_config = {"from_attributes": True}
class SystemConfigCreate(BaseModel):
key: str
value: str
description: str | None = None
class SystemConfigUpdate(BaseModel):
value: str | int | float
@@ -36,7 +36,9 @@ def _load_refs(record: ChatGenerationTask) -> list[dict]:
return []
def _build_user_content(record: ChatGenerationTask) -> list[dict[str, Any]]:
async def _build_user_content(record: ChatGenerationTask, db: AsyncSession | None = None) -> list[dict[str, Any]]:
from app.utils.media import media_to_base64
if record.gen_type == "image":
params = f"图片参数:分辨率档位={record.image_size or '2K'},比例={record.image_proportion or '1:1'},像素={record.image_px or '2048x2048'}"
else:
@@ -54,7 +56,15 @@ def _build_user_content(record: ChatGenerationTask) -> list[dict[str, Any]]:
ref_url = ref.get("url") or ""
if not ref_url:
continue
url = _absolute_url(ref_url)
if db and await get_llm_media_as_base64(db):
if ref_type == "image":
url = await media_to_base64(ref_url, "image/png")
elif ref_type == "video":
url = await media_to_base64(ref_url, "video/mp4")
else:
continue
else:
url = _absolute_url(ref_url)
if ref_type == "image":
parts.append({"type": "image_url", "image_url": {"url": url}})
elif ref_type == "video":
@@ -94,7 +104,7 @@ async def build_prompt_with_chatapi(db: AsyncSession, record: ChatGenerationTask
"model": config.model_name,
"messages": [
{"role": "system", "content": system_prompt},
{"role": "user", "content": _build_user_content(record)},
{"role": "user", "content": await _build_user_content(record, db)},
],
"max_tokens": config.max_tokens,
"temperature": config.temperature,
@@ -1536,9 +1536,16 @@ async def optimize_hot_opening_video_prompt(
trace_id: str | None = None,
) -> tuple[dict[str, Any], str, dict[str, Any]]:
duration = int(video_config["duration"])
from app.utils.media import media_to_base64, get_llm_media_as_base64
if await get_llm_media_as_base64(db):
video_url_final = await media_to_base64(material_video_url, "video/mp4")
image_url_final = await media_to_base64(generated_image_url, "image/png")
else:
video_url_final = _build_file_url_or_data_uri(material_video_url)
image_url_final = _build_file_url_or_data_uri(generated_image_url)
references = [
{"type": "video", "url": _build_file_url_or_data_uri(material_video_url)},
{"type": "image", "url": _build_file_url_or_data_uri(generated_image_url)},
{"type": "video", "url": video_url_final},
{"type": "image", "url": image_url_final},
]
client_schema = build_dynamic_schema(video_config, schema_config_snapshot)
reference_video_fps = int(video_config.get("reference_video_fps") or DEFAULT_REFERENCE_VIDEO_FPS)
+60 -49
View File
@@ -248,7 +248,63 @@ async def _call_openai_compatible(
if ref_type == "video":
video_urls.append(ref_url)
def _build_file_url_or_data_uri(file_url: str, fallback_mime: str) -> str:
async def _build_multimodal_content(
user_content: str,
image_urls: list[str],
video_urls: list[str],
) -> tuple[dict, dict | None]:
"""构建多模态 user_message。返回 (actual_message, log_message)。"""
from app.utils.media import media_to_base64
content_parts = [{"type": "text", "text": user_content}]
from app.utils.media import get_llm_media_as_base64
as_base64 = await get_llm_media_as_base64(db)
for img in image_urls:
if as_base64:
url = await media_to_base64(img, "image/png")
else:
url = _file_url_or_data_uri(img, "image/png")
content_parts.append({
"type": "image_url",
"image_url": {"url": url},
})
for video in video_urls:
if as_base64:
url = await media_to_base64(video, "video/mp4")
else:
url = _file_url_or_data_uri(video, "video/mp4")
content_parts.append({
"type": "video_url",
"video_url": {"url": url},
})
user_message = {
"role": "user",
"content": content_parts,
}
# Log-friendly version: keep original paths instead of base64
log_content_parts = [{"type": "text", "text": user_content}]
for img in image_urls:
log_content_parts.append({
"type": "image_url",
"image_url": {"url": img},
})
for video in video_urls:
log_content_parts.append({
"type": "video_url",
"video_url": {"url": video},
})
log_user_message = {
"role": "user",
"content": log_content_parts,
}
return user_message, log_user_message
def _file_url_or_data_uri(file_url: str, fallback_mime: str) -> str:
"""
Convert local upload path to base64 data URI.
Keep remote http/https/data URLs as-is.
@@ -268,54 +324,9 @@ async def _call_openai_compatible(
return f"data:{mime};base64,{b64}"
if image_urls or video_urls:
content_parts = [{"type": "text", "text": user_content}]
for img in image_urls:
url = _build_file_url_or_data_uri(img, "image/png")
content_parts.append({
"type": "image_url",
"image_url": {
"url": url,
},
})
for video in video_urls:
url = _build_file_url_or_data_uri(video, "video/mp4")
content_parts.append({
"type": "video_url",
"video_url": {
"url": url,
},
})
user_message = {
"role": "user",
"content": content_parts,
}
# Log-friendly version: keep original paths instead of base64
log_content_parts = [{"type": "text", "text": user_content}]
for img in image_urls:
log_content_parts.append({
"type": "image_url",
"image_url": {
"url": img,
},
})
for video in video_urls:
log_content_parts.append({
"type": "video_url",
"video_url": {
"url": video,
},
})
log_user_message = {
"role": "user",
"content": log_content_parts,
}
user_message, log_user_message = await _build_multimodal_content(
user_content, image_urls, video_urls
)
else:
user_message = {
"role": "user",
@@ -77,8 +77,12 @@ def build_file_url_or_data_uri(file_url: str, fallback_mime: str = "video/mp4")
# return f"data:{mime};base64,{b64}"
def build_user_message(user_text: str, video_url: str) -> tuple[dict[str, Any], dict[str, Any], str]:
real_url = build_file_url_or_data_uri(video_url)
async def build_user_message(user_text: str, video_url: str, db=None) -> tuple[dict[str, Any], dict[str, Any], str]:
from app.utils.media import media_to_base64, get_llm_media_as_base64
if await get_llm_media_as_base64(db):
real_url = await media_to_base64(video_url, "video/mp4")
else:
real_url = build_file_url_or_data_uri(video_url)
content_parts = [
{
"type": "video_url",
@@ -543,7 +547,7 @@ async def analyze_video_for_shot_split(
system_prompt = build_video_analysis_system_prompt(mode=mode)
user_text = build_video_analysis_user_text(mode=mode)
user_message, log_user_message, real_video_url = build_user_message(user_text, video_url)
user_message, log_user_message, real_video_url = await build_user_message(user_text, video_url, db)
request_data: dict[str, Any] = {
"model": config.model_name,
+54
View File
@@ -0,0 +1,54 @@
"""媒体文件 → base64 data URI 转换工具。"""
import base64
import mimetypes
import os
import httpx
from sqlalchemy import select
from app.config import settings
from app.models.system_config import SystemConfig
async def get_llm_media_as_base64(db=None) -> bool:
"""读取 SystemConfig 中的 llm_media_as_base64 设置。无 DB 连接时回退到 env。"""
if db is not None:
try:
result = await db.execute(
select(SystemConfig).where(SystemConfig.key == "llm_media_as_base64").limit(1)
)
config = result.scalar_one_or_none()
if config:
return config.value.lower() in ("true", "1", "yes")
except Exception:
pass
return settings.LLM_MEDIA_AS_BASE64
async def media_to_base64(url: str, fallback_mime: str = "image/png", max_mb: int = 20) -> str:
"""将任意媒体 URL/路径转为 base64 data URI。
- data: URI → 原样返回
- http(s):// → 下载后编码(受 max_mb 限制)
- /uploads/xxx 或本地路径 → 读盘编码
"""
if url.startswith("data:"):
return url
if url.startswith(("http://", "https://")):
async with httpx.AsyncClient(timeout=60) as client:
resp = await client.get(url)
size_mb = len(resp.content) / (1024 * 1024)
if size_mb > max_mb:
raise ValueError(f"媒体文件过大 ({size_mb:.1f}MB > {max_mb}MB)")
b64 = base64.b64encode(resp.content).decode()
mime = resp.headers.get("content-type") or fallback_mime
return f"data:{mime};base64,{b64}"
# 本地路径
relative_path = url.replace("/uploads/", "", 1).lstrip("/")
file_path = os.path.join(settings.UPLOAD_LOCAL_PATH, relative_path)
mime = mimetypes.guess_type(file_path)[0] or fallback_mime
with open(file_path, "rb") as f:
b64 = base64.b64encode(f.read()).decode()
return f"data:{mime};base64,{b64}"