取消背景图
This commit is contained in:
@@ -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()
|
||||
Reference in New Issue
Block a user