913 lines
42 KiB
Python
913 lines
42 KiB
Python
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,
|
||
HOME_MATERIAL_GENERATION_PROMPT_MAX_LEN,
|
||
HOME_MATERIAL_MEDIA_REFERENCES_JSON_MAX_LEN,
|
||
HOME_MATERIAL_MEDIA_REFERENCES_MAX_COUNT,
|
||
HomeMaterialAssetStatus,
|
||
HomeMaterialConfigKeyEnum,
|
||
HomeMaterialLogEventEnum,
|
||
HomeMaterialMediaType,
|
||
HomeMaterialPublicResponseMode,
|
||
HomeMaterialWatermarkPosition,
|
||
HomeMaterialWatermarkSizeMode,
|
||
HomeMaterialWatermarkType,
|
||
)
|
||
from app.enums.common import LogEventStatusEnum, LogSourceEnum
|
||
from app.enums.upload_resource import UploadResourceModuleEnum, UploadResourceSourceModelEnum
|
||
from app.models.base import async_session
|
||
from app.models.home_material import HomeMaterialAsset, HomeMaterialCategory, HomeMaterialWatermark
|
||
from app.models.system_config import SystemConfig
|
||
from app.schemas.home_material import (
|
||
HomeMaterialAssetListOut,
|
||
HomeMaterialAssetOut,
|
||
HomeMaterialAssetStatusOut,
|
||
HomeMaterialAssetUpdate,
|
||
HomeMaterialCategoryCreate,
|
||
HomeMaterialCategoryListOut,
|
||
HomeMaterialCategoryOut,
|
||
HomeMaterialCategoryUpdate,
|
||
HomeMaterialConfigOut,
|
||
HomeMaterialConfigUpdate,
|
||
HomeMaterialPublicCategoryListOut,
|
||
HomeMaterialPublicFlatOut,
|
||
HomeMaterialMediaReference,
|
||
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.services.operation_log_service import build_exception_detail, log_operation_event
|
||
from app.services.upload_resource.bind_service import bind_upload_resources
|
||
from app.utils.id_gen import generate_id
|
||
|
||
|
||
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 _json_loads_list(value: str | None) -> list[Any]:
|
||
if not value:
|
||
return []
|
||
try:
|
||
data = json.loads(value)
|
||
return data if isinstance(data, list) else []
|
||
except Exception as exc:
|
||
log_operation_event(
|
||
domain="home_material",
|
||
module="home_material_asset",
|
||
event_type=HomeMaterialLogEventEnum.MEDIA_REFERENCES_PARSE_FAILED.value,
|
||
event_status=LogEventStatusEnum.WARNING.value,
|
||
source=LogSourceEnum.SERVICE.value,
|
||
detail={"raw_prefix": value[:500]},
|
||
error=str(exc),
|
||
)
|
||
return []
|
||
|
||
|
||
def _clean_title(value: str | None) -> str | None:
|
||
value = (value or "").strip()
|
||
return value or None
|
||
|
||
|
||
def _clean_generation_prompt(value: str | None) -> str | None:
|
||
text = (value or "").strip()
|
||
if not text:
|
||
return None
|
||
if len(text) > HOME_MATERIAL_GENERATION_PROMPT_MAX_LEN:
|
||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=f"生成提词不能超过 {HOME_MATERIAL_GENERATION_PROMPT_MAX_LEN} 个字符")
|
||
return text
|
||
|
||
|
||
def _media_references_from_json(value: str | None) -> list[HomeMaterialMediaReference]:
|
||
if not value or not value.strip():
|
||
return []
|
||
if len(value) > HOME_MATERIAL_MEDIA_REFERENCES_JSON_MAX_LEN:
|
||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=f"附件JSON不能超过 {HOME_MATERIAL_MEDIA_REFERENCES_JSON_MAX_LEN} 个字符")
|
||
try:
|
||
raw = json.loads(value)
|
||
except Exception as exc:
|
||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="附件JSON格式错误") from exc
|
||
if raw is None:
|
||
return []
|
||
if not isinstance(raw, list):
|
||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="附件必须是数组格式")
|
||
return _normalize_media_references(raw)
|
||
|
||
|
||
def _normalize_media_references(value: list[Any] | None) -> list[HomeMaterialMediaReference]:
|
||
if not value:
|
||
return []
|
||
if len(value) > HOME_MATERIAL_MEDIA_REFERENCES_MAX_COUNT:
|
||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=f"附件最多 {HOME_MATERIAL_MEDIA_REFERENCES_MAX_COUNT} 个")
|
||
refs: list[HomeMaterialMediaReference] = []
|
||
for item in value:
|
||
try:
|
||
ref = item if isinstance(item, HomeMaterialMediaReference) else HomeMaterialMediaReference(**item)
|
||
except Exception as exc:
|
||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=f"附件格式错误:{exc}") from exc
|
||
refs.append(ref)
|
||
return refs
|
||
|
||
|
||
def _dump_media_references_json(refs: list[HomeMaterialMediaReference] | None) -> str | None:
|
||
items = [r.model_dump(mode="json", exclude_none=True) for r in (refs or [])]
|
||
if not items:
|
||
return None
|
||
text = _json_dumps(items)
|
||
if len(text) > HOME_MATERIAL_MEDIA_REFERENCES_JSON_MAX_LEN:
|
||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=f"附件JSON不能超过 {HOME_MATERIAL_MEDIA_REFERENCES_JSON_MAX_LEN} 个字符")
|
||
return text
|
||
|
||
|
||
def _media_references_out(value: str | None) -> list[HomeMaterialMediaReference]:
|
||
return _normalize_media_references(_json_loads_list(value))
|
||
|
||
|
||
def _media_reference_resource_ids(refs: list[HomeMaterialMediaReference]) -> list[str]:
|
||
return [str(r.upload_resource_id).strip() for r in refs if r.upload_resource_id and str(r.upload_resource_id).strip()]
|
||
|
||
|
||
def _media_reference_urls(refs: list[HomeMaterialMediaReference]) -> list[str]:
|
||
return [str(r.url).strip() for r in refs if r.url and str(r.url).strip()]
|
||
|
||
|
||
def _media_reference_type_counts(refs: list[HomeMaterialMediaReference]) -> dict[str, int]:
|
||
counts: dict[str, int] = {}
|
||
for ref in refs:
|
||
counts[ref.type] = counts.get(ref.type, 0) + 1
|
||
return counts
|
||
|
||
|
||
def _normalize_watermark_config_dict(value: dict[str, Any] | None, fallback_watermark_id: str | None = None) -> dict[str, Any]:
|
||
"""兼容旧水印配置。旧数据没有 watermark_type 时按 image 处理。"""
|
||
data = dict(value or {})
|
||
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),
|
||
"generation_prompt": asset.generation_prompt,
|
||
"media_references_count": len(_media_references_out(asset.media_references_json)),
|
||
"is_active": asset.is_active,
|
||
"sort_order": asset.sort_order,
|
||
"deleted_at": asset.deleted_at,
|
||
}
|
||
|
||
|
||
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,
|
||
}
|
||
|
||
|
||
|
||
def _log_generation_config_event(
|
||
event_type: str,
|
||
*,
|
||
admin_id: str | None,
|
||
asset_id: str | None = None,
|
||
event_status: str = LogEventStatusEnum.SUCCESS.value,
|
||
message: str | None = None,
|
||
detail: dict[str, Any] | None = None,
|
||
error: str | None = None,
|
||
) -> None:
|
||
log_operation_event(
|
||
domain="home_material",
|
||
module="home_material_asset",
|
||
event_type=event_type,
|
||
event_status=event_status,
|
||
source=LogSourceEnum.API.value,
|
||
user_id=admin_id,
|
||
asset_id=asset_id,
|
||
message=message,
|
||
detail=detail or {},
|
||
error=error,
|
||
)
|
||
|
||
|
||
async def _flush_refresh(db: AsyncSession, obj: Any) -> None:
|
||
"""
|
||
写入后立即返回 ORM 对象前必须显式刷新。
|
||
|
||
本模块模型继承 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,
|
||
generation_prompt: str | None = None,
|
||
media_references_json: str | None = None,
|
||
admin_id: str | None,
|
||
) -> tuple[HomeMaterialUploadResultOut, dict[str, Any]]:
|
||
await self._get_category(db, category_id, active_only=False)
|
||
clean_title = _clean_title(title)
|
||
clean_prompt = _clean_generation_prompt(generation_prompt)
|
||
refs = _media_references_from_json(media_references_json)
|
||
refs_json = _dump_media_references_json(refs)
|
||
_log_generation_config_event(
|
||
HomeMaterialLogEventEnum.GENERATION_CONFIG_VALIDATE_SUCCESS.value,
|
||
admin_id=admin_id,
|
||
detail={
|
||
"generation_prompt_length": len(clean_prompt or ""),
|
||
"media_references_count": len(refs),
|
||
"media_reference_types": _media_reference_type_counts(refs),
|
||
},
|
||
)
|
||
watermark_type = watermark_config.watermark_type
|
||
final_watermark_id: str | None = None
|
||
if watermark_type == HomeMaterialWatermarkType.IMAGE:
|
||
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")),
|
||
generation_prompt=clean_prompt,
|
||
media_references_json=refs_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)
|
||
bind_stats = await self._bind_media_reference_resources(db, asset_id=asset.id, admin_id=admin_id, refs=refs)
|
||
after = _asset_snapshot(asset) or {}
|
||
after["media_reference_bind_stats"] = bind_stats
|
||
return self._upload_result(asset, message="素材已上传,水印处理中"), after
|
||
|
||
async def list_assets(
|
||
self,
|
||
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 {}
|
||
try:
|
||
_log_generation_config_event(
|
||
HomeMaterialLogEventEnum.GENERATION_CONFIG_VALIDATE_STARTED.value,
|
||
admin_id=admin_id,
|
||
asset_id=asset_id,
|
||
event_status=LogEventStatusEnum.STARTED.value,
|
||
)
|
||
clean_prompt = _clean_generation_prompt(req.generation_prompt)
|
||
refs = _normalize_media_references(req.media_references)
|
||
refs_json = _dump_media_references_json(refs)
|
||
_log_generation_config_event(
|
||
HomeMaterialLogEventEnum.GENERATION_CONFIG_VALIDATE_SUCCESS.value,
|
||
admin_id=admin_id,
|
||
asset_id=asset_id,
|
||
detail={
|
||
"generation_prompt_length": len(clean_prompt or ""),
|
||
"media_references_count": len(refs),
|
||
"media_reference_types": _media_reference_type_counts(refs),
|
||
},
|
||
)
|
||
_log_generation_config_event(
|
||
HomeMaterialLogEventEnum.GENERATION_CONFIG_UPDATE_STARTED.value,
|
||
admin_id=admin_id,
|
||
asset_id=asset_id,
|
||
event_status=LogEventStatusEnum.STARTED.value,
|
||
)
|
||
asset.category_id = req.category_id
|
||
asset.title = req.title
|
||
asset.is_active = req.is_active
|
||
asset.sort_order = req.sort_order
|
||
asset.generation_prompt = clean_prompt
|
||
asset.media_references_json = refs_json
|
||
asset.updated_by = admin_id
|
||
db.add(asset)
|
||
await _flush_refresh(db, asset)
|
||
bind_stats = await self._bind_media_reference_resources(db, asset_id=asset.id, admin_id=admin_id, refs=refs)
|
||
after = _asset_snapshot(asset) or {}
|
||
after["media_reference_bind_stats"] = bind_stats
|
||
_log_generation_config_event(
|
||
HomeMaterialLogEventEnum.GENERATION_CONFIG_UPDATE_SUCCESS.value,
|
||
admin_id=admin_id,
|
||
asset_id=asset_id,
|
||
detail={
|
||
"changed_fields": [k for k in ("generation_prompt", "media_references_count") if before.get(k) != after.get(k)],
|
||
"generation_prompt_length": len(clean_prompt or ""),
|
||
"media_references_count": len(refs),
|
||
"media_reference_types": _media_reference_type_counts(refs),
|
||
"bind_stats": bind_stats,
|
||
},
|
||
)
|
||
return await self.get_asset_detail(db, asset_id), before, after
|
||
except Exception as exc:
|
||
_log_generation_config_event(
|
||
HomeMaterialLogEventEnum.GENERATION_CONFIG_UPDATE_FAILED.value,
|
||
admin_id=admin_id,
|
||
asset_id=asset_id,
|
||
event_status=LogEventStatusEnum.FAILED.value,
|
||
detail=build_exception_detail(exc, {"before": before}),
|
||
error=str(exc),
|
||
)
|
||
raise
|
||
|
||
async def prepare_regenerate(
|
||
self,
|
||
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 _bind_media_reference_resources(
|
||
self,
|
||
db: AsyncSession,
|
||
*,
|
||
asset_id: str,
|
||
admin_id: str | None,
|
||
refs: list[HomeMaterialMediaReference],
|
||
) -> dict[str, int]:
|
||
if not admin_id or not refs:
|
||
return {"matched": 0, "bound": 0, "skipped": 0, "conflict": 0}
|
||
return await bind_upload_resources(
|
||
db,
|
||
user_id=admin_id,
|
||
module=UploadResourceModuleEnum.HOME_MATERIAL.value,
|
||
source_model=UploadResourceSourceModelEnum.HOME_MATERIAL_ASSET.value,
|
||
source_id=asset_id,
|
||
resource_ids=_media_reference_resource_ids(refs),
|
||
urls=_media_reference_urls(refs),
|
||
allow_common_migrate=False,
|
||
)
|
||
|
||
async def get_asset_status(self, db: AsyncSession, asset_id: str) -> HomeMaterialAssetStatusOut:
|
||
asset = await self._get_asset(db, asset_id)
|
||
return HomeMaterialAssetStatusOut(
|
||
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),
|
||
generation_prompt=asset.generation_prompt,
|
||
media_references=_media_references_out(asset.media_references_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()
|