Files
YuShiSheJiShi/backend/app/services/design_service.py
18dfc75372 fix(backend): 修复视频生成接口401认证失败问题
- 修正可灵视频任务提交时认证失败导致的视频生成错误
- 解决接口返回500错误的问题
- 优化视频生成相关的错误日志提示
- 保证上传目录准备状态的正确显示
- 提升后台服务日志的稳定性和连续性
2026-04-13 14:41:02 +08:00

380 lines
13 KiB
Python
Raw Permalink 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.
"""
设计服务
处理设计相关的业务逻辑,支持 AI 多视角生图 + mock 降级
"""
import os
import uuid
import logging
from typing import List, Optional, Tuple
import httpx
from sqlalchemy.orm import Session
from sqlalchemy import desc
from ..models import Design, DesignImage, Category, SubType, Color
from ..schemas import DesignCreate
from ..config import settings
from .mock_generator import generate_mock_design
from .prompt_builder import get_views_for_category, build_prompt
from . import ai_generator
logger = logging.getLogger(__name__)
def _has_ai_key() -> bool:
"""检查是否配置了 AI API Key从数据库配置优先读取"""
from .config_service import get_ai_config
ai_config = get_ai_config()
model = ai_config.get("AI_IMAGE_MODEL", "flux-dev")
if model in ("seedream-5.0", "seedream-4.5"):
return bool(ai_config.get("VOLCENGINE_API_KEY"))
return bool(ai_config.get("SILICONFLOW_API_KEY"))
async def create_design_async(db: Session, user_id: int, design_data: DesignCreate) -> Design:
"""
创建设计记录(异步版本,支持 AI 多视角生图)
流程:
1. 创建 Design 记录status=generating
2. 获取品类视角列表
3. 循环每个视角:构建 prompt → 调用 AI 生图 → 下载保存 → 创建 DesignImage
4. 第一张效果图 URL 存入 design.image_url兼容旧逻辑
5. 更新 status=completed
6. 失败时降级到 mock_generator
"""
# 获取关联信息
category = db.query(Category).filter(Category.id == design_data.category_id).first()
if not category:
raise ValueError(f"品类不存在: {design_data.category_id}")
sub_type = None
if design_data.sub_type_id:
sub_type = db.query(SubType).filter(SubType.id == design_data.sub_type_id).first()
color = None
if design_data.color_id:
color = db.query(Color).filter(Color.id == design_data.color_id).first()
# 创建设计记录
design = Design(
user_id=user_id,
category_id=design_data.category_id,
sub_type_id=design_data.sub_type_id,
color_id=design_data.color_id,
prompt=design_data.prompt,
carving_technique=design_data.carving_technique,
design_style=design_data.design_style,
motif=design_data.motif,
size_spec=design_data.size_spec,
surface_finish=design_data.surface_finish,
usage_scene=design_data.usage_scene,
status="generating"
)
db.add(design)
db.flush() # 获取 ID
# 尝试 AI 生图
if _has_ai_key():
try:
await _generate_ai_images(db, design, category, sub_type, color, design_data)
db.commit()
db.refresh(design)
return design
except Exception as e:
logger.error(f"AI 生图全部失败,降级到 mock: {e}")
db.rollback()
# 重新查询,因为 rollback 后 ORM 对象可能失效
design = db.query(Design).filter(Design.id == design.id).first()
if not design:
# rollback 导致 design 也没了,重新创建
design = Design(
user_id=user_id,
category_id=design_data.category_id,
sub_type_id=design_data.sub_type_id,
color_id=design_data.color_id,
prompt=design_data.prompt,
carving_technique=design_data.carving_technique,
design_style=design_data.design_style,
motif=design_data.motif,
size_spec=design_data.size_spec,
surface_finish=design_data.surface_finish,
usage_scene=design_data.usage_scene,
status="generating"
)
db.add(design)
db.flush()
# 降级到 mock 生成
_generate_mock_fallback(db, design, category, sub_type, color, design_data)
db.commit()
db.refresh(design)
return design
async def _generate_ai_images(
db: Session,
design: Design,
category,
sub_type,
color,
design_data: DesignCreate,
) -> None:
"""使用 AI 模型为每个视角生成图片
多视角一致性策略:
- SiliconFlow Kolors: 通过复用 seed 保持一致
- Seedream 5.0 lite: 通过参考图image参数保持一致
"""
views = get_views_for_category(category.name)
from .config_service import get_ai_config
ai_config = get_ai_config()
model = ai_config.get("AI_IMAGE_MODEL", "flux-dev")
shared_seed = None # Kolors 用: 第一张图的 seed
first_remote_url = None # Seedream 用: 第一张图的远程 URL 作为参考图
for idx, view_name in enumerate(views):
# 构建 prompt
prompt_text = build_prompt(
category_name=category.name,
view_name=view_name,
sub_type_name=sub_type.name if sub_type else design_data.sub_type_name,
color_name=color.name if color else None,
user_prompt=design_data.prompt,
carving_technique=design_data.carving_technique,
design_style=design_data.design_style,
motif=design_data.motif,
size_spec=design_data.size_spec,
surface_finish=design_data.surface_finish,
usage_scene=design_data.usage_scene,
)
# 调用 AI 生图
# 后续视角传入 seedKolors或参考图 URLSeedream保持一致性
ref_url = first_remote_url if idx > 0 else None
# 后续视角在提示词前加一致性约束前缀(根据视角名称定制)
final_prompt = prompt_text
if idx > 0:
# 根据视角名称生成更具体的一致性前缀
view_angle_map = {
"正面图": "moving the camera to face the object directly from the front",
"侧面图": "moving the camera 90 degrees to the left side of the object",
"背面图": "moving the camera 180 degrees to see the reverse/back side of the object",
}
angle_desc = view_angle_map.get(view_name, "changing the camera angle")
consistency_prefix = (
f"Photograph the EXACT SAME jade object from the reference image, {angle_desc}. "
"The object does NOT move or change - only the camera position changes. "
"The shape, size, color, material texture, and all physical features must remain IDENTICAL. "
)
final_prompt = consistency_prefix + prompt_text
remote_url, returned_seed = await ai_generator.generate_image(
final_prompt, model, seed=shared_seed, ref_image_url=ref_url
)
# 第一张图保存信息供后续视角复用
if idx == 0:
first_remote_url = remote_url
if returned_seed is not None:
shared_seed = returned_seed
logger.info(f"多视角生图: seed={shared_seed}, ref_url={remote_url[:60]}...")
# 下载到本地持久化存储远程URL是临时链接会过期失效
image_url = await _download_image_to_local(remote_url, design.id, idx)
logger.info(f"视角[{view_name}] 已下载到本地: {image_url}")
# 创建 DesignImage 记录
design_image = DesignImage(
design_id=design.id,
view_name=view_name,
image_url=image_url,
model_used=model,
prompt_used=prompt_text,
sort_order=idx,
)
db.add(design_image)
# 第一张图(效果图)存入 design.image_url 兼容旧逻辑
if idx == 0:
design.image_url = image_url
design.status = "completed"
async def _download_image_to_local(remote_url: str, design_id: int, idx: int) -> str:
"""
下载远程 AI 生成的图片到本地 uploads/designs/ 目录
第三方AI服务生成的图片URL是临时链接会过期失效必须下载到本地持久化
Returns:
本地图片 URL 路径,如 /uploads/designs/123_0_xxxx.png
"""
designs_dir = os.path.join(settings.UPLOAD_DIR, "designs")
os.makedirs(designs_dir, exist_ok=True)
filename = f"{design_id}_{idx}_{uuid.uuid4().hex[:8]}.png"
local_path = os.path.join(designs_dir, filename)
try:
async with httpx.AsyncClient(timeout=60, follow_redirects=True) as client:
resp = await client.get(remote_url)
resp.raise_for_status()
with open(local_path, "wb") as f:
f.write(resp.content)
logger.info(f"图片下载完成: {len(resp.content)} 字节 -> {local_path}")
except Exception as e:
logger.error(f"图片下载失败回退使用远程URL: {e}")
return remote_url # 下载失败时回退使用远程URL
return f"/uploads/designs/{filename}"
def _generate_mock_fallback(
db: Session,
design: Design,
category,
sub_type,
color,
design_data: DesignCreate,
) -> None:
"""降级使用 mock 生成器"""
save_path = os.path.join(settings.UPLOAD_DIR, "designs", f"{design.id}.png")
image_url = generate_mock_design(
category_name=category.name,
sub_type_name=sub_type.name if sub_type else design_data.sub_type_name,
color_name=color.name if color else None,
prompt=design_data.prompt,
save_path=save_path,
carving_technique=design_data.carving_technique,
design_style=design_data.design_style,
motif=design_data.motif,
size_spec=design_data.size_spec,
surface_finish=design_data.surface_finish,
usage_scene=design_data.usage_scene,
)
design.image_url = image_url
design.status = "completed"
logger.info(f"Mock 降级生成完成: design_id={design.id}")
def create_design(db: Session, user_id: int, design_data: DesignCreate) -> Design:
"""
同步版本创建设计(兼容旧调用,仅用 mock
"""
category = db.query(Category).filter(Category.id == design_data.category_id).first()
if not category:
raise ValueError(f"品类不存在: {design_data.category_id}")
sub_type = None
if design_data.sub_type_id:
sub_type = db.query(SubType).filter(SubType.id == design_data.sub_type_id).first()
color = None
if design_data.color_id:
color = db.query(Color).filter(Color.id == design_data.color_id).first()
design = Design(
user_id=user_id,
category_id=design_data.category_id,
sub_type_id=design_data.sub_type_id,
color_id=design_data.color_id,
prompt=design_data.prompt,
carving_technique=design_data.carving_technique,
design_style=design_data.design_style,
motif=design_data.motif,
size_spec=design_data.size_spec,
surface_finish=design_data.surface_finish,
usage_scene=design_data.usage_scene,
status="generating"
)
db.add(design)
db.flush()
_generate_mock_fallback(db, design, category, sub_type, color, design_data)
db.commit()
db.refresh(design)
return design
def get_user_designs(
db: Session,
user_id: int,
page: int = 1,
page_size: int = 20
) -> Tuple[List[Design], int]:
"""
分页查询用户设计历史
Returns:
(设计列表, 总数)
"""
query = db.query(Design).filter(Design.user_id == user_id)
# 获取总数
total = query.count()
# 分页查询,按创建时间倒序
offset = (page - 1) * page_size
designs = query.order_by(desc(Design.created_at)).offset(offset).limit(page_size).all()
return designs, total
def get_design_by_id(db: Session, design_id: int, user_id: int) -> Optional[Design]:
"""
获取单个设计
只返回属于该用户的设计
"""
return db.query(Design).filter(
Design.id == design_id,
Design.user_id == user_id
).first()
def delete_design(db: Session, design_id: int, user_id: int) -> bool:
"""
删除设计
1. 查找设计(必须属于该用户)
2. 删除图片文件
3. 删除数据库记录
Returns:
是否删除成功
"""
design = db.query(Design).filter(
Design.id == design_id,
Design.user_id == user_id
).first()
if not design:
return False
# 删除主图片文件
if design.image_url:
file_path = design.image_url.lstrip("/")
if os.path.exists(file_path):
try:
os.remove(file_path)
except Exception:
pass
# 删除多视角图片文件
for img in design.images:
if img.image_url:
fp = img.image_url.lstrip("/")
if os.path.exists(fp):
try:
os.remove(fp)
except Exception:
pass
# 删除数据库记录
db.delete(design)
db.commit()
return True