管理后台上传集成API | 首页素材装修追加提词/附件
This commit is contained in:
@@ -7,6 +7,7 @@ from app.api.admin.home_material import router as home_material_router
|
||||
from app.api.admin.private_portrait import router as private_portrait_router
|
||||
from app.api.admin.recharge_package import router as recharge_package_router
|
||||
from app.api.admin.menu_config import router as menu_config_router
|
||||
from app.api.admin.upload import router as admin_upload_router
|
||||
|
||||
router = APIRouter()
|
||||
router.include_router(video_prompt_schema_config_router)
|
||||
@@ -16,3 +17,4 @@ router.include_router(home_material_router)
|
||||
router.include_router(private_portrait_router)
|
||||
router.include_router(recharge_package_router)
|
||||
router.include_router(menu_config_router)
|
||||
router.include_router(admin_upload_router)
|
||||
|
||||
@@ -336,6 +336,8 @@ async def upload_home_material_asset(
|
||||
file: UploadFile = File(..., description="图片或视频素材文件。"),
|
||||
media_type: HomeMaterialMediaType = Form(..., description="素材类型:image图片,video视频。"),
|
||||
title: str | None = Form(None, description="素材标题。为空时不再自动回填文件名。"),
|
||||
generation_prompt: str | None = Form(None, description="生成提词,可为空。"),
|
||||
media_references_json: str | None = Form(None, description="附件/参考素材JSON字符串,格式参考 generation_ai.create_task 的 media_references。"),
|
||||
watermark_type: HomeMaterialWatermarkType = Form(HomeMaterialWatermarkType.IMAGE, description="水印类型:image 图片水印;repeated_text 重复文字水印。"),
|
||||
watermark_id: str | None = Form(None, description="图片水印ID,可选。watermark_type=image 时使用。"),
|
||||
watermark_file: UploadFile | None = File(None, description="临时图片水印,可选。watermark_type=image 时使用。"),
|
||||
@@ -395,6 +397,8 @@ async def upload_home_material_asset(
|
||||
file=file,
|
||||
media_type=media_type,
|
||||
title=title,
|
||||
generation_prompt=generation_prompt,
|
||||
media_references_json=media_references_json,
|
||||
watermark_id=watermark_id,
|
||||
watermark_file=watermark_file,
|
||||
watermark_config=config,
|
||||
@@ -452,7 +456,7 @@ async def get_home_material_asset_status(
|
||||
"/assets/{asset_id}",
|
||||
response_model=HomeMaterialAssetOut,
|
||||
summary="修改首页素材展示信息",
|
||||
description="只修改行业、标题、启用状态、排序,不重新生成水印。",
|
||||
description="修改行业、标题、启用状态、排序、生成提词和附件JSON,不重新生成水印。",
|
||||
)
|
||||
async def update_home_material_asset(
|
||||
req: HomeMaterialAssetUpdate,
|
||||
@@ -465,7 +469,7 @@ async def update_home_material_asset(
|
||||
db,
|
||||
admin.id,
|
||||
admin.username,
|
||||
HomeMaterialOperationEnum.ASSET_UPDATE.value,
|
||||
(HomeMaterialOperationEnum.ASSET_GENERATION_CONFIG_UPDATE.value if (before.get("generation_prompt") != after.get("generation_prompt") or before.get("media_references_count") != after.get("media_references_count")) else HomeMaterialOperationEnum.ASSET_UPDATE.value),
|
||||
"PUT",
|
||||
f"/admin/home-material/assets/{asset_id}",
|
||||
detail=_detail(before=before, after=after),
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from fastapi import APIRouter, Depends, File, Form, UploadFile
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.dependencies import get_admin_user, get_db
|
||||
from app.enums.admin_upload import AdminUploadResourceTypeEnum, AdminUploadSceneEnum
|
||||
from app.models.user import User
|
||||
from app.schemas.admin_upload import AdminUploadFileOut
|
||||
from app.services.admin_upload import admin_upload_service
|
||||
|
||||
router = APIRouter(prefix="/admin/uploads", tags=["admin-upload"])
|
||||
|
||||
|
||||
@router.post(
|
||||
"/files",
|
||||
response_model=AdminUploadFileOut,
|
||||
summary="管理后台全局文件上传",
|
||||
description=(
|
||||
"管理后台纯文件上传统一入口。通过 scene 区分业务场景并落到独立目录;"
|
||||
"首页素材附件使用 scene=home_material_reference,系统Logo使用 system_logo,PDF使用 system_pdf,开户缩略图使用 open_type_thumb。"
|
||||
),
|
||||
)
|
||||
async def upload_admin_file(
|
||||
file: UploadFile = File(..., description="上传文件。"),
|
||||
scene: AdminUploadSceneEnum = Form(..., description="上传场景。"),
|
||||
resource_type: AdminUploadResourceTypeEnum = Form(..., description="资源类型:image/video/audio/pdf/file。"),
|
||||
duration_seconds: float | None = Form(None, ge=0, description="音视频时长,单位秒。"),
|
||||
admin: User = Depends(get_admin_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
result = await admin_upload_service.upload_file(
|
||||
db,
|
||||
file=file,
|
||||
scene=scene,
|
||||
resource_type=resource_type,
|
||||
duration_seconds=duration_seconds,
|
||||
admin=admin,
|
||||
)
|
||||
await db.commit()
|
||||
return result
|
||||
@@ -0,0 +1,55 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from enum import StrEnum
|
||||
|
||||
|
||||
class AdminUploadSceneEnum(StrEnum):
|
||||
"""管理后台全局上传场景。"""
|
||||
|
||||
SYSTEM_LOGO = "system_logo"
|
||||
SYSTEM_PDF = "system_pdf"
|
||||
OPEN_TYPE_THUMB = "open_type_thumb"
|
||||
HOME_MATERIAL_REFERENCE = "home_material_reference"
|
||||
ADMIN_COMMON = "admin_common"
|
||||
|
||||
|
||||
class AdminUploadResourceTypeEnum(StrEnum):
|
||||
"""管理后台全局上传资源类型。"""
|
||||
|
||||
IMAGE = "image"
|
||||
VIDEO = "video"
|
||||
AUDIO = "audio"
|
||||
PDF = "pdf"
|
||||
FILE = "file"
|
||||
|
||||
|
||||
class AdminUploadLogEventEnum(StrEnum):
|
||||
"""管理后台上传步骤日志事件。"""
|
||||
|
||||
VALIDATE_STARTED = "ADMIN_UPLOAD_VALIDATE_STARTED"
|
||||
VALIDATE_SUCCESS = "ADMIN_UPLOAD_VALIDATE_SUCCESS"
|
||||
SAVE_STARTED = "ADMIN_UPLOAD_SAVE_STARTED"
|
||||
SAVE_SUCCESS = "ADMIN_UPLOAD_SAVE_SUCCESS"
|
||||
DB_RECORD_SUCCESS = "ADMIN_UPLOAD_DB_RECORD_SUCCESS"
|
||||
FAILED = "ADMIN_UPLOAD_FAILED"
|
||||
|
||||
|
||||
ADMIN_UPLOAD_IMAGE_EXTENSIONS = {".jpg", ".jpeg", ".png", ".webp", ".gif"}
|
||||
ADMIN_UPLOAD_VIDEO_EXTENSIONS = {".mp4", ".mov", ".m4v", ".webm"}
|
||||
ADMIN_UPLOAD_AUDIO_EXTENSIONS = {".mp3", ".wav", ".m4a", ".aac"}
|
||||
ADMIN_UPLOAD_PDF_EXTENSIONS = {".pdf"}
|
||||
|
||||
ADMIN_UPLOAD_IMAGE_MAX_BYTES = 20 * 1024 * 1024
|
||||
ADMIN_UPLOAD_VIDEO_MAX_BYTES = 300 * 1024 * 1024
|
||||
ADMIN_UPLOAD_AUDIO_MAX_BYTES = 30 * 1024 * 1024
|
||||
ADMIN_UPLOAD_PDF_MAX_BYTES = 20 * 1024 * 1024
|
||||
ADMIN_UPLOAD_FILE_MAX_BYTES = 100 * 1024 * 1024
|
||||
ADMIN_UPLOAD_FILE_NAME_MAX_LEN = 255
|
||||
|
||||
ADMIN_UPLOAD_SCENE_LABELS: dict[str, str] = {
|
||||
AdminUploadSceneEnum.SYSTEM_LOGO.value: "系统Logo",
|
||||
AdminUploadSceneEnum.SYSTEM_PDF.value: "系统PDF",
|
||||
AdminUploadSceneEnum.OPEN_TYPE_THUMB.value: "开户方式缩略图",
|
||||
AdminUploadSceneEnum.HOME_MATERIAL_REFERENCE.value: "首页素材附件",
|
||||
AdminUploadSceneEnum.ADMIN_COMMON.value: "后台公共上传",
|
||||
}
|
||||
@@ -63,6 +63,17 @@ class HomeMaterialPublicResponseMode(StrEnum):
|
||||
FLAT = "flat"
|
||||
|
||||
|
||||
class HomeMaterialLogEventEnum(StrEnum):
|
||||
"""首页素材步骤日志事件。"""
|
||||
|
||||
GENERATION_CONFIG_VALIDATE_STARTED = "HOME_MATERIAL_GENERATION_CONFIG_VALIDATE_STARTED"
|
||||
GENERATION_CONFIG_VALIDATE_SUCCESS = "HOME_MATERIAL_GENERATION_CONFIG_VALIDATE_SUCCESS"
|
||||
GENERATION_CONFIG_UPDATE_STARTED = "HOME_MATERIAL_GENERATION_CONFIG_UPDATE_STARTED"
|
||||
GENERATION_CONFIG_UPDATE_SUCCESS = "HOME_MATERIAL_GENERATION_CONFIG_UPDATE_SUCCESS"
|
||||
GENERATION_CONFIG_UPDATE_FAILED = "HOME_MATERIAL_GENERATION_CONFIG_UPDATE_FAILED"
|
||||
MEDIA_REFERENCES_PARSE_FAILED = "HOME_MATERIAL_MEDIA_REFERENCES_PARSE_FAILED"
|
||||
|
||||
|
||||
class HomeMaterialOperationEnum(StrEnum):
|
||||
"""后台操作日志 action 枚举。"""
|
||||
|
||||
@@ -75,6 +86,7 @@ class HomeMaterialOperationEnum(StrEnum):
|
||||
WATERMARK_DELETE = "删除首页素材水印"
|
||||
ASSET_UPLOAD = "上传首页素材"
|
||||
ASSET_UPDATE = "修改首页素材"
|
||||
ASSET_GENERATION_CONFIG_UPDATE = "修改首页素材生成配置"
|
||||
ASSET_DELETE = "删除首页素材"
|
||||
ASSET_REGENERATE_WATERMARK = "重新生成首页素材水印"
|
||||
|
||||
@@ -83,6 +95,10 @@ HOME_MATERIAL_IMAGE_EXTENSIONS = {".jpg", ".jpeg", ".png", ".webp"}
|
||||
HOME_MATERIAL_VIDEO_EXTENSIONS = {".mp4", ".mov", ".m4v", ".webm"}
|
||||
HOME_MATERIAL_WATERMARK_EXTENSIONS = {".png", ".webp", ".jpg", ".jpeg"}
|
||||
|
||||
HOME_MATERIAL_GENERATION_PROMPT_MAX_LEN = 8000
|
||||
HOME_MATERIAL_MEDIA_REFERENCES_MAX_COUNT = 20
|
||||
HOME_MATERIAL_MEDIA_REFERENCES_JSON_MAX_LEN = 20000
|
||||
|
||||
HOME_MATERIAL_DEFAULT_CONFIG = {
|
||||
"enabled": False,
|
||||
"title": "行业素材案例",
|
||||
|
||||
@@ -7,6 +7,8 @@ class UploadResourceModuleEnum(StrEnum):
|
||||
"""上传资源所属业务模块。"""
|
||||
|
||||
COMMON = "common"
|
||||
ADMIN_UPLOAD = "admin_upload"
|
||||
HOME_MATERIAL = "home_material"
|
||||
HOT_OPENING_REPLICATE = "hot_opening_replicate"
|
||||
SHOT_REPLICATE = "shot_replicate"
|
||||
|
||||
@@ -18,6 +20,8 @@ class UploadResourceTypeEnum(StrEnum):
|
||||
VIDEO = "video"
|
||||
AUDIO = "audio"
|
||||
SHOT_SEGMENT = "shot_segment"
|
||||
PDF = "pdf"
|
||||
FILE = "file"
|
||||
|
||||
|
||||
class UploadResourceBindStatusEnum(StrEnum):
|
||||
@@ -76,6 +80,9 @@ class UploadResourceSourceModelEnum(StrEnum):
|
||||
MODULE_GENERATION_PROJECT = "ModuleGenerationProject"
|
||||
SHOT_REPLICATE_TASK_SET = "ShotReplicateTaskSet"
|
||||
SHOT_REPLICATE_SEGMENT = "ShotReplicateSegment"
|
||||
HOME_MATERIAL_ASSET = "HomeMaterialAsset"
|
||||
SYSTEM_CONFIG = "SystemConfig"
|
||||
OPEN_TYPE = "OpenType"
|
||||
|
||||
|
||||
class UploadResourceEventEnum(StrEnum):
|
||||
@@ -155,6 +162,8 @@ class UploadResourceEventEnum(StrEnum):
|
||||
|
||||
UPLOAD_RESOURCE_MODULE_LABELS: dict[str, str] = {
|
||||
UploadResourceModuleEnum.COMMON.value: "普通上传",
|
||||
UploadResourceModuleEnum.ADMIN_UPLOAD.value: "管理后台上传",
|
||||
UploadResourceModuleEnum.HOME_MATERIAL.value: "首页素材",
|
||||
UploadResourceModuleEnum.HOT_OPENING_REPLICATE.value: "爆款开头复刻",
|
||||
UploadResourceModuleEnum.SHOT_REPLICATE.value: "拆镜复刻",
|
||||
}
|
||||
@@ -164,4 +173,6 @@ UPLOAD_RESOURCE_TYPE_LABELS: dict[str, str] = {
|
||||
UploadResourceTypeEnum.VIDEO.value: "视频",
|
||||
UploadResourceTypeEnum.AUDIO.value: "音频",
|
||||
UploadResourceTypeEnum.SHOT_SEGMENT.value: "拆镜切片",
|
||||
UploadResourceTypeEnum.PDF.value: "PDF",
|
||||
UploadResourceTypeEnum.FILE.value: "文件",
|
||||
}
|
||||
|
||||
@@ -40,6 +40,8 @@ class HomeMaterialAsset(Base, TimestampMixin, SoftDeleteMixin):
|
||||
cover_storage_path: Mapped[str | None] = mapped_column(Text, nullable=True, comment="视频封面本地路径")
|
||||
watermark_id: Mapped[str | None] = mapped_column(String(32), nullable=True, index=True, comment="水印图片ID")
|
||||
watermark_config_json: Mapped[str | None] = mapped_column(Text, nullable=True, comment="水印配置快照JSON")
|
||||
generation_prompt: Mapped[str | None] = mapped_column(Text, nullable=True, comment="生成提词")
|
||||
media_references_json: Mapped[str | None] = mapped_column(Text, nullable=True, comment="附件/参考素材JSON字符串")
|
||||
status: Mapped[str] = mapped_column(
|
||||
String(32),
|
||||
default=HomeMaterialAssetStatus.DRAFT.value,
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from app.enums.admin_upload import AdminUploadResourceTypeEnum, AdminUploadSceneEnum
|
||||
|
||||
|
||||
class AdminUploadMediaReferenceOut(BaseModel):
|
||||
url: str = Field(..., description="附件URL。")
|
||||
type: str = Field(..., description="附件类型:image/video/audio/pdf/file。")
|
||||
name: str | None = Field(None, description="附件名称。")
|
||||
duration: float | None = Field(None, description="音视频时长,单位秒。")
|
||||
source: str = Field("admin_upload", description="来源标识。")
|
||||
upload_resource_id: str | None = Field(None, description="UploadResource资源ID。")
|
||||
display_url: str | None = Field(None, description="展示URL。")
|
||||
preview_url: str | None = Field(None, description="预览URL。")
|
||||
role: str | None = Field(None, description="参考素材角色。")
|
||||
|
||||
|
||||
class AdminUploadFileOut(BaseModel):
|
||||
resource_id: str = Field(..., description="UploadResource资源ID。")
|
||||
scene: AdminUploadSceneEnum = Field(..., description="上传场景。")
|
||||
module: str = Field(..., description="上传资源模块。")
|
||||
resource_type: AdminUploadResourceTypeEnum = Field(..., description="资源类型。")
|
||||
url: str = Field(..., description="文件URL。")
|
||||
file_name: str = Field(..., description="保存后的文件名。")
|
||||
original_file_name: str | None = Field(None, description="原始文件名。")
|
||||
file_size_bytes: int = Field(0, description="文件大小,单位字节。")
|
||||
duration_seconds: float | None = Field(None, description="音视频时长,单位秒。")
|
||||
media_reference: AdminUploadMediaReferenceOut | None = Field(None, description="兼容生成任务 media_references 的附件引用对象。")
|
||||
@@ -3,9 +3,11 @@ from __future__ import annotations
|
||||
from decimal import Decimal
|
||||
from typing import Any
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator
|
||||
from pydantic import AliasChoices, BaseModel, ConfigDict, Field, field_validator, model_validator
|
||||
|
||||
from app.enums.home_material import (
|
||||
HOME_MATERIAL_GENERATION_PROMPT_MAX_LEN,
|
||||
HOME_MATERIAL_MEDIA_REFERENCES_MAX_COUNT,
|
||||
HomeMaterialAssetStatus,
|
||||
HomeMaterialMediaType,
|
||||
HomeMaterialPublicResponseMode,
|
||||
@@ -92,6 +94,40 @@ class HomeMaterialWatermarkListOut(BaseModel):
|
||||
total: int = Field(0, description="符合条件的水印总数。")
|
||||
|
||||
|
||||
|
||||
|
||||
class HomeMaterialMediaReference(BaseModel):
|
||||
url: str = Field(..., min_length=1, description="附件URL。")
|
||||
type: str = Field(..., pattern=r"^(image|video|audio)$", description="附件类型:image/video/audio。")
|
||||
name: str | None = Field(None, max_length=255, description="附件名称。")
|
||||
duration: float | None = Field(None, ge=0, description="视频/音频时长,单位秒。")
|
||||
source: str | None = Field("admin_upload", max_length=64, description="来源标识。")
|
||||
upload_resource_id: str | None = Field(None, max_length=32, validation_alias=AliasChoices("upload_resource_id", "uploadResourceId"), description="UploadResource资源ID。")
|
||||
display_url: str | None = Field(None, validation_alias=AliasChoices("display_url", "displayUrl"), description="展示URL。")
|
||||
preview_url: str | None = Field(None, validation_alias=AliasChoices("preview_url", "previewUrl"), description="预览URL。")
|
||||
role: str | None = Field(None, max_length=64, description="参考素材角色。")
|
||||
|
||||
model_config = ConfigDict(populate_by_name=True)
|
||||
|
||||
@field_validator("url", "display_url", "preview_url", mode="before")
|
||||
@classmethod
|
||||
def clean_url(cls, value: str | None) -> str | None:
|
||||
if value is None:
|
||||
return None
|
||||
text = str(value).strip()
|
||||
return text or None
|
||||
|
||||
@model_validator(mode="after")
|
||||
def fill_urls_and_role(self) -> "HomeMaterialMediaReference":
|
||||
if not self.display_url:
|
||||
self.display_url = self.url
|
||||
if not self.preview_url:
|
||||
self.preview_url = self.display_url or self.url
|
||||
if not self.role:
|
||||
self.role = f"reference_{self.type}"
|
||||
return self
|
||||
|
||||
|
||||
class HomeMaterialTextWatermarkConfig(BaseModel):
|
||||
"""重复文字水印配置。字体固定由后端 HOME_MATERIAL_TEXT_WATERMARK_FONT 指定,不允许前端传字体,避免版权和一致性问题。"""
|
||||
|
||||
@@ -180,6 +216,8 @@ class HomeMaterialAssetOut(BaseModel):
|
||||
watermark_id: str | None = Field(None, description="图片水印ID。重复文字水印素材为空。")
|
||||
watermark_name: str | None = Field(None, description="图片水印名称,列表接口批量查询回填。重复文字水印显示为空。")
|
||||
watermark_config: HomeMaterialWatermarkConfig | dict[str, Any] | None = Field(None, description="水印配置快照。")
|
||||
generation_prompt: str | None = Field(None, description="生成提词。")
|
||||
media_references: list[HomeMaterialMediaReference] = Field(default_factory=list, description="附件/参考素材列表。")
|
||||
width: int | None = Field(None, description="素材宽度。")
|
||||
height: int | None = Field(None, description="素材高度。")
|
||||
duration_seconds: Decimal | float | None = Field(None, description="视频时长,图片为空。")
|
||||
@@ -205,10 +243,12 @@ class HomeMaterialAssetUpdate(BaseModel):
|
||||
title: str | None = Field(None, max_length=128, description="素材标题。")
|
||||
is_active: bool = Field(True, description="是否前台展示。")
|
||||
sort_order: int = Field(0, ge=0, le=999999, description="排序值。")
|
||||
generation_prompt: str | None = Field(None, max_length=HOME_MATERIAL_GENERATION_PROMPT_MAX_LEN, description="生成提词。")
|
||||
media_references: list[HomeMaterialMediaReference] | None = Field(None, max_length=HOME_MATERIAL_MEDIA_REFERENCES_MAX_COUNT, description="附件/参考素材列表。")
|
||||
|
||||
@field_validator("title")
|
||||
@field_validator("title", "generation_prompt")
|
||||
@classmethod
|
||||
def clean_title(cls, value: str | None) -> str | None:
|
||||
def clean_text(cls, value: str | None) -> str | None:
|
||||
value = (value or "").strip()
|
||||
return value or None
|
||||
|
||||
@@ -256,6 +296,8 @@ class HomeMaterialPublicAssetOut(BaseModel):
|
||||
width: int | None = Field(None, description="宽度。")
|
||||
height: int | None = Field(None, description="高度。")
|
||||
duration_seconds: Decimal | float | None = Field(None, description="视频时长。")
|
||||
generation_prompt: str | None = Field(None, description="生成提词。")
|
||||
media_references: list[HomeMaterialMediaReference] = Field(default_factory=list, description="附件/参考素材列表。")
|
||||
sort_order: int = Field(0, description="排序。")
|
||||
|
||||
|
||||
@@ -303,4 +345,6 @@ class HomeMaterialUploadResultOut(BaseModel):
|
||||
watermarked_url: str | None = Field(None, description="水印素材URL。")
|
||||
cover_url: str | None = Field(None, description="视频封面URL。")
|
||||
watermark_config: HomeMaterialWatermarkConfig | dict[str, Any] | None = Field(None, description="水印配置快照。")
|
||||
generation_prompt: str | None = Field(None, description="生成提词。")
|
||||
media_references: list[HomeMaterialMediaReference] = Field(default_factory=list, description="附件/参考素材列表。")
|
||||
message: str = Field("", description="提示消息。")
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
from app.services.admin_upload.service import admin_upload_service
|
||||
|
||||
__all__ = ["admin_upload_service"]
|
||||
@@ -0,0 +1,186 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from fastapi import UploadFile
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.enums.admin_upload import AdminUploadLogEventEnum, AdminUploadResourceTypeEnum, AdminUploadSceneEnum
|
||||
from app.enums.common import LogEventStatusEnum, LogSourceEnum
|
||||
from app.enums.upload_resource import (
|
||||
UploadResourceBindStatusEnum,
|
||||
UploadResourceCreatedByEnum,
|
||||
UploadResourceDeletePolicyEnum,
|
||||
UploadResourceDurationSourceEnum,
|
||||
UploadResourceModuleEnum,
|
||||
)
|
||||
from app.models.user import User
|
||||
from app.schemas.admin_upload import AdminUploadFileOut, AdminUploadMediaReferenceOut
|
||||
from app.services.admin_upload.storage import save_upload_file
|
||||
from app.services.operation_log_service import build_exception_detail, log_operation_event
|
||||
from app.services.upload_resource.core_service import record_external_upload_resource
|
||||
|
||||
|
||||
def _duration(value: float | None) -> float | None:
|
||||
if value is None:
|
||||
return None
|
||||
try:
|
||||
v = float(value)
|
||||
return round(v, 3) if v >= 0 else None
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def _module_for_scene(scene: AdminUploadSceneEnum) -> str:
|
||||
if scene == AdminUploadSceneEnum.HOME_MATERIAL_REFERENCE:
|
||||
return UploadResourceModuleEnum.HOME_MATERIAL.value
|
||||
return UploadResourceModuleEnum.ADMIN_UPLOAD.value
|
||||
|
||||
|
||||
def _role_for_type(resource_type: AdminUploadResourceTypeEnum) -> str | None:
|
||||
if resource_type == AdminUploadResourceTypeEnum.IMAGE:
|
||||
return "reference_image"
|
||||
if resource_type == AdminUploadResourceTypeEnum.VIDEO:
|
||||
return "reference_video"
|
||||
if resource_type == AdminUploadResourceTypeEnum.AUDIO:
|
||||
return "reference_audio"
|
||||
return None
|
||||
|
||||
|
||||
def _media_reference(*, url: str, resource_type: AdminUploadResourceTypeEnum, name: str | None, duration_seconds: float | None, resource_id: str) -> AdminUploadMediaReferenceOut:
|
||||
return AdminUploadMediaReferenceOut(
|
||||
url=url,
|
||||
type=resource_type.value,
|
||||
name=name,
|
||||
duration=duration_seconds,
|
||||
source="admin_upload",
|
||||
upload_resource_id=resource_id,
|
||||
display_url=url,
|
||||
preview_url=url,
|
||||
role=_role_for_type(resource_type),
|
||||
)
|
||||
|
||||
|
||||
def _log(event_type: str, *, admin: User, scene: str, resource_type: str, status: str = LogEventStatusEnum.SUCCESS.value, message: str | None = None, detail: dict[str, Any] | None = None, error: str | None = None) -> None:
|
||||
log_operation_event(
|
||||
domain="admin_upload",
|
||||
module="admin_upload",
|
||||
event_type=event_type,
|
||||
event_status=status,
|
||||
source=LogSourceEnum.API.value,
|
||||
user_id=admin.id,
|
||||
message=message,
|
||||
detail={"scene": scene, "resource_type": resource_type, **(detail or {})},
|
||||
error=error,
|
||||
)
|
||||
|
||||
|
||||
class AdminUploadService:
|
||||
async def upload_file(
|
||||
self,
|
||||
db: AsyncSession,
|
||||
*,
|
||||
file: UploadFile,
|
||||
scene: AdminUploadSceneEnum,
|
||||
resource_type: AdminUploadResourceTypeEnum,
|
||||
admin: User,
|
||||
duration_seconds: float | None = None,
|
||||
) -> AdminUploadFileOut:
|
||||
duration = _duration(duration_seconds)
|
||||
_log(
|
||||
AdminUploadLogEventEnum.VALIDATE_STARTED.value,
|
||||
admin=admin,
|
||||
scene=scene.value,
|
||||
resource_type=resource_type.value,
|
||||
status=LogEventStatusEnum.STARTED.value,
|
||||
detail={"filename": file.filename, "content_type": file.content_type},
|
||||
)
|
||||
stored = None
|
||||
try:
|
||||
_log(
|
||||
AdminUploadLogEventEnum.VALIDATE_SUCCESS.value,
|
||||
admin=admin,
|
||||
scene=scene.value,
|
||||
resource_type=resource_type.value,
|
||||
detail={"filename": file.filename, "duration_seconds": duration},
|
||||
)
|
||||
_log(
|
||||
AdminUploadLogEventEnum.SAVE_STARTED.value,
|
||||
admin=admin,
|
||||
scene=scene.value,
|
||||
resource_type=resource_type.value,
|
||||
status=LogEventStatusEnum.STARTED.value,
|
||||
detail={"filename": file.filename},
|
||||
)
|
||||
stored = await save_upload_file(file, scene=scene, resource_type=resource_type, admin_id=admin.id)
|
||||
_log(
|
||||
AdminUploadLogEventEnum.SAVE_SUCCESS.value,
|
||||
admin=admin,
|
||||
scene=scene.value,
|
||||
resource_type=resource_type.value,
|
||||
detail={"url": stored.file_url, "storage_path": stored.storage_path, "file_size_bytes": stored.file_size_bytes},
|
||||
)
|
||||
module = _module_for_scene(scene)
|
||||
resource = await record_external_upload_resource(
|
||||
db,
|
||||
user_id=admin.id,
|
||||
module=module,
|
||||
resource_type=resource_type.value,
|
||||
resource_url=stored.file_url,
|
||||
storage_path=stored.storage_path,
|
||||
file_size_bytes=stored.file_size_bytes,
|
||||
file_name=stored.file_name,
|
||||
mime_type=file.content_type,
|
||||
duration_seconds=duration,
|
||||
duration_source=UploadResourceDurationSourceEnum.CLIENT.value if duration is not None else None,
|
||||
bind_status=UploadResourceBindStatusEnum.PENDING.value,
|
||||
delete_policy=UploadResourceDeletePolicyEnum.USER_DELETABLE.value,
|
||||
created_by=UploadResourceCreatedByEnum.API.value,
|
||||
metadata={
|
||||
"scene": scene.value,
|
||||
"original_filename": stored.original_file_name,
|
||||
"client_duration_seconds": duration_seconds,
|
||||
"source": "admin_upload",
|
||||
},
|
||||
)
|
||||
await db.refresh(resource)
|
||||
_log(
|
||||
AdminUploadLogEventEnum.DB_RECORD_SUCCESS.value,
|
||||
admin=admin,
|
||||
scene=scene.value,
|
||||
resource_type=resource_type.value,
|
||||
detail={"resource_id": resource.id, "url": stored.file_url, "module": module},
|
||||
)
|
||||
media_reference = _media_reference(
|
||||
url=stored.file_url,
|
||||
resource_type=resource_type,
|
||||
name=stored.original_file_name or stored.file_name,
|
||||
duration_seconds=resource.duration_seconds,
|
||||
resource_id=resource.id,
|
||||
)
|
||||
return AdminUploadFileOut(
|
||||
resource_id=resource.id,
|
||||
scene=scene,
|
||||
module=module,
|
||||
resource_type=resource_type,
|
||||
url=stored.file_url,
|
||||
file_name=stored.file_name,
|
||||
original_file_name=stored.original_file_name,
|
||||
file_size_bytes=stored.file_size_bytes,
|
||||
duration_seconds=resource.duration_seconds,
|
||||
media_reference=media_reference,
|
||||
)
|
||||
except Exception as exc:
|
||||
_log(
|
||||
AdminUploadLogEventEnum.FAILED.value,
|
||||
admin=admin,
|
||||
scene=scene.value,
|
||||
resource_type=resource_type.value,
|
||||
status=LogEventStatusEnum.FAILED.value,
|
||||
detail=build_exception_detail(exc, {"filename": file.filename}),
|
||||
error=str(exc),
|
||||
)
|
||||
raise
|
||||
|
||||
|
||||
admin_upload_service = AdminUploadService()
|
||||
@@ -0,0 +1,188 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import re
|
||||
import shutil
|
||||
import tempfile
|
||||
import uuid
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
|
||||
from fastapi import HTTPException, UploadFile
|
||||
|
||||
from app.config import settings
|
||||
from app.enums.admin_upload import (
|
||||
ADMIN_UPLOAD_AUDIO_EXTENSIONS,
|
||||
ADMIN_UPLOAD_AUDIO_MAX_BYTES,
|
||||
ADMIN_UPLOAD_FILE_MAX_BYTES,
|
||||
ADMIN_UPLOAD_FILE_NAME_MAX_LEN,
|
||||
ADMIN_UPLOAD_IMAGE_EXTENSIONS,
|
||||
ADMIN_UPLOAD_IMAGE_MAX_BYTES,
|
||||
ADMIN_UPLOAD_PDF_EXTENSIONS,
|
||||
ADMIN_UPLOAD_PDF_MAX_BYTES,
|
||||
ADMIN_UPLOAD_VIDEO_EXTENSIONS,
|
||||
ADMIN_UPLOAD_VIDEO_MAX_BYTES,
|
||||
AdminUploadResourceTypeEnum,
|
||||
AdminUploadSceneEnum,
|
||||
)
|
||||
|
||||
CHUNK_SIZE = 1024 * 1024
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class StoredAdminUploadFile:
|
||||
storage_path: str
|
||||
file_url: str
|
||||
file_name: str
|
||||
original_file_name: str | None
|
||||
file_size_bytes: int
|
||||
|
||||
|
||||
def _upload_root() -> Path:
|
||||
return Path(settings.UPLOAD_LOCAL_PATH).resolve()
|
||||
|
||||
|
||||
def _safe_original_name(filename: str | None) -> str | None:
|
||||
if not filename:
|
||||
return None
|
||||
name = Path(filename).name.strip()
|
||||
name = re.sub(r"[\\/\r\n\t]+", "_", name)
|
||||
return name[:ADMIN_UPLOAD_FILE_NAME_MAX_LEN] or None
|
||||
|
||||
|
||||
def _ext(filename: str | None) -> str:
|
||||
return Path(filename or "").suffix.lower()
|
||||
|
||||
|
||||
def _allowed_extensions(resource_type: AdminUploadResourceTypeEnum) -> set[str] | None:
|
||||
if resource_type == AdminUploadResourceTypeEnum.IMAGE:
|
||||
return ADMIN_UPLOAD_IMAGE_EXTENSIONS
|
||||
if resource_type == AdminUploadResourceTypeEnum.VIDEO:
|
||||
return ADMIN_UPLOAD_VIDEO_EXTENSIONS
|
||||
if resource_type == AdminUploadResourceTypeEnum.AUDIO:
|
||||
return ADMIN_UPLOAD_AUDIO_EXTENSIONS
|
||||
if resource_type == AdminUploadResourceTypeEnum.PDF:
|
||||
return ADMIN_UPLOAD_PDF_EXTENSIONS
|
||||
return None
|
||||
|
||||
|
||||
def _max_bytes(resource_type: AdminUploadResourceTypeEnum) -> int:
|
||||
if resource_type == AdminUploadResourceTypeEnum.IMAGE:
|
||||
return ADMIN_UPLOAD_IMAGE_MAX_BYTES
|
||||
if resource_type == AdminUploadResourceTypeEnum.VIDEO:
|
||||
return ADMIN_UPLOAD_VIDEO_MAX_BYTES
|
||||
if resource_type == AdminUploadResourceTypeEnum.AUDIO:
|
||||
return ADMIN_UPLOAD_AUDIO_MAX_BYTES
|
||||
if resource_type == AdminUploadResourceTypeEnum.PDF:
|
||||
return ADMIN_UPLOAD_PDF_MAX_BYTES
|
||||
return ADMIN_UPLOAD_FILE_MAX_BYTES
|
||||
|
||||
|
||||
def validate_file_name(filename: str | None, resource_type: AdminUploadResourceTypeEnum) -> str:
|
||||
safe_name = _safe_original_name(filename)
|
||||
if not safe_name:
|
||||
raise HTTPException(status_code=400, detail="请选择文件")
|
||||
allowed = _allowed_extensions(resource_type)
|
||||
ext = _ext(safe_name)
|
||||
if allowed is not None and ext not in allowed:
|
||||
if resource_type == AdminUploadResourceTypeEnum.IMAGE:
|
||||
raise HTTPException(status_code=400, detail="仅支持 jpg/jpeg/png/webp/gif 图片")
|
||||
if resource_type == AdminUploadResourceTypeEnum.VIDEO:
|
||||
raise HTTPException(status_code=400, detail="仅支持 mp4/mov/m4v/webm 视频")
|
||||
if resource_type == AdminUploadResourceTypeEnum.AUDIO:
|
||||
raise HTTPException(status_code=400, detail="仅支持 mp3/wav/m4a/aac 音频")
|
||||
if resource_type == AdminUploadResourceTypeEnum.PDF:
|
||||
raise HTTPException(status_code=400, detail="仅支持 PDF 文件")
|
||||
return safe_name
|
||||
|
||||
|
||||
def _kind_dir(resource_type: AdminUploadResourceTypeEnum) -> str:
|
||||
if resource_type == AdminUploadResourceTypeEnum.IMAGE:
|
||||
return "images"
|
||||
if resource_type == AdminUploadResourceTypeEnum.VIDEO:
|
||||
return "videos"
|
||||
if resource_type == AdminUploadResourceTypeEnum.AUDIO:
|
||||
return "audios"
|
||||
return "files"
|
||||
|
||||
|
||||
def _scene_base_dir(scene: AdminUploadSceneEnum, resource_type: AdminUploadResourceTypeEnum) -> Path:
|
||||
if scene == AdminUploadSceneEnum.HOME_MATERIAL_REFERENCE:
|
||||
if resource_type not in {AdminUploadResourceTypeEnum.IMAGE, AdminUploadResourceTypeEnum.VIDEO, AdminUploadResourceTypeEnum.AUDIO}:
|
||||
raise HTTPException(status_code=400, detail="首页素材附件仅支持图片、视频、音频")
|
||||
return Path("home_materials") / "references" / _kind_dir(resource_type)
|
||||
if scene == AdminUploadSceneEnum.SYSTEM_LOGO:
|
||||
if resource_type != AdminUploadResourceTypeEnum.IMAGE:
|
||||
raise HTTPException(status_code=400, detail="系统Logo仅支持图片")
|
||||
return Path("admin_uploads") / "system_logo" / "images"
|
||||
if scene == AdminUploadSceneEnum.SYSTEM_PDF:
|
||||
if resource_type != AdminUploadResourceTypeEnum.PDF:
|
||||
raise HTTPException(status_code=400, detail="系统PDF仅支持PDF文件")
|
||||
return Path("admin_uploads") / "system_pdf" / "files"
|
||||
if scene == AdminUploadSceneEnum.OPEN_TYPE_THUMB:
|
||||
if resource_type != AdminUploadResourceTypeEnum.IMAGE:
|
||||
raise HTTPException(status_code=400, detail="开户方式缩略图仅支持图片")
|
||||
return Path("admin_uploads") / "open_type_thumb" / "images"
|
||||
return Path("admin_uploads") / "common" / _kind_dir(resource_type)
|
||||
|
||||
|
||||
def build_target(scene: AdminUploadSceneEnum, resource_type: AdminUploadResourceTypeEnum, admin_id: str, original_filename: str | None) -> tuple[Path, str, str]:
|
||||
safe_original = validate_file_name(original_filename, resource_type)
|
||||
now = datetime.now()
|
||||
date_dir = now.strftime("%Y/%m/%d")
|
||||
timestamp = now.strftime("%Y%m%d_%H%M%S")
|
||||
suffix = uuid.uuid4().hex[:8]
|
||||
ext = _ext(safe_original)
|
||||
prefix = {
|
||||
AdminUploadResourceTypeEnum.IMAGE: "admin_img",
|
||||
AdminUploadResourceTypeEnum.VIDEO: "admin_video",
|
||||
AdminUploadResourceTypeEnum.AUDIO: "admin_audio",
|
||||
AdminUploadResourceTypeEnum.PDF: "admin_pdf",
|
||||
}.get(resource_type, "admin_file")
|
||||
file_name = f"{prefix}_{admin_id}_{timestamp}_{suffix}{ext}"
|
||||
rel_dir = _scene_base_dir(scene, resource_type) / date_dir
|
||||
storage_path = _upload_root() / rel_dir / file_name
|
||||
file_url = f"/uploads/{(rel_dir / file_name).as_posix()}"
|
||||
return storage_path, file_url, file_name
|
||||
|
||||
|
||||
async def save_upload_file(file: UploadFile, *, scene: AdminUploadSceneEnum, resource_type: AdminUploadResourceTypeEnum, admin_id: str) -> StoredAdminUploadFile:
|
||||
original_file_name = validate_file_name(file.filename, resource_type)
|
||||
max_bytes = _max_bytes(resource_type)
|
||||
fd, temp_path = tempfile.mkstemp(prefix="admin_upload_", suffix=".tmp")
|
||||
total = 0
|
||||
final_path: Path | None = None
|
||||
try:
|
||||
with os.fdopen(fd, "wb") as out:
|
||||
while True:
|
||||
chunk = await file.read(CHUNK_SIZE)
|
||||
if not chunk:
|
||||
break
|
||||
total += len(chunk)
|
||||
if total > max_bytes:
|
||||
raise HTTPException(status_code=400, detail=f"文件大小不能超过 {max_bytes // 1024 // 1024}MB")
|
||||
out.write(chunk)
|
||||
final_path, file_url, file_name = build_target(scene, resource_type, admin_id, original_file_name)
|
||||
final_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
shutil.move(temp_path, final_path)
|
||||
temp_path = ""
|
||||
return StoredAdminUploadFile(
|
||||
storage_path=str(final_path),
|
||||
file_url=file_url,
|
||||
file_name=file_name,
|
||||
original_file_name=original_file_name,
|
||||
file_size_bytes=total,
|
||||
)
|
||||
except Exception:
|
||||
if temp_path:
|
||||
try:
|
||||
os.remove(temp_path)
|
||||
except OSError:
|
||||
pass
|
||||
if final_path and final_path.exists():
|
||||
try:
|
||||
final_path.unlink()
|
||||
except OSError:
|
||||
pass
|
||||
raise
|
||||
@@ -16,6 +16,7 @@ from app.enums.home_material import (
|
||||
from app.models.home_material import HomeMaterialAsset, HomeMaterialCategory, HomeMaterialWatermark
|
||||
from app.schemas.home_material import (
|
||||
HomeMaterialAssetOut,
|
||||
HomeMaterialMediaReference,
|
||||
HomeMaterialCategoryOut,
|
||||
HomeMaterialPublicAssetOut,
|
||||
HomeMaterialPublicCategoryGroupOut,
|
||||
@@ -39,6 +40,24 @@ def _load_json(value: str | None) -> dict[str, Any] | None:
|
||||
return None
|
||||
|
||||
|
||||
def _load_media_references(value: str | None) -> list[HomeMaterialMediaReference]:
|
||||
if not value:
|
||||
return []
|
||||
try:
|
||||
data = json.loads(value)
|
||||
if not isinstance(data, list):
|
||||
return []
|
||||
refs: list[HomeMaterialMediaReference] = []
|
||||
for item in data:
|
||||
try:
|
||||
refs.append(HomeMaterialMediaReference(**item))
|
||||
except Exception:
|
||||
continue
|
||||
return refs
|
||||
except Exception:
|
||||
return []
|
||||
|
||||
|
||||
class HomeMaterialQueryService:
|
||||
"""首页素材高性能查询组装层:列表查询 → ID 去重 → 批量查询 → map 组装。"""
|
||||
|
||||
@@ -152,6 +171,8 @@ class HomeMaterialQueryService:
|
||||
watermark_id=asset.watermark_id,
|
||||
watermark_name=watermark.name if watermark else None,
|
||||
watermark_config=_load_json(asset.watermark_config_json),
|
||||
generation_prompt=asset.generation_prompt,
|
||||
media_references=_load_media_references(asset.media_references_json),
|
||||
width=asset.width,
|
||||
height=asset.height,
|
||||
duration_seconds=asset.duration_seconds,
|
||||
@@ -340,6 +361,8 @@ class HomeMaterialQueryService:
|
||||
width=a.width,
|
||||
height=a.height,
|
||||
duration_seconds=a.duration_seconds,
|
||||
generation_prompt=a.generation_prompt,
|
||||
media_references=_load_media_references(a.media_references_json),
|
||||
sort_order=a.sort_order,
|
||||
)
|
||||
for a in assets
|
||||
@@ -403,6 +426,8 @@ class HomeMaterialQueryService:
|
||||
width=asset.width,
|
||||
height=asset.height,
|
||||
duration_seconds=asset.duration_seconds,
|
||||
generation_prompt=asset.generation_prompt,
|
||||
media_references=_load_media_references(asset.media_references_json),
|
||||
sort_order=asset.sort_order,
|
||||
)
|
||||
)
|
||||
|
||||
@@ -12,14 +12,20 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from app.config import settings
|
||||
from app.enums.home_material import (
|
||||
HOME_MATERIAL_DEFAULT_CONFIG,
|
||||
HOME_MATERIAL_GENERATION_PROMPT_MAX_LEN,
|
||||
HOME_MATERIAL_MEDIA_REFERENCES_JSON_MAX_LEN,
|
||||
HOME_MATERIAL_MEDIA_REFERENCES_MAX_COUNT,
|
||||
HomeMaterialAssetStatus,
|
||||
HomeMaterialConfigKeyEnum,
|
||||
HomeMaterialLogEventEnum,
|
||||
HomeMaterialMediaType,
|
||||
HomeMaterialPublicResponseMode,
|
||||
HomeMaterialWatermarkPosition,
|
||||
HomeMaterialWatermarkSizeMode,
|
||||
HomeMaterialWatermarkType,
|
||||
)
|
||||
from app.enums.common import LogEventStatusEnum, LogSourceEnum
|
||||
from app.enums.upload_resource import UploadResourceModuleEnum, UploadResourceSourceModelEnum
|
||||
from app.models.base import async_session
|
||||
from app.models.home_material import HomeMaterialAsset, HomeMaterialCategory, HomeMaterialWatermark
|
||||
from app.models.system_config import SystemConfig
|
||||
@@ -36,6 +42,7 @@ from app.schemas.home_material import (
|
||||
HomeMaterialConfigUpdate,
|
||||
HomeMaterialPublicCategoryListOut,
|
||||
HomeMaterialPublicFlatOut,
|
||||
HomeMaterialMediaReference,
|
||||
HomeMaterialPublicGroupedOut,
|
||||
HomeMaterialRegenerateWatermarkRequest,
|
||||
HomeMaterialTextWatermarkPreviewRequest,
|
||||
@@ -49,6 +56,8 @@ from app.schemas.home_material import (
|
||||
from app.services.home_material.query import query_service
|
||||
from app.services.home_material.storage import storage_service
|
||||
from app.services.home_material.watermark_processor import watermark_processor
|
||||
from app.services.operation_log_service import build_exception_detail, log_operation_event
|
||||
from app.services.upload_resource.bind_service import bind_upload_resources
|
||||
from app.utils.id_gen import generate_id
|
||||
|
||||
|
||||
@@ -66,11 +75,99 @@ def _json_loads(value: str | None) -> dict[str, Any] | None:
|
||||
return None
|
||||
|
||||
|
||||
def _json_loads_list(value: str | None) -> list[Any]:
|
||||
if not value:
|
||||
return []
|
||||
try:
|
||||
data = json.loads(value)
|
||||
return data if isinstance(data, list) else []
|
||||
except Exception as exc:
|
||||
log_operation_event(
|
||||
domain="home_material",
|
||||
module="home_material_asset",
|
||||
event_type=HomeMaterialLogEventEnum.MEDIA_REFERENCES_PARSE_FAILED.value,
|
||||
event_status=LogEventStatusEnum.WARNING.value,
|
||||
source=LogSourceEnum.SERVICE.value,
|
||||
detail={"raw_prefix": value[:500]},
|
||||
error=str(exc),
|
||||
)
|
||||
return []
|
||||
|
||||
|
||||
def _clean_title(value: str | None) -> str | None:
|
||||
value = (value or "").strip()
|
||||
return value or None
|
||||
|
||||
|
||||
def _clean_generation_prompt(value: str | None) -> str | None:
|
||||
text = (value or "").strip()
|
||||
if not text:
|
||||
return None
|
||||
if len(text) > HOME_MATERIAL_GENERATION_PROMPT_MAX_LEN:
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=f"生成提词不能超过 {HOME_MATERIAL_GENERATION_PROMPT_MAX_LEN} 个字符")
|
||||
return text
|
||||
|
||||
|
||||
def _media_references_from_json(value: str | None) -> list[HomeMaterialMediaReference]:
|
||||
if not value or not value.strip():
|
||||
return []
|
||||
if len(value) > HOME_MATERIAL_MEDIA_REFERENCES_JSON_MAX_LEN:
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=f"附件JSON不能超过 {HOME_MATERIAL_MEDIA_REFERENCES_JSON_MAX_LEN} 个字符")
|
||||
try:
|
||||
raw = json.loads(value)
|
||||
except Exception as exc:
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="附件JSON格式错误") from exc
|
||||
if raw is None:
|
||||
return []
|
||||
if not isinstance(raw, list):
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="附件必须是数组格式")
|
||||
return _normalize_media_references(raw)
|
||||
|
||||
|
||||
def _normalize_media_references(value: list[Any] | None) -> list[HomeMaterialMediaReference]:
|
||||
if not value:
|
||||
return []
|
||||
if len(value) > HOME_MATERIAL_MEDIA_REFERENCES_MAX_COUNT:
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=f"附件最多 {HOME_MATERIAL_MEDIA_REFERENCES_MAX_COUNT} 个")
|
||||
refs: list[HomeMaterialMediaReference] = []
|
||||
for item in value:
|
||||
try:
|
||||
ref = item if isinstance(item, HomeMaterialMediaReference) else HomeMaterialMediaReference(**item)
|
||||
except Exception as exc:
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=f"附件格式错误:{exc}") from exc
|
||||
refs.append(ref)
|
||||
return refs
|
||||
|
||||
|
||||
def _dump_media_references_json(refs: list[HomeMaterialMediaReference] | None) -> str | None:
|
||||
items = [r.model_dump(mode="json", exclude_none=True) for r in (refs or [])]
|
||||
if not items:
|
||||
return None
|
||||
text = _json_dumps(items)
|
||||
if len(text) > HOME_MATERIAL_MEDIA_REFERENCES_JSON_MAX_LEN:
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=f"附件JSON不能超过 {HOME_MATERIAL_MEDIA_REFERENCES_JSON_MAX_LEN} 个字符")
|
||||
return text
|
||||
|
||||
|
||||
def _media_references_out(value: str | None) -> list[HomeMaterialMediaReference]:
|
||||
return _normalize_media_references(_json_loads_list(value))
|
||||
|
||||
|
||||
def _media_reference_resource_ids(refs: list[HomeMaterialMediaReference]) -> list[str]:
|
||||
return [str(r.upload_resource_id).strip() for r in refs if r.upload_resource_id and str(r.upload_resource_id).strip()]
|
||||
|
||||
|
||||
def _media_reference_urls(refs: list[HomeMaterialMediaReference]) -> list[str]:
|
||||
return [str(r.url).strip() for r in refs if r.url and str(r.url).strip()]
|
||||
|
||||
|
||||
def _media_reference_type_counts(refs: list[HomeMaterialMediaReference]) -> dict[str, int]:
|
||||
counts: dict[str, int] = {}
|
||||
for ref in refs:
|
||||
counts[ref.type] = counts.get(ref.type, 0) + 1
|
||||
return counts
|
||||
|
||||
|
||||
def _normalize_watermark_config_dict(value: dict[str, Any] | None, fallback_watermark_id: str | None = None) -> dict[str, Any]:
|
||||
"""兼容旧水印配置。旧数据没有 watermark_type 时按 image 处理。"""
|
||||
data = dict(value or {})
|
||||
@@ -108,6 +205,8 @@ def _asset_snapshot(asset: HomeMaterialAsset | None) -> dict[str, Any] | None:
|
||||
"cover_url": asset.cover_url,
|
||||
"watermark_id": asset.watermark_id,
|
||||
"watermark_config": _json_loads(asset.watermark_config_json),
|
||||
"generation_prompt": asset.generation_prompt,
|
||||
"media_references_count": len(_media_references_out(asset.media_references_json)),
|
||||
"is_active": asset.is_active,
|
||||
"sort_order": asset.sort_order,
|
||||
"deleted_at": asset.deleted_at,
|
||||
@@ -142,6 +241,31 @@ def _watermark_snapshot(watermark: HomeMaterialWatermark | None) -> dict[str, An
|
||||
}
|
||||
|
||||
|
||||
|
||||
def _log_generation_config_event(
|
||||
event_type: str,
|
||||
*,
|
||||
admin_id: str | None,
|
||||
asset_id: str | None = None,
|
||||
event_status: str = LogEventStatusEnum.SUCCESS.value,
|
||||
message: str | None = None,
|
||||
detail: dict[str, Any] | None = None,
|
||||
error: str | None = None,
|
||||
) -> None:
|
||||
log_operation_event(
|
||||
domain="home_material",
|
||||
module="home_material_asset",
|
||||
event_type=event_type,
|
||||
event_status=event_status,
|
||||
source=LogSourceEnum.API.value,
|
||||
user_id=admin_id,
|
||||
asset_id=asset_id,
|
||||
message=message,
|
||||
detail=detail or {},
|
||||
error=error,
|
||||
)
|
||||
|
||||
|
||||
async def _flush_refresh(db: AsyncSession, obj: Any) -> None:
|
||||
"""
|
||||
写入后立即返回 ORM 对象前必须显式刷新。
|
||||
@@ -372,10 +496,24 @@ class HomeMaterialService:
|
||||
watermark_config: HomeMaterialWatermarkConfig,
|
||||
is_active: bool,
|
||||
sort_order: int,
|
||||
generation_prompt: str | None = None,
|
||||
media_references_json: str | None = None,
|
||||
admin_id: str | None,
|
||||
) -> tuple[HomeMaterialUploadResultOut, dict[str, Any]]:
|
||||
await self._get_category(db, category_id, active_only=False)
|
||||
clean_title = _clean_title(title)
|
||||
clean_prompt = _clean_generation_prompt(generation_prompt)
|
||||
refs = _media_references_from_json(media_references_json)
|
||||
refs_json = _dump_media_references_json(refs)
|
||||
_log_generation_config_event(
|
||||
HomeMaterialLogEventEnum.GENERATION_CONFIG_VALIDATE_SUCCESS.value,
|
||||
admin_id=admin_id,
|
||||
detail={
|
||||
"generation_prompt_length": len(clean_prompt or ""),
|
||||
"media_references_count": len(refs),
|
||||
"media_reference_types": _media_reference_type_counts(refs),
|
||||
},
|
||||
)
|
||||
watermark_type = watermark_config.watermark_type
|
||||
final_watermark_id: str | None = None
|
||||
if watermark_type == HomeMaterialWatermarkType.IMAGE:
|
||||
@@ -415,6 +553,8 @@ class HomeMaterialService:
|
||||
original_storage_path=stored.storage_path,
|
||||
watermark_id=final_watermark_id,
|
||||
watermark_config_json=_json_dumps(cfg.model_dump(mode="json")),
|
||||
generation_prompt=clean_prompt,
|
||||
media_references_json=refs_json,
|
||||
status=HomeMaterialAssetStatus.PROCESSING.value,
|
||||
width=probe.width,
|
||||
height=probe.height,
|
||||
@@ -427,7 +567,10 @@ class HomeMaterialService:
|
||||
)
|
||||
db.add(asset)
|
||||
await _flush_refresh(db, asset)
|
||||
return self._upload_result(asset, message="素材已上传,水印处理中"), _asset_snapshot(asset) or {}
|
||||
bind_stats = await self._bind_media_reference_resources(db, asset_id=asset.id, admin_id=admin_id, refs=refs)
|
||||
after = _asset_snapshot(asset) or {}
|
||||
after["media_reference_bind_stats"] = bind_stats
|
||||
return self._upload_result(asset, message="素材已上传,水印处理中"), after
|
||||
|
||||
async def list_assets(
|
||||
self,
|
||||
@@ -466,15 +609,67 @@ class HomeMaterialService:
|
||||
asset = await self._get_asset(db, asset_id)
|
||||
await self._get_category(db, req.category_id)
|
||||
before = _asset_snapshot(asset) or {}
|
||||
asset.category_id = req.category_id
|
||||
asset.title = req.title
|
||||
asset.is_active = req.is_active
|
||||
asset.sort_order = req.sort_order
|
||||
asset.updated_by = admin_id
|
||||
db.add(asset)
|
||||
await _flush_refresh(db, asset)
|
||||
after = _asset_snapshot(asset) or {}
|
||||
return await self.get_asset_detail(db, asset_id), before, after
|
||||
try:
|
||||
_log_generation_config_event(
|
||||
HomeMaterialLogEventEnum.GENERATION_CONFIG_VALIDATE_STARTED.value,
|
||||
admin_id=admin_id,
|
||||
asset_id=asset_id,
|
||||
event_status=LogEventStatusEnum.STARTED.value,
|
||||
)
|
||||
clean_prompt = _clean_generation_prompt(req.generation_prompt)
|
||||
refs = _normalize_media_references(req.media_references)
|
||||
refs_json = _dump_media_references_json(refs)
|
||||
_log_generation_config_event(
|
||||
HomeMaterialLogEventEnum.GENERATION_CONFIG_VALIDATE_SUCCESS.value,
|
||||
admin_id=admin_id,
|
||||
asset_id=asset_id,
|
||||
detail={
|
||||
"generation_prompt_length": len(clean_prompt or ""),
|
||||
"media_references_count": len(refs),
|
||||
"media_reference_types": _media_reference_type_counts(refs),
|
||||
},
|
||||
)
|
||||
_log_generation_config_event(
|
||||
HomeMaterialLogEventEnum.GENERATION_CONFIG_UPDATE_STARTED.value,
|
||||
admin_id=admin_id,
|
||||
asset_id=asset_id,
|
||||
event_status=LogEventStatusEnum.STARTED.value,
|
||||
)
|
||||
asset.category_id = req.category_id
|
||||
asset.title = req.title
|
||||
asset.is_active = req.is_active
|
||||
asset.sort_order = req.sort_order
|
||||
asset.generation_prompt = clean_prompt
|
||||
asset.media_references_json = refs_json
|
||||
asset.updated_by = admin_id
|
||||
db.add(asset)
|
||||
await _flush_refresh(db, asset)
|
||||
bind_stats = await self._bind_media_reference_resources(db, asset_id=asset.id, admin_id=admin_id, refs=refs)
|
||||
after = _asset_snapshot(asset) or {}
|
||||
after["media_reference_bind_stats"] = bind_stats
|
||||
_log_generation_config_event(
|
||||
HomeMaterialLogEventEnum.GENERATION_CONFIG_UPDATE_SUCCESS.value,
|
||||
admin_id=admin_id,
|
||||
asset_id=asset_id,
|
||||
detail={
|
||||
"changed_fields": [k for k in ("generation_prompt", "media_references_count") if before.get(k) != after.get(k)],
|
||||
"generation_prompt_length": len(clean_prompt or ""),
|
||||
"media_references_count": len(refs),
|
||||
"media_reference_types": _media_reference_type_counts(refs),
|
||||
"bind_stats": bind_stats,
|
||||
},
|
||||
)
|
||||
return await self.get_asset_detail(db, asset_id), before, after
|
||||
except Exception as exc:
|
||||
_log_generation_config_event(
|
||||
HomeMaterialLogEventEnum.GENERATION_CONFIG_UPDATE_FAILED.value,
|
||||
admin_id=admin_id,
|
||||
asset_id=asset_id,
|
||||
event_status=LogEventStatusEnum.FAILED.value,
|
||||
detail=build_exception_detail(exc, {"before": before}),
|
||||
error=str(exc),
|
||||
)
|
||||
raise
|
||||
|
||||
async def prepare_regenerate(
|
||||
self,
|
||||
@@ -509,6 +704,28 @@ class HomeMaterialService:
|
||||
db.add(asset)
|
||||
return asset, before
|
||||
|
||||
|
||||
async def _bind_media_reference_resources(
|
||||
self,
|
||||
db: AsyncSession,
|
||||
*,
|
||||
asset_id: str,
|
||||
admin_id: str | None,
|
||||
refs: list[HomeMaterialMediaReference],
|
||||
) -> dict[str, int]:
|
||||
if not admin_id or not refs:
|
||||
return {"matched": 0, "bound": 0, "skipped": 0, "conflict": 0}
|
||||
return await bind_upload_resources(
|
||||
db,
|
||||
user_id=admin_id,
|
||||
module=UploadResourceModuleEnum.HOME_MATERIAL.value,
|
||||
source_model=UploadResourceSourceModelEnum.HOME_MATERIAL_ASSET.value,
|
||||
source_id=asset_id,
|
||||
resource_ids=_media_reference_resource_ids(refs),
|
||||
urls=_media_reference_urls(refs),
|
||||
allow_common_migrate=False,
|
||||
)
|
||||
|
||||
async def get_asset_status(self, db: AsyncSession, asset_id: str) -> HomeMaterialAssetStatusOut:
|
||||
asset = await self._get_asset(db, asset_id)
|
||||
return HomeMaterialAssetStatusOut(
|
||||
@@ -541,6 +758,8 @@ class HomeMaterialService:
|
||||
watermarked_url=asset.watermarked_url,
|
||||
cover_url=asset.cover_url,
|
||||
watermark_config=_json_loads(asset.watermark_config_json),
|
||||
generation_prompt=asset.generation_prompt,
|
||||
media_references=_media_references_out(asset.media_references_json),
|
||||
message=message,
|
||||
)
|
||||
|
||||
|
||||
@@ -18,6 +18,8 @@ COMMON_AUDIO_RE = re.compile(r"^audios/(?P<year>\d{4})/(?P<month>\d{2})/(?P<day>
|
||||
MODULE_RE = re.compile(r"^(?P<module>hot_opening_replicate|shot_replicate)/(?P<kind>images|videos)/(?P<year>\d{4})/(?P<month>\d{2})/(?P<day>\d{2})/(?P<prefix>video_img|video_ref)_(?P<user_id>[^_]+)_(?P<ymd>\d{8})_(?P<hms>\d{6})_(?P<rand>[0-9a-fA-F]{8})\.(?P<ext>[^/]+)$")
|
||||
SHOT_SEGMENT_RE = re.compile(r"^shot_segments/(?P<year>\d{4})/(?P<month>\d{2})/(?P<day>\d{2})/(?P<segment_id>[^/]+)\.mp4$")
|
||||
LEGACY_GEN_RE = re.compile(r"^(?P<kind>images|videos)/gen_(?P<user_id>[^_]+)_(?P<rand>[0-9a-zA-Z]+)\.(?P<ext>[^/]+)$")
|
||||
ADMIN_UPLOAD_RE = re.compile(r"^admin_uploads/(?P<scene>system_logo|system_pdf|open_type_thumb|common)/(?P<kind>images|videos|audios|files)/(?P<year>\d{4})/(?P<month>\d{2})/(?P<day>\d{2})/(?P<prefix>admin_img|admin_video|admin_audio|admin_pdf|admin_file)_(?P<user_id>[^_]+)_(?P<ymd>\d{8})_(?P<hms>\d{6})_(?P<rand>[0-9a-fA-F]{8})\.(?P<ext>[^/]+)$")
|
||||
HOME_MATERIAL_REFERENCE_RE = re.compile(r"^home_materials/references/(?P<kind>images|videos|audios)/(?P<year>\d{4})/(?P<month>\d{2})/(?P<day>\d{2})/(?P<prefix>admin_img|admin_video|admin_audio)_(?P<user_id>[^_]+)_(?P<ymd>\d{8})_(?P<hms>\d{6})_(?P<rand>[0-9a-fA-F]{8})\.(?P<ext>[^/]+)$")
|
||||
|
||||
IMAGE_EXTS = {".jpg", ".jpeg", ".png", ".webp", ".gif", ".bmp", ".svg"}
|
||||
VIDEO_EXTS = {".mp4", ".mov", ".m4v", ".webm", ".avi", ".mkv"}
|
||||
@@ -115,6 +117,26 @@ def parse_upload_path(path: str | os.PathLike[str], *, include_legacy: bool = Fa
|
||||
except ValueError:
|
||||
return None
|
||||
|
||||
for pattern, module in (
|
||||
(ADMIN_UPLOAD_RE, UploadResourceModuleEnum.ADMIN_UPLOAD.value),
|
||||
(HOME_MATERIAL_REFERENCE_RE, UploadResourceModuleEnum.HOME_MATERIAL.value),
|
||||
):
|
||||
m = pattern.match(rel)
|
||||
if m:
|
||||
d = m.groupdict()
|
||||
kind = d.get("kind")
|
||||
if kind == "images":
|
||||
rtype = UploadResourceTypeEnum.IMAGE.value
|
||||
elif kind == "videos":
|
||||
rtype = UploadResourceTypeEnum.VIDEO.value
|
||||
elif kind == "audios":
|
||||
rtype = UploadResourceTypeEnum.AUDIO.value
|
||||
elif d.get("prefix") == "admin_pdf":
|
||||
rtype = UploadResourceTypeEnum.PDF.value
|
||||
else:
|
||||
rtype = UploadResourceTypeEnum.FILE.value
|
||||
return _base(abs_path, module, rtype, d.get("user_id"), _parse_created_at(d))
|
||||
|
||||
ignored_prefixes = ("home_materials/",)
|
||||
if rel in {"site_logo.png"} or rel.startswith(ignored_prefixes) or rel.startswith("pdf_"):
|
||||
return ParsedUploadPath(
|
||||
|
||||
Reference in New Issue
Block a user