Merge branch 'main' of gitee.com:wg123/video-gen into main
This commit is contained in:
@@ -16,6 +16,7 @@ from app.api.v1.video_engines import router as video_engines_router
|
||||
from app.api.v1.image_engines import router as image_engines_router
|
||||
from app.api.v1.generation_ai import router as generation_ai_router
|
||||
from app.api.v1.hot_opening_replicate import router as hot_opening_replicate_router
|
||||
from app.api.v1.shot_replicate import router as shot_replicate_router
|
||||
from app.api.v1.test import router as test_router
|
||||
from app.api.v1.user_oauth import router as user_oauth_router
|
||||
from app.api.v1.user_oauth_app import router as user_oauth_app_router
|
||||
@@ -37,6 +38,7 @@ api_router.include_router(video_engines_router)
|
||||
api_router.include_router(image_engines_router)
|
||||
api_router.include_router(generation_ai_router)
|
||||
api_router.include_router(hot_opening_replicate_router)
|
||||
api_router.include_router(shot_replicate_router)
|
||||
api_router.include_router(test_router)
|
||||
api_router.include_router(user_oauth_router)
|
||||
api_router.include_router(user_oauth_app_router)
|
||||
|
||||
@@ -1,10 +1,14 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from types import SimpleNamespace
|
||||
|
||||
from fastapi import APIRouter, Body, Depends, HTTPException, Path, Query
|
||||
from sqlalchemy import inspect as sa_inspect
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.dependencies import get_current_user, get_db
|
||||
from app.models.user import User
|
||||
from app.enums.hot_opening_replicate import ModuleCodeEnum
|
||||
from app.schemas.hot_opening_replicate import (
|
||||
HotOpeningActionOut,
|
||||
HotOpeningDeleteOut,
|
||||
@@ -35,14 +39,94 @@ from app.services.hot_opening_replicate_service import (
|
||||
update_hot_opening_material_input,
|
||||
update_hot_opening_video_prompt_schema,
|
||||
)
|
||||
from app.services.module_generation_log_service import log_module_error
|
||||
from app.tasks.celery_app import celery_app
|
||||
|
||||
MODULE = ModuleCodeEnum.HOT_OPENING_REPLICATE.value
|
||||
|
||||
|
||||
|
||||
def _safe_user_id(user: object | None) -> str | None:
|
||||
"""从 ORM 对象中安全取用户ID,避免 rollback/commit 后访问过期属性触发 MissingGreenlet。"""
|
||||
if user is None:
|
||||
return None
|
||||
try:
|
||||
value = getattr(user, "__dict__", {}).get("id")
|
||||
if value is not None:
|
||||
return str(value)
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
identity = sa_inspect(user).identity
|
||||
if identity:
|
||||
return str(identity[0])
|
||||
except Exception:
|
||||
pass
|
||||
return None
|
||||
|
||||
|
||||
def _safe_user_is_admin(user: object | None) -> bool:
|
||||
"""安全判断管理员身份;如果对象属性已过期,保守按普通用户处理。"""
|
||||
if user is None:
|
||||
return False
|
||||
try:
|
||||
data = getattr(user, "__dict__", {})
|
||||
if "is_admin" in data:
|
||||
return bool(data.get("is_admin"))
|
||||
except Exception:
|
||||
pass
|
||||
return False
|
||||
|
||||
|
||||
def _user_context(user: object | None) -> SimpleNamespace:
|
||||
return SimpleNamespace(id=_safe_user_id(user), is_admin=_safe_user_is_admin(user))
|
||||
|
||||
router = APIRouter(
|
||||
prefix="/hot-opening-replications",
|
||||
tags=["hot-opening-replications"],
|
||||
)
|
||||
|
||||
|
||||
|
||||
|
||||
def _log_api_error(
|
||||
*,
|
||||
event_type: str,
|
||||
current_user: User | None = None,
|
||||
project_id: str | None = None,
|
||||
step_id: str | None = None,
|
||||
message: str | None = None,
|
||||
exc: BaseException | None = None,
|
||||
detail: dict | None = None,
|
||||
) -> None:
|
||||
log_module_error(
|
||||
module=MODULE,
|
||||
event_type=event_type,
|
||||
project_id=project_id,
|
||||
step_id=step_id,
|
||||
user_id=_safe_user_id(current_user),
|
||||
message=message,
|
||||
detail=detail,
|
||||
exc=exc,
|
||||
)
|
||||
|
||||
|
||||
def _log_api_exception_from_locals(exc: BaseException, local_values: dict, message: str) -> None:
|
||||
current_user = local_values.get("current_user")
|
||||
project_id = local_values.get("project_id_value") or local_values.get("project_id")
|
||||
step_id = local_values.get("step_id_value") or local_values.get("step_id")
|
||||
req = local_values.get("req")
|
||||
detail = {"request": req.model_dump() if hasattr(req, "model_dump") else str(req) if req is not None else None}
|
||||
_log_api_error(
|
||||
event_type="API_REQUEST_FAILED",
|
||||
current_user=current_user if isinstance(current_user, User) else None,
|
||||
project_id=str(project_id) if project_id else None,
|
||||
step_id=str(step_id) if step_id else None,
|
||||
message=message,
|
||||
detail=detail,
|
||||
exc=exc,
|
||||
)
|
||||
|
||||
async def _reload_project_detail(
|
||||
db: AsyncSession,
|
||||
current_user: User,
|
||||
@@ -52,7 +136,7 @@ async def _reload_project_detail(
|
||||
project = await _get_project_for_user(
|
||||
db,
|
||||
project_id=project_id,
|
||||
user=current_user,
|
||||
user=_user_context(current_user),
|
||||
for_update=False,
|
||||
populate_existing=True,
|
||||
)
|
||||
@@ -72,14 +156,33 @@ async def _mark_dispatch_failed_and_raise(
|
||||
try:
|
||||
await mark_hot_opening_step_dispatch_failed(
|
||||
db,
|
||||
current_user=current_user,
|
||||
current_user=_user_context(current_user),
|
||||
project_id=project_id,
|
||||
step_id=step_id,
|
||||
error_message=message,
|
||||
)
|
||||
await db.commit()
|
||||
except Exception:
|
||||
except Exception as exc:
|
||||
await db.rollback()
|
||||
_log_api_error(
|
||||
event_type="CELERY_DISPATCH_MARK_FAILED",
|
||||
current_user=current_user,
|
||||
project_id=project_id,
|
||||
step_id=step_id,
|
||||
message="Celery 投递失败后标记步骤失败也失败",
|
||||
detail={"dispatch_error": message},
|
||||
exc=exc,
|
||||
)
|
||||
log_module_error(
|
||||
module=MODULE,
|
||||
event_type="CELERY_DISPATCH_FAILED",
|
||||
project_id=project_id,
|
||||
step_id=step_id,
|
||||
user_id=_safe_user_id(current_user),
|
||||
message=message,
|
||||
detail={"reason": "celery_dispatch_failed"},
|
||||
error=message,
|
||||
)
|
||||
raise HTTPException(status_code=503, detail=message)
|
||||
|
||||
|
||||
@@ -118,6 +221,7 @@ async def create_task(
|
||||
raise
|
||||
except Exception as exc:
|
||||
await db.rollback()
|
||||
_log_api_exception_from_locals(exc, locals(), f"创建爆款开头复刻项目失败: {exc}")
|
||||
raise HTTPException(status_code=500, detail=f"创建爆款开头复刻项目失败: {exc}")
|
||||
|
||||
return await _reload_project_detail(db, current_user, project_id_value)
|
||||
@@ -185,6 +289,7 @@ async def update_material(
|
||||
raise
|
||||
except Exception as exc:
|
||||
await db.rollback()
|
||||
_log_api_exception_from_locals(exc, locals(), f"修改素材输入失败: {exc}")
|
||||
raise HTTPException(status_code=500, detail=f"修改素材输入失败: {exc}")
|
||||
|
||||
return HotOpeningActionOut(
|
||||
@@ -228,6 +333,7 @@ async def update_image_prompt(
|
||||
raise
|
||||
except Exception as exc:
|
||||
await db.rollback()
|
||||
_log_api_exception_from_locals(exc, locals(), f"修改图片 AI 提词失败: {exc}")
|
||||
raise HTTPException(status_code=500, detail=f"修改图片 AI 提词失败: {exc}")
|
||||
|
||||
return HotOpeningActionOut(
|
||||
@@ -300,6 +406,14 @@ async def generate_image_prompt(
|
||||
):
|
||||
_ = req
|
||||
if celery_app is None:
|
||||
_log_api_error(
|
||||
event_type="CELERY_DISABLED",
|
||||
current_user=current_user,
|
||||
project_id=project_id,
|
||||
step_id=step_id,
|
||||
message="Celery未启用:请配置 REDIS_URL 或 CELERY_BROKER_URL 后启动 worker",
|
||||
detail={"api": "hot_opening_replicate"},
|
||||
)
|
||||
raise HTTPException(status_code=503, detail="Celery未启用:请配置 REDIS_URL 或 CELERY_BROKER_URL 后启动 worker")
|
||||
|
||||
try:
|
||||
@@ -312,6 +426,7 @@ async def generate_image_prompt(
|
||||
raise
|
||||
except Exception as exc:
|
||||
await db.rollback()
|
||||
_log_api_exception_from_locals(exc, locals(), f"图片提词任务创建失败: {exc}")
|
||||
raise HTTPException(status_code=500, detail=f"图片提词任务创建失败: {exc}")
|
||||
|
||||
from app.tasks.hot_opening_replicate_tasks import start_image_prompt_optimize
|
||||
@@ -354,6 +469,14 @@ async def generate_image(
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
if celery_app is None:
|
||||
_log_api_error(
|
||||
event_type="CELERY_DISABLED",
|
||||
current_user=current_user,
|
||||
project_id=project_id,
|
||||
step_id=step_id,
|
||||
message="Celery未启用:请配置 REDIS_URL 或 CELERY_BROKER_URL 后启动 worker",
|
||||
detail={"api": "hot_opening_replicate"},
|
||||
)
|
||||
raise HTTPException(status_code=503, detail="Celery未启用:请配置 REDIS_URL 或 CELERY_BROKER_URL 后启动 worker")
|
||||
|
||||
try:
|
||||
@@ -369,6 +492,7 @@ async def generate_image(
|
||||
raise
|
||||
except Exception as exc:
|
||||
await db.rollback()
|
||||
_log_api_exception_from_locals(exc, locals(), f"图片生成任务创建失败: {exc}")
|
||||
raise HTTPException(status_code=500, detail=f"图片生成任务创建失败: {exc}")
|
||||
|
||||
from app.tasks.generation_create_tasks import chatapi_create_generation_task
|
||||
@@ -411,6 +535,14 @@ async def generate_video_prompt(
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
if celery_app is None:
|
||||
_log_api_error(
|
||||
event_type="CELERY_DISABLED",
|
||||
current_user=current_user,
|
||||
project_id=project_id,
|
||||
step_id=step_id,
|
||||
message="Celery未启用:请配置 REDIS_URL 或 CELERY_BROKER_URL 后启动 worker",
|
||||
detail={"api": "hot_opening_replicate"},
|
||||
)
|
||||
raise HTTPException(status_code=503, detail="Celery未启用:请配置 REDIS_URL 或 CELERY_BROKER_URL 后启动 worker")
|
||||
|
||||
try:
|
||||
@@ -423,6 +555,7 @@ async def generate_video_prompt(
|
||||
raise
|
||||
except Exception as exc:
|
||||
await db.rollback()
|
||||
_log_api_exception_from_locals(exc, locals(), f"视频提词任务创建失败: {exc}")
|
||||
raise HTTPException(status_code=500, detail=f"视频提词任务创建失败: {exc}")
|
||||
|
||||
from app.tasks.hot_opening_replicate_tasks import start_video_prompt_optimize
|
||||
@@ -466,6 +599,14 @@ async def generate_video(
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
if celery_app is None:
|
||||
_log_api_error(
|
||||
event_type="CELERY_DISABLED",
|
||||
current_user=current_user,
|
||||
project_id=project_id,
|
||||
step_id=step_id,
|
||||
message="Celery未启用:请配置 REDIS_URL 或 CELERY_BROKER_URL 后启动 worker",
|
||||
detail={"api": "hot_opening_replicate"},
|
||||
)
|
||||
raise HTTPException(status_code=503, detail="Celery未启用:请配置 REDIS_URL 或 CELERY_BROKER_URL 后启动 worker")
|
||||
|
||||
try:
|
||||
@@ -481,6 +622,7 @@ async def generate_video(
|
||||
raise
|
||||
except Exception as exc:
|
||||
await db.rollback()
|
||||
_log_api_exception_from_locals(exc, locals(), f"视频生成任务创建失败: {exc}")
|
||||
raise HTTPException(status_code=500, detail=f"视频生成任务创建失败: {exc}")
|
||||
|
||||
from app.tasks.generation_create_tasks import chatapi_create_generation_task
|
||||
|
||||
@@ -83,13 +83,87 @@ async def recharge(
|
||||
|
||||
@router.post("/wechat/callback")
|
||||
async def wechat_callback(request: Request, db: AsyncSession = Depends(get_db)):
|
||||
data = await request.json()
|
||||
if not await verify_wechat_callback(data, db):
|
||||
raise HTTPException(status_code=400, detail="签名验证失败")
|
||||
order_no = data.get("out_trade_no")
|
||||
if order_no:
|
||||
await process_payment_success_by_order_no(db, order_no)
|
||||
return {"code": "SUCCESS", "message": "OK"}
|
||||
# 读取微信支付回调数据
|
||||
body_bytes = await request.body()
|
||||
body_str = body_bytes.decode("utf-8")
|
||||
|
||||
# 获取配置
|
||||
from app.services.payment import _get_payment_configs, _is_mock_mode, _get_wechat_client
|
||||
db_configs = await _get_payment_configs(db)
|
||||
|
||||
# 检查 mock 模式
|
||||
if _is_mock_mode(db_configs):
|
||||
try:
|
||||
import json
|
||||
data = json.loads(body_str) if body_str else {}
|
||||
order_no = data.get("out_trade_no")
|
||||
if order_no:
|
||||
await process_payment_success_by_order_no(db, order_no)
|
||||
logger.info(f"Mock WeChat callback processed: order_no={order_no}")
|
||||
return {"code": "SUCCESS", "message": "OK"}
|
||||
except Exception as e:
|
||||
logger.exception(f"Mock WeChat callback error: {e}")
|
||||
return {"code": "SUCCESS", "message": "OK"} # 微信要求即使处理失败也返回成功
|
||||
|
||||
# 真实模式:使用 wechatpayv3 SDK 验证回调并解析数据
|
||||
try:
|
||||
mch_id = db_configs.get("payment_wechat_mch_id", "")
|
||||
private_key = db_configs.get("payment_wechat_private_key", "")
|
||||
cert_serial_no = db_configs.get("payment_wechat_cert_serial_no", "")
|
||||
api_v3_key = db_configs.get("payment_wechat_api_v3_key", "")
|
||||
gateway = db_configs.get("payment_wechat_gateway", "")
|
||||
|
||||
client = _get_wechat_client(mch_id, private_key, cert_serial_no, api_v3_key, gateway)
|
||||
if not client:
|
||||
logger.error("WeChat client not initialized for callback")
|
||||
return {"code": "SUCCESS", "message": "OK"}
|
||||
|
||||
# 从请求头获取必要信息
|
||||
headers = dict(request.headers)
|
||||
timestamp = headers.get("Wechatpay-Timestamp", "")
|
||||
nonce = headers.get("Wechatpay-Nonce", "")
|
||||
signature = headers.get("Wechatpay-Signature", "")
|
||||
serial_no = headers.get("Wechatpay-Serial", "")
|
||||
|
||||
# 验证签名
|
||||
is_verified = client.verify(
|
||||
timestamp=timestamp,
|
||||
nonce=nonce,
|
||||
body=body_str,
|
||||
signature=signature,
|
||||
serial_no=serial_no
|
||||
)
|
||||
|
||||
if not is_verified:
|
||||
logger.warning("WeChat callback signature verification failed")
|
||||
raise HTTPException(status_code=400, detail="签名验证失败")
|
||||
|
||||
# 解密回调数据
|
||||
decrypted_data = client.decrypt(body_str)
|
||||
if not decrypted_data:
|
||||
logger.error("WeChat callback decryption failed")
|
||||
raise HTTPException(status_code=400, detail="数据解密失败")
|
||||
|
||||
# 处理支付成功回调
|
||||
if decrypted_data.get("event_type") == "TRANSACTION.SUCCESS":
|
||||
resource = decrypted_data.get("resource", {})
|
||||
order_no = resource.get("out_trade_no", "")
|
||||
transaction_id = resource.get("transaction_id", "")
|
||||
amount_info = resource.get("amount", {})
|
||||
total_amount = amount_info.get("total", 0) / 100 # 转换为元
|
||||
|
||||
if order_no:
|
||||
await process_payment_success_by_order_no(db, order_no, transaction_id, total_amount)
|
||||
logger.info(
|
||||
f"WeChat callback processed: order_no={order_no}, "
|
||||
f"transaction_id={transaction_id}, amount={total_amount}"
|
||||
)
|
||||
|
||||
return {"code": "SUCCESS", "message": "OK"}
|
||||
except Exception as e:
|
||||
logger.exception(f"WeChat callback processing error: {e}")
|
||||
# 微信支付要求即使处理失败也返回成功,避免重复回调
|
||||
return {"code": "SUCCESS", "message": "OK"}
|
||||
|
||||
|
||||
@router.post("/alipay/callback")
|
||||
@@ -183,13 +257,19 @@ async def cancel_order(
|
||||
if order.status != "pending":
|
||||
raise HTTPException(status_code=400, detail=f"订单状态为{order.status},无法取消")
|
||||
|
||||
# If it's an Alipay order, call close API first
|
||||
# If it's an Alipay or WeChat order, call close API first
|
||||
db_configs = await _get_payment_configs(db)
|
||||
if order.payment_method == "alipay":
|
||||
db_configs = await _get_payment_configs(db)
|
||||
try:
|
||||
await _close_alipay_order(db, order, db_configs)
|
||||
except Exception as e:
|
||||
logger.exception(f"Failed to close Alipay order {order_no}: {e}")
|
||||
elif order.payment_method == "wechat":
|
||||
try:
|
||||
from app.services.payment import _close_wechat_order
|
||||
await _close_wechat_order(db, order, db_configs)
|
||||
except Exception as e:
|
||||
logger.exception(f"Failed to close WeChat order {order_no}: {e}")
|
||||
|
||||
order.status = "cancelled"
|
||||
await db.flush()
|
||||
|
||||
@@ -0,0 +1,668 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from types import SimpleNamespace
|
||||
|
||||
from fastapi import APIRouter, Body, Depends, HTTPException, Path, Query
|
||||
from sqlalchemy import inspect as sa_inspect
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.dependencies import get_current_user, get_db
|
||||
from app.models.user import User
|
||||
from app.enums.shot_replicate import ModuleCodeEnum
|
||||
from app.schemas.shot_replicate import (
|
||||
ShotReplicateActionOut,
|
||||
ShotReplicateDeleteOut,
|
||||
ShotReplicateGenerateImagePromptRequest,
|
||||
ShotReplicateGenerateImageRequest,
|
||||
ShotReplicateGenerateVideoPromptRequest,
|
||||
ShotReplicateGenerateVideoRequest,
|
||||
ShotReplicateImagePromptUpdateRequest,
|
||||
ShotReplicateMaterialUpdateRequest,
|
||||
ShotReplicateSpecOut,
|
||||
ShotReplicateTaskDetailOut,
|
||||
ShotReplicateVideoPromptSchemaUpdateRequest,
|
||||
ShotSegmentDetailOut,
|
||||
ShotSegmentListOut,
|
||||
ShotSegmentReplicationCreateRequest,
|
||||
ShotSplitByAIOut,
|
||||
ShotSplitByAIRequest,
|
||||
ShotSplitCustomOut,
|
||||
ShotSplitCustomRequest,
|
||||
ShotTaskSetCreate,
|
||||
ShotTaskSetDetailOut,
|
||||
ShotTaskSetListOut,
|
||||
)
|
||||
from app.services.shot_replicate_flow_service import (
|
||||
_get_project_for_user,
|
||||
create_shot_replicate_project_from_segment,
|
||||
delete_shot_replicate_project,
|
||||
generate_image_from_prompt,
|
||||
generate_video_from_prompt,
|
||||
mark_shot_replicate_step_dispatch_failed,
|
||||
project_to_detail_out,
|
||||
submit_image_prompt_optimize,
|
||||
submit_video_prompt_optimize,
|
||||
update_shot_replicate_image_prompt,
|
||||
update_shot_replicate_material_input,
|
||||
update_shot_replicate_video_prompt_schema,
|
||||
)
|
||||
from app.services.shot_replicate_taskset_service import (
|
||||
create_custom_segment,
|
||||
create_segments_by_ai,
|
||||
create_task_set,
|
||||
get_segment_for_user,
|
||||
list_segments,
|
||||
list_task_sets,
|
||||
segment_detail,
|
||||
task_set_detail,
|
||||
)
|
||||
from app.services.module_generation_log_service import log_module_error, log_module_event_file
|
||||
from app.tasks.celery_app import celery_app
|
||||
|
||||
MODULE = ModuleCodeEnum.SHOT_REPLICATE.value
|
||||
|
||||
|
||||
|
||||
def _safe_user_id(user: object | None) -> str | None:
|
||||
"""从 ORM 对象中安全取用户ID,避免 rollback/commit 后访问过期属性触发 MissingGreenlet。"""
|
||||
if user is None:
|
||||
return None
|
||||
try:
|
||||
value = getattr(user, "__dict__", {}).get("id")
|
||||
if value is not None:
|
||||
return str(value)
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
identity = sa_inspect(user).identity
|
||||
if identity:
|
||||
return str(identity[0])
|
||||
except Exception:
|
||||
pass
|
||||
return None
|
||||
|
||||
|
||||
def _safe_user_is_admin(user: object | None) -> bool:
|
||||
"""安全判断管理员身份;如果对象属性已过期,保守按普通用户处理。"""
|
||||
if user is None:
|
||||
return False
|
||||
try:
|
||||
data = getattr(user, "__dict__", {})
|
||||
if "is_admin" in data:
|
||||
return bool(data.get("is_admin"))
|
||||
except Exception:
|
||||
pass
|
||||
return False
|
||||
|
||||
|
||||
def _user_context(user: object | None) -> SimpleNamespace:
|
||||
return SimpleNamespace(id=_safe_user_id(user), is_admin=_safe_user_is_admin(user))
|
||||
|
||||
router = APIRouter(prefix="/shot-replications", tags=["shot-replications"])
|
||||
|
||||
|
||||
|
||||
|
||||
def _log_api_error(
|
||||
*,
|
||||
event_type: str,
|
||||
current_user: User | None = None,
|
||||
project_id: str | None = None,
|
||||
step_id: str | None = None,
|
||||
message: str | None = None,
|
||||
exc: BaseException | None = None,
|
||||
detail: dict | None = None,
|
||||
) -> None:
|
||||
log_module_error(
|
||||
module=MODULE,
|
||||
event_type=event_type,
|
||||
project_id=project_id,
|
||||
step_id=step_id,
|
||||
user_id=_safe_user_id(current_user),
|
||||
message=message,
|
||||
detail=detail,
|
||||
exc=exc,
|
||||
)
|
||||
|
||||
|
||||
def _log_api_exception_from_locals(exc: BaseException, local_values: dict, message: str) -> None:
|
||||
current_user = local_values.get("current_user")
|
||||
project_id = local_values.get("project_id_value") or local_values.get("project_id") or local_values.get("task_set_id") or local_values.get("task_set_id_value")
|
||||
step_id = local_values.get("step_id_value") or local_values.get("step_id") or local_values.get("segment_id") or local_values.get("segment_id_value")
|
||||
req = local_values.get("req")
|
||||
detail = {"api": local_values.get("__name__"), "request": req.model_dump() if hasattr(req, "model_dump") else str(req) if req is not None else None}
|
||||
_log_api_error(
|
||||
event_type="API_REQUEST_FAILED",
|
||||
current_user=current_user if isinstance(current_user, User) else None,
|
||||
project_id=str(project_id) if project_id else None,
|
||||
step_id=str(step_id) if step_id else None,
|
||||
message=message,
|
||||
detail=detail,
|
||||
exc=exc,
|
||||
)
|
||||
|
||||
async def _reload_project_detail(db: AsyncSession, current_user: User, project_id: str) -> ShotReplicateTaskDetailOut:
|
||||
project = await _get_project_for_user(
|
||||
db,
|
||||
project_id=project_id,
|
||||
user=_user_context(current_user),
|
||||
for_update=False,
|
||||
populate_existing=True,
|
||||
)
|
||||
return await project_to_detail_out(db, project)
|
||||
|
||||
|
||||
async def _mark_dispatch_failed_and_raise(
|
||||
db: AsyncSession,
|
||||
*,
|
||||
current_user: User,
|
||||
project_id: str,
|
||||
step_id: str | None,
|
||||
message: str,
|
||||
) -> None:
|
||||
if step_id:
|
||||
try:
|
||||
await mark_shot_replicate_step_dispatch_failed(
|
||||
db,
|
||||
current_user=_user_context(current_user),
|
||||
project_id=project_id,
|
||||
step_id=step_id,
|
||||
error_message=message,
|
||||
)
|
||||
await db.commit()
|
||||
except Exception as exc:
|
||||
await db.rollback()
|
||||
_log_api_error(
|
||||
event_type="CELERY_DISPATCH_MARK_FAILED",
|
||||
current_user=current_user,
|
||||
project_id=project_id,
|
||||
step_id=step_id,
|
||||
message="Celery 投递失败后标记步骤失败也失败",
|
||||
detail={"dispatch_error": message},
|
||||
exc=exc,
|
||||
)
|
||||
log_module_error(
|
||||
module=MODULE,
|
||||
event_type="CELERY_DISPATCH_FAILED",
|
||||
project_id=project_id,
|
||||
step_id=step_id,
|
||||
user_id=_safe_user_id(current_user),
|
||||
message=message,
|
||||
detail={"reason": "celery_dispatch_failed"},
|
||||
error=message,
|
||||
)
|
||||
raise HTTPException(status_code=503, detail=message)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/spec",
|
||||
response_model=ShotReplicateSpecOut,
|
||||
summary="查询拆镜复刻模块状态枚举和步骤 JSON 结构说明",
|
||||
)
|
||||
async def get_spec():
|
||||
return ShotReplicateSpecOut()
|
||||
|
||||
|
||||
@router.post(
|
||||
"/task-sets",
|
||||
response_model=ShotTaskSetDetailOut,
|
||||
summary="创建拆镜总任务集并异步分析原视频",
|
||||
)
|
||||
async def create_shot_task_set(
|
||||
req: ShotTaskSetCreate = Body(...),
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
try:
|
||||
task_set = await create_task_set(db, current_user=current_user, req=req)
|
||||
task_set_id = task_set.id
|
||||
await db.commit()
|
||||
except HTTPException:
|
||||
await db.rollback()
|
||||
raise
|
||||
except Exception as exc:
|
||||
await db.rollback()
|
||||
_log_api_exception_from_locals(exc, locals(), f"创建拆镜总任务集失败: {exc}")
|
||||
raise HTTPException(status_code=500, detail=f"创建拆镜总任务集失败: {exc}")
|
||||
|
||||
if celery_app:
|
||||
try:
|
||||
from app.tasks.shot_replicate_tasks import analyze_original_video
|
||||
|
||||
analyze_original_video.apply_async(args=[task_set_id], queue="gen_chatapi_create", countdown=0)
|
||||
except Exception as exc:
|
||||
# 分析任务投递失败时保留总任务,前端可稍后通过恢复/重试处理。
|
||||
_log_api_error(
|
||||
event_type="CELERY_DISPATCH_FAILED",
|
||||
current_user=current_user,
|
||||
project_id=task_set_id,
|
||||
message=f"拆镜分析任务投递失败: {exc}",
|
||||
detail={"task_set_id": task_set_id, "task": "analyze_original_video"},
|
||||
exc=exc,
|
||||
)
|
||||
raise HTTPException(status_code=503, detail=f"拆镜分析任务投递失败: {exc}")
|
||||
|
||||
return await task_set_detail(db, current_user=_user_context(current_user), task_set_id=task_set_id)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/task-sets",
|
||||
response_model=ShotTaskSetListOut,
|
||||
summary="查询拆镜总任务集列表",
|
||||
)
|
||||
async def list_shot_task_sets(
|
||||
status: str | None = Query(None, description="总任务状态,见 ShotTaskSetStatusEnum"),
|
||||
analysis_status: str | None = Query(None, description="分析状态,见 ShotAnalysisStatusEnum"),
|
||||
split_status: str | None = Query(None, description="拆镜状态,见 ShotSplitStatusEnum"),
|
||||
keyword: str | None = Query(None, description="标题/内容关键词"),
|
||||
page: int = Query(1, ge=1),
|
||||
page_size: int = Query(20, ge=1, le=100),
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
return await list_task_sets(
|
||||
db,
|
||||
current_user=current_user,
|
||||
status=status,
|
||||
analysis_status=analysis_status,
|
||||
split_status=split_status,
|
||||
keyword=keyword,
|
||||
page=page,
|
||||
page_size=page_size,
|
||||
)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/task-sets/{task_set_id}",
|
||||
response_model=ShotTaskSetDetailOut,
|
||||
summary="获取拆镜总任务集详情",
|
||||
)
|
||||
async def get_shot_task_set(
|
||||
task_set_id: str = Path(...),
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
return await task_set_detail(db, current_user=_user_context(current_user), task_set_id=task_set_id)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/task-sets/{task_set_id}/split-by-ai",
|
||||
response_model=ShotSplitByAIOut,
|
||||
summary="按 AI 建议方案异步拆镜",
|
||||
)
|
||||
async def split_by_ai(
|
||||
task_set_id: str = Path(...),
|
||||
req: ShotSplitByAIRequest = Body(default_factory=ShotSplitByAIRequest),
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
try:
|
||||
out = await create_segments_by_ai(db, current_user=current_user, task_set_id=task_set_id, req=req)
|
||||
segment_ids = [item.id for item in out.segments]
|
||||
await db.commit()
|
||||
except HTTPException:
|
||||
await db.rollback()
|
||||
raise
|
||||
except Exception as exc:
|
||||
await db.rollback()
|
||||
_log_api_exception_from_locals(exc, locals(), f"按 AI 建议拆镜失败: {exc}")
|
||||
raise HTTPException(status_code=500, detail=f"按 AI 建议拆镜失败: {exc}")
|
||||
|
||||
if celery_app:
|
||||
from app.tasks.shot_replicate_tasks import split_one_segment
|
||||
|
||||
for segment_id in segment_ids:
|
||||
split_one_segment.apply_async(args=[segment_id], queue="gen_result_download", countdown=0)
|
||||
return out
|
||||
|
||||
|
||||
@router.post(
|
||||
"/task-sets/{task_set_id}/split-custom",
|
||||
response_model=ShotSplitCustomOut,
|
||||
summary="按用户自定义开始/结束秒异步拆单条片段",
|
||||
)
|
||||
async def split_custom(
|
||||
task_set_id: str = Path(...),
|
||||
req: ShotSplitCustomRequest = Body(...),
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
try:
|
||||
out = await create_custom_segment(db, current_user=current_user, task_set_id=task_set_id, req=req)
|
||||
segment_id = out.segment.id
|
||||
await db.commit()
|
||||
except HTTPException:
|
||||
await db.rollback()
|
||||
raise
|
||||
except Exception as exc:
|
||||
await db.rollback()
|
||||
_log_api_exception_from_locals(exc, locals(), f"自定义拆镜失败: {exc}")
|
||||
raise HTTPException(status_code=500, detail=f"自定义拆镜失败: {exc}")
|
||||
|
||||
if celery_app:
|
||||
from app.tasks.shot_replicate_tasks import split_one_segment
|
||||
|
||||
split_one_segment.apply_async(args=[segment_id], queue="gen_result_download", countdown=0)
|
||||
return out
|
||||
|
||||
|
||||
@router.get(
|
||||
"/task-sets/{task_set_id}/segments",
|
||||
response_model=ShotSegmentListOut,
|
||||
summary="查询拆镜片段列表",
|
||||
)
|
||||
async def list_task_set_segments(
|
||||
task_set_id: str = Path(...),
|
||||
source_mode: str | None = Query(None, description="ai_suggestion/custom"),
|
||||
split_status: str | None = Query(None, description="拆镜状态"),
|
||||
analysis_status: str | None = Query(None, description="片段分析状态"),
|
||||
replicate_status: str | None = Query(None, description="复刻状态"),
|
||||
page: int = Query(1, ge=1),
|
||||
page_size: int = Query(20, ge=1, le=100),
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
return await list_segments(
|
||||
db,
|
||||
current_user=current_user,
|
||||
task_set_id=task_set_id,
|
||||
source_mode=source_mode,
|
||||
split_status=split_status,
|
||||
analysis_status=analysis_status,
|
||||
replicate_status=replicate_status,
|
||||
page=page,
|
||||
page_size=page_size,
|
||||
)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/segments/{segment_id}",
|
||||
response_model=ShotSegmentDetailOut,
|
||||
summary="获取拆镜片段详情",
|
||||
)
|
||||
async def get_segment(
|
||||
segment_id: str = Path(...),
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
return await segment_detail(db, current_user=current_user, segment_id=segment_id)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/segments/{segment_id}/replication-projects",
|
||||
response_model=ShotReplicateActionOut,
|
||||
summary="将拆镜片段创建为拆镜复刻项目",
|
||||
)
|
||||
async def create_replication_project_from_segment(
|
||||
segment_id: str = Path(...),
|
||||
req: ShotSegmentReplicationCreateRequest = Body(...),
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
try:
|
||||
segment = await get_segment_for_user(db, segment_id=segment_id, user=current_user, for_update=True)
|
||||
project = await create_shot_replicate_project_from_segment(db, current_user=current_user, segment=segment, req=req)
|
||||
project_id = project.id
|
||||
await db.commit()
|
||||
except HTTPException:
|
||||
await db.rollback()
|
||||
raise
|
||||
except Exception as exc:
|
||||
await db.rollback()
|
||||
_log_api_exception_from_locals(exc, locals(), f"创建拆镜复刻项目失败: {exc}")
|
||||
raise HTTPException(status_code=500, detail=f"创建拆镜复刻项目失败: {exc}")
|
||||
|
||||
return ShotReplicateActionOut(
|
||||
message="已从拆镜片段创建复刻项目,素材视频已锁定",
|
||||
project_id=project_id,
|
||||
step_id=None,
|
||||
detail=await _reload_project_detail(db, current_user, project_id),
|
||||
)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/projects/{project_id}",
|
||||
response_model=ShotReplicateTaskDetailOut,
|
||||
summary="获取拆镜复刻项目详情",
|
||||
)
|
||||
async def get_project(
|
||||
project_id: str = Path(...),
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
return await _reload_project_detail(db, current_user, project_id)
|
||||
|
||||
|
||||
@router.put(
|
||||
"/projects/{project_id}/material",
|
||||
response_model=ShotReplicateActionOut,
|
||||
summary="修改拆镜复刻素材信息,素材视频不允许修改",
|
||||
)
|
||||
async def update_material(
|
||||
project_id: str = Path(...),
|
||||
req: ShotReplicateMaterialUpdateRequest = Body(...),
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
try:
|
||||
project_id_value, step_id_value = await update_shot_replicate_material_input(db, current_user=current_user, project_id=project_id, req=req)
|
||||
await db.commit()
|
||||
except HTTPException:
|
||||
await db.rollback()
|
||||
raise
|
||||
except Exception as exc:
|
||||
await db.rollback()
|
||||
_log_api_exception_from_locals(exc, locals(), f"修改素材输入失败: {exc}")
|
||||
raise HTTPException(status_code=500, detail=f"修改素材输入失败: {exc}")
|
||||
return ShotReplicateActionOut(message="素材输入已修改,素材视频保持锁定", project_id=project_id_value, step_id=step_id_value, detail=await _reload_project_detail(db, current_user, project_id_value))
|
||||
|
||||
|
||||
@router.put(
|
||||
"/projects/{project_id}/steps/{step_id}/image-prompt",
|
||||
response_model=ShotReplicateActionOut,
|
||||
summary="直接修改图片 AI 优化提词",
|
||||
)
|
||||
async def update_image_prompt(
|
||||
project_id: str = Path(...),
|
||||
step_id: str = Path(...),
|
||||
req: ShotReplicateImagePromptUpdateRequest = Body(...),
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
try:
|
||||
project, step = await update_shot_replicate_image_prompt(db, current_user=current_user, project_id=project_id, step_id=step_id, req=req)
|
||||
project_id_value, step_id_value = project.id, step.id
|
||||
await db.commit()
|
||||
except HTTPException:
|
||||
await db.rollback()
|
||||
raise
|
||||
except Exception as exc:
|
||||
await db.rollback()
|
||||
_log_api_exception_from_locals(exc, locals(), f"修改图片 AI 提词失败: {exc}")
|
||||
raise HTTPException(status_code=500, detail=f"修改图片 AI 提词失败: {exc}")
|
||||
return ShotReplicateActionOut(message="图片 AI 提词已修改,后续步骤已软删除", project_id=project_id_value, step_id=step_id_value, detail=await _reload_project_detail(db, current_user, project_id_value))
|
||||
|
||||
|
||||
@router.put(
|
||||
"/projects/{project_id}/steps/{step_id}/video-prompt-schema",
|
||||
response_model=ShotReplicateActionOut,
|
||||
summary="修改视频 AI 提词 JSON schema",
|
||||
)
|
||||
async def update_video_prompt_schema(
|
||||
project_id: str = Path(...),
|
||||
step_id: str = Path(...),
|
||||
req: ShotReplicateVideoPromptSchemaUpdateRequest = Body(...),
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
try:
|
||||
project, step = await update_shot_replicate_video_prompt_schema(db, current_user=current_user, project_id=project_id, step_id=step_id, req=req)
|
||||
project_id_value, step_id_value = project.id, step.id
|
||||
await db.commit()
|
||||
except HTTPException:
|
||||
await db.rollback()
|
||||
raise
|
||||
except Exception as exc:
|
||||
await db.rollback()
|
||||
_log_api_exception_from_locals(exc, locals(), f"修改视频 AI 提词失败: {exc}")
|
||||
raise HTTPException(status_code=500, detail=f"修改视频 AI 提词失败: {exc}")
|
||||
return ShotReplicateActionOut(message="视频 AI 提词 schema 已修改,第5步视频生成已软删除", project_id=project_id_value, step_id=step_id_value, detail=await _reload_project_detail(db, current_user, project_id_value))
|
||||
|
||||
|
||||
@router.post(
|
||||
"/projects/{project_id}/generate-image-prompt",
|
||||
response_model=ShotReplicateActionOut,
|
||||
summary="生成图片 AI 提词",
|
||||
)
|
||||
async def generate_image_prompt(
|
||||
project_id: str = Path(...),
|
||||
req: ShotReplicateGenerateImagePromptRequest = Body(default_factory=ShotReplicateGenerateImagePromptRequest),
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
try:
|
||||
project, step = await submit_image_prompt_optimize(db, current_user=current_user, project_id=project_id, req=req)
|
||||
project_id_value, step_id_value = project.id, step.id
|
||||
await db.commit()
|
||||
except HTTPException:
|
||||
await db.rollback()
|
||||
raise
|
||||
except Exception as exc:
|
||||
await db.rollback()
|
||||
_log_api_exception_from_locals(exc, locals(), f"提交图片 AI 提词失败: {exc}")
|
||||
raise HTTPException(status_code=500, detail=f"提交图片 AI 提词失败: {exc}")
|
||||
|
||||
try:
|
||||
if celery_app:
|
||||
from app.tasks.shot_replicate_flow_tasks import start_image_prompt_optimize
|
||||
|
||||
start_image_prompt_optimize.apply_async(args=[project_id_value, step_id_value], queue="gen_chatapi_create", countdown=0)
|
||||
except Exception as exc:
|
||||
await _mark_dispatch_failed_and_raise(db, current_user=current_user, project_id=project_id_value, step_id=step_id_value, message=f"图片 AI 提词任务投递失败: {exc}")
|
||||
|
||||
return ShotReplicateActionOut(message="图片 AI 提词任务已提交", project_id=project_id_value, step_id=step_id_value, detail=await _reload_project_detail(db, current_user, project_id_value))
|
||||
|
||||
|
||||
@router.post(
|
||||
"/projects/{project_id}/generate-image",
|
||||
response_model=ShotReplicateActionOut,
|
||||
summary="根据图片 AI 提词生成图片",
|
||||
)
|
||||
async def generate_image(
|
||||
project_id: str = Path(...),
|
||||
req: ShotReplicateGenerateImageRequest = Body(...),
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
try:
|
||||
project, step, chat_task = await generate_image_from_prompt(db, current_user=current_user, project_id=project_id, req=req)
|
||||
project_id_value, step_id_value, chat_task_id_value = project.id, step.id, chat_task.id
|
||||
await db.commit()
|
||||
except HTTPException:
|
||||
await db.rollback()
|
||||
raise
|
||||
except Exception as exc:
|
||||
await db.rollback()
|
||||
_log_api_exception_from_locals(exc, locals(), f"提交图片生成失败: {exc}")
|
||||
raise HTTPException(status_code=500, detail=f"提交图片生成失败: {exc}")
|
||||
|
||||
try:
|
||||
if celery_app:
|
||||
from app.tasks.generation_create_tasks import chatapi_create_generation_task
|
||||
|
||||
chatapi_create_generation_task.apply_async(args=[chat_task_id_value], queue="gen_chatapi_create", countdown=0)
|
||||
except Exception as exc:
|
||||
await _mark_dispatch_failed_and_raise(db, current_user=current_user, project_id=project_id_value, step_id=step_id_value, message=f"图片生成任务投递失败: {exc}")
|
||||
|
||||
return ShotReplicateActionOut(message="图片生成任务已提交", project_id=project_id_value, step_id=step_id_value, detail=await _reload_project_detail(db, current_user, project_id_value))
|
||||
|
||||
|
||||
@router.post(
|
||||
"/projects/{project_id}/generate-video-prompt",
|
||||
response_model=ShotReplicateActionOut,
|
||||
summary="生成视频 AI 提词 JSON schema",
|
||||
)
|
||||
async def generate_video_prompt(
|
||||
project_id: str = Path(...),
|
||||
req: ShotReplicateGenerateVideoPromptRequest = Body(...),
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
try:
|
||||
project, step = await submit_video_prompt_optimize(db, current_user=current_user, project_id=project_id, req=req)
|
||||
project_id_value, step_id_value = project.id, step.id
|
||||
await db.commit()
|
||||
except HTTPException:
|
||||
await db.rollback()
|
||||
raise
|
||||
except Exception as exc:
|
||||
await db.rollback()
|
||||
_log_api_exception_from_locals(exc, locals(), f"提交视频 AI 提词失败: {exc}")
|
||||
raise HTTPException(status_code=500, detail=f"提交视频 AI 提词失败: {exc}")
|
||||
|
||||
try:
|
||||
if celery_app:
|
||||
from app.tasks.shot_replicate_flow_tasks import start_video_prompt_optimize
|
||||
|
||||
start_video_prompt_optimize.apply_async(args=[project_id_value, step_id_value], queue="gen_chatapi_create", countdown=0)
|
||||
except Exception as exc:
|
||||
await _mark_dispatch_failed_and_raise(db, current_user=current_user, project_id=project_id_value, step_id=step_id_value, message=f"视频 AI 提词任务投递失败: {exc}")
|
||||
|
||||
return ShotReplicateActionOut(message="视频 AI 提词任务已提交", project_id=project_id_value, step_id=step_id_value, detail=await _reload_project_detail(db, current_user, project_id_value))
|
||||
|
||||
|
||||
@router.post(
|
||||
"/projects/{project_id}/generate-video",
|
||||
response_model=ShotReplicateActionOut,
|
||||
summary="根据视频 AI 提词生成视频",
|
||||
)
|
||||
async def generate_video(
|
||||
project_id: str = Path(...),
|
||||
req: ShotReplicateGenerateVideoRequest = Body(...),
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
try:
|
||||
project, step, chat_task = await generate_video_from_prompt(db, current_user=current_user, project_id=project_id, req=req)
|
||||
project_id_value, step_id_value, chat_task_id_value = project.id, step.id, chat_task.id
|
||||
await db.commit()
|
||||
except HTTPException:
|
||||
await db.rollback()
|
||||
raise
|
||||
except Exception as exc:
|
||||
await db.rollback()
|
||||
_log_api_exception_from_locals(exc, locals(), f"提交视频生成失败: {exc}")
|
||||
raise HTTPException(status_code=500, detail=f"提交视频生成失败: {exc}")
|
||||
|
||||
try:
|
||||
if celery_app:
|
||||
from app.tasks.generation_create_tasks import chatapi_create_generation_task
|
||||
|
||||
chatapi_create_generation_task.apply_async(args=[chat_task_id_value], queue="gen_chatapi_create", countdown=0)
|
||||
except Exception as exc:
|
||||
await _mark_dispatch_failed_and_raise(db, current_user=current_user, project_id=project_id_value, step_id=step_id_value, message=f"视频生成任务投递失败: {exc}")
|
||||
|
||||
return ShotReplicateActionOut(message="视频生成任务已提交", project_id=project_id_value, step_id=step_id_value, detail=await _reload_project_detail(db, current_user, project_id_value))
|
||||
|
||||
|
||||
@router.delete(
|
||||
"/projects/{project_id}",
|
||||
response_model=ShotReplicateDeleteOut,
|
||||
summary="软删除拆镜复刻项目",
|
||||
)
|
||||
async def delete_project(
|
||||
project_id: str = Path(...),
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
try:
|
||||
out = await delete_shot_replicate_project(db, current_user=current_user, project_id=project_id)
|
||||
await db.commit()
|
||||
return out
|
||||
except HTTPException:
|
||||
await db.rollback()
|
||||
raise
|
||||
except Exception as exc:
|
||||
await db.rollback()
|
||||
_log_api_exception_from_locals(exc, locals(), f"删除拆镜复刻项目失败: {exc}")
|
||||
raise HTTPException(status_code=500, detail=f"删除拆镜复刻项目失败: {exc}")
|
||||
Reference in New Issue
Block a user