取消背景图

This commit is contained in:
2026-06-30 17:10:51 +08:00
parent ef9d9e8a74
commit 0c63512a29
39 changed files with 5931 additions and 661 deletions
+2
View File
@@ -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_configskey=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-10position 支持九宫格和 customcustom 需要 custom_x_ratio/custom_y_ratio"
"size_mode=ratio 使用 width_ratiosize_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_urlstatus=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"}