首页素材装修模块完成
This commit is contained in:
@@ -0,0 +1,153 @@
|
||||
"""add home material decoration
|
||||
|
||||
Revision ID: e5f260ed1459
|
||||
Revises: 170005531deb
|
||||
Create Date: 2026-06-30 14:36:51.291905
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = 'e5f260ed1459'
|
||||
down_revision: Union[str, None] = '170005531deb'
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
op.create_table('home_material_assets',
|
||||
sa.Column('id', sa.String(length=32), nullable=False),
|
||||
sa.Column('category_id', sa.String(length=32), nullable=False, comment='行业类别ID'),
|
||||
sa.Column('title', sa.String(length=128), nullable=True, comment='素材标题'),
|
||||
sa.Column('media_type', sa.String(length=16), server_default='image', nullable=False, comment='素材类型:image图片,video视频'),
|
||||
sa.Column('original_url', sa.Text(), nullable=False, comment='原始素材URL'),
|
||||
sa.Column('original_storage_path', sa.Text(), nullable=False, comment='原始素材本地路径'),
|
||||
sa.Column('watermarked_url', sa.Text(), nullable=True, comment='水印素材URL'),
|
||||
sa.Column('watermarked_storage_path', sa.Text(), nullable=True, comment='水印素材本地路径'),
|
||||
sa.Column('cover_url', sa.Text(), nullable=True, comment='视频封面URL'),
|
||||
sa.Column('cover_storage_path', sa.Text(), nullable=True, comment='视频封面本地路径'),
|
||||
sa.Column('watermark_id', sa.String(length=32), nullable=True, comment='水印图片ID'),
|
||||
sa.Column('watermark_config_json', sa.Text(), nullable=True, comment='水印配置快照JSON'),
|
||||
sa.Column('status', sa.String(length=32), server_default='draft', nullable=False, comment='处理状态:draft/processing/success/failed'),
|
||||
sa.Column('error_message', sa.Text(), nullable=True, comment='处理失败原因'),
|
||||
sa.Column('width', sa.Integer(), nullable=True, comment='素材宽度'),
|
||||
sa.Column('height', sa.Integer(), nullable=True, comment='素材高度'),
|
||||
sa.Column('duration_seconds', sa.Numeric(precision=10, scale=3), nullable=True, comment='视频时长,图片为空'),
|
||||
sa.Column('file_size_bytes', sa.BigInteger(), server_default='0', nullable=False, comment='原始文件大小'),
|
||||
sa.Column('watermarked_file_size_bytes', sa.BigInteger(), nullable=True, comment='水印后文件大小'),
|
||||
sa.Column('is_active', sa.Boolean(), server_default='true', nullable=False, comment='是否前台展示'),
|
||||
sa.Column('sort_order', sa.Integer(), server_default='0', nullable=False, comment='排序,越小越靠前'),
|
||||
sa.Column('processed_at', sa.DateTime(timezone=True), nullable=True, comment='处理完成时间'),
|
||||
sa.Column('created_by', sa.String(length=32), nullable=True, comment='创建管理员ID'),
|
||||
sa.Column('updated_by', sa.String(length=32), nullable=True, comment='更新管理员ID'),
|
||||
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
|
||||
sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
|
||||
sa.Column('deleted_at', sa.DateTime(timezone=True), nullable=True),
|
||||
sa.PrimaryKeyConstraint('id')
|
||||
)
|
||||
op.create_index('idx_home_material_assets_admin_list', 'home_material_assets', ['deleted_at', 'category_id', 'media_type', 'status', 'is_active', 'sort_order', 'created_at'], unique=False)
|
||||
op.create_index('idx_home_material_assets_category_sort', 'home_material_assets', ['category_id', 'sort_order', 'created_at'], unique=False)
|
||||
op.create_index('idx_home_material_assets_status', 'home_material_assets', ['status'], unique=False)
|
||||
op.create_index(op.f('ix_home_material_assets_category_id'), 'home_material_assets', ['category_id'], unique=False)
|
||||
op.create_index(op.f('ix_home_material_assets_created_by'), 'home_material_assets', ['created_by'], unique=False)
|
||||
op.create_index(op.f('ix_home_material_assets_deleted_at'), 'home_material_assets', ['deleted_at'], unique=False)
|
||||
op.create_index(op.f('ix_home_material_assets_is_active'), 'home_material_assets', ['is_active'], unique=False)
|
||||
op.create_index(op.f('ix_home_material_assets_media_type'), 'home_material_assets', ['media_type'], unique=False)
|
||||
op.create_index(op.f('ix_home_material_assets_processed_at'), 'home_material_assets', ['processed_at'], unique=False)
|
||||
op.create_index(op.f('ix_home_material_assets_sort_order'), 'home_material_assets', ['sort_order'], unique=False)
|
||||
op.create_index(op.f('ix_home_material_assets_status'), 'home_material_assets', ['status'], unique=False)
|
||||
op.create_index(op.f('ix_home_material_assets_title'), 'home_material_assets', ['title'], unique=False)
|
||||
op.create_index(op.f('ix_home_material_assets_updated_by'), 'home_material_assets', ['updated_by'], unique=False)
|
||||
op.create_index(op.f('ix_home_material_assets_watermark_id'), 'home_material_assets', ['watermark_id'], unique=False)
|
||||
op.create_table('home_material_categories',
|
||||
sa.Column('id', sa.String(length=32), nullable=False),
|
||||
sa.Column('name', sa.String(length=64), nullable=False, comment='行业名称'),
|
||||
sa.Column('key', sa.String(length=64), nullable=False, comment='行业唯一标识,前台可按 key 查询'),
|
||||
sa.Column('description', sa.String(length=255), nullable=True, comment='行业描述'),
|
||||
sa.Column('icon', sa.String(length=64), nullable=True, comment='前端图标名称'),
|
||||
sa.Column('is_active', sa.Boolean(), server_default='true', nullable=False, comment='是否启用'),
|
||||
sa.Column('sort_order', sa.Integer(), server_default='0', nullable=False, comment='排序,越小越靠前'),
|
||||
sa.Column('created_by', sa.String(length=32), nullable=True, comment='创建管理员ID'),
|
||||
sa.Column('updated_by', sa.String(length=32), nullable=True, comment='更新管理员ID'),
|
||||
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
|
||||
sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
|
||||
sa.Column('deleted_at', sa.DateTime(timezone=True), nullable=True),
|
||||
sa.PrimaryKeyConstraint('id')
|
||||
)
|
||||
op.create_index('idx_home_material_categories_active_sort', 'home_material_categories', ['deleted_at', 'is_active', 'sort_order', 'created_at'], unique=False)
|
||||
op.create_index('idx_home_material_categories_key', 'home_material_categories', ['key'], unique=False)
|
||||
op.create_index(op.f('ix_home_material_categories_created_by'), 'home_material_categories', ['created_by'], unique=False)
|
||||
op.create_index(op.f('ix_home_material_categories_deleted_at'), 'home_material_categories', ['deleted_at'], unique=False)
|
||||
op.create_index(op.f('ix_home_material_categories_is_active'), 'home_material_categories', ['is_active'], unique=False)
|
||||
op.create_index(op.f('ix_home_material_categories_name'), 'home_material_categories', ['name'], unique=False)
|
||||
op.create_index(op.f('ix_home_material_categories_sort_order'), 'home_material_categories', ['sort_order'], unique=False)
|
||||
op.create_index(op.f('ix_home_material_categories_updated_by'), 'home_material_categories', ['updated_by'], unique=False)
|
||||
op.create_table('home_material_watermarks',
|
||||
sa.Column('id', sa.String(length=32), nullable=False),
|
||||
sa.Column('name', sa.String(length=128), nullable=False, comment='水印名称'),
|
||||
sa.Column('file_url', sa.Text(), nullable=False, comment='水印图片URL'),
|
||||
sa.Column('storage_path', sa.Text(), nullable=False, comment='水印图片本地路径'),
|
||||
sa.Column('file_name', sa.String(length=255), nullable=True, comment='原始文件名'),
|
||||
sa.Column('file_size_bytes', sa.BigInteger(), server_default='0', nullable=False, comment='文件大小'),
|
||||
sa.Column('width', sa.Integer(), nullable=True, comment='水印图片宽度'),
|
||||
sa.Column('height', sa.Integer(), nullable=True, comment='水印图片高度'),
|
||||
sa.Column('is_default', sa.Boolean(), server_default='false', nullable=False, comment='是否默认水印'),
|
||||
sa.Column('is_active', sa.Boolean(), server_default='true', nullable=False, comment='是否启用'),
|
||||
sa.Column('created_by', sa.String(length=32), nullable=True, comment='创建管理员ID'),
|
||||
sa.Column('updated_by', sa.String(length=32), nullable=True, comment='更新管理员ID'),
|
||||
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
|
||||
sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
|
||||
sa.Column('deleted_at', sa.DateTime(timezone=True), nullable=True),
|
||||
sa.PrimaryKeyConstraint('id')
|
||||
)
|
||||
op.create_index('idx_home_material_watermarks_active', 'home_material_watermarks', ['deleted_at', 'is_active', 'created_at'], unique=False)
|
||||
op.create_index('idx_home_material_watermarks_default', 'home_material_watermarks', ['deleted_at', 'is_default'], unique=False)
|
||||
op.create_index(op.f('ix_home_material_watermarks_created_by'), 'home_material_watermarks', ['created_by'], unique=False)
|
||||
op.create_index(op.f('ix_home_material_watermarks_deleted_at'), 'home_material_watermarks', ['deleted_at'], unique=False)
|
||||
op.create_index(op.f('ix_home_material_watermarks_is_active'), 'home_material_watermarks', ['is_active'], unique=False)
|
||||
op.create_index(op.f('ix_home_material_watermarks_is_default'), 'home_material_watermarks', ['is_default'], unique=False)
|
||||
op.create_index(op.f('ix_home_material_watermarks_name'), 'home_material_watermarks', ['name'], unique=False)
|
||||
op.create_index(op.f('ix_home_material_watermarks_updated_by'), 'home_material_watermarks', ['updated_by'], unique=False)
|
||||
# ### end Alembic commands ###
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
op.drop_index(op.f('ix_home_material_watermarks_updated_by'), table_name='home_material_watermarks')
|
||||
op.drop_index(op.f('ix_home_material_watermarks_name'), table_name='home_material_watermarks')
|
||||
op.drop_index(op.f('ix_home_material_watermarks_is_default'), table_name='home_material_watermarks')
|
||||
op.drop_index(op.f('ix_home_material_watermarks_is_active'), table_name='home_material_watermarks')
|
||||
op.drop_index(op.f('ix_home_material_watermarks_deleted_at'), table_name='home_material_watermarks')
|
||||
op.drop_index(op.f('ix_home_material_watermarks_created_by'), table_name='home_material_watermarks')
|
||||
op.drop_index('idx_home_material_watermarks_default', table_name='home_material_watermarks')
|
||||
op.drop_index('idx_home_material_watermarks_active', table_name='home_material_watermarks')
|
||||
op.drop_table('home_material_watermarks')
|
||||
op.drop_index(op.f('ix_home_material_categories_updated_by'), table_name='home_material_categories')
|
||||
op.drop_index(op.f('ix_home_material_categories_sort_order'), table_name='home_material_categories')
|
||||
op.drop_index(op.f('ix_home_material_categories_name'), table_name='home_material_categories')
|
||||
op.drop_index(op.f('ix_home_material_categories_is_active'), table_name='home_material_categories')
|
||||
op.drop_index(op.f('ix_home_material_categories_deleted_at'), table_name='home_material_categories')
|
||||
op.drop_index(op.f('ix_home_material_categories_created_by'), table_name='home_material_categories')
|
||||
op.drop_index('idx_home_material_categories_key', table_name='home_material_categories')
|
||||
op.drop_index('idx_home_material_categories_active_sort', table_name='home_material_categories')
|
||||
op.drop_table('home_material_categories')
|
||||
op.drop_index(op.f('ix_home_material_assets_watermark_id'), table_name='home_material_assets')
|
||||
op.drop_index(op.f('ix_home_material_assets_updated_by'), table_name='home_material_assets')
|
||||
op.drop_index(op.f('ix_home_material_assets_title'), table_name='home_material_assets')
|
||||
op.drop_index(op.f('ix_home_material_assets_status'), table_name='home_material_assets')
|
||||
op.drop_index(op.f('ix_home_material_assets_sort_order'), table_name='home_material_assets')
|
||||
op.drop_index(op.f('ix_home_material_assets_processed_at'), table_name='home_material_assets')
|
||||
op.drop_index(op.f('ix_home_material_assets_media_type'), table_name='home_material_assets')
|
||||
op.drop_index(op.f('ix_home_material_assets_is_active'), table_name='home_material_assets')
|
||||
op.drop_index(op.f('ix_home_material_assets_deleted_at'), table_name='home_material_assets')
|
||||
op.drop_index(op.f('ix_home_material_assets_created_by'), table_name='home_material_assets')
|
||||
op.drop_index(op.f('ix_home_material_assets_category_id'), table_name='home_material_assets')
|
||||
op.drop_index('idx_home_material_assets_status', table_name='home_material_assets')
|
||||
op.drop_index('idx_home_material_assets_category_sort', table_name='home_material_assets')
|
||||
op.drop_index('idx_home_material_assets_admin_list', table_name='home_material_assets')
|
||||
op.drop_table('home_material_assets')
|
||||
# ### end Alembic commands ###
|
||||
@@ -3,8 +3,10 @@ from fastapi import APIRouter
|
||||
from app.api.admin.video_prompt_schema_config import router as video_prompt_schema_config_router
|
||||
from app.api.admin.resource_capacity import router as resource_capacity_router
|
||||
from app.api.admin.team import router as team_router
|
||||
from app.api.admin.home_material import router as home_material_router
|
||||
|
||||
router = APIRouter()
|
||||
router.include_router(video_prompt_schema_config_router)
|
||||
router.include_router(resource_capacity_router)
|
||||
router.include_router(team_router)
|
||||
router.include_router(home_material_router)
|
||||
|
||||
@@ -0,0 +1,527 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from typing import Annotated
|
||||
|
||||
from fastapi import APIRouter, Depends, File, Form, Path, Query, UploadFile
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.dependencies import get_admin_user, get_db
|
||||
from app.enums.home_material import (
|
||||
HomeMaterialAssetStatus,
|
||||
HomeMaterialMediaType,
|
||||
HomeMaterialOperationEnum,
|
||||
HomeMaterialWatermarkPosition,
|
||||
HomeMaterialWatermarkSizeMode,
|
||||
HomeMaterialWatermarkType,
|
||||
)
|
||||
from app.models.user import User
|
||||
from app.schemas.home_material import (
|
||||
HomeMaterialAssetListOut,
|
||||
HomeMaterialAssetOut,
|
||||
HomeMaterialAssetStatusOut,
|
||||
HomeMaterialAssetUpdate,
|
||||
HomeMaterialCategoryCreate,
|
||||
HomeMaterialCategoryListOut,
|
||||
HomeMaterialCategoryOut,
|
||||
HomeMaterialCategoryUpdate,
|
||||
HomeMaterialConfigOut,
|
||||
HomeMaterialConfigUpdate,
|
||||
HomeMaterialRegenerateWatermarkRequest,
|
||||
HomeMaterialTextWatermarkConfig,
|
||||
HomeMaterialTextWatermarkPreviewRequest,
|
||||
HomeMaterialTextWatermarkPreviewResponse,
|
||||
HomeMaterialUploadResultOut,
|
||||
HomeMaterialWatermarkConfig,
|
||||
HomeMaterialWatermarkListOut,
|
||||
HomeMaterialWatermarkOut,
|
||||
HomeMaterialWatermarkUpdate,
|
||||
)
|
||||
from app.services.home_material import home_material_service
|
||||
from app.services.operation_log import log_operation
|
||||
|
||||
router = APIRouter(prefix="/admin/home-material", tags=["admin-home-material"])
|
||||
|
||||
|
||||
def _detail(**kwargs) -> str:
|
||||
return json.dumps(kwargs, ensure_ascii=False, default=str)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/config",
|
||||
response_model=HomeMaterialConfigOut,
|
||||
summary="获取首页素材展示配置",
|
||||
description="获取首页素材行业装修展示配置。配置存储于 system_configs,key=home_material_showcase_config。enabled=false 时前台不展示该模块。",
|
||||
)
|
||||
async def get_home_material_config(
|
||||
admin: User = Depends(get_admin_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
_ = admin
|
||||
return await home_material_service.get_config(db)
|
||||
|
||||
|
||||
@router.put(
|
||||
"/config",
|
||||
response_model=HomeMaterialConfigOut,
|
||||
summary="保存首页素材展示配置",
|
||||
description="保存首页素材展示开关、标题、副标题、后台是否展示原素材配置,并写入操作日志。",
|
||||
)
|
||||
async def save_home_material_config(
|
||||
req: HomeMaterialConfigUpdate,
|
||||
admin: User = Depends(get_admin_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
result, before, after = await home_material_service.save_config(db, req)
|
||||
await log_operation(
|
||||
db,
|
||||
admin.id,
|
||||
admin.username,
|
||||
HomeMaterialOperationEnum.CONFIG_UPDATE.value,
|
||||
"PUT",
|
||||
"/admin/home-material/config",
|
||||
detail=_detail(before=before, after=after),
|
||||
)
|
||||
await db.commit()
|
||||
return result
|
||||
|
||||
|
||||
@router.get(
|
||||
"/categories",
|
||||
response_model=HomeMaterialCategoryListOut,
|
||||
summary="后台首页素材行业列表",
|
||||
description="分页查询首页素材行业。列表不会连表,会先查行业列表,再按 category_id 批量 group by 查询素材数量后 map 回填。",
|
||||
)
|
||||
async def list_home_material_categories(
|
||||
page: int = Query(1, ge=1, description="页码,默认1。"),
|
||||
page_size: int = Query(20, ge=1, le=200, description="每页数量,默认20,最大200。"),
|
||||
keyword: str | None = Query(None, description="搜索行业名称或 key。"),
|
||||
is_active: bool | None = Query(None, description="是否启用。不传表示全部。"),
|
||||
admin: User = Depends(get_admin_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
_ = admin
|
||||
return await home_material_service.list_categories(db, page=page, page_size=page_size, keyword=keyword, is_active=is_active)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/categories",
|
||||
response_model=HomeMaterialCategoryOut,
|
||||
summary="新增首页素材行业",
|
||||
description="新增首页素材行业类别。key 只允许字母、数字、下划线、中划线,且未软删记录内唯一。",
|
||||
)
|
||||
async def create_home_material_category(
|
||||
req: HomeMaterialCategoryCreate,
|
||||
admin: User = Depends(get_admin_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
result, after = await home_material_service.create_category(db, req, admin.id)
|
||||
await log_operation(
|
||||
db,
|
||||
admin.id,
|
||||
admin.username,
|
||||
HomeMaterialOperationEnum.CATEGORY_CREATE.value,
|
||||
"POST",
|
||||
"/admin/home-material/categories",
|
||||
detail=_detail(after=after),
|
||||
)
|
||||
await db.commit()
|
||||
return result
|
||||
|
||||
|
||||
@router.put(
|
||||
"/categories/{category_id}",
|
||||
response_model=HomeMaterialCategoryOut,
|
||||
summary="修改首页素材行业",
|
||||
description="修改首页素材行业名称、key、描述、图标、启用状态和排序,并记录修改前后快照。",
|
||||
)
|
||||
async def update_home_material_category(
|
||||
req: HomeMaterialCategoryUpdate,
|
||||
category_id: str = Path(..., description="行业ID。"),
|
||||
admin: User = Depends(get_admin_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
result, before, after = await home_material_service.update_category(db, category_id, req, admin.id)
|
||||
await log_operation(
|
||||
db,
|
||||
admin.id,
|
||||
admin.username,
|
||||
HomeMaterialOperationEnum.CATEGORY_UPDATE.value,
|
||||
"PUT",
|
||||
f"/admin/home-material/categories/{category_id}",
|
||||
detail=_detail(before=before, after=after),
|
||||
)
|
||||
await db.commit()
|
||||
return result
|
||||
|
||||
|
||||
@router.delete(
|
||||
"/categories/{category_id}",
|
||||
summary="删除首页素材行业",
|
||||
description="软删首页素材行业。若行业下还有未删除素材,接口会拒绝删除,建议改为禁用。",
|
||||
)
|
||||
async def delete_home_material_category(
|
||||
category_id: str = Path(..., description="行业ID。"),
|
||||
admin: User = Depends(get_admin_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
category, before = await home_material_service.delete_category(db, category_id, admin.id)
|
||||
await log_operation(
|
||||
db,
|
||||
admin.id,
|
||||
admin.username,
|
||||
HomeMaterialOperationEnum.CATEGORY_DELETE.value,
|
||||
"DELETE",
|
||||
f"/admin/home-material/categories/{category_id}",
|
||||
detail=_detail(before=before, after={"deleted_at": category.deleted_at}),
|
||||
)
|
||||
await db.commit()
|
||||
return {"message": "ok"}
|
||||
|
||||
|
||||
@router.get(
|
||||
"/watermarks",
|
||||
response_model=HomeMaterialWatermarkListOut,
|
||||
summary="后台水印图片列表",
|
||||
description="分页查询首页素材水印图片库。",
|
||||
)
|
||||
async def list_home_material_watermarks(
|
||||
page: int = Query(1, ge=1, description="页码,默认1。"),
|
||||
page_size: int = Query(20, ge=1, le=200, description="每页数量,默认20,最大200。"),
|
||||
is_active: bool | None = Query(None, description="是否启用。不传表示全部。"),
|
||||
admin: User = Depends(get_admin_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
_ = admin
|
||||
return await home_material_service.list_watermarks(db, page=page, page_size=page_size, is_active=is_active)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/watermarks",
|
||||
response_model=HomeMaterialWatermarkOut,
|
||||
summary="上传水印图片",
|
||||
description="上传水印图片到水印库。支持 png/webp/jpg/jpeg。is_default=true 时自动取消其他默认水印。",
|
||||
)
|
||||
async def upload_home_material_watermark(
|
||||
file: UploadFile = File(..., description="水印图片文件,建议 png 或 webp。"),
|
||||
name: str | None = Form(None, description="水印名称,不传使用文件名。"),
|
||||
is_default: bool = Form(False, description="是否设为默认水印。"),
|
||||
admin: User = Depends(get_admin_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
result, after = await home_material_service.upload_watermark(db, file=file, name=name, is_default=is_default, admin_id=admin.id)
|
||||
await log_operation(
|
||||
db,
|
||||
admin.id,
|
||||
admin.username,
|
||||
HomeMaterialOperationEnum.WATERMARK_UPLOAD.value,
|
||||
"POST",
|
||||
"/admin/home-material/watermarks",
|
||||
detail=_detail(after=after),
|
||||
)
|
||||
await db.commit()
|
||||
return result
|
||||
|
||||
|
||||
@router.put(
|
||||
"/watermarks/{watermark_id}",
|
||||
response_model=HomeMaterialWatermarkOut,
|
||||
summary="修改水印图片信息",
|
||||
description="修改水印名称、默认状态、启用状态。设为默认水印时自动取消其他默认水印。",
|
||||
)
|
||||
async def update_home_material_watermark(
|
||||
req: HomeMaterialWatermarkUpdate,
|
||||
watermark_id: str = Path(..., description="水印ID。"),
|
||||
admin: User = Depends(get_admin_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
result, before, after = await home_material_service.update_watermark(db, watermark_id, req, admin.id)
|
||||
await log_operation(
|
||||
db,
|
||||
admin.id,
|
||||
admin.username,
|
||||
HomeMaterialOperationEnum.WATERMARK_UPDATE.value,
|
||||
"PUT",
|
||||
f"/admin/home-material/watermarks/{watermark_id}",
|
||||
detail=_detail(before=before, after=after),
|
||||
)
|
||||
await db.commit()
|
||||
return result
|
||||
|
||||
|
||||
@router.delete(
|
||||
"/watermarks/{watermark_id}",
|
||||
summary="删除水印图片",
|
||||
description="软删水印图片记录,不物理删除文件;历史素材仍保留已生成的水印素材。",
|
||||
)
|
||||
async def delete_home_material_watermark(
|
||||
watermark_id: str = Path(..., description="水印ID。"),
|
||||
admin: User = Depends(get_admin_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
watermark, before = await home_material_service.delete_watermark(db, watermark_id, admin.id)
|
||||
await log_operation(
|
||||
db,
|
||||
admin.id,
|
||||
admin.username,
|
||||
HomeMaterialOperationEnum.WATERMARK_DELETE.value,
|
||||
"DELETE",
|
||||
f"/admin/home-material/watermarks/{watermark_id}",
|
||||
detail=_detail(before=before, after={"deleted_at": watermark.deleted_at}),
|
||||
)
|
||||
await db.commit()
|
||||
return {"message": "ok"}
|
||||
|
||||
|
||||
@router.post(
|
||||
"/text-watermark-preview",
|
||||
response_model=HomeMaterialTextWatermarkPreviewResponse,
|
||||
summary="生成重复文字水印精准预览层",
|
||||
description=(
|
||||
"根据素材真实宽高和重复文字水印配置,使用后端固定开源字体生成透明 PNG 预览层 data URL。"
|
||||
"该接口和最终 FFmpeg 叠加前的文字水印层共用同一套渲染逻辑,用于保证预览和实际效果一致。"
|
||||
),
|
||||
)
|
||||
async def preview_home_material_text_watermark(
|
||||
req: HomeMaterialTextWatermarkPreviewRequest,
|
||||
admin: User = Depends(get_admin_user),
|
||||
):
|
||||
_ = admin
|
||||
return await home_material_service.preview_text_watermark_layer(req)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/assets",
|
||||
response_model=HomeMaterialAssetListOut,
|
||||
summary="后台首页素材列表",
|
||||
description="分页查询首页素材。不会连表,先查素材列表,再批量查询行业和水印 map 后回填 category_name/category_key/watermark_name。",
|
||||
)
|
||||
async def list_home_material_assets(
|
||||
page: int = Query(1, ge=1, description="页码,默认1。"),
|
||||
page_size: int = Query(20, ge=1, le=200, description="每页数量,默认20,最大200。"),
|
||||
category_id: str | None = Query(None, description="行业ID。"),
|
||||
media_type: HomeMaterialMediaType | None = Query(None, description="素材类型:image图片,video视频。"),
|
||||
status: HomeMaterialAssetStatus | None = Query(None, description="处理状态:draft草稿,processing处理中,success成功,failed失败。"),
|
||||
is_active: bool | None = Query(None, description="是否前台展示。"),
|
||||
keyword: str | None = Query(None, description="素材标题搜索。"),
|
||||
admin: User = Depends(get_admin_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
_ = admin
|
||||
return await home_material_service.list_assets(
|
||||
db,
|
||||
page=page,
|
||||
page_size=page_size,
|
||||
category_id=category_id,
|
||||
media_type=media_type,
|
||||
status=status,
|
||||
is_active=is_active,
|
||||
keyword=keyword,
|
||||
)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/assets",
|
||||
response_model=HomeMaterialUploadResultOut,
|
||||
summary="上传首页素材并生成水印",
|
||||
description=(
|
||||
"上传图片或视频素材,并使用水印图片生成水印版本。默认 wait=false 立即返回 processing,前端轮询 /assets/{asset_id}/status。"
|
||||
"可传 watermark_id 使用水印库,也可传 watermark_file 临时上传水印;二者都不传时使用默认水印。"
|
||||
"水印透明度 opacity_level 为 1-10;position 支持九宫格和 custom;custom 需要 custom_x_ratio/custom_y_ratio;"
|
||||
"size_mode=ratio 使用 width_ratio,size_mode=px 使用 width_px。"
|
||||
),
|
||||
)
|
||||
async def upload_home_material_asset(
|
||||
category_id: str = Form(..., description="行业ID。"),
|
||||
file: UploadFile = File(..., description="图片或视频素材文件。"),
|
||||
media_type: HomeMaterialMediaType = Form(..., description="素材类型:image图片,video视频。"),
|
||||
title: str | None = Form(None, description="素材标题。为空时不再自动回填文件名。"),
|
||||
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 时使用。"),
|
||||
opacity_level: int = Form(6, ge=1, le=10, description="图片水印透明度档位,1-10。"),
|
||||
position: HomeMaterialWatermarkPosition = Form(HomeMaterialWatermarkPosition.BOTTOM_RIGHT, description="水印位置。"),
|
||||
custom_x_ratio: float | None = Form(None, ge=0, le=1, description="自定义位置X比例,position=custom时必填。"),
|
||||
custom_y_ratio: float | None = Form(None, ge=0, le=1, description="自定义位置Y比例,position=custom时必填。"),
|
||||
size_mode: HomeMaterialWatermarkSizeMode = Form(HomeMaterialWatermarkSizeMode.RATIO, description="水印尺寸模式:ratio/px。"),
|
||||
width_ratio: float | None = Form(0.18, ge=0.01, le=1, description="size_mode=ratio时使用,水印宽度占素材宽度比例。"),
|
||||
width_px: int | None = Form(None, ge=1, le=10000, description="size_mode=px时使用,固定水印宽度。"),
|
||||
margin_x: int = Form(24, ge=0, le=2000, description="图片水印横向边距。"),
|
||||
margin_y: int = Form(24, ge=0, le=2000, description="图片水印纵向边距。"),
|
||||
text_watermark_text: str | None = Form(None, description="重复文字水印内容。watermark_type=repeated_text 时必填。"),
|
||||
text_watermark_opacity_level: int = Form(2, ge=1, le=10, description="重复文字透明度档位,1-10。"),
|
||||
text_watermark_font_size_px: int = Form(28, ge=8, le=160, description="重复文字字号 px。"),
|
||||
text_watermark_color: str = Form("#ffffff", pattern=r"^#[0-9A-Fa-f]{6}$", description="重复文字颜色,#RRGGBB。"),
|
||||
text_watermark_rotate_deg: int = Form(-30, ge=-90, le=90, description="重复文字旋转角度。"),
|
||||
text_watermark_gap_x: int = Form(220, ge=20, le=2000, description="重复文字横向间距。"),
|
||||
text_watermark_gap_y: int = Form(140, ge=20, le=2000, description="重复文字纵向间距。"),
|
||||
text_watermark_staggered: bool = Form(True, description="重复文字是否交错排列。"),
|
||||
is_active: bool = Form(True, description="是否前台展示。"),
|
||||
sort_order: int = Form(0, ge=0, le=999999, description="排序值。"),
|
||||
wait: bool = Form(False, description="是否等待处理完成。"),
|
||||
wait_timeout_seconds: int = Form(30, ge=1, le=60, description="wait=true时最长等待秒数。"),
|
||||
admin: User = Depends(get_admin_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
text_config = None
|
||||
if watermark_type == HomeMaterialWatermarkType.REPEATED_TEXT:
|
||||
text_config = HomeMaterialTextWatermarkConfig(
|
||||
text=text_watermark_text or "",
|
||||
opacity_level=text_watermark_opacity_level,
|
||||
font_size_px=text_watermark_font_size_px,
|
||||
color=text_watermark_color,
|
||||
rotate_deg=text_watermark_rotate_deg,
|
||||
gap_x=text_watermark_gap_x,
|
||||
gap_y=text_watermark_gap_y,
|
||||
staggered=text_watermark_staggered,
|
||||
)
|
||||
config = HomeMaterialWatermarkConfig(
|
||||
watermark_type=watermark_type,
|
||||
watermark_id=watermark_id,
|
||||
opacity_level=opacity_level,
|
||||
position=position,
|
||||
custom_x_ratio=custom_x_ratio,
|
||||
custom_y_ratio=custom_y_ratio,
|
||||
size_mode=size_mode,
|
||||
width_ratio=width_ratio,
|
||||
width_px=width_px,
|
||||
margin_x=margin_x,
|
||||
margin_y=margin_y,
|
||||
text_watermark=text_config,
|
||||
)
|
||||
result, after = await home_material_service.upload_asset(
|
||||
db,
|
||||
category_id=category_id,
|
||||
file=file,
|
||||
media_type=media_type,
|
||||
title=title,
|
||||
watermark_id=watermark_id,
|
||||
watermark_file=watermark_file,
|
||||
watermark_config=config,
|
||||
is_active=is_active,
|
||||
sort_order=sort_order,
|
||||
admin_id=admin.id,
|
||||
)
|
||||
await log_operation(
|
||||
db,
|
||||
admin.id,
|
||||
admin.username,
|
||||
HomeMaterialOperationEnum.ASSET_UPLOAD.value,
|
||||
"POST",
|
||||
"/admin/home-material/assets",
|
||||
detail=_detail(after=after),
|
||||
)
|
||||
await db.commit()
|
||||
home_material_service.start_watermark_task(result.id)
|
||||
if wait:
|
||||
return await home_material_service.wait_for_asset_result(result.id, wait_timeout_seconds)
|
||||
return result
|
||||
|
||||
|
||||
@router.get(
|
||||
"/assets/{asset_id}",
|
||||
response_model=HomeMaterialAssetOut,
|
||||
summary="后台首页素材详情",
|
||||
description="查询单个素材详情。详情仍不使用 ORM relationship,会按 category_id/watermark_id 批量查询 map 后组装。",
|
||||
)
|
||||
async def get_home_material_asset(
|
||||
asset_id: str = Path(..., description="素材ID。"),
|
||||
admin: User = Depends(get_admin_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
_ = admin
|
||||
return await home_material_service.get_asset_detail(db, asset_id)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/assets/{asset_id}/status",
|
||||
response_model=HomeMaterialAssetStatusOut,
|
||||
summary="查询首页素材水印处理状态",
|
||||
description="前端上传或重新生成后轮询本接口,status=success 时展示 watermarked_url,status=failed 时展示 error_message 并允许重新生成。",
|
||||
)
|
||||
async def get_home_material_asset_status(
|
||||
asset_id: str = Path(..., description="素材ID。"),
|
||||
admin: User = Depends(get_admin_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
_ = admin
|
||||
return await home_material_service.get_asset_status(db, asset_id)
|
||||
|
||||
|
||||
@router.put(
|
||||
"/assets/{asset_id}",
|
||||
response_model=HomeMaterialAssetOut,
|
||||
summary="修改首页素材展示信息",
|
||||
description="只修改行业、标题、启用状态、排序,不重新生成水印。",
|
||||
)
|
||||
async def update_home_material_asset(
|
||||
req: HomeMaterialAssetUpdate,
|
||||
asset_id: str = Path(..., description="素材ID。"),
|
||||
admin: User = Depends(get_admin_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
result, before, after = await home_material_service.update_asset(db, asset_id, req, admin.id)
|
||||
await log_operation(
|
||||
db,
|
||||
admin.id,
|
||||
admin.username,
|
||||
HomeMaterialOperationEnum.ASSET_UPDATE.value,
|
||||
"PUT",
|
||||
f"/admin/home-material/assets/{asset_id}",
|
||||
detail=_detail(before=before, after=after),
|
||||
)
|
||||
await db.commit()
|
||||
return result
|
||||
|
||||
|
||||
@router.post(
|
||||
"/assets/{asset_id}/regenerate-watermark",
|
||||
response_model=HomeMaterialUploadResultOut,
|
||||
summary="重新生成首页素材水印",
|
||||
description="基于原始素材和新的水印配置重新生成水印文件。默认 wait=false,前端轮询状态接口。失败时保留旧 watermarked_url,但 status=failed。",
|
||||
)
|
||||
async def regenerate_home_material_watermark(
|
||||
req: HomeMaterialRegenerateWatermarkRequest,
|
||||
asset_id: str = Path(..., description="素材ID。"),
|
||||
admin: User = Depends(get_admin_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
result, before, after = await home_material_service.prepare_regenerate(db, asset_id, req, admin.id)
|
||||
await log_operation(
|
||||
db,
|
||||
admin.id,
|
||||
admin.username,
|
||||
HomeMaterialOperationEnum.ASSET_REGENERATE_WATERMARK.value,
|
||||
"POST",
|
||||
f"/admin/home-material/assets/{asset_id}/regenerate-watermark",
|
||||
detail=_detail(before=before, after=after),
|
||||
)
|
||||
await db.commit()
|
||||
home_material_service.start_watermark_task(asset_id)
|
||||
if req.wait:
|
||||
return await home_material_service.wait_for_asset_result(asset_id, req.wait_timeout_seconds)
|
||||
return result
|
||||
|
||||
|
||||
@router.delete(
|
||||
"/assets/{asset_id}",
|
||||
summary="删除首页素材",
|
||||
description="软删素材记录,不物理删除原文件和水印文件,避免误删历史展示资源。",
|
||||
)
|
||||
async def delete_home_material_asset(
|
||||
asset_id: str = Path(..., description="素材ID。"),
|
||||
admin: User = Depends(get_admin_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
asset, before = await home_material_service.delete_asset(db, asset_id, admin.id)
|
||||
await log_operation(
|
||||
db,
|
||||
admin.id,
|
||||
admin.username,
|
||||
HomeMaterialOperationEnum.ASSET_DELETE.value,
|
||||
"DELETE",
|
||||
f"/admin/home-material/assets/{asset_id}",
|
||||
detail=_detail(before=before, after={"deleted_at": asset.deleted_at}),
|
||||
)
|
||||
await db.commit()
|
||||
return {"message": "ok"}
|
||||
@@ -28,6 +28,7 @@ from app.api.v1.material_consumption import router as material_consumption_route
|
||||
from app.api.v1.open_type import router as open_type_router
|
||||
from app.api.v1.resources_material import router as resources_material_router
|
||||
from app.api.v1.contact import router as contact_router
|
||||
from app.api.v1.home_materials import router as home_materials_router
|
||||
from app.api.admin import router as admin_module_router
|
||||
|
||||
api_router = APIRouter()
|
||||
@@ -59,4 +60,5 @@ api_router.include_router(material_consumption_router)
|
||||
api_router.include_router(open_type_router)
|
||||
api_router.include_router(resources_material_router)
|
||||
api_router.include_router(contact_router)
|
||||
api_router.include_router(home_materials_router)
|
||||
api_router.include_router(admin_module_router)
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from fastapi import APIRouter, Depends, Query
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.dependencies import get_db
|
||||
from app.enums.home_material import HomeMaterialMediaType, HomeMaterialPublicResponseMode
|
||||
from app.schemas.home_material import (
|
||||
HomeMaterialPublicCategoryListOut,
|
||||
HomeMaterialPublicFlatOut,
|
||||
HomeMaterialPublicGroupedOut,
|
||||
)
|
||||
from app.services.home_material import home_material_service
|
||||
|
||||
router = APIRouter(prefix="/home-materials", tags=["home-materials"])
|
||||
|
||||
|
||||
@router.get(
|
||||
"/categories",
|
||||
response_model=HomeMaterialPublicCategoryListOut,
|
||||
summary="获取首页素材行业列表",
|
||||
description=(
|
||||
"获取首页素材行业列表,用于前端渲染行业 Tab/筛选。"
|
||||
"只返回已启用、未软删行业;only_has_assets=true 时只返回存在 success + is_active + watermarked_url 的行业。"
|
||||
"统计素材数量时不连表,先查行业列表,再按 category_id 批量 group by 查询素材数量并 map 回填。"
|
||||
),
|
||||
)
|
||||
async def get_public_home_material_categories(
|
||||
with_asset_count: bool = Query(True, description="是否返回每个行业下可展示素材数量。"),
|
||||
media_type: HomeMaterialMediaType | None = Query(None, description="统计指定素材类型:image图片,video视频。不传表示全部。"),
|
||||
only_has_assets: bool = Query(True, description="是否只返回有可展示素材的行业。"),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
return await home_material_service.get_public_categories(
|
||||
db,
|
||||
with_asset_count=with_asset_count,
|
||||
media_type=media_type,
|
||||
only_has_assets=only_has_assets,
|
||||
)
|
||||
|
||||
|
||||
@router.get(
|
||||
"",
|
||||
response_model=HomeMaterialPublicGroupedOut | HomeMaterialPublicFlatOut,
|
||||
summary="获取首页素材展示数据",
|
||||
description=(
|
||||
"获取首页素材展示数据。支持不传行业返回全部行业分组;支持 category_id/category_key/category_ids/category_keys 按行业过滤,"
|
||||
"优先级为 category_id > category_key > category_ids > category_keys。"
|
||||
"response_mode=grouped 时按行业分组并限制每个行业 limit_per_category 条;"
|
||||
"response_mode=flat 时返回平铺分页素材流。前台接口只返回水印素材 url,不返回原始素材 original_url。"
|
||||
),
|
||||
)
|
||||
async def get_public_home_materials(
|
||||
category_id: str | None = Query(None, description="单个行业ID。优先级最高。"),
|
||||
category_key: str | None = Query(None, description="单个行业key。未传 category_id 时生效。"),
|
||||
category_ids: str | None = Query(None, description="多个行业ID,英文逗号分隔。未传单行业参数时生效。"),
|
||||
category_keys: str | None = Query(None, description="多个行业key,英文逗号分隔。优先级最低。"),
|
||||
media_type: HomeMaterialMediaType | None = Query(None, description="素材类型筛选:image图片,video视频。不传表示全部。"),
|
||||
limit_per_category: int = Query(8, ge=1, le=50, description="grouped 模式下每个行业最多返回几条素材,默认8,最大50。"),
|
||||
include_empty_categories: bool = Query(False, description="grouped 模式下是否返回无素材行业。默认 false。"),
|
||||
response_mode: HomeMaterialPublicResponseMode = Query(HomeMaterialPublicResponseMode.GROUPED, description="返回模式:grouped按行业分组,flat平铺分页。"),
|
||||
page: int = Query(1, ge=1, description="flat 模式页码,默认1。"),
|
||||
page_size: int = Query(20, ge=1, le=100, description="flat 模式每页数量,默认20,最大100。"),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
return await home_material_service.get_public_home_materials(
|
||||
db,
|
||||
category_id=category_id,
|
||||
category_key=category_key,
|
||||
category_ids=category_ids,
|
||||
category_keys=category_keys,
|
||||
media_type=media_type,
|
||||
limit_per_category=limit_per_category,
|
||||
include_empty_categories=include_empty_categories,
|
||||
response_mode=response_mode,
|
||||
page=page,
|
||||
page_size=page_size,
|
||||
)
|
||||
@@ -65,6 +65,27 @@ class Settings(BaseSettings):
|
||||
STORAGE_VIDEO_COVER_LOCAL_PATH: str = "./storage/generate/covers"
|
||||
UPLOAD_LOCAL_PATH: str = "./storage/uploads"
|
||||
|
||||
# 首页素材行业装修配置。
|
||||
# 文件实际挂载仍走 /uploads,目录在 UPLOAD_LOCAL_PATH/home_materials 下。
|
||||
HOME_MATERIAL_MAX_IMAGE_MB: int = 20
|
||||
HOME_MATERIAL_MAX_VIDEO_MB: int = 300
|
||||
HOME_MATERIAL_MAX_WATERMARK_MB: int = 10
|
||||
HOME_MATERIAL_MAX_VIDEO_DURATION_SECONDS: int = 300
|
||||
HOME_MATERIAL_IMAGE_WATERMARK_CONCURRENCY: int = 4
|
||||
HOME_MATERIAL_VIDEO_WATERMARK_CONCURRENCY: int = 2
|
||||
HOME_MATERIAL_FFMPEG_TIMEOUT_SECONDS: int = 600
|
||||
HOME_MATERIAL_PROCESSING_STALE_MINUTES: int = 30
|
||||
# 重复文字水印配置。
|
||||
# 字体必须使用开源可商用字体文件,推荐 Noto Sans CJK SC / Source Han Sans SC,且前端预览层和后端实际生成均由后端使用该字体渲染。
|
||||
# 不允许自动 fallback 到系统字体,避免预览不一致和字体授权风险。
|
||||
HOME_MATERIAL_TEXT_WATERMARK_FONT: str = "./resource/font/NotoSansCJKsc-Regular.otf"
|
||||
HOME_MATERIAL_TEXT_WATERMARK_FONT_LICENSE: str = "SIL Open Font License 1.1"
|
||||
HOME_MATERIAL_TEXT_WATERMARK_MAX_TEXT_LENGTH: int = 64
|
||||
HOME_MATERIAL_TEXT_WATERMARK_MAX_FONT_SIZE: int = 160
|
||||
HOME_MATERIAL_TEXT_WATERMARK_MAX_GAP: int = 2000
|
||||
HOME_MATERIAL_TEXT_WATERMARK_PREVIEW_MAX_WIDTH: int = 8192
|
||||
HOME_MATERIAL_TEXT_WATERMARK_PREVIEW_MAX_HEIGHT: int = 8192
|
||||
|
||||
# 本地视频封面截帧配置。
|
||||
# 说明:
|
||||
# - FFMPEG_BIN 为空时自动从系统 PATH 查找 ffmpeg / ffmpeg.exe。
|
||||
|
||||
@@ -12,3 +12,4 @@ from app.enums.sms import *
|
||||
from app.enums.notification import *
|
||||
from app.enums.resource_capacity import *
|
||||
from app.enums.team import *
|
||||
from app.enums.home_material import *
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from enum import StrEnum
|
||||
|
||||
|
||||
class HomeMaterialConfigKeyEnum(StrEnum):
|
||||
"""首页素材装修配置 key。"""
|
||||
|
||||
SHOWCASE_CONFIG = "home_material_showcase_config"
|
||||
|
||||
|
||||
class HomeMaterialMediaType(StrEnum):
|
||||
"""首页素材媒体类型。"""
|
||||
|
||||
IMAGE = "image"
|
||||
VIDEO = "video"
|
||||
|
||||
|
||||
class HomeMaterialAssetStatus(StrEnum):
|
||||
"""首页素材水印处理状态。"""
|
||||
|
||||
DRAFT = "draft"
|
||||
PROCESSING = "processing"
|
||||
SUCCESS = "success"
|
||||
FAILED = "failed"
|
||||
|
||||
|
||||
|
||||
|
||||
class HomeMaterialWatermarkType(StrEnum):
|
||||
"""首页素材水印类型。"""
|
||||
|
||||
IMAGE = "image"
|
||||
REPEATED_TEXT = "repeated_text"
|
||||
|
||||
|
||||
class HomeMaterialWatermarkPosition(StrEnum):
|
||||
"""水印位置枚举。"""
|
||||
|
||||
TOP_LEFT = "top_left"
|
||||
TOP_CENTER = "top_center"
|
||||
TOP_RIGHT = "top_right"
|
||||
MIDDLE_LEFT = "middle_left"
|
||||
CENTER = "center"
|
||||
MIDDLE_RIGHT = "middle_right"
|
||||
BOTTOM_LEFT = "bottom_left"
|
||||
BOTTOM_CENTER = "bottom_center"
|
||||
BOTTOM_RIGHT = "bottom_right"
|
||||
CUSTOM = "custom"
|
||||
|
||||
|
||||
class HomeMaterialWatermarkSizeMode(StrEnum):
|
||||
"""水印尺寸模式。"""
|
||||
|
||||
RATIO = "ratio"
|
||||
PX = "px"
|
||||
|
||||
|
||||
class HomeMaterialPublicResponseMode(StrEnum):
|
||||
"""前台素材返回模式。"""
|
||||
|
||||
GROUPED = "grouped"
|
||||
FLAT = "flat"
|
||||
|
||||
|
||||
class HomeMaterialOperationEnum(StrEnum):
|
||||
"""后台操作日志 action 枚举。"""
|
||||
|
||||
CONFIG_UPDATE = "更新首页素材展示配置"
|
||||
CATEGORY_CREATE = "新增首页素材行业"
|
||||
CATEGORY_UPDATE = "修改首页素材行业"
|
||||
CATEGORY_DELETE = "删除首页素材行业"
|
||||
WATERMARK_UPLOAD = "上传首页素材水印"
|
||||
WATERMARK_UPDATE = "修改首页素材水印"
|
||||
WATERMARK_DELETE = "删除首页素材水印"
|
||||
ASSET_UPLOAD = "上传首页素材"
|
||||
ASSET_UPDATE = "修改首页素材"
|
||||
ASSET_DELETE = "删除首页素材"
|
||||
ASSET_REGENERATE_WATERMARK = "重新生成首页素材水印"
|
||||
|
||||
|
||||
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_DEFAULT_CONFIG = {
|
||||
"enabled": False,
|
||||
"title": "行业素材案例",
|
||||
"subtitle": "精选图片与视频素材展示",
|
||||
"show_original_in_admin": True,
|
||||
}
|
||||
@@ -30,6 +30,7 @@ from app.models.shot_replicate_segment import ShotReplicateSegment
|
||||
from app.models.user_oauth import UserOAuth
|
||||
from app.models.user_oauth_account import UserOAuthAccount
|
||||
from app.models.user_oauth_app import UserOAuthApp
|
||||
from app.models.home_material import HomeMaterialAsset, HomeMaterialCategory, HomeMaterialWatermark
|
||||
|
||||
__all__ = [
|
||||
"Base", "TimestampMixin", "SoftDeleteMixin", "engine", "async_session",
|
||||
@@ -44,4 +45,5 @@ __all__ = [
|
||||
"ModuleGenerationProject", "ModuleGenerationStep",
|
||||
"ShotReplicateTaskSet", "ShotReplicateSegment",
|
||||
"UserOAuth", "UserOAuthAccount", "UserOAuthApp",
|
||||
"HomeMaterialAsset", "HomeMaterialCategory", "HomeMaterialWatermark",
|
||||
]
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
from app.models.home_material.asset import HomeMaterialAsset
|
||||
from app.models.home_material.category import HomeMaterialCategory
|
||||
from app.models.home_material.watermark import HomeMaterialWatermark
|
||||
|
||||
__all__ = [
|
||||
"HomeMaterialAsset",
|
||||
"HomeMaterialCategory",
|
||||
"HomeMaterialWatermark",
|
||||
]
|
||||
@@ -0,0 +1,61 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from decimal import Decimal
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import BigInteger, Boolean, DateTime, Index, Integer, Numeric, String, Text
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from app.enums.home_material import HomeMaterialAssetStatus, HomeMaterialMediaType
|
||||
from app.models.base import Base, SoftDeleteMixin, TimestampMixin
|
||||
|
||||
|
||||
class HomeMaterialAsset(Base, TimestampMixin, SoftDeleteMixin):
|
||||
"""首页素材资产。"""
|
||||
|
||||
__tablename__ = "home_material_assets"
|
||||
__table_args__ = (
|
||||
Index("idx_home_material_assets_admin_list", "deleted_at", "category_id", "media_type", "status", "is_active", "sort_order", "created_at"),
|
||||
Index("idx_home_material_assets_category_sort", "category_id", "sort_order", "created_at"),
|
||||
Index("idx_home_material_assets_status", "status"),
|
||||
)
|
||||
|
||||
id: Mapped[str] = mapped_column(String(32), primary_key=True)
|
||||
category_id: Mapped[str] = mapped_column(String(32), nullable=False, index=True, comment="行业类别ID")
|
||||
title: Mapped[str | None] = mapped_column(String(128), nullable=True, index=True, comment="素材标题")
|
||||
media_type: Mapped[str] = mapped_column(
|
||||
String(16),
|
||||
default=HomeMaterialMediaType.IMAGE.value,
|
||||
server_default=HomeMaterialMediaType.IMAGE.value,
|
||||
nullable=False,
|
||||
index=True,
|
||||
comment="素材类型:image图片,video视频",
|
||||
)
|
||||
original_url: Mapped[str] = mapped_column(Text, nullable=False, comment="原始素材URL")
|
||||
original_storage_path: Mapped[str] = mapped_column(Text, nullable=False, comment="原始素材本地路径")
|
||||
watermarked_url: Mapped[str | None] = mapped_column(Text, nullable=True, comment="水印素材URL")
|
||||
watermarked_storage_path: Mapped[str | None] = mapped_column(Text, nullable=True, comment="水印素材本地路径")
|
||||
cover_url: Mapped[str | None] = mapped_column(Text, nullable=True, comment="视频封面URL")
|
||||
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")
|
||||
status: Mapped[str] = mapped_column(
|
||||
String(32),
|
||||
default=HomeMaterialAssetStatus.DRAFT.value,
|
||||
server_default=HomeMaterialAssetStatus.DRAFT.value,
|
||||
nullable=False,
|
||||
index=True,
|
||||
comment="处理状态:draft/processing/success/failed",
|
||||
)
|
||||
error_message: Mapped[str | None] = mapped_column(Text, nullable=True, comment="处理失败原因")
|
||||
width: Mapped[int | None] = mapped_column(Integer, nullable=True, comment="素材宽度")
|
||||
height: Mapped[int | None] = mapped_column(Integer, nullable=True, comment="素材高度")
|
||||
duration_seconds: Mapped[Decimal | None] = mapped_column(Numeric(10, 3), nullable=True, comment="视频时长,图片为空")
|
||||
file_size_bytes: Mapped[int] = mapped_column(BigInteger, default=0, server_default="0", nullable=False, comment="原始文件大小")
|
||||
watermarked_file_size_bytes: Mapped[int | None] = mapped_column(BigInteger, nullable=True, comment="水印后文件大小")
|
||||
is_active: Mapped[bool] = mapped_column(Boolean, default=True, server_default="true", nullable=False, index=True, comment="是否前台展示")
|
||||
sort_order: Mapped[int] = mapped_column(Integer, default=0, server_default="0", nullable=False, index=True, comment="排序,越小越靠前")
|
||||
processed_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True, index=True, comment="处理完成时间")
|
||||
created_by: Mapped[str | None] = mapped_column(String(32), nullable=True, index=True, comment="创建管理员ID")
|
||||
updated_by: Mapped[str | None] = mapped_column(String(32), nullable=True, index=True, comment="更新管理员ID")
|
||||
@@ -0,0 +1,40 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from sqlalchemy import Boolean, Index, Integer, String
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from app.models.base import Base, SoftDeleteMixin, TimestampMixin
|
||||
|
||||
|
||||
class HomeMaterialCategory(Base, TimestampMixin, SoftDeleteMixin):
|
||||
"""首页素材行业类别。"""
|
||||
|
||||
__tablename__ = "home_material_categories"
|
||||
__table_args__ = (
|
||||
Index("idx_home_material_categories_active_sort", "deleted_at", "is_active", "sort_order", "created_at"),
|
||||
Index("idx_home_material_categories_key", "key"),
|
||||
)
|
||||
|
||||
id: Mapped[str] = mapped_column(String(32), primary_key=True)
|
||||
name: Mapped[str] = mapped_column(String(64), nullable=False, index=True, comment="行业名称")
|
||||
key: Mapped[str] = mapped_column(String(64), nullable=False, comment="行业唯一标识,前台可按 key 查询")
|
||||
description: Mapped[str | None] = mapped_column(String(255), nullable=True, comment="行业描述")
|
||||
icon: Mapped[str | None] = mapped_column(String(64), nullable=True, comment="前端图标名称")
|
||||
is_active: Mapped[bool] = mapped_column(
|
||||
Boolean,
|
||||
default=True,
|
||||
server_default="true",
|
||||
nullable=False,
|
||||
index=True,
|
||||
comment="是否启用",
|
||||
)
|
||||
sort_order: Mapped[int] = mapped_column(
|
||||
Integer,
|
||||
default=0,
|
||||
server_default="0",
|
||||
nullable=False,
|
||||
index=True,
|
||||
comment="排序,越小越靠前",
|
||||
)
|
||||
created_by: Mapped[str | None] = mapped_column(String(32), nullable=True, index=True, comment="创建管理员ID")
|
||||
updated_by: Mapped[str | None] = mapped_column(String(32), nullable=True, index=True, comment="更新管理员ID")
|
||||
@@ -0,0 +1,29 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from sqlalchemy import BigInteger, Boolean, Index, Integer, String, Text
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from app.models.base import Base, SoftDeleteMixin, TimestampMixin
|
||||
|
||||
|
||||
class HomeMaterialWatermark(Base, TimestampMixin, SoftDeleteMixin):
|
||||
"""首页素材水印图片库。"""
|
||||
|
||||
__tablename__ = "home_material_watermarks"
|
||||
__table_args__ = (
|
||||
Index("idx_home_material_watermarks_active", "deleted_at", "is_active", "created_at"),
|
||||
Index("idx_home_material_watermarks_default", "deleted_at", "is_default"),
|
||||
)
|
||||
|
||||
id: Mapped[str] = mapped_column(String(32), primary_key=True)
|
||||
name: Mapped[str] = mapped_column(String(128), nullable=False, index=True, comment="水印名称")
|
||||
file_url: Mapped[str] = mapped_column(Text, nullable=False, comment="水印图片URL")
|
||||
storage_path: Mapped[str] = mapped_column(Text, nullable=False, comment="水印图片本地路径")
|
||||
file_name: Mapped[str | None] = mapped_column(String(255), nullable=True, comment="原始文件名")
|
||||
file_size_bytes: Mapped[int] = mapped_column(BigInteger, default=0, server_default="0", nullable=False, comment="文件大小")
|
||||
width: Mapped[int | None] = mapped_column(Integer, nullable=True, comment="水印图片宽度")
|
||||
height: Mapped[int | None] = mapped_column(Integer, nullable=True, comment="水印图片高度")
|
||||
is_default: Mapped[bool] = mapped_column(Boolean, default=False, server_default="false", nullable=False, index=True, comment="是否默认水印")
|
||||
is_active: Mapped[bool] = mapped_column(Boolean, default=True, server_default="true", nullable=False, index=True, comment="是否启用")
|
||||
created_by: Mapped[str | None] = mapped_column(String(32), nullable=True, index=True, comment="创建管理员ID")
|
||||
updated_by: Mapped[str | None] = mapped_column(String(32), nullable=True, index=True, comment="更新管理员ID")
|
||||
@@ -0,0 +1,301 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from decimal import Decimal
|
||||
from typing import Any
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator
|
||||
|
||||
from app.enums.home_material import (
|
||||
HomeMaterialAssetStatus,
|
||||
HomeMaterialMediaType,
|
||||
HomeMaterialPublicResponseMode,
|
||||
HomeMaterialWatermarkPosition,
|
||||
HomeMaterialWatermarkSizeMode,
|
||||
HomeMaterialWatermarkType,
|
||||
)
|
||||
from app.schemas.common import NaiveDatetimeOptional
|
||||
|
||||
|
||||
class HomeMaterialConfigOut(BaseModel):
|
||||
enabled: bool = Field(False, description="首页是否展示素材装修模块。false 时前台行业和素材接口返回 enabled=false 且列表为空。")
|
||||
title: str = Field("行业素材案例", max_length=64, description="首页素材模块标题。")
|
||||
subtitle: str = Field("精选图片与视频素材展示", max_length=128, description="首页素材模块副标题。")
|
||||
show_original_in_admin: bool = Field(True, description="后台是否展示原始素材链接。前台接口永远不返回原始素材链接。")
|
||||
|
||||
|
||||
class HomeMaterialConfigUpdate(BaseModel):
|
||||
enabled: bool = Field(False, description="首页是否展示素材装修模块。")
|
||||
title: str = Field("行业素材案例", min_length=1, max_length=64, description="首页素材模块标题。")
|
||||
subtitle: str = Field("精选图片与视频素材展示", max_length=128, description="首页素材模块副标题。")
|
||||
show_original_in_admin: bool = Field(True, description="后台是否展示原始素材链接。")
|
||||
|
||||
|
||||
class HomeMaterialCategoryCreate(BaseModel):
|
||||
name: str = Field(..., min_length=1, max_length=64, description="行业名称,例如:美妆护肤、食品生鲜。")
|
||||
key: str = Field(..., min_length=1, max_length=64, pattern=r"^[A-Za-z0-9_-]+$", description="行业唯一标识,只允许字母、数字、下划线、中划线;前台可通过 category_key 查询。")
|
||||
description: str | None = Field(None, max_length=255, description="行业描述。")
|
||||
icon: str | None = Field(None, max_length=64, description="前端图标名称,例如 PictureOutlined、VideoCameraOutlined。")
|
||||
is_active: bool = Field(True, description="是否启用。禁用后前台不展示。")
|
||||
sort_order: int = Field(0, ge=0, le=999999, description="排序值,越小越靠前。")
|
||||
|
||||
|
||||
class HomeMaterialCategoryUpdate(HomeMaterialCategoryCreate):
|
||||
pass
|
||||
|
||||
|
||||
class HomeMaterialCategoryOut(BaseModel):
|
||||
id: str = Field(..., description="行业ID。")
|
||||
name: str = Field(..., description="行业名称。")
|
||||
key: str = Field(..., description="行业唯一标识。")
|
||||
description: str | None = Field(None, description="行业描述。")
|
||||
icon: str | None = Field(None, description="前端图标名称。")
|
||||
is_active: bool = Field(True, description="是否启用。")
|
||||
sort_order: int = Field(0, description="排序值。")
|
||||
asset_count: int = Field(0, description="可展示素材总数。后台列表按未删除素材统计,前台按 success + is_active 统计。")
|
||||
image_count: int = Field(0, description="图片素材数量。")
|
||||
video_count: int = Field(0, description="视频素材数量。")
|
||||
created_at: NaiveDatetimeOptional = Field(None, description="创建时间。")
|
||||
updated_at: NaiveDatetimeOptional = Field(None, description="更新时间。")
|
||||
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
|
||||
class HomeMaterialCategoryListOut(BaseModel):
|
||||
items: list[HomeMaterialCategoryOut] = Field(default_factory=list, description="行业列表。")
|
||||
total: int = Field(0, description="符合条件的行业总数。")
|
||||
|
||||
|
||||
class HomeMaterialWatermarkOut(BaseModel):
|
||||
id: str = Field(..., description="水印ID。")
|
||||
name: str = Field(..., description="水印名称。")
|
||||
file_url: str = Field(..., description="水印图片URL。")
|
||||
file_name: str | None = Field(None, description="上传文件名。")
|
||||
file_size_bytes: int = Field(0, description="文件大小,单位字节。")
|
||||
width: int | None = Field(None, description="水印图片宽度。")
|
||||
height: int | None = Field(None, description="水印图片高度。")
|
||||
is_default: bool = Field(False, description="是否默认水印。")
|
||||
is_active: bool = Field(True, description="是否启用。")
|
||||
created_at: NaiveDatetimeOptional = None
|
||||
updated_at: NaiveDatetimeOptional = None
|
||||
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
|
||||
class HomeMaterialWatermarkUpdate(BaseModel):
|
||||
name: str = Field(..., min_length=1, max_length=128, description="水印名称。")
|
||||
is_default: bool = Field(False, description="是否设为默认水印。设为 true 时会自动取消其他默认水印。")
|
||||
is_active: bool = Field(True, description="是否启用。")
|
||||
|
||||
|
||||
class HomeMaterialWatermarkListOut(BaseModel):
|
||||
items: list[HomeMaterialWatermarkOut] = Field(default_factory=list, description="水印列表。")
|
||||
total: int = Field(0, description="符合条件的水印总数。")
|
||||
|
||||
|
||||
class HomeMaterialTextWatermarkConfig(BaseModel):
|
||||
"""重复文字水印配置。字体固定由后端 HOME_MATERIAL_TEXT_WATERMARK_FONT 指定,不允许前端传字体,避免版权和一致性问题。"""
|
||||
|
||||
text: str = Field(..., min_length=1, max_length=64, description="重复水印文字。")
|
||||
opacity_level: int = Field(2, ge=1, le=10, description="文字透明度档位,1-10;1=0.1,10=1.0。")
|
||||
opacity: float = Field(0.2, ge=0.1, le=1.0, description="实际透明度,后端根据 opacity_level 计算。")
|
||||
font_size_px: int = Field(28, ge=8, le=160, description="字号,单位 px。")
|
||||
color: str = Field("#ffffff", pattern=r"^#[0-9A-Fa-f]{6}$", description="文字颜色,#RRGGBB。")
|
||||
rotate_deg: int = Field(-30, ge=-90, le=90, description="文字旋转角度,单位度。")
|
||||
gap_x: int = Field(220, ge=20, le=2000, description="横向平铺间距。")
|
||||
gap_y: int = Field(140, ge=20, le=2000, description="纵向平铺间距。")
|
||||
staggered: bool = Field(True, description="是否交错排列。")
|
||||
|
||||
@field_validator("text")
|
||||
@classmethod
|
||||
def validate_text(cls, value: str) -> str:
|
||||
value = (value or "").strip()
|
||||
if not value:
|
||||
raise ValueError("重复文字水印内容不能为空")
|
||||
return value
|
||||
|
||||
@model_validator(mode="after")
|
||||
def fill_opacity(self) -> "HomeMaterialTextWatermarkConfig":
|
||||
self.opacity = round(self.opacity_level / 10, 2)
|
||||
return self
|
||||
|
||||
|
||||
class HomeMaterialWatermarkConfig(BaseModel):
|
||||
watermark_type: HomeMaterialWatermarkType = Field(HomeMaterialWatermarkType.IMAGE, description="水印类型:image=图片水印;repeated_text=重复文字水印。旧数据无该字段时默认 image。")
|
||||
watermark_id: str | None = Field(None, description="图片水印ID。watermark_type=image 时使用。上传素材时可由 watermark_id 指定,也可通过临时 watermark_file 生成。")
|
||||
opacity_level: int = Field(6, ge=1, le=10, description="图片水印透明度档位,1-10;1=0.1,10=1.0。")
|
||||
opacity: float = Field(0.6, ge=0.1, le=1.0, description="图片水印实际透明度,后端根据 opacity_level 计算。")
|
||||
position: HomeMaterialWatermarkPosition = Field(HomeMaterialWatermarkPosition.BOTTOM_RIGHT, description="图片水印位置枚举。")
|
||||
custom_x_ratio: float | None = Field(None, ge=0, le=1, description="图片水印自定义拖拽位置 X 比例,position=custom 时使用,0-1。")
|
||||
custom_y_ratio: float | None = Field(None, ge=0, le=1, description="图片水印自定义拖拽位置 Y 比例,position=custom 时使用,0-1。")
|
||||
size_mode: HomeMaterialWatermarkSizeMode = Field(HomeMaterialWatermarkSizeMode.RATIO, description="图片水印尺寸模式:ratio=按素材宽度比例;px=固定像素宽度。")
|
||||
width_ratio: float | None = Field(0.18, ge=0.01, le=1, description="图片水印 size_mode=ratio 时使用,水印宽度占素材宽度比例,例如 0.18。")
|
||||
width_px: int | None = Field(None, ge=1, le=10000, description="图片水印 size_mode=px 时使用,固定水印宽度。")
|
||||
margin_x: int = Field(24, ge=0, le=2000, description="图片水印横向边距,九宫格位置使用。")
|
||||
margin_y: int = Field(24, ge=0, le=2000, description="图片水印纵向边距,九宫格位置使用。")
|
||||
text_watermark: HomeMaterialTextWatermarkConfig | None = Field(None, description="重复文字水印配置。watermark_type=repeated_text 时必填。")
|
||||
|
||||
@model_validator(mode="after")
|
||||
def validate_by_type(self) -> "HomeMaterialWatermarkConfig":
|
||||
self.opacity = round(self.opacity_level / 10, 2)
|
||||
if self.watermark_type == HomeMaterialWatermarkType.REPEATED_TEXT:
|
||||
if self.text_watermark is None:
|
||||
raise ValueError("watermark_type=repeated_text 时 text_watermark 必填")
|
||||
self.watermark_id = None
|
||||
return self
|
||||
|
||||
if self.position == HomeMaterialWatermarkPosition.CUSTOM:
|
||||
if self.custom_x_ratio is None or self.custom_y_ratio is None:
|
||||
raise ValueError("position=custom 时 custom_x_ratio 和 custom_y_ratio 必填")
|
||||
if self.size_mode == HomeMaterialWatermarkSizeMode.RATIO and self.width_ratio is None:
|
||||
raise ValueError("size_mode=ratio 时 width_ratio 必填")
|
||||
if self.size_mode == HomeMaterialWatermarkSizeMode.PX and self.width_px is None:
|
||||
raise ValueError("size_mode=px 时 width_px 必填")
|
||||
self.text_watermark = None
|
||||
return self
|
||||
|
||||
|
||||
class HomeMaterialTextWatermarkPreviewRequest(BaseModel):
|
||||
width: int = Field(..., ge=1, le=8192, description="预览层宽度,建议传素材真实宽度。")
|
||||
height: int = Field(..., ge=1, le=8192, description="预览层高度,建议传素材真实高度。")
|
||||
text_watermark: HomeMaterialTextWatermarkConfig = Field(..., description="重复文字水印配置。")
|
||||
|
||||
|
||||
class HomeMaterialTextWatermarkPreviewResponse(BaseModel):
|
||||
width: int = Field(..., description="预览层宽度。")
|
||||
height: int = Field(..., description="预览层高度。")
|
||||
preview_layer_data_url: str = Field(..., description="透明 PNG 预览层 data URL。")
|
||||
|
||||
|
||||
class HomeMaterialAssetOut(BaseModel):
|
||||
id: str = Field(..., description="素材ID。")
|
||||
category_id: str = Field(..., description="行业ID。")
|
||||
category_name: str | None = Field(None, description="行业名称,列表接口批量查询回填。")
|
||||
category_key: str | None = Field(None, description="行业key,列表接口批量查询回填。")
|
||||
title: str | None = Field(None, description="素材标题。")
|
||||
media_type: HomeMaterialMediaType = Field(..., description="素材类型:image=图片,video=视频。")
|
||||
status: HomeMaterialAssetStatus = Field(..., description="处理状态:draft草稿,processing处理中,success成功,failed失败。")
|
||||
original_url: str | None = Field(None, description="原始素材URL,仅后台返回;前台不返回。")
|
||||
watermarked_url: str | None = Field(None, description="水印素材URL。")
|
||||
cover_url: str | None = Field(None, description="视频封面URL。")
|
||||
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="水印配置快照。")
|
||||
width: int | None = Field(None, description="素材宽度。")
|
||||
height: int | None = Field(None, description="素材高度。")
|
||||
duration_seconds: Decimal | float | None = Field(None, description="视频时长,图片为空。")
|
||||
file_size_bytes: int = Field(0, description="原始文件大小。")
|
||||
watermarked_file_size_bytes: int | None = Field(None, description="水印后文件大小。")
|
||||
is_active: bool = Field(True, description="是否前台展示。")
|
||||
sort_order: int = Field(0, description="排序值。")
|
||||
error_message: str | None = Field(None, description="失败原因。")
|
||||
processed_at: NaiveDatetimeOptional = Field(None, description="处理完成时间。")
|
||||
created_at: NaiveDatetimeOptional = None
|
||||
updated_at: NaiveDatetimeOptional = None
|
||||
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
|
||||
class HomeMaterialAssetListOut(BaseModel):
|
||||
items: list[HomeMaterialAssetOut] = Field(default_factory=list, description="素材列表。")
|
||||
total: int = Field(0, description="符合条件的素材总数。")
|
||||
|
||||
|
||||
class HomeMaterialAssetUpdate(BaseModel):
|
||||
category_id: str = Field(..., description="行业ID。")
|
||||
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="排序值。")
|
||||
|
||||
@field_validator("title")
|
||||
@classmethod
|
||||
def clean_title(cls, value: str | None) -> str | None:
|
||||
value = (value or "").strip()
|
||||
return value or None
|
||||
|
||||
|
||||
class HomeMaterialRegenerateWatermarkRequest(HomeMaterialWatermarkConfig):
|
||||
wait: bool = Field(False, description="是否等待水印处理完成。默认 false,前端轮询状态。")
|
||||
wait_timeout_seconds: int = Field(30, ge=1, le=60, description="wait=true 时最长等待秒数,最大 60 秒。")
|
||||
|
||||
|
||||
class HomeMaterialAssetStatusOut(BaseModel):
|
||||
id: str = Field(..., description="素材ID。")
|
||||
status: HomeMaterialAssetStatus = Field(..., description="处理状态。")
|
||||
error_message: str | None = Field(None, description="失败原因。")
|
||||
original_url: str | None = Field(None, description="原始素材URL,仅后台返回。")
|
||||
watermarked_url: str | None = Field(None, description="水印素材URL。")
|
||||
cover_url: str | None = Field(None, description="视频封面URL。")
|
||||
processed_at: NaiveDatetimeOptional = Field(None, description="处理完成时间。")
|
||||
|
||||
|
||||
class HomeMaterialPublicCategoryOut(BaseModel):
|
||||
id: str = Field(..., description="行业ID。")
|
||||
name: str = Field(..., description="行业名称。")
|
||||
key: str = Field(..., description="行业key。")
|
||||
icon: str | None = Field(None, description="前端图标名。")
|
||||
description: str | None = Field(None, description="行业描述。")
|
||||
sort_order: int = Field(0, description="排序。")
|
||||
asset_count: int = Field(0, description="可展示素材总数。")
|
||||
image_count: int = Field(0, description="图片素材数量。")
|
||||
video_count: int = Field(0, description="视频素材数量。")
|
||||
|
||||
|
||||
class HomeMaterialPublicCategoryListOut(BaseModel):
|
||||
enabled: bool = Field(False, description="首页素材展示开关。false 时 items 为空。")
|
||||
title: str = Field("行业素材案例", description="首页素材模块标题。")
|
||||
subtitle: str = Field("精选图片与视频素材展示", description="首页素材模块副标题。")
|
||||
items: list[HomeMaterialPublicCategoryOut] = Field(default_factory=list, description="行业列表。")
|
||||
|
||||
|
||||
class HomeMaterialPublicAssetOut(BaseModel):
|
||||
id: str = Field(..., description="素材ID。")
|
||||
title: str | None = Field(None, description="素材标题。")
|
||||
media_type: HomeMaterialMediaType = Field(..., description="素材类型。")
|
||||
url: str = Field(..., description="前台展示URL,永远返回 watermarked_url。")
|
||||
cover_url: str | None = Field(None, description="视频封面URL。")
|
||||
width: int | None = Field(None, description="宽度。")
|
||||
height: int | None = Field(None, description="高度。")
|
||||
duration_seconds: Decimal | float | None = Field(None, description="视频时长。")
|
||||
sort_order: int = Field(0, description="排序。")
|
||||
|
||||
|
||||
class HomeMaterialPublicCategoryGroupOut(BaseModel):
|
||||
category: HomeMaterialPublicCategoryOut
|
||||
assets: list[HomeMaterialPublicAssetOut] = Field(default_factory=list)
|
||||
|
||||
|
||||
class HomeMaterialPublicGroupedOut(BaseModel):
|
||||
enabled: bool = Field(True, description="首页素材展示开关。")
|
||||
title: str = Field("行业素材案例", description="首页素材模块标题。")
|
||||
subtitle: str = Field("精选图片与视频素材展示", description="首页素材模块副标题。")
|
||||
response_mode: HomeMaterialPublicResponseMode = Field(HomeMaterialPublicResponseMode.GROUPED, description="返回模式。")
|
||||
categories: list[HomeMaterialPublicCategoryGroupOut] = Field(default_factory=list, description="行业分组素材。")
|
||||
|
||||
|
||||
class HomeMaterialPublicFlatItemOut(HomeMaterialPublicAssetOut):
|
||||
category_id: str = Field(..., description="行业ID。")
|
||||
category_name: str = Field(..., description="行业名称。")
|
||||
category_key: str = Field(..., description="行业key。")
|
||||
category_icon: str | None = Field(None, description="行业图标。")
|
||||
|
||||
|
||||
class HomeMaterialPublicFlatOut(BaseModel):
|
||||
enabled: bool = Field(True, description="首页素材展示开关。")
|
||||
title: str = Field("行业素材案例", description="首页素材模块标题。")
|
||||
subtitle: str = Field("精选图片与视频素材展示", description="首页素材模块副标题。")
|
||||
response_mode: HomeMaterialPublicResponseMode = Field(HomeMaterialPublicResponseMode.FLAT, description="返回模式。")
|
||||
items: list[HomeMaterialPublicFlatItemOut] = Field(default_factory=list, description="平铺素材列表。")
|
||||
total: int = Field(0, description="总数。")
|
||||
|
||||
|
||||
class HomeMaterialUploadResultOut(BaseModel):
|
||||
id: str = Field(..., description="素材ID。")
|
||||
category_id: str = Field(..., description="行业ID。")
|
||||
title: str | None = Field(None, description="素材标题。")
|
||||
media_type: HomeMaterialMediaType = Field(..., description="素材类型。")
|
||||
status: HomeMaterialAssetStatus = Field(..., description="处理状态。")
|
||||
original_url: str | None = Field(None, description="原始素材URL。")
|
||||
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="水印配置快照。")
|
||||
message: str = Field("", description="提示消息。")
|
||||
@@ -0,0 +1,3 @@
|
||||
from app.services.home_material.service import HomeMaterialService, home_material_service
|
||||
|
||||
__all__ = ["HomeMaterialService", "home_material_service"]
|
||||
@@ -0,0 +1,412 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from collections import defaultdict
|
||||
from typing import Any, Iterable
|
||||
|
||||
from sqlalchemy import case, func, or_, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.sql import Select
|
||||
|
||||
from app.enums.home_material import (
|
||||
HomeMaterialAssetStatus,
|
||||
HomeMaterialMediaType,
|
||||
HomeMaterialPublicResponseMode,
|
||||
)
|
||||
from app.models.home_material import HomeMaterialAsset, HomeMaterialCategory, HomeMaterialWatermark
|
||||
from app.schemas.home_material import (
|
||||
HomeMaterialAssetOut,
|
||||
HomeMaterialCategoryOut,
|
||||
HomeMaterialPublicAssetOut,
|
||||
HomeMaterialPublicCategoryGroupOut,
|
||||
HomeMaterialPublicCategoryOut,
|
||||
HomeMaterialPublicFlatItemOut,
|
||||
HomeMaterialWatermarkOut,
|
||||
)
|
||||
|
||||
|
||||
def _unique(values: Iterable[str | None]) -> list[str]:
|
||||
return list({v for v in values if v})
|
||||
|
||||
|
||||
def _load_json(value: str | None) -> dict[str, Any] | None:
|
||||
if not value:
|
||||
return None
|
||||
try:
|
||||
data = json.loads(value)
|
||||
return data if isinstance(data, dict) else None
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
class HomeMaterialQueryService:
|
||||
"""首页素材高性能查询组装层:列表查询 → ID 去重 → 批量查询 → map 组装。"""
|
||||
|
||||
async def batch_categories_map(self, db: AsyncSession, category_ids: Iterable[str | None]) -> dict[str, HomeMaterialCategory]:
|
||||
ids = _unique(category_ids)
|
||||
if not ids:
|
||||
return {}
|
||||
result = await db.execute(
|
||||
select(HomeMaterialCategory).where(HomeMaterialCategory.id.in_(ids), HomeMaterialCategory.deleted_at.is_(None))
|
||||
)
|
||||
return {row.id: row for row in result.scalars().all()}
|
||||
|
||||
async def batch_watermarks_map(self, db: AsyncSession, watermark_ids: Iterable[str | None]) -> dict[str, HomeMaterialWatermark]:
|
||||
ids = _unique(watermark_ids)
|
||||
if not ids:
|
||||
return {}
|
||||
result = await db.execute(
|
||||
select(HomeMaterialWatermark).where(HomeMaterialWatermark.id.in_(ids), HomeMaterialWatermark.deleted_at.is_(None))
|
||||
)
|
||||
return {row.id: row for row in result.scalars().all()}
|
||||
|
||||
async def asset_counts_map(
|
||||
self,
|
||||
db: AsyncSession,
|
||||
category_ids: Iterable[str] | None = None,
|
||||
*,
|
||||
media_type: HomeMaterialMediaType | str | None = None,
|
||||
public_only: bool = False,
|
||||
) -> dict[str, dict[str, int]]:
|
||||
stmt = select(
|
||||
HomeMaterialAsset.category_id,
|
||||
func.count(HomeMaterialAsset.id).label("asset_count"),
|
||||
func.coalesce(func.sum(case((HomeMaterialAsset.media_type == HomeMaterialMediaType.IMAGE.value, 1), else_=0)), 0).label("image_count"),
|
||||
func.coalesce(func.sum(case((HomeMaterialAsset.media_type == HomeMaterialMediaType.VIDEO.value, 1), else_=0)), 0).label("video_count"),
|
||||
).where(HomeMaterialAsset.deleted_at.is_(None))
|
||||
ids = _unique(category_ids or [])
|
||||
if ids:
|
||||
stmt = stmt.where(HomeMaterialAsset.category_id.in_(ids))
|
||||
if media_type:
|
||||
stmt = stmt.where(HomeMaterialAsset.media_type == HomeMaterialMediaType(media_type).value)
|
||||
if public_only:
|
||||
stmt = stmt.where(
|
||||
HomeMaterialAsset.is_active.is_(True),
|
||||
HomeMaterialAsset.status == HomeMaterialAssetStatus.SUCCESS.value,
|
||||
HomeMaterialAsset.watermarked_url.is_not(None),
|
||||
)
|
||||
stmt = stmt.group_by(HomeMaterialAsset.category_id)
|
||||
result = await db.execute(stmt)
|
||||
out: dict[str, dict[str, int]] = {}
|
||||
for row in result.all():
|
||||
out[row.category_id] = {
|
||||
"asset_count": int(row.asset_count or 0),
|
||||
"image_count": int(row.image_count or 0),
|
||||
"video_count": int(row.video_count or 0),
|
||||
}
|
||||
return out
|
||||
|
||||
def category_to_out(self, category: HomeMaterialCategory, counts: dict[str, int] | None = None) -> HomeMaterialCategoryOut:
|
||||
c = counts or {}
|
||||
return HomeMaterialCategoryOut(
|
||||
id=category.id,
|
||||
name=category.name,
|
||||
key=category.key,
|
||||
description=category.description,
|
||||
icon=category.icon,
|
||||
is_active=category.is_active,
|
||||
sort_order=category.sort_order,
|
||||
asset_count=int(c.get("asset_count", 0)),
|
||||
image_count=int(c.get("image_count", 0)),
|
||||
video_count=int(c.get("video_count", 0)),
|
||||
created_at=category.created_at,
|
||||
updated_at=category.updated_at,
|
||||
)
|
||||
|
||||
def watermark_to_out(self, watermark: HomeMaterialWatermark) -> HomeMaterialWatermarkOut:
|
||||
return HomeMaterialWatermarkOut(
|
||||
id=watermark.id,
|
||||
name=watermark.name,
|
||||
file_url=watermark.file_url,
|
||||
file_name=watermark.file_name,
|
||||
file_size_bytes=watermark.file_size_bytes,
|
||||
width=watermark.width,
|
||||
height=watermark.height,
|
||||
is_default=watermark.is_default,
|
||||
is_active=watermark.is_active,
|
||||
created_at=watermark.created_at,
|
||||
updated_at=watermark.updated_at,
|
||||
)
|
||||
|
||||
def asset_to_out(
|
||||
self,
|
||||
asset: HomeMaterialAsset,
|
||||
*,
|
||||
category_map: dict[str, HomeMaterialCategory] | None = None,
|
||||
watermark_map: dict[str, HomeMaterialWatermark] | None = None,
|
||||
include_original: bool = True,
|
||||
) -> HomeMaterialAssetOut:
|
||||
category = (category_map or {}).get(asset.category_id)
|
||||
watermark = (watermark_map or {}).get(asset.watermark_id or "")
|
||||
return HomeMaterialAssetOut(
|
||||
id=asset.id,
|
||||
category_id=asset.category_id,
|
||||
category_name=category.name if category else None,
|
||||
category_key=category.key if category else None,
|
||||
title=asset.title,
|
||||
media_type=HomeMaterialMediaType(asset.media_type),
|
||||
status=HomeMaterialAssetStatus(asset.status),
|
||||
original_url=asset.original_url if include_original else None,
|
||||
watermarked_url=asset.watermarked_url,
|
||||
cover_url=asset.cover_url,
|
||||
watermark_id=asset.watermark_id,
|
||||
watermark_name=watermark.name if watermark else None,
|
||||
watermark_config=_load_json(asset.watermark_config_json),
|
||||
width=asset.width,
|
||||
height=asset.height,
|
||||
duration_seconds=asset.duration_seconds,
|
||||
file_size_bytes=asset.file_size_bytes,
|
||||
watermarked_file_size_bytes=asset.watermarked_file_size_bytes,
|
||||
is_active=asset.is_active,
|
||||
sort_order=asset.sort_order,
|
||||
error_message=asset.error_message,
|
||||
processed_at=asset.processed_at,
|
||||
created_at=asset.created_at,
|
||||
updated_at=asset.updated_at,
|
||||
)
|
||||
|
||||
async def list_admin_assets(
|
||||
self,
|
||||
db: AsyncSession,
|
||||
*,
|
||||
page: int,
|
||||
page_size: int,
|
||||
category_id: str | None = None,
|
||||
media_type: HomeMaterialMediaType | str | None = None,
|
||||
status: HomeMaterialAssetStatus | str | None = None,
|
||||
is_active: bool | None = None,
|
||||
keyword: str | None = None,
|
||||
include_original: bool = True,
|
||||
) -> tuple[list[HomeMaterialAssetOut], int]:
|
||||
stmt = select(HomeMaterialAsset).where(HomeMaterialAsset.deleted_at.is_(None))
|
||||
count_stmt = select(func.count(HomeMaterialAsset.id)).where(HomeMaterialAsset.deleted_at.is_(None))
|
||||
conditions = []
|
||||
if category_id:
|
||||
conditions.append(HomeMaterialAsset.category_id == category_id)
|
||||
if media_type:
|
||||
conditions.append(HomeMaterialAsset.media_type == HomeMaterialMediaType(media_type).value)
|
||||
if status:
|
||||
conditions.append(HomeMaterialAsset.status == HomeMaterialAssetStatus(status).value)
|
||||
if is_active is not None:
|
||||
conditions.append(HomeMaterialAsset.is_active.is_(is_active))
|
||||
if keyword:
|
||||
conditions.append(HomeMaterialAsset.title.ilike(f"%{keyword}%"))
|
||||
for condition in conditions:
|
||||
stmt = stmt.where(condition)
|
||||
count_stmt = count_stmt.where(condition)
|
||||
total = int((await db.execute(count_stmt)).scalar_one() or 0)
|
||||
result = await db.execute(
|
||||
stmt.order_by(HomeMaterialAsset.sort_order.asc(), HomeMaterialAsset.created_at.desc())
|
||||
.offset((page - 1) * page_size)
|
||||
.limit(page_size)
|
||||
)
|
||||
assets = result.scalars().all()
|
||||
category_map = await self.batch_categories_map(db, [a.category_id for a in assets])
|
||||
watermark_map = await self.batch_watermarks_map(db, [a.watermark_id for a in assets])
|
||||
return [self.asset_to_out(a, category_map=category_map, watermark_map=watermark_map, include_original=include_original) for a in assets], total
|
||||
|
||||
async def resolve_category_ids(
|
||||
self,
|
||||
db: AsyncSession,
|
||||
*,
|
||||
category_id: str | None = None,
|
||||
category_key: str | None = None,
|
||||
category_ids: str | None = None,
|
||||
category_keys: str | None = None,
|
||||
active_only: bool = True,
|
||||
) -> list[str] | None:
|
||||
"""解析前台行业筛选参数,优先级:category_id > category_key > category_ids > category_keys。None 表示不限制。"""
|
||||
if category_id:
|
||||
return [category_id]
|
||||
stmt = select(HomeMaterialCategory.id).where(HomeMaterialCategory.deleted_at.is_(None))
|
||||
if active_only:
|
||||
stmt = stmt.where(HomeMaterialCategory.is_active.is_(True))
|
||||
if category_key:
|
||||
result = await db.execute(stmt.where(HomeMaterialCategory.key == category_key).limit(1))
|
||||
cid = result.scalar_one_or_none()
|
||||
return [cid] if cid else []
|
||||
if category_ids:
|
||||
return [v.strip() for v in category_ids.split(",") if v.strip()]
|
||||
if category_keys:
|
||||
keys = [v.strip() for v in category_keys.split(",") if v.strip()]
|
||||
if not keys:
|
||||
return []
|
||||
result = await db.execute(stmt.where(HomeMaterialCategory.key.in_(keys)))
|
||||
return list(result.scalars().all())
|
||||
return None
|
||||
|
||||
async def list_public_categories(
|
||||
self,
|
||||
db: AsyncSession,
|
||||
*,
|
||||
with_asset_count: bool = True,
|
||||
media_type: HomeMaterialMediaType | str | None = None,
|
||||
only_has_assets: bool = True,
|
||||
) -> list[HomeMaterialPublicCategoryOut]:
|
||||
result = await db.execute(
|
||||
select(HomeMaterialCategory)
|
||||
.where(HomeMaterialCategory.deleted_at.is_(None), HomeMaterialCategory.is_active.is_(True))
|
||||
.order_by(HomeMaterialCategory.sort_order.asc(), HomeMaterialCategory.created_at.desc())
|
||||
)
|
||||
categories = result.scalars().all()
|
||||
counts = await self.asset_counts_map(db, [c.id for c in categories], media_type=media_type, public_only=True) if with_asset_count or only_has_assets else {}
|
||||
items: list[HomeMaterialPublicCategoryOut] = []
|
||||
for category in categories:
|
||||
c = counts.get(category.id, {})
|
||||
if only_has_assets and int(c.get("asset_count", 0)) <= 0:
|
||||
continue
|
||||
items.append(
|
||||
HomeMaterialPublicCategoryOut(
|
||||
id=category.id,
|
||||
name=category.name,
|
||||
key=category.key,
|
||||
icon=category.icon,
|
||||
description=category.description,
|
||||
sort_order=category.sort_order,
|
||||
asset_count=int(c.get("asset_count", 0)),
|
||||
image_count=int(c.get("image_count", 0)),
|
||||
video_count=int(c.get("video_count", 0)),
|
||||
)
|
||||
)
|
||||
return items
|
||||
|
||||
async def list_public_grouped(
|
||||
self,
|
||||
db: AsyncSession,
|
||||
*,
|
||||
category_ids: list[str] | None,
|
||||
media_type: HomeMaterialMediaType | str | None,
|
||||
limit_per_category: int,
|
||||
include_empty_categories: bool,
|
||||
) -> list[HomeMaterialPublicCategoryGroupOut]:
|
||||
cat_stmt = select(HomeMaterialCategory).where(HomeMaterialCategory.deleted_at.is_(None), HomeMaterialCategory.is_active.is_(True))
|
||||
if category_ids is not None:
|
||||
if not category_ids:
|
||||
return []
|
||||
cat_stmt = cat_stmt.where(HomeMaterialCategory.id.in_(category_ids))
|
||||
cat_result = await db.execute(cat_stmt.order_by(HomeMaterialCategory.sort_order.asc(), HomeMaterialCategory.created_at.desc()))
|
||||
categories = cat_result.scalars().all()
|
||||
ids = [c.id for c in categories]
|
||||
if not ids:
|
||||
return []
|
||||
|
||||
asset_base = select(
|
||||
HomeMaterialAsset.id.label("id"),
|
||||
func.row_number()
|
||||
.over(
|
||||
partition_by=HomeMaterialAsset.category_id,
|
||||
order_by=(HomeMaterialAsset.sort_order.asc(), HomeMaterialAsset.created_at.desc()),
|
||||
)
|
||||
.label("rn"),
|
||||
).where(
|
||||
HomeMaterialAsset.deleted_at.is_(None),
|
||||
HomeMaterialAsset.is_active.is_(True),
|
||||
HomeMaterialAsset.status == HomeMaterialAssetStatus.SUCCESS.value,
|
||||
HomeMaterialAsset.watermarked_url.is_not(None),
|
||||
HomeMaterialAsset.category_id.in_(ids),
|
||||
)
|
||||
if media_type:
|
||||
asset_base = asset_base.where(HomeMaterialAsset.media_type == HomeMaterialMediaType(media_type).value)
|
||||
ranked = asset_base.subquery()
|
||||
asset_result = await db.execute(
|
||||
select(HomeMaterialAsset)
|
||||
.where(HomeMaterialAsset.id.in_(select(ranked.c.id).where(ranked.c.rn <= limit_per_category)))
|
||||
.order_by(HomeMaterialAsset.category_id.asc(), HomeMaterialAsset.sort_order.asc(), HomeMaterialAsset.created_at.desc())
|
||||
)
|
||||
grouped: dict[str, list[HomeMaterialAsset]] = defaultdict(list)
|
||||
for asset in asset_result.scalars().all():
|
||||
grouped[asset.category_id].append(asset)
|
||||
|
||||
out: list[HomeMaterialPublicCategoryGroupOut] = []
|
||||
for category in categories:
|
||||
assets = grouped.get(category.id, [])
|
||||
if not include_empty_categories and not assets:
|
||||
continue
|
||||
out.append(
|
||||
HomeMaterialPublicCategoryGroupOut(
|
||||
id=category.id,
|
||||
name=category.name,
|
||||
key=category.key,
|
||||
icon=category.icon,
|
||||
description=category.description,
|
||||
sort_order=category.sort_order,
|
||||
assets=[
|
||||
HomeMaterialPublicAssetOut(
|
||||
id=a.id,
|
||||
title=a.title,
|
||||
media_type=HomeMaterialMediaType(a.media_type),
|
||||
url=a.watermarked_url or "",
|
||||
cover_url=a.cover_url,
|
||||
width=a.width,
|
||||
height=a.height,
|
||||
duration_seconds=a.duration_seconds,
|
||||
sort_order=a.sort_order,
|
||||
)
|
||||
for a in assets
|
||||
],
|
||||
)
|
||||
)
|
||||
return out
|
||||
|
||||
async def list_public_flat(
|
||||
self,
|
||||
db: AsyncSession,
|
||||
*,
|
||||
category_ids: list[str] | None,
|
||||
media_type: HomeMaterialMediaType | str | None,
|
||||
page: int,
|
||||
page_size: int,
|
||||
) -> tuple[list[HomeMaterialPublicFlatItemOut], int]:
|
||||
stmt = select(HomeMaterialAsset).where(
|
||||
HomeMaterialAsset.deleted_at.is_(None),
|
||||
HomeMaterialAsset.is_active.is_(True),
|
||||
HomeMaterialAsset.status == HomeMaterialAssetStatus.SUCCESS.value,
|
||||
HomeMaterialAsset.watermarked_url.is_not(None),
|
||||
)
|
||||
count_stmt = select(func.count(HomeMaterialAsset.id)).where(
|
||||
HomeMaterialAsset.deleted_at.is_(None),
|
||||
HomeMaterialAsset.is_active.is_(True),
|
||||
HomeMaterialAsset.status == HomeMaterialAssetStatus.SUCCESS.value,
|
||||
HomeMaterialAsset.watermarked_url.is_not(None),
|
||||
)
|
||||
if category_ids is not None:
|
||||
if not category_ids:
|
||||
return [], 0
|
||||
stmt = stmt.where(HomeMaterialAsset.category_id.in_(category_ids))
|
||||
count_stmt = count_stmt.where(HomeMaterialAsset.category_id.in_(category_ids))
|
||||
if media_type:
|
||||
stmt = stmt.where(HomeMaterialAsset.media_type == HomeMaterialMediaType(media_type).value)
|
||||
count_stmt = count_stmt.where(HomeMaterialAsset.media_type == HomeMaterialMediaType(media_type).value)
|
||||
total = int((await db.execute(count_stmt)).scalar_one() or 0)
|
||||
result = await db.execute(
|
||||
stmt.order_by(HomeMaterialAsset.sort_order.asc(), HomeMaterialAsset.created_at.desc())
|
||||
.offset((page - 1) * page_size)
|
||||
.limit(page_size)
|
||||
)
|
||||
assets = result.scalars().all()
|
||||
category_map = await self.batch_categories_map(db, [a.category_id for a in assets])
|
||||
items: list[HomeMaterialPublicFlatItemOut] = []
|
||||
for asset in assets:
|
||||
category = category_map.get(asset.category_id)
|
||||
if not category:
|
||||
continue
|
||||
items.append(
|
||||
HomeMaterialPublicFlatItemOut(
|
||||
id=asset.id,
|
||||
category_id=category.id,
|
||||
category_name=category.name,
|
||||
category_key=category.key,
|
||||
title=asset.title,
|
||||
media_type=HomeMaterialMediaType(asset.media_type),
|
||||
url=asset.watermarked_url or "",
|
||||
cover_url=asset.cover_url,
|
||||
width=asset.width,
|
||||
height=asset.height,
|
||||
duration_seconds=asset.duration_seconds,
|
||||
sort_order=asset.sort_order,
|
||||
)
|
||||
)
|
||||
return items, total
|
||||
|
||||
|
||||
query_service = HomeMaterialQueryService()
|
||||
@@ -0,0 +1,693 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from typing import Any
|
||||
|
||||
from fastapi import HTTPException, UploadFile, status
|
||||
from sqlalchemy import func, or_, select, update
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.config import settings
|
||||
from app.enums.home_material import (
|
||||
HOME_MATERIAL_DEFAULT_CONFIG,
|
||||
HomeMaterialAssetStatus,
|
||||
HomeMaterialConfigKeyEnum,
|
||||
HomeMaterialMediaType,
|
||||
HomeMaterialPublicResponseMode,
|
||||
HomeMaterialWatermarkPosition,
|
||||
HomeMaterialWatermarkSizeMode,
|
||||
HomeMaterialWatermarkType,
|
||||
)
|
||||
from app.models.base import async_session
|
||||
from app.models.home_material import HomeMaterialAsset, HomeMaterialCategory, HomeMaterialWatermark
|
||||
from app.models.system_config import SystemConfig
|
||||
from app.schemas.home_material import (
|
||||
HomeMaterialAssetListOut,
|
||||
HomeMaterialAssetOut,
|
||||
HomeMaterialAssetStatusOut,
|
||||
HomeMaterialAssetUpdate,
|
||||
HomeMaterialCategoryCreate,
|
||||
HomeMaterialCategoryListOut,
|
||||
HomeMaterialCategoryOut,
|
||||
HomeMaterialCategoryUpdate,
|
||||
HomeMaterialConfigOut,
|
||||
HomeMaterialConfigUpdate,
|
||||
HomeMaterialPublicCategoryListOut,
|
||||
HomeMaterialPublicFlatOut,
|
||||
HomeMaterialPublicGroupedOut,
|
||||
HomeMaterialRegenerateWatermarkRequest,
|
||||
HomeMaterialTextWatermarkPreviewRequest,
|
||||
HomeMaterialTextWatermarkPreviewResponse,
|
||||
HomeMaterialUploadResultOut,
|
||||
HomeMaterialWatermarkConfig,
|
||||
HomeMaterialWatermarkListOut,
|
||||
HomeMaterialWatermarkOut,
|
||||
HomeMaterialWatermarkUpdate,
|
||||
)
|
||||
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.utils.id_gen import generate_id
|
||||
|
||||
|
||||
def _json_dumps(data: Any) -> str:
|
||||
return json.dumps(data, ensure_ascii=False, default=str)
|
||||
|
||||
|
||||
def _json_loads(value: str | None) -> dict[str, Any] | None:
|
||||
if not value:
|
||||
return None
|
||||
try:
|
||||
data = json.loads(value)
|
||||
return data if isinstance(data, dict) else None
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def _clean_title(value: str | None) -> str | None:
|
||||
value = (value or "").strip()
|
||||
return value or None
|
||||
|
||||
|
||||
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 {})
|
||||
watermark_type = data.get("watermark_type") or HomeMaterialWatermarkType.IMAGE.value
|
||||
data["watermark_type"] = watermark_type
|
||||
if watermark_type == HomeMaterialWatermarkType.REPEATED_TEXT.value:
|
||||
data["watermark_id"] = None
|
||||
return HomeMaterialWatermarkConfig(**data).model_dump(mode="json")
|
||||
if fallback_watermark_id and not data.get("watermark_id"):
|
||||
data["watermark_id"] = fallback_watermark_id
|
||||
return HomeMaterialWatermarkConfig(**data).model_dump(mode="json")
|
||||
|
||||
|
||||
def _config_to_out(data: dict[str, Any] | None) -> HomeMaterialConfigOut:
|
||||
raw = {**HOME_MATERIAL_DEFAULT_CONFIG, **(data or {})}
|
||||
return HomeMaterialConfigOut(
|
||||
enabled=bool(raw.get("enabled", False)),
|
||||
title=str(raw.get("title") or HOME_MATERIAL_DEFAULT_CONFIG["title"]),
|
||||
subtitle=str(raw.get("subtitle") or HOME_MATERIAL_DEFAULT_CONFIG["subtitle"]),
|
||||
show_original_in_admin=bool(raw.get("show_original_in_admin", True)),
|
||||
)
|
||||
|
||||
|
||||
def _asset_snapshot(asset: HomeMaterialAsset | None) -> dict[str, Any] | None:
|
||||
if not asset:
|
||||
return None
|
||||
return {
|
||||
"id": asset.id,
|
||||
"category_id": asset.category_id,
|
||||
"title": asset.title,
|
||||
"media_type": asset.media_type,
|
||||
"status": asset.status,
|
||||
"original_url": asset.original_url,
|
||||
"watermarked_url": asset.watermarked_url,
|
||||
"cover_url": asset.cover_url,
|
||||
"watermark_id": asset.watermark_id,
|
||||
"watermark_config": _json_loads(asset.watermark_config_json),
|
||||
"is_active": asset.is_active,
|
||||
"sort_order": asset.sort_order,
|
||||
"deleted_at": asset.deleted_at,
|
||||
}
|
||||
|
||||
|
||||
def _category_snapshot(category: HomeMaterialCategory | None) -> dict[str, Any] | None:
|
||||
if not category:
|
||||
return None
|
||||
return {
|
||||
"id": category.id,
|
||||
"name": category.name,
|
||||
"key": category.key,
|
||||
"description": category.description,
|
||||
"icon": category.icon,
|
||||
"is_active": category.is_active,
|
||||
"sort_order": category.sort_order,
|
||||
"deleted_at": category.deleted_at,
|
||||
}
|
||||
|
||||
|
||||
def _watermark_snapshot(watermark: HomeMaterialWatermark | None) -> dict[str, Any] | None:
|
||||
if not watermark:
|
||||
return None
|
||||
return {
|
||||
"id": watermark.id,
|
||||
"name": watermark.name,
|
||||
"file_url": watermark.file_url,
|
||||
"is_default": watermark.is_default,
|
||||
"is_active": watermark.is_active,
|
||||
"deleted_at": watermark.deleted_at,
|
||||
}
|
||||
|
||||
|
||||
async def _flush_refresh(db: AsyncSession, obj: Any) -> None:
|
||||
"""
|
||||
写入后立即返回 ORM 对象前必须显式刷新。
|
||||
|
||||
本模块模型继承 TimestampMixin,updated_at 使用 onupdate=func.now()。
|
||||
AsyncSession 下 flush 后直接访问 updated_at/created_at 可能触发隐式 IO,
|
||||
导致 MissingGreenlet。统一 flush + refresh,避免响应组装阶段懒加载。
|
||||
"""
|
||||
await db.flush()
|
||||
await db.refresh(obj)
|
||||
|
||||
|
||||
class HomeMaterialService:
|
||||
"""首页素材装修主业务服务。API 层只调用本服务,内部再委托 query/storage/processor。"""
|
||||
|
||||
async def get_config(self, db: AsyncSession) -> HomeMaterialConfigOut:
|
||||
result = await db.execute(
|
||||
select(SystemConfig.value)
|
||||
.where(SystemConfig.key == HomeMaterialConfigKeyEnum.SHOWCASE_CONFIG.value)
|
||||
.limit(1)
|
||||
)
|
||||
value = result.scalar_one_or_none()
|
||||
if not value:
|
||||
return _config_to_out(None)
|
||||
return _config_to_out(_json_loads(value))
|
||||
|
||||
async def save_config(self, db: AsyncSession, req: HomeMaterialConfigUpdate) -> tuple[HomeMaterialConfigOut, dict[str, Any], dict[str, Any]]:
|
||||
before = await self.get_config(db)
|
||||
after = HomeMaterialConfigOut(**req.model_dump())
|
||||
result = await db.execute(
|
||||
select(SystemConfig)
|
||||
.where(SystemConfig.key == HomeMaterialConfigKeyEnum.SHOWCASE_CONFIG.value)
|
||||
.limit(1)
|
||||
)
|
||||
config = result.scalar_one_or_none()
|
||||
payload = after.model_dump()
|
||||
if config:
|
||||
config.value = _json_dumps(payload)
|
||||
config.description = "首页素材行业装修展示配置"
|
||||
db.add(config)
|
||||
else:
|
||||
db.add(
|
||||
SystemConfig(
|
||||
id=generate_id(),
|
||||
key=HomeMaterialConfigKeyEnum.SHOWCASE_CONFIG.value,
|
||||
value=_json_dumps(payload),
|
||||
description="首页素材行业装修展示配置",
|
||||
)
|
||||
)
|
||||
return after, before.model_dump(), after.model_dump()
|
||||
|
||||
async def list_categories(
|
||||
self,
|
||||
db: AsyncSession,
|
||||
*,
|
||||
page: int,
|
||||
page_size: int,
|
||||
keyword: str | None = None,
|
||||
is_active: bool | None = None,
|
||||
) -> HomeMaterialCategoryListOut:
|
||||
stmt = select(HomeMaterialCategory).where(HomeMaterialCategory.deleted_at.is_(None))
|
||||
count_stmt = select(func.count(HomeMaterialCategory.id)).where(HomeMaterialCategory.deleted_at.is_(None))
|
||||
conditions = []
|
||||
if keyword:
|
||||
like = f"%{keyword}%"
|
||||
conditions.append(or_(HomeMaterialCategory.name.ilike(like), HomeMaterialCategory.key.ilike(like)))
|
||||
if is_active is not None:
|
||||
conditions.append(HomeMaterialCategory.is_active.is_(is_active))
|
||||
for cond in conditions:
|
||||
stmt = stmt.where(cond)
|
||||
count_stmt = count_stmt.where(cond)
|
||||
total = int((await db.execute(count_stmt)).scalar_one() or 0)
|
||||
result = await db.execute(
|
||||
stmt.order_by(HomeMaterialCategory.sort_order.asc(), HomeMaterialCategory.created_at.desc())
|
||||
.offset((page - 1) * page_size)
|
||||
.limit(page_size)
|
||||
)
|
||||
categories = result.scalars().all()
|
||||
counts = await query_service.asset_counts_map(db, [c.id for c in categories])
|
||||
return HomeMaterialCategoryListOut(
|
||||
items=[query_service.category_to_out(c, counts.get(c.id)) for c in categories],
|
||||
total=total,
|
||||
)
|
||||
|
||||
async def create_category(self, db: AsyncSession, req: HomeMaterialCategoryCreate, admin_id: str | None) -> tuple[HomeMaterialCategoryOut, dict[str, Any]]:
|
||||
await self._ensure_category_key_available(db, req.key)
|
||||
category = HomeMaterialCategory(
|
||||
id=generate_id(),
|
||||
name=req.name,
|
||||
key=req.key,
|
||||
description=req.description,
|
||||
icon=req.icon,
|
||||
is_active=req.is_active,
|
||||
sort_order=req.sort_order,
|
||||
created_by=admin_id,
|
||||
updated_by=admin_id,
|
||||
)
|
||||
db.add(category)
|
||||
await _flush_refresh(db, category)
|
||||
return query_service.category_to_out(category), _category_snapshot(category) or {}
|
||||
|
||||
async def update_category(self, db: AsyncSession, category_id: str, req: HomeMaterialCategoryUpdate, admin_id: str | None) -> tuple[HomeMaterialCategoryOut, dict[str, Any], dict[str, Any]]:
|
||||
category = await self._get_category(db, category_id)
|
||||
before = _category_snapshot(category) or {}
|
||||
if req.key != category.key:
|
||||
await self._ensure_category_key_available(db, req.key, exclude_id=category_id)
|
||||
category.name = req.name
|
||||
category.key = req.key
|
||||
category.description = req.description
|
||||
category.icon = req.icon
|
||||
category.is_active = req.is_active
|
||||
category.sort_order = req.sort_order
|
||||
category.updated_by = admin_id
|
||||
db.add(category)
|
||||
await _flush_refresh(db, category)
|
||||
after = _category_snapshot(category) or {}
|
||||
return query_service.category_to_out(category), before, after
|
||||
|
||||
async def delete_category(self, db: AsyncSession, category_id: str, admin_id: str | None) -> tuple[HomeMaterialCategory, dict[str, Any]]:
|
||||
category = await self._get_category(db, category_id)
|
||||
count = int((await db.execute(select(func.count(HomeMaterialAsset.id)).where(HomeMaterialAsset.category_id == category_id, HomeMaterialAsset.deleted_at.is_(None)))).scalar_one() or 0)
|
||||
if count > 0:
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="该行业下仍有素材,请先删除素材或禁用行业")
|
||||
before = _category_snapshot(category) or {}
|
||||
category.deleted_at = datetime.now(timezone.utc)
|
||||
category.updated_by = admin_id
|
||||
db.add(category)
|
||||
return category, before
|
||||
|
||||
async def _ensure_category_key_available(self, db: AsyncSession, key: str, exclude_id: str | None = None) -> None:
|
||||
stmt = select(HomeMaterialCategory.id).where(HomeMaterialCategory.key == key, HomeMaterialCategory.deleted_at.is_(None))
|
||||
if exclude_id:
|
||||
stmt = stmt.where(HomeMaterialCategory.id != exclude_id)
|
||||
exists = (await db.execute(stmt.limit(1))).scalar_one_or_none()
|
||||
if exists:
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="行业 key 已存在")
|
||||
|
||||
async def _get_category(self, db: AsyncSession, category_id: str, *, active_only: bool = False) -> HomeMaterialCategory:
|
||||
stmt = select(HomeMaterialCategory).where(HomeMaterialCategory.id == category_id, HomeMaterialCategory.deleted_at.is_(None))
|
||||
if active_only:
|
||||
stmt = stmt.where(HomeMaterialCategory.is_active.is_(True))
|
||||
result = await db.execute(stmt.limit(1))
|
||||
category = result.scalar_one_or_none()
|
||||
if not category:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="行业不存在或已删除")
|
||||
return category
|
||||
|
||||
async def upload_watermark(self, db: AsyncSession, *, file: UploadFile, name: str | None, is_default: bool, admin_id: str | None) -> tuple[HomeMaterialWatermarkOut, dict[str, Any]]:
|
||||
stored = await storage_service.save_watermark_file(file)
|
||||
info = await watermark_processor.probe(stored.storage_path)
|
||||
if is_default:
|
||||
await db.execute(update(HomeMaterialWatermark).where(HomeMaterialWatermark.deleted_at.is_(None)).values(is_default=False))
|
||||
watermark = HomeMaterialWatermark(
|
||||
id=generate_id(),
|
||||
name=name or stored.file_name,
|
||||
file_url=stored.file_url,
|
||||
storage_path=stored.storage_path,
|
||||
file_name=stored.file_name,
|
||||
file_size_bytes=stored.file_size_bytes,
|
||||
width=info.width,
|
||||
height=info.height,
|
||||
is_default=is_default,
|
||||
is_active=True,
|
||||
created_by=admin_id,
|
||||
updated_by=admin_id,
|
||||
)
|
||||
db.add(watermark)
|
||||
await _flush_refresh(db, watermark)
|
||||
return query_service.watermark_to_out(watermark), _watermark_snapshot(watermark) or {}
|
||||
|
||||
async def list_watermarks(self, db: AsyncSession, *, page: int, page_size: int, is_active: bool | None = None) -> HomeMaterialWatermarkListOut:
|
||||
stmt = select(HomeMaterialWatermark).where(HomeMaterialWatermark.deleted_at.is_(None))
|
||||
count_stmt = select(func.count(HomeMaterialWatermark.id)).where(HomeMaterialWatermark.deleted_at.is_(None))
|
||||
if is_active is not None:
|
||||
stmt = stmt.where(HomeMaterialWatermark.is_active.is_(is_active))
|
||||
count_stmt = count_stmt.where(HomeMaterialWatermark.is_active.is_(is_active))
|
||||
total = int((await db.execute(count_stmt)).scalar_one() or 0)
|
||||
result = await db.execute(
|
||||
stmt.order_by(HomeMaterialWatermark.is_default.desc(), HomeMaterialWatermark.created_at.desc())
|
||||
.offset((page - 1) * page_size)
|
||||
.limit(page_size)
|
||||
)
|
||||
return HomeMaterialWatermarkListOut(items=[query_service.watermark_to_out(w) for w in result.scalars().all()], total=total)
|
||||
|
||||
async def update_watermark(self, db: AsyncSession, watermark_id: str, req: HomeMaterialWatermarkUpdate, admin_id: str | None) -> tuple[HomeMaterialWatermarkOut, dict[str, Any], dict[str, Any]]:
|
||||
watermark = await self._get_watermark(db, watermark_id)
|
||||
before = _watermark_snapshot(watermark) or {}
|
||||
if req.is_default:
|
||||
await db.execute(update(HomeMaterialWatermark).where(HomeMaterialWatermark.deleted_at.is_(None), HomeMaterialWatermark.id != watermark_id).values(is_default=False))
|
||||
watermark.name = req.name
|
||||
watermark.is_default = req.is_default
|
||||
watermark.is_active = req.is_active
|
||||
watermark.updated_by = admin_id
|
||||
db.add(watermark)
|
||||
await _flush_refresh(db, watermark)
|
||||
return query_service.watermark_to_out(watermark), before, _watermark_snapshot(watermark) or {}
|
||||
|
||||
async def delete_watermark(self, db: AsyncSession, watermark_id: str, admin_id: str | None) -> tuple[HomeMaterialWatermark, dict[str, Any]]:
|
||||
watermark = await self._get_watermark(db, watermark_id)
|
||||
before = _watermark_snapshot(watermark) or {}
|
||||
watermark.deleted_at = datetime.now(timezone.utc)
|
||||
watermark.is_active = False
|
||||
watermark.is_default = False
|
||||
watermark.updated_by = admin_id
|
||||
db.add(watermark)
|
||||
return watermark, before
|
||||
|
||||
async def _get_watermark(self, db: AsyncSession, watermark_id: str, *, active_only: bool = False) -> HomeMaterialWatermark:
|
||||
stmt = select(HomeMaterialWatermark).where(HomeMaterialWatermark.id == watermark_id, HomeMaterialWatermark.deleted_at.is_(None))
|
||||
if active_only:
|
||||
stmt = stmt.where(HomeMaterialWatermark.is_active.is_(True))
|
||||
result = await db.execute(stmt.limit(1))
|
||||
watermark = result.scalar_one_or_none()
|
||||
if not watermark:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="水印不存在或已删除")
|
||||
return watermark
|
||||
|
||||
async def upload_asset(
|
||||
self,
|
||||
db: AsyncSession,
|
||||
*,
|
||||
category_id: str,
|
||||
file: UploadFile,
|
||||
media_type: HomeMaterialMediaType,
|
||||
title: str | None,
|
||||
watermark_id: str | None,
|
||||
watermark_file: UploadFile | None,
|
||||
watermark_config: HomeMaterialWatermarkConfig,
|
||||
is_active: bool,
|
||||
sort_order: int,
|
||||
admin_id: str | None,
|
||||
) -> tuple[HomeMaterialUploadResultOut, dict[str, Any]]:
|
||||
await self._get_category(db, category_id, active_only=False)
|
||||
clean_title = _clean_title(title)
|
||||
watermark_type = watermark_config.watermark_type
|
||||
final_watermark_id: str | None = None
|
||||
if watermark_type == HomeMaterialWatermarkType.IMAGE:
|
||||
final_watermark_id = watermark_id
|
||||
if watermark_file is not None:
|
||||
watermark_out, _ = await self.upload_watermark(db, file=watermark_file, name=f"临时水印-{clean_title or '首页素材'}", is_default=False, admin_id=admin_id)
|
||||
final_watermark_id = watermark_out.id
|
||||
if not final_watermark_id:
|
||||
default_wm = await db.execute(
|
||||
select(HomeMaterialWatermark)
|
||||
.where(HomeMaterialWatermark.deleted_at.is_(None), HomeMaterialWatermark.is_active.is_(True), HomeMaterialWatermark.is_default.is_(True))
|
||||
.limit(1)
|
||||
)
|
||||
wm = default_wm.scalar_one_or_none()
|
||||
final_watermark_id = wm.id if wm else None
|
||||
if not final_watermark_id:
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="请先选择或上传水印图片")
|
||||
await self._get_watermark(db, final_watermark_id, active_only=True)
|
||||
elif watermark_file is not None:
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="重复文字水印模式不允许上传图片水印文件")
|
||||
|
||||
stored = await storage_service.save_asset_file(file, media_type)
|
||||
probe = await watermark_processor.probe(stored.storage_path)
|
||||
if media_type == HomeMaterialMediaType.VIDEO:
|
||||
max_duration = int(getattr(settings, "HOME_MATERIAL_MAX_VIDEO_DURATION_SECONDS", 300))
|
||||
if probe.duration_seconds is not None and probe.duration_seconds > max_duration:
|
||||
storage_service.safe_remove(stored.storage_path)
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=f"视频时长超限,最大 {max_duration} 秒")
|
||||
|
||||
cfg = watermark_config.model_copy(update={"watermark_id": final_watermark_id, "opacity": round(watermark_config.opacity_level / 10, 2)})
|
||||
asset = HomeMaterialAsset(
|
||||
id=generate_id(),
|
||||
category_id=category_id,
|
||||
title=clean_title,
|
||||
media_type=media_type.value,
|
||||
original_url=stored.file_url,
|
||||
original_storage_path=stored.storage_path,
|
||||
watermark_id=final_watermark_id,
|
||||
watermark_config_json=_json_dumps(cfg.model_dump(mode="json")),
|
||||
status=HomeMaterialAssetStatus.PROCESSING.value,
|
||||
width=probe.width,
|
||||
height=probe.height,
|
||||
duration_seconds=probe.duration_seconds,
|
||||
file_size_bytes=stored.file_size_bytes,
|
||||
is_active=is_active,
|
||||
sort_order=sort_order,
|
||||
created_by=admin_id,
|
||||
updated_by=admin_id,
|
||||
)
|
||||
db.add(asset)
|
||||
await _flush_refresh(db, asset)
|
||||
return self._upload_result(asset, message="素材已上传,水印处理中"), _asset_snapshot(asset) or {}
|
||||
|
||||
async def list_assets(
|
||||
self,
|
||||
db: AsyncSession,
|
||||
*,
|
||||
page: int,
|
||||
page_size: int,
|
||||
category_id: str | None,
|
||||
media_type: HomeMaterialMediaType | str | None,
|
||||
status: HomeMaterialAssetStatus | str | None,
|
||||
is_active: bool | None,
|
||||
keyword: str | None,
|
||||
) -> HomeMaterialAssetListOut:
|
||||
config = await self.get_config(db)
|
||||
items, total = await query_service.list_admin_assets(
|
||||
db,
|
||||
page=page,
|
||||
page_size=page_size,
|
||||
category_id=category_id,
|
||||
media_type=media_type,
|
||||
status=status,
|
||||
is_active=is_active,
|
||||
keyword=keyword,
|
||||
include_original=config.show_original_in_admin,
|
||||
)
|
||||
return HomeMaterialAssetListOut(items=items, total=total)
|
||||
|
||||
async def get_asset_detail(self, db: AsyncSession, asset_id: str) -> HomeMaterialAssetOut:
|
||||
asset = await self._get_asset(db, asset_id)
|
||||
category_map = await query_service.batch_categories_map(db, [asset.category_id])
|
||||
watermark_map = await query_service.batch_watermarks_map(db, [asset.watermark_id])
|
||||
config = await self.get_config(db)
|
||||
return query_service.asset_to_out(asset, category_map=category_map, watermark_map=watermark_map, include_original=config.show_original_in_admin)
|
||||
|
||||
async def update_asset(self, db: AsyncSession, asset_id: str, req: HomeMaterialAssetUpdate, admin_id: str | None) -> tuple[HomeMaterialAssetOut, dict[str, Any], dict[str, Any]]:
|
||||
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
|
||||
|
||||
async def prepare_regenerate(
|
||||
self,
|
||||
db: AsyncSession,
|
||||
asset_id: str,
|
||||
req: HomeMaterialRegenerateWatermarkRequest,
|
||||
admin_id: str | None,
|
||||
) -> tuple[HomeMaterialUploadResultOut, dict[str, Any], dict[str, Any]]:
|
||||
asset = await self._get_asset(db, asset_id)
|
||||
before = _asset_snapshot(asset) or {}
|
||||
req_data = req.model_dump(exclude={"wait", "wait_timeout_seconds"})
|
||||
if req.watermark_type == HomeMaterialWatermarkType.IMAGE:
|
||||
final_watermark_id = req.watermark_id or asset.watermark_id
|
||||
await self._get_watermark(db, final_watermark_id or "", active_only=True)
|
||||
cfg = HomeMaterialWatermarkConfig(**req_data).model_copy(update={"watermark_id": final_watermark_id})
|
||||
else:
|
||||
cfg = HomeMaterialWatermarkConfig(**req_data).model_copy(update={"watermark_id": None})
|
||||
asset.watermark_id = cfg.watermark_id
|
||||
asset.watermark_config_json = _json_dumps(cfg.model_dump(mode="json"))
|
||||
asset.status = HomeMaterialAssetStatus.PROCESSING.value
|
||||
asset.error_message = None
|
||||
asset.updated_by = admin_id
|
||||
db.add(asset)
|
||||
await _flush_refresh(db, asset)
|
||||
return self._upload_result(asset, message="水印重新生成中"), before, _asset_snapshot(asset) or {}
|
||||
|
||||
async def delete_asset(self, db: AsyncSession, asset_id: str, admin_id: str | None) -> tuple[HomeMaterialAsset, dict[str, Any]]:
|
||||
asset = await self._get_asset(db, asset_id)
|
||||
before = _asset_snapshot(asset) or {}
|
||||
asset.deleted_at = datetime.now(timezone.utc)
|
||||
asset.updated_by = admin_id
|
||||
db.add(asset)
|
||||
return asset, before
|
||||
|
||||
async def get_asset_status(self, db: AsyncSession, asset_id: str) -> HomeMaterialAssetStatusOut:
|
||||
asset = await self._get_asset(db, asset_id)
|
||||
return HomeMaterialAssetStatusOut(
|
||||
id=asset.id,
|
||||
status=HomeMaterialAssetStatus(asset.status),
|
||||
error_message=asset.error_message,
|
||||
original_url=asset.original_url,
|
||||
watermarked_url=asset.watermarked_url,
|
||||
cover_url=asset.cover_url,
|
||||
processed_at=asset.processed_at,
|
||||
)
|
||||
|
||||
async def _get_asset(self, db: AsyncSession, asset_id: str) -> HomeMaterialAsset:
|
||||
result = await db.execute(
|
||||
select(HomeMaterialAsset).where(HomeMaterialAsset.id == asset_id, HomeMaterialAsset.deleted_at.is_(None)).limit(1)
|
||||
)
|
||||
asset = result.scalar_one_or_none()
|
||||
if not asset:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="素材不存在或已删除")
|
||||
return asset
|
||||
|
||||
def _upload_result(self, asset: HomeMaterialAsset, *, message: str) -> HomeMaterialUploadResultOut:
|
||||
return HomeMaterialUploadResultOut(
|
||||
id=asset.id,
|
||||
category_id=asset.category_id,
|
||||
title=asset.title,
|
||||
media_type=HomeMaterialMediaType(asset.media_type),
|
||||
status=HomeMaterialAssetStatus(asset.status),
|
||||
original_url=asset.original_url,
|
||||
watermarked_url=asset.watermarked_url,
|
||||
cover_url=asset.cover_url,
|
||||
watermark_config=_json_loads(asset.watermark_config_json),
|
||||
message=message,
|
||||
)
|
||||
|
||||
async def wait_for_asset_result(self, asset_id: str, timeout_seconds: int) -> HomeMaterialUploadResultOut:
|
||||
deadline = asyncio.get_running_loop().time() + max(1, min(timeout_seconds, 60))
|
||||
while True:
|
||||
async with async_session() as db:
|
||||
result = await db.execute(select(HomeMaterialAsset).where(HomeMaterialAsset.id == asset_id).limit(1))
|
||||
asset = result.scalar_one_or_none()
|
||||
if not asset:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="素材不存在")
|
||||
if asset.status in (HomeMaterialAssetStatus.SUCCESS.value, HomeMaterialAssetStatus.FAILED.value):
|
||||
return self._upload_result(asset, message="水印处理完成" if asset.status == HomeMaterialAssetStatus.SUCCESS.value else "水印处理失败")
|
||||
if asyncio.get_running_loop().time() >= deadline:
|
||||
async with async_session() as db:
|
||||
result = await db.execute(select(HomeMaterialAsset).where(HomeMaterialAsset.id == asset_id).limit(1))
|
||||
asset = result.scalar_one()
|
||||
return self._upload_result(asset, message="水印仍在处理中,请继续轮询状态")
|
||||
await asyncio.sleep(0.5)
|
||||
|
||||
def start_watermark_task(self, asset_id: str) -> None:
|
||||
asyncio.create_task(self._run_watermark_task(asset_id))
|
||||
|
||||
async def _run_watermark_task(self, asset_id: str) -> None:
|
||||
try:
|
||||
async with async_session() as db:
|
||||
asset = await self._get_asset(db, asset_id)
|
||||
cfg = _normalize_watermark_config_dict(_json_loads(asset.watermark_config_json), asset.watermark_id)
|
||||
watermark_path: str | None = None
|
||||
if cfg.get("watermark_type") == HomeMaterialWatermarkType.IMAGE.value:
|
||||
watermark = await self._get_watermark(db, cfg.get("watermark_id") or asset.watermark_id or "", active_only=True)
|
||||
watermark_path = watermark.storage_path
|
||||
output_path, output_url = storage_service.build_watermarked_target(asset.id, asset.media_type)
|
||||
cover_path = cover_url = None
|
||||
if asset.media_type == HomeMaterialMediaType.VIDEO.value:
|
||||
cover_path, cover_url = storage_service.build_cover_target(asset.id)
|
||||
result = await watermark_processor.apply_watermark(
|
||||
media_type=HomeMaterialMediaType(asset.media_type),
|
||||
source_path=asset.original_storage_path,
|
||||
watermark_path=watermark_path,
|
||||
output_path=output_path,
|
||||
config=cfg,
|
||||
cover_path=cover_path,
|
||||
)
|
||||
asset.watermarked_storage_path = result.output_path
|
||||
asset.watermarked_url = output_url
|
||||
asset.cover_storage_path = result.cover_path
|
||||
asset.cover_url = cover_url if result.cover_path else None
|
||||
asset.width = result.width
|
||||
asset.height = result.height
|
||||
asset.duration_seconds = result.duration_seconds
|
||||
asset.watermarked_file_size_bytes = result.file_size_bytes
|
||||
asset.status = HomeMaterialAssetStatus.SUCCESS.value
|
||||
asset.error_message = None
|
||||
asset.processed_at = datetime.now(timezone.utc)
|
||||
db.add(asset)
|
||||
await db.commit()
|
||||
except Exception as exc:
|
||||
async with async_session() as db:
|
||||
result = await db.execute(select(HomeMaterialAsset).where(HomeMaterialAsset.id == asset_id).limit(1))
|
||||
asset = result.scalar_one_or_none()
|
||||
if asset:
|
||||
asset.status = HomeMaterialAssetStatus.FAILED.value
|
||||
asset.error_message = str(exc)[:4000]
|
||||
db.add(asset)
|
||||
await db.commit()
|
||||
|
||||
async def preview_text_watermark_layer(self, req: HomeMaterialTextWatermarkPreviewRequest) -> HomeMaterialTextWatermarkPreviewResponse:
|
||||
data_url = await asyncio.to_thread(
|
||||
watermark_processor.generate_repeated_text_layer_data_url,
|
||||
width=req.width,
|
||||
height=req.height,
|
||||
text_config=req.text_watermark,
|
||||
)
|
||||
return HomeMaterialTextWatermarkPreviewResponse(
|
||||
width=req.width,
|
||||
height=req.height,
|
||||
preview_layer_data_url=data_url,
|
||||
)
|
||||
|
||||
async def mark_stale_processing_failed(self, db: AsyncSession) -> int:
|
||||
minutes = int(getattr(settings, "HOME_MATERIAL_PROCESSING_STALE_MINUTES", 30))
|
||||
cutoff = datetime.now(timezone.utc) - timedelta(minutes=minutes)
|
||||
result = await db.execute(
|
||||
update(HomeMaterialAsset)
|
||||
.where(
|
||||
HomeMaterialAsset.deleted_at.is_(None),
|
||||
HomeMaterialAsset.status == HomeMaterialAssetStatus.PROCESSING.value,
|
||||
HomeMaterialAsset.updated_at < cutoff,
|
||||
)
|
||||
.values(status=HomeMaterialAssetStatus.FAILED.value, error_message="处理任务超时或服务重启,请重新生成水印")
|
||||
)
|
||||
return int(result.rowcount or 0)
|
||||
|
||||
async def get_public_categories(
|
||||
self,
|
||||
db: AsyncSession,
|
||||
*,
|
||||
with_asset_count: bool,
|
||||
media_type: HomeMaterialMediaType | str | None,
|
||||
only_has_assets: bool,
|
||||
) -> HomeMaterialPublicCategoryListOut:
|
||||
config = await self.get_config(db)
|
||||
if not config.enabled:
|
||||
return HomeMaterialPublicCategoryListOut(enabled=False, title=config.title, subtitle=config.subtitle, items=[])
|
||||
items = await query_service.list_public_categories(db, with_asset_count=with_asset_count, media_type=media_type, only_has_assets=only_has_assets)
|
||||
return HomeMaterialPublicCategoryListOut(enabled=True, title=config.title, subtitle=config.subtitle, items=items)
|
||||
|
||||
async def get_public_home_materials(
|
||||
self,
|
||||
db: AsyncSession,
|
||||
*,
|
||||
category_id: str | None,
|
||||
category_key: str | None,
|
||||
category_ids: str | None,
|
||||
category_keys: str | None,
|
||||
media_type: HomeMaterialMediaType | str | None,
|
||||
limit_per_category: int,
|
||||
include_empty_categories: bool,
|
||||
response_mode: HomeMaterialPublicResponseMode,
|
||||
page: int,
|
||||
page_size: int,
|
||||
) -> HomeMaterialPublicGroupedOut | HomeMaterialPublicFlatOut:
|
||||
config = await self.get_config(db)
|
||||
if not config.enabled:
|
||||
if response_mode == HomeMaterialPublicResponseMode.FLAT:
|
||||
return HomeMaterialPublicFlatOut(enabled=False, title=config.title, subtitle=config.subtitle, response_mode=response_mode, items=[], total=0)
|
||||
return HomeMaterialPublicGroupedOut(enabled=False, title=config.title, subtitle=config.subtitle, response_mode=response_mode, categories=[])
|
||||
resolved_ids = await query_service.resolve_category_ids(
|
||||
db,
|
||||
category_id=category_id,
|
||||
category_key=category_key,
|
||||
category_ids=category_ids,
|
||||
category_keys=category_keys,
|
||||
active_only=True,
|
||||
)
|
||||
if response_mode == HomeMaterialPublicResponseMode.FLAT:
|
||||
items, total = await query_service.list_public_flat(db, category_ids=resolved_ids, media_type=media_type, page=page, page_size=page_size)
|
||||
return HomeMaterialPublicFlatOut(enabled=True, title=config.title, subtitle=config.subtitle, response_mode=response_mode, items=items, total=total)
|
||||
categories = await query_service.list_public_grouped(
|
||||
db,
|
||||
category_ids=resolved_ids,
|
||||
media_type=media_type,
|
||||
limit_per_category=limit_per_category,
|
||||
include_empty_categories=include_empty_categories,
|
||||
)
|
||||
return HomeMaterialPublicGroupedOut(enabled=True, title=config.title, subtitle=config.subtitle, response_mode=response_mode, categories=categories)
|
||||
|
||||
|
||||
home_material_service = HomeMaterialService()
|
||||
@@ -0,0 +1,170 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import shutil
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from typing import Iterable
|
||||
|
||||
from fastapi import HTTPException, UploadFile, status
|
||||
|
||||
from app.config import settings
|
||||
from app.enums.home_material import (
|
||||
HOME_MATERIAL_IMAGE_EXTENSIONS,
|
||||
HOME_MATERIAL_VIDEO_EXTENSIONS,
|
||||
HOME_MATERIAL_WATERMARK_EXTENSIONS,
|
||||
HomeMaterialMediaType,
|
||||
)
|
||||
from app.utils.id_gen import generate_id
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class StoredFile:
|
||||
storage_path: str
|
||||
file_url: str
|
||||
file_name: str
|
||||
file_size_bytes: int
|
||||
suffix: str
|
||||
|
||||
|
||||
class HomeMaterialStorageService:
|
||||
"""首页素材文件存储服务。只处理文件、目录、URL,不访问数据库。"""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self.upload_root = Path(settings.UPLOAD_LOCAL_PATH).resolve()
|
||||
self.home_root = self.upload_root / "home_materials"
|
||||
|
||||
def _date_dir(self) -> str:
|
||||
return datetime.now().strftime("%Y/%m/%d")
|
||||
|
||||
def _safe_suffix(self, filename: str | None) -> str:
|
||||
return Path(filename or "").suffix.lower()
|
||||
|
||||
def _assert_extension(self, suffix: str, allowed: Iterable[str], label: str) -> None:
|
||||
if suffix not in set(allowed):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=f"{label}文件类型不支持:{suffix or '无扩展名'}",
|
||||
)
|
||||
|
||||
def _limit_mb(self, media_type: HomeMaterialMediaType | str) -> int:
|
||||
if media_type == HomeMaterialMediaType.VIDEO or str(media_type) == HomeMaterialMediaType.VIDEO.value:
|
||||
return int(getattr(settings, "HOME_MATERIAL_MAX_VIDEO_MB", 300))
|
||||
return int(getattr(settings, "HOME_MATERIAL_MAX_IMAGE_MB", 20))
|
||||
|
||||
def _build_target(self, relative_dir: str, suffix: str) -> tuple[Path, str]:
|
||||
file_id = generate_id()
|
||||
relative = Path(relative_dir) / self._date_dir() / f"{file_id}{suffix}"
|
||||
path = self.home_root / relative
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
return path, f"/uploads/home_materials/{relative.as_posix()}"
|
||||
|
||||
async def save_upload_file(
|
||||
self,
|
||||
file: UploadFile,
|
||||
*,
|
||||
relative_dir: str,
|
||||
allowed_extensions: Iterable[str],
|
||||
label: str,
|
||||
max_mb: int | None = None,
|
||||
) -> StoredFile:
|
||||
suffix = self._safe_suffix(file.filename)
|
||||
self._assert_extension(suffix, allowed_extensions, label)
|
||||
target_path, file_url = self._build_target(relative_dir, suffix)
|
||||
max_bytes = int(max_mb or 0) * 1024 * 1024 if max_mb else None
|
||||
tmp_path = target_path.with_name(target_path.name + ".part")
|
||||
size = 0
|
||||
try:
|
||||
with tmp_path.open("wb") as out:
|
||||
while True:
|
||||
chunk = await file.read(1024 * 1024)
|
||||
if not chunk:
|
||||
break
|
||||
size += len(chunk)
|
||||
if max_bytes and size > max_bytes:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=f"{label}文件大小超限,最大 {max_mb}MB",
|
||||
)
|
||||
out.write(chunk)
|
||||
os.replace(tmp_path, target_path)
|
||||
except Exception:
|
||||
try:
|
||||
if tmp_path.exists():
|
||||
tmp_path.unlink()
|
||||
finally:
|
||||
raise
|
||||
finally:
|
||||
await file.seek(0)
|
||||
|
||||
return StoredFile(
|
||||
storage_path=str(target_path),
|
||||
file_url=file_url,
|
||||
file_name=file.filename or target_path.name,
|
||||
file_size_bytes=size,
|
||||
suffix=suffix,
|
||||
)
|
||||
|
||||
async def save_asset_file(self, file: UploadFile, media_type: HomeMaterialMediaType | str) -> StoredFile:
|
||||
media = HomeMaterialMediaType(media_type)
|
||||
if media == HomeMaterialMediaType.IMAGE:
|
||||
return await self.save_upload_file(
|
||||
file,
|
||||
relative_dir="original/images",
|
||||
allowed_extensions=HOME_MATERIAL_IMAGE_EXTENSIONS,
|
||||
label="图片素材",
|
||||
max_mb=self._limit_mb(media),
|
||||
)
|
||||
return await self.save_upload_file(
|
||||
file,
|
||||
relative_dir="original/videos",
|
||||
allowed_extensions=HOME_MATERIAL_VIDEO_EXTENSIONS,
|
||||
label="视频素材",
|
||||
max_mb=self._limit_mb(media),
|
||||
)
|
||||
|
||||
async def save_watermark_file(self, file: UploadFile) -> StoredFile:
|
||||
return await self.save_upload_file(
|
||||
file,
|
||||
relative_dir="watermarks",
|
||||
allowed_extensions=HOME_MATERIAL_WATERMARK_EXTENSIONS,
|
||||
label="水印图片",
|
||||
max_mb=int(getattr(settings, "HOME_MATERIAL_MAX_WATERMARK_MB", 10)),
|
||||
)
|
||||
|
||||
def build_watermarked_target(self, asset_id: str, media_type: HomeMaterialMediaType | str) -> tuple[str, str]:
|
||||
media = HomeMaterialMediaType(media_type)
|
||||
suffix = ".png" if media == HomeMaterialMediaType.IMAGE else ".mp4"
|
||||
folder = "watermarked/images" if media == HomeMaterialMediaType.IMAGE else "watermarked/videos"
|
||||
relative = Path(folder) / self._date_dir() / f"{asset_id}{suffix}"
|
||||
path = self.home_root / relative
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
return str(path), f"/uploads/home_materials/{relative.as_posix()}"
|
||||
|
||||
def build_cover_target(self, asset_id: str) -> tuple[str, str]:
|
||||
relative = Path("covers") / self._date_dir() / f"{asset_id}.jpg"
|
||||
path = self.home_root / relative
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
return str(path), f"/uploads/home_materials/{relative.as_posix()}"
|
||||
|
||||
@staticmethod
|
||||
def file_size(path: str | None) -> int | None:
|
||||
if not path:
|
||||
return None
|
||||
try:
|
||||
return Path(path).stat().st_size
|
||||
except FileNotFoundError:
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
def safe_remove(path: str | None) -> None:
|
||||
if not path:
|
||||
return
|
||||
try:
|
||||
Path(path).unlink(missing_ok=True)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
storage_service = HomeMaterialStorageService()
|
||||
@@ -0,0 +1,622 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import base64
|
||||
import json
|
||||
import math
|
||||
import os
|
||||
import shutil
|
||||
import subprocess
|
||||
import tempfile
|
||||
from dataclasses import dataclass
|
||||
from decimal import Decimal
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from fastapi import HTTPException, status
|
||||
|
||||
try:
|
||||
from PIL import Image, ImageDraw, ImageFont
|
||||
except Exception: # pragma: no cover - 运行时给出明确错误
|
||||
Image = None # type: ignore[assignment]
|
||||
ImageDraw = None # type: ignore[assignment]
|
||||
ImageFont = None # type: ignore[assignment]
|
||||
|
||||
from app.config import settings
|
||||
from app.enums.home_material import (
|
||||
HomeMaterialMediaType,
|
||||
HomeMaterialWatermarkPosition,
|
||||
HomeMaterialWatermarkSizeMode,
|
||||
HomeMaterialWatermarkType,
|
||||
)
|
||||
from app.schemas.home_material import HomeMaterialTextWatermarkConfig, HomeMaterialWatermarkConfig
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class MediaProbeInfo:
|
||||
width: int | None = None
|
||||
height: int | None = None
|
||||
duration_seconds: Decimal | None = None
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class WatermarkProcessResult:
|
||||
output_path: str
|
||||
width: int | None
|
||||
height: int | None
|
||||
duration_seconds: Decimal | None
|
||||
file_size_bytes: int
|
||||
cover_path: str | None = None
|
||||
|
||||
|
||||
class HomeMaterialWatermarkProcessor:
|
||||
"""首页素材 FFmpeg 水印处理器。纯处理层,不访问数据库。"""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self._image_semaphore = asyncio.Semaphore(int(getattr(settings, "HOME_MATERIAL_IMAGE_WATERMARK_CONCURRENCY", 4)))
|
||||
self._video_semaphore = asyncio.Semaphore(int(getattr(settings, "HOME_MATERIAL_VIDEO_WATERMARK_CONCURRENCY", 2)))
|
||||
|
||||
def _ffmpeg_bin(self) -> str:
|
||||
configured = getattr(settings, "FFMPEG_BIN", "") or ""
|
||||
if configured:
|
||||
return configured
|
||||
found = shutil.which("ffmpeg") or shutil.which("ffmpeg.exe")
|
||||
if not found:
|
||||
raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="未找到 ffmpeg,请配置 FFMPEG_BIN 或安装 ffmpeg")
|
||||
return found
|
||||
|
||||
def _ffprobe_bin(self) -> str:
|
||||
configured = getattr(settings, "FFMPEG_BIN", "") or ""
|
||||
if configured:
|
||||
p = Path(configured)
|
||||
candidate = p.with_name("ffprobe.exe" if p.name.endswith(".exe") else "ffprobe")
|
||||
if candidate.exists():
|
||||
return str(candidate)
|
||||
found = shutil.which("ffprobe") or shutil.which("ffprobe.exe")
|
||||
if not found:
|
||||
raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="未找到 ffprobe,请确认 ffmpeg 环境完整")
|
||||
return found
|
||||
|
||||
@staticmethod
|
||||
def _run_blocking(args: list[str], timeout: int) -> tuple[str, str]:
|
||||
"""在线程里执行 FFmpeg/ffprobe。
|
||||
|
||||
Windows 下 uvicorn/watchfiles 有概率使用不支持子进程的 SelectorEventLoop,
|
||||
asyncio.create_subprocess_exec 会直接抛 NotImplementedError。这里统一改为
|
||||
subprocess.run + asyncio.to_thread,仍然不会阻塞 Web Server 事件循环。
|
||||
"""
|
||||
creationflags = 0
|
||||
if os.name == "nt":
|
||||
creationflags = getattr(subprocess, "CREATE_NO_WINDOW", 0)
|
||||
try:
|
||||
completed = subprocess.run(
|
||||
args,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE,
|
||||
timeout=timeout,
|
||||
check=False,
|
||||
creationflags=creationflags,
|
||||
)
|
||||
except subprocess.TimeoutExpired as exc:
|
||||
raise RuntimeError("FFmpeg 处理超时") from exc
|
||||
out = completed.stdout.decode("utf-8", errors="ignore")
|
||||
err = completed.stderr.decode("utf-8", errors="ignore")
|
||||
if completed.returncode != 0:
|
||||
raise RuntimeError(err[-2000:] or f"FFmpeg 退出码异常:{completed.returncode}")
|
||||
return out, err
|
||||
|
||||
async def _run(self, args: list[str], timeout: int | None = None) -> tuple[str, str]:
|
||||
effective_timeout = timeout or int(getattr(settings, "HOME_MATERIAL_FFMPEG_TIMEOUT_SECONDS", 600))
|
||||
return await asyncio.to_thread(self._run_blocking, args, effective_timeout)
|
||||
|
||||
async def probe(self, path: str) -> MediaProbeInfo:
|
||||
args = [
|
||||
self._ffprobe_bin(),
|
||||
"-v",
|
||||
"error",
|
||||
"-select_streams",
|
||||
"v:0",
|
||||
"-show_entries",
|
||||
"stream=width,height,duration",
|
||||
"-of",
|
||||
"json",
|
||||
path,
|
||||
]
|
||||
stdout, _ = await self._run(args, timeout=30)
|
||||
try:
|
||||
data = json.loads(stdout or "{}")
|
||||
stream = (data.get("streams") or [{}])[0]
|
||||
duration_raw = stream.get("duration")
|
||||
return MediaProbeInfo(
|
||||
width=int(stream["width"]) if stream.get("width") is not None else None,
|
||||
height=int(stream["height"]) if stream.get("height") is not None else None,
|
||||
duration_seconds=Decimal(str(duration_raw)).quantize(Decimal("0.001")) if duration_raw not in (None, "N/A") else None,
|
||||
)
|
||||
except Exception:
|
||||
return MediaProbeInfo()
|
||||
|
||||
@staticmethod
|
||||
def _value(config: HomeMaterialWatermarkConfig | HomeMaterialTextWatermarkConfig | dict[str, Any], key: str, default: Any = None) -> Any:
|
||||
if isinstance(config, dict):
|
||||
return config.get(key, default)
|
||||
return getattr(config, key, default)
|
||||
|
||||
def _watermark_type(self, config: HomeMaterialWatermarkConfig | dict[str, Any]) -> str:
|
||||
raw = self._value(config, "watermark_type", None) or HomeMaterialWatermarkType.IMAGE.value
|
||||
return HomeMaterialWatermarkType(raw).value
|
||||
|
||||
def _watermark_width(self, source_width: int | None, config: HomeMaterialWatermarkConfig | dict[str, Any]) -> int:
|
||||
size_mode = self._value(config, "size_mode", HomeMaterialWatermarkSizeMode.RATIO.value)
|
||||
if hasattr(size_mode, "value"):
|
||||
size_mode = size_mode.value
|
||||
width_px = self._value(config, "width_px", None)
|
||||
width_ratio = self._value(config, "width_ratio", 0.18) or 0.18
|
||||
if size_mode == HomeMaterialWatermarkSizeMode.PX.value and width_px:
|
||||
return max(1, int(width_px))
|
||||
base_width = source_width or 1080
|
||||
return max(1, int(base_width * float(width_ratio)))
|
||||
|
||||
def _overlay_xy(self, config: HomeMaterialWatermarkConfig | dict[str, Any]) -> tuple[str, str]:
|
||||
position = self._value(config, "position", HomeMaterialWatermarkPosition.BOTTOM_RIGHT.value)
|
||||
if hasattr(position, "value"):
|
||||
position = position.value
|
||||
margin_x = int(self._value(config, "margin_x", 24) or 24)
|
||||
margin_y = int(self._value(config, "margin_y", 24) or 24)
|
||||
custom_x_ratio = self._value(config, "custom_x_ratio", None)
|
||||
custom_y_ratio = self._value(config, "custom_y_ratio", None)
|
||||
|
||||
if position == HomeMaterialWatermarkPosition.CUSTOM.value:
|
||||
x_ratio = float(custom_x_ratio if custom_x_ratio is not None else 0.5)
|
||||
y_ratio = float(custom_y_ratio if custom_y_ratio is not None else 0.5)
|
||||
return f"(main_w-overlay_w)*{x_ratio:.6f}", f"(main_h-overlay_h)*{y_ratio:.6f}"
|
||||
|
||||
positions: dict[str, tuple[str, str]] = {
|
||||
HomeMaterialWatermarkPosition.TOP_LEFT.value: (str(margin_x), str(margin_y)),
|
||||
HomeMaterialWatermarkPosition.TOP_CENTER.value: ("(main_w-overlay_w)/2", str(margin_y)),
|
||||
HomeMaterialWatermarkPosition.TOP_RIGHT.value: (f"main_w-overlay_w-{margin_x}", str(margin_y)),
|
||||
HomeMaterialWatermarkPosition.MIDDLE_LEFT.value: (str(margin_x), "(main_h-overlay_h)/2"),
|
||||
HomeMaterialWatermarkPosition.CENTER.value: ("(main_w-overlay_w)/2", "(main_h-overlay_h)/2"),
|
||||
HomeMaterialWatermarkPosition.MIDDLE_RIGHT.value: (f"main_w-overlay_w-{margin_x}", "(main_h-overlay_h)/2"),
|
||||
HomeMaterialWatermarkPosition.BOTTOM_LEFT.value: (str(margin_x), f"main_h-overlay_h-{margin_y}"),
|
||||
HomeMaterialWatermarkPosition.BOTTOM_CENTER.value: ("(main_w-overlay_w)/2", f"main_h-overlay_h-{margin_y}"),
|
||||
HomeMaterialWatermarkPosition.BOTTOM_RIGHT.value: (f"main_w-overlay_w-{margin_x}", f"main_h-overlay_h-{margin_y}"),
|
||||
}
|
||||
return positions.get(str(position), positions[HomeMaterialWatermarkPosition.BOTTOM_RIGHT.value])
|
||||
|
||||
def _opacity(self, config: HomeMaterialWatermarkConfig | HomeMaterialTextWatermarkConfig | dict[str, Any]) -> float:
|
||||
level = int(self._value(config, "opacity_level", 6) or 6)
|
||||
return min(max(level, 1), 10) / 10
|
||||
|
||||
def _filter_complex_image_watermark(self, source_width: int | None, config: HomeMaterialWatermarkConfig | dict[str, Any]) -> str:
|
||||
wm_width = self._watermark_width(source_width, config)
|
||||
opacity = self._opacity(config)
|
||||
x, y = self._overlay_xy(config)
|
||||
return f"[1:v]format=rgba,colorchannelmixer=aa={opacity:.2f},scale={wm_width}:-1[wm];[0:v][wm]overlay={x}:{y}[v]"
|
||||
|
||||
@staticmethod
|
||||
def _hex_to_rgb(value: str) -> tuple[int, int, int]:
|
||||
raw = (value or "#ffffff").strip()
|
||||
if not raw.startswith("#") or len(raw) != 7:
|
||||
raw = "#ffffff"
|
||||
try:
|
||||
return int(raw[1:3], 16), int(raw[3:5], 16), int(raw[5:7], 16)
|
||||
except Exception:
|
||||
return 255, 255, 255
|
||||
|
||||
def _font_path(self) -> str:
|
||||
configured = str(getattr(settings, "HOME_MATERIAL_TEXT_WATERMARK_FONT", "") or "").strip()
|
||||
if not configured:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail="重复文字水印字体未配置,请配置 HOME_MATERIAL_TEXT_WATERMARK_FONT 为开源可商用字体文件路径,例如 Noto Sans CJK SC / Source Han Sans SC。",
|
||||
)
|
||||
path = Path(configured)
|
||||
if not path.exists() or not path.is_file():
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail=f"重复文字水印字体文件不存在或不可读:{configured}",
|
||||
)
|
||||
return str(path)
|
||||
|
||||
def _assert_pillow_available(self) -> None:
|
||||
if Image is None or ImageDraw is None or ImageFont is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail="重复文字水印依赖 Pillow,请先安装 pillow,并配置 HOME_MATERIAL_TEXT_WATERMARK_FONT。",
|
||||
)
|
||||
|
||||
def _text_config(self, config: HomeMaterialWatermarkConfig | HomeMaterialTextWatermarkConfig | dict[str, Any]) -> HomeMaterialTextWatermarkConfig | dict[str, Any]:
|
||||
if isinstance(config, HomeMaterialTextWatermarkConfig):
|
||||
return config
|
||||
if isinstance(config, HomeMaterialWatermarkConfig):
|
||||
if config.text_watermark is None:
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="重复文字水印配置不能为空")
|
||||
return config.text_watermark
|
||||
text_config = config.get("text_watermark")
|
||||
if not isinstance(text_config, dict):
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="重复文字水印配置不能为空")
|
||||
return text_config
|
||||
|
||||
def generate_repeated_text_layer_file(
|
||||
self,
|
||||
*,
|
||||
width: int,
|
||||
height: int,
|
||||
text_config: HomeMaterialTextWatermarkConfig | dict[str, Any],
|
||||
output_path: str,
|
||||
) -> str:
|
||||
"""用固定开源字体生成透明重复文字水印层。
|
||||
|
||||
该方法同时用于前端精准预览和最终 FFmpeg overlay,保证前端看到的透明层和实际叠加层来自同一套渲染逻辑。
|
||||
"""
|
||||
self._assert_pillow_available()
|
||||
font_path = self._font_path()
|
||||
|
||||
safe_width = max(1, int(width))
|
||||
safe_height = max(1, int(height))
|
||||
max_text_length = int(getattr(settings, "HOME_MATERIAL_TEXT_WATERMARK_MAX_TEXT_LENGTH", 64))
|
||||
max_font_size = int(getattr(settings, "HOME_MATERIAL_TEXT_WATERMARK_MAX_FONT_SIZE", 160))
|
||||
max_gap = int(getattr(settings, "HOME_MATERIAL_TEXT_WATERMARK_MAX_GAP", 2000))
|
||||
|
||||
text = str(self._value(text_config, "text", "") or "").strip()[:max_text_length]
|
||||
if not text:
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="重复文字水印内容不能为空")
|
||||
font_size = max(8, min(max_font_size, int(self._value(text_config, "font_size_px", 28) or 28)))
|
||||
rotate_deg = max(-90, min(90, int(self._value(text_config, "rotate_deg", -30) or 0)))
|
||||
gap_x = max(20, min(max_gap, int(self._value(text_config, "gap_x", 220) or 220)))
|
||||
gap_y = max(20, min(max_gap, int(self._value(text_config, "gap_y", 140) or 140)))
|
||||
staggered = bool(self._value(text_config, "staggered", True))
|
||||
opacity = self._opacity(text_config)
|
||||
r, g, b = self._hex_to_rgb(str(self._value(text_config, "color", "#ffffff") or "#ffffff"))
|
||||
alpha = int(round(opacity * 255))
|
||||
|
||||
font = ImageFont.truetype(font_path, font_size)
|
||||
measure = Image.new("RGBA", (1, 1), (0, 0, 0, 0))
|
||||
measure_draw = ImageDraw.Draw(measure)
|
||||
bbox = measure_draw.textbbox((0, 0), text, font=font)
|
||||
text_w = max(1, bbox[2] - bbox[0])
|
||||
text_h = max(1, bbox[3] - bbox[1])
|
||||
padding = max(8, int(font_size * 0.8))
|
||||
|
||||
text_img = Image.new("RGBA", (text_w + padding * 2, text_h + padding * 2), (0, 0, 0, 0))
|
||||
text_draw = ImageDraw.Draw(text_img)
|
||||
text_draw.text((padding - bbox[0], padding - bbox[1]), text, font=font, fill=(r, g, b, alpha))
|
||||
if rotate_deg:
|
||||
text_img = text_img.rotate(rotate_deg, resample=Image.Resampling.BICUBIC, expand=True)
|
||||
|
||||
layer = Image.new("RGBA", (safe_width, safe_height), (0, 0, 0, 0))
|
||||
tile_w, tile_h = text_img.size
|
||||
step_x = max(gap_x, int(tile_w * 0.8))
|
||||
step_y = max(gap_y, int(tile_h * 0.8))
|
||||
start_x = -tile_w
|
||||
start_y = -tile_h
|
||||
end_x = safe_width + tile_w
|
||||
end_y = safe_height + tile_h
|
||||
|
||||
row = 0
|
||||
y = start_y
|
||||
while y <= end_y:
|
||||
offset = step_x // 2 if staggered and row % 2 == 1 else 0
|
||||
x = start_x + offset
|
||||
while x <= end_x:
|
||||
layer.alpha_composite(text_img, (int(x), int(y)))
|
||||
x += step_x
|
||||
y += step_y
|
||||
row += 1
|
||||
|
||||
target = Path(output_path)
|
||||
target.parent.mkdir(parents=True, exist_ok=True)
|
||||
layer.save(str(target), format="PNG")
|
||||
return str(target)
|
||||
|
||||
def generate_repeated_text_layer_data_url(
|
||||
self,
|
||||
*,
|
||||
width: int,
|
||||
height: int,
|
||||
text_config: HomeMaterialTextWatermarkConfig | dict[str, Any],
|
||||
) -> str:
|
||||
max_width = int(getattr(settings, "HOME_MATERIAL_TEXT_WATERMARK_PREVIEW_MAX_WIDTH", 8192))
|
||||
max_height = int(getattr(settings, "HOME_MATERIAL_TEXT_WATERMARK_PREVIEW_MAX_HEIGHT", 8192))
|
||||
safe_width = max(1, min(max_width, int(width)))
|
||||
safe_height = max(1, min(max_height, int(height)))
|
||||
with tempfile.NamedTemporaryFile(prefix="home_material_text_wm_", suffix=".png", delete=False) as tmp:
|
||||
tmp_path = tmp.name
|
||||
try:
|
||||
self.generate_repeated_text_layer_file(width=safe_width, height=safe_height, text_config=text_config, output_path=tmp_path)
|
||||
raw = Path(tmp_path).read_bytes()
|
||||
return "data:image/png;base64," + base64.b64encode(raw).decode("ascii")
|
||||
finally:
|
||||
try:
|
||||
Path(tmp_path).unlink(missing_ok=True)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
async def apply_watermark(
|
||||
self,
|
||||
*,
|
||||
media_type: HomeMaterialMediaType | str,
|
||||
source_path: str,
|
||||
watermark_path: str | None,
|
||||
output_path: str,
|
||||
config: HomeMaterialWatermarkConfig | dict[str, Any],
|
||||
cover_path: str | None = None,
|
||||
) -> WatermarkProcessResult:
|
||||
media = HomeMaterialMediaType(media_type)
|
||||
watermark_type = self._watermark_type(config)
|
||||
if media == HomeMaterialMediaType.IMAGE:
|
||||
async with self._image_semaphore:
|
||||
if watermark_type == HomeMaterialWatermarkType.REPEATED_TEXT.value:
|
||||
return await self._apply_image_repeated_text_watermark(source_path, output_path, config)
|
||||
if not watermark_path:
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="图片水印文件不能为空")
|
||||
return await self._apply_image_watermark(source_path, watermark_path, output_path, config)
|
||||
async with self._video_semaphore:
|
||||
if watermark_type == HomeMaterialWatermarkType.REPEATED_TEXT.value:
|
||||
return await self._apply_video_repeated_text_watermark(source_path, output_path, config, cover_path=cover_path)
|
||||
if not watermark_path:
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="图片水印文件不能为空")
|
||||
return await self._apply_video_watermark(source_path, watermark_path, output_path, config, cover_path=cover_path)
|
||||
|
||||
async def _apply_image_watermark(
|
||||
self,
|
||||
source_path: str,
|
||||
watermark_path: str,
|
||||
output_path: str,
|
||||
config: HomeMaterialWatermarkConfig | dict[str, Any],
|
||||
) -> WatermarkProcessResult:
|
||||
source_info = await self.probe(source_path)
|
||||
tmp_path = output_path + ".part"
|
||||
Path(output_path).parent.mkdir(parents=True, exist_ok=True)
|
||||
filter_complex = self._filter_complex_image_watermark(source_info.width, config)
|
||||
args = [
|
||||
self._ffmpeg_bin(),
|
||||
"-y",
|
||||
"-i",
|
||||
source_path,
|
||||
"-i",
|
||||
watermark_path,
|
||||
"-filter_complex",
|
||||
filter_complex,
|
||||
"-map",
|
||||
"[v]",
|
||||
"-frames:v",
|
||||
"1",
|
||||
"-f",
|
||||
"image2",
|
||||
"-vcodec",
|
||||
"png",
|
||||
tmp_path,
|
||||
]
|
||||
try:
|
||||
await self._run(args)
|
||||
os.replace(tmp_path, output_path)
|
||||
finally:
|
||||
if os.path.exists(tmp_path):
|
||||
os.remove(tmp_path)
|
||||
output_info = await self.probe(output_path)
|
||||
return WatermarkProcessResult(
|
||||
output_path=output_path,
|
||||
width=output_info.width or source_info.width,
|
||||
height=output_info.height or source_info.height,
|
||||
duration_seconds=None,
|
||||
file_size_bytes=os.path.getsize(output_path),
|
||||
)
|
||||
|
||||
async def _apply_video_watermark(
|
||||
self,
|
||||
source_path: str,
|
||||
watermark_path: str,
|
||||
output_path: str,
|
||||
config: HomeMaterialWatermarkConfig | dict[str, Any],
|
||||
cover_path: str | None = None,
|
||||
) -> WatermarkProcessResult:
|
||||
source_info = await self.probe(source_path)
|
||||
max_duration = int(getattr(settings, "HOME_MATERIAL_MAX_VIDEO_DURATION_SECONDS", 300))
|
||||
if source_info.duration_seconds is not None and source_info.duration_seconds > Decimal(max_duration):
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=f"视频时长超限,最大 {max_duration} 秒")
|
||||
|
||||
tmp_path = output_path + ".part"
|
||||
Path(output_path).parent.mkdir(parents=True, exist_ok=True)
|
||||
filter_complex = self._filter_complex_image_watermark(source_info.width, config)
|
||||
args = [
|
||||
self._ffmpeg_bin(),
|
||||
"-y",
|
||||
"-i",
|
||||
source_path,
|
||||
"-i",
|
||||
watermark_path,
|
||||
"-filter_complex",
|
||||
filter_complex,
|
||||
"-map",
|
||||
"[v]",
|
||||
"-map",
|
||||
"0:a?",
|
||||
"-c:v",
|
||||
"libx264",
|
||||
"-preset",
|
||||
"veryfast",
|
||||
"-crf",
|
||||
"23",
|
||||
"-c:a",
|
||||
"copy",
|
||||
"-movflags",
|
||||
"+faststart",
|
||||
"-f",
|
||||
"mp4",
|
||||
tmp_path,
|
||||
]
|
||||
try:
|
||||
await self._run(args)
|
||||
os.replace(tmp_path, output_path)
|
||||
finally:
|
||||
if os.path.exists(tmp_path):
|
||||
os.remove(tmp_path)
|
||||
|
||||
output_info = await self.probe(output_path)
|
||||
generated_cover = None
|
||||
if cover_path:
|
||||
generated_cover = await self.generate_cover(output_path, cover_path)
|
||||
return WatermarkProcessResult(
|
||||
output_path=output_path,
|
||||
width=output_info.width or source_info.width,
|
||||
height=output_info.height or source_info.height,
|
||||
duration_seconds=output_info.duration_seconds or source_info.duration_seconds,
|
||||
file_size_bytes=os.path.getsize(output_path),
|
||||
cover_path=generated_cover,
|
||||
)
|
||||
|
||||
async def _apply_image_repeated_text_watermark(
|
||||
self,
|
||||
source_path: str,
|
||||
output_path: str,
|
||||
config: HomeMaterialWatermarkConfig | dict[str, Any],
|
||||
) -> WatermarkProcessResult:
|
||||
source_info = await self.probe(source_path)
|
||||
width = int(source_info.width or 1080)
|
||||
height = int(source_info.height or 1920)
|
||||
tmp_path = output_path + ".part"
|
||||
layer_path = output_path + ".text-layer.png"
|
||||
Path(output_path).parent.mkdir(parents=True, exist_ok=True)
|
||||
self.generate_repeated_text_layer_file(width=width, height=height, text_config=self._text_config(config), output_path=layer_path)
|
||||
args = [
|
||||
self._ffmpeg_bin(),
|
||||
"-y",
|
||||
"-i",
|
||||
source_path,
|
||||
"-i",
|
||||
layer_path,
|
||||
"-filter_complex",
|
||||
"[1:v]format=rgba[wm];[0:v][wm]overlay=0:0[v]",
|
||||
"-map",
|
||||
"[v]",
|
||||
"-frames:v",
|
||||
"1",
|
||||
"-f",
|
||||
"image2",
|
||||
"-vcodec",
|
||||
"png",
|
||||
tmp_path,
|
||||
]
|
||||
try:
|
||||
await self._run(args)
|
||||
os.replace(tmp_path, output_path)
|
||||
finally:
|
||||
for p in (tmp_path, layer_path):
|
||||
if os.path.exists(p):
|
||||
os.remove(p)
|
||||
output_info = await self.probe(output_path)
|
||||
return WatermarkProcessResult(
|
||||
output_path=output_path,
|
||||
width=output_info.width or source_info.width,
|
||||
height=output_info.height or source_info.height,
|
||||
duration_seconds=None,
|
||||
file_size_bytes=os.path.getsize(output_path),
|
||||
)
|
||||
|
||||
async def _apply_video_repeated_text_watermark(
|
||||
self,
|
||||
source_path: str,
|
||||
output_path: str,
|
||||
config: HomeMaterialWatermarkConfig | dict[str, Any],
|
||||
cover_path: str | None = None,
|
||||
) -> WatermarkProcessResult:
|
||||
source_info = await self.probe(source_path)
|
||||
max_duration = int(getattr(settings, "HOME_MATERIAL_MAX_VIDEO_DURATION_SECONDS", 300))
|
||||
if source_info.duration_seconds is not None and source_info.duration_seconds > Decimal(max_duration):
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=f"视频时长超限,最大 {max_duration} 秒")
|
||||
width = int(source_info.width or 1080)
|
||||
height = int(source_info.height or 1920)
|
||||
tmp_path = output_path + ".part"
|
||||
layer_path = output_path + ".text-layer.png"
|
||||
Path(output_path).parent.mkdir(parents=True, exist_ok=True)
|
||||
self.generate_repeated_text_layer_file(width=width, height=height, text_config=self._text_config(config), output_path=layer_path)
|
||||
args = [
|
||||
self._ffmpeg_bin(),
|
||||
"-y",
|
||||
"-i",
|
||||
source_path,
|
||||
"-loop",
|
||||
"1",
|
||||
"-i",
|
||||
layer_path,
|
||||
"-filter_complex",
|
||||
"[1:v]format=rgba[wm];[0:v][wm]overlay=0:0[v]",
|
||||
"-map",
|
||||
"[v]",
|
||||
"-map",
|
||||
"0:a?",
|
||||
"-c:v",
|
||||
"libx264",
|
||||
"-preset",
|
||||
"veryfast",
|
||||
"-crf",
|
||||
"23",
|
||||
"-c:a",
|
||||
"copy",
|
||||
"-movflags",
|
||||
"+faststart",
|
||||
"-shortest",
|
||||
"-f",
|
||||
"mp4",
|
||||
tmp_path,
|
||||
]
|
||||
try:
|
||||
await self._run(args)
|
||||
os.replace(tmp_path, output_path)
|
||||
finally:
|
||||
for p in (tmp_path, layer_path):
|
||||
if os.path.exists(p):
|
||||
os.remove(p)
|
||||
|
||||
output_info = await self.probe(output_path)
|
||||
generated_cover = None
|
||||
if cover_path:
|
||||
generated_cover = await self.generate_cover(output_path, cover_path)
|
||||
return WatermarkProcessResult(
|
||||
output_path=output_path,
|
||||
width=output_info.width or source_info.width,
|
||||
height=output_info.height or source_info.height,
|
||||
duration_seconds=output_info.duration_seconds or source_info.duration_seconds,
|
||||
file_size_bytes=os.path.getsize(output_path),
|
||||
cover_path=generated_cover,
|
||||
)
|
||||
|
||||
async def generate_cover(self, video_path: str, cover_path: str) -> str:
|
||||
tmp_path = cover_path + ".part"
|
||||
Path(cover_path).parent.mkdir(parents=True, exist_ok=True)
|
||||
seek = getattr(settings, "VIDEO_COVER_SEEK_TIME", "00:00:01") or "00:00:01"
|
||||
width = int(getattr(settings, "VIDEO_COVER_WIDTH", 720))
|
||||
args = [
|
||||
self._ffmpeg_bin(),
|
||||
"-y",
|
||||
"-ss",
|
||||
seek,
|
||||
"-i",
|
||||
video_path,
|
||||
"-frames:v",
|
||||
"1",
|
||||
"-vf",
|
||||
f"scale={width}:-2",
|
||||
"-f",
|
||||
"image2",
|
||||
"-vcodec",
|
||||
"mjpeg",
|
||||
tmp_path,
|
||||
]
|
||||
try:
|
||||
await self._run(args, timeout=int(getattr(settings, "VIDEO_COVER_TIMEOUT_SECONDS", 15)))
|
||||
os.replace(tmp_path, cover_path)
|
||||
return cover_path
|
||||
except Exception:
|
||||
if os.path.exists(tmp_path):
|
||||
os.remove(tmp_path)
|
||||
fallback_seek = getattr(settings, "VIDEO_COVER_FALLBACK_SEEK_TIME", "00:00:00") or "00:00:00"
|
||||
fallback_args = args.copy()
|
||||
fallback_args[fallback_args.index(seek)] = fallback_seek
|
||||
await self._run(fallback_args, timeout=int(getattr(settings, "VIDEO_COVER_TIMEOUT_SECONDS", 15)))
|
||||
os.replace(tmp_path, cover_path)
|
||||
return cover_path
|
||||
|
||||
|
||||
watermark_processor = HomeMaterialWatermarkProcessor()
|
||||
Binary file not shown.
Reference in New Issue
Block a user