管理后台上传集成API | 首页素材装修追加提词/附件

This commit is contained in:
2026-07-09 10:40:23 +08:00
parent 465ed9fa04
commit 0148bea66a
22 changed files with 1296 additions and 63 deletions
@@ -16,6 +16,7 @@ from app.enums.home_material import (
from app.models.home_material import HomeMaterialAsset, HomeMaterialCategory, HomeMaterialWatermark
from app.schemas.home_material import (
HomeMaterialAssetOut,
HomeMaterialMediaReference,
HomeMaterialCategoryOut,
HomeMaterialPublicAssetOut,
HomeMaterialPublicCategoryGroupOut,
@@ -39,6 +40,24 @@ def _load_json(value: str | None) -> dict[str, Any] | None:
return None
def _load_media_references(value: str | None) -> list[HomeMaterialMediaReference]:
if not value:
return []
try:
data = json.loads(value)
if not isinstance(data, list):
return []
refs: list[HomeMaterialMediaReference] = []
for item in data:
try:
refs.append(HomeMaterialMediaReference(**item))
except Exception:
continue
return refs
except Exception:
return []
class HomeMaterialQueryService:
"""首页素材高性能查询组装层:列表查询 → ID 去重 → 批量查询 → map 组装。"""
@@ -152,6 +171,8 @@ class HomeMaterialQueryService:
watermark_id=asset.watermark_id,
watermark_name=watermark.name if watermark else None,
watermark_config=_load_json(asset.watermark_config_json),
generation_prompt=asset.generation_prompt,
media_references=_load_media_references(asset.media_references_json),
width=asset.width,
height=asset.height,
duration_seconds=asset.duration_seconds,
@@ -340,6 +361,8 @@ class HomeMaterialQueryService:
width=a.width,
height=a.height,
duration_seconds=a.duration_seconds,
generation_prompt=a.generation_prompt,
media_references=_load_media_references(a.media_references_json),
sort_order=a.sort_order,
)
for a in assets
@@ -403,6 +426,8 @@ class HomeMaterialQueryService:
width=asset.width,
height=asset.height,
duration_seconds=asset.duration_seconds,
generation_prompt=asset.generation_prompt,
media_references=_load_media_references(asset.media_references_json),
sort_order=asset.sort_order,
)
)
@@ -12,14 +12,20 @@ from sqlalchemy.ext.asyncio import AsyncSession
from app.config import settings
from app.enums.home_material import (
HOME_MATERIAL_DEFAULT_CONFIG,
HOME_MATERIAL_GENERATION_PROMPT_MAX_LEN,
HOME_MATERIAL_MEDIA_REFERENCES_JSON_MAX_LEN,
HOME_MATERIAL_MEDIA_REFERENCES_MAX_COUNT,
HomeMaterialAssetStatus,
HomeMaterialConfigKeyEnum,
HomeMaterialLogEventEnum,
HomeMaterialMediaType,
HomeMaterialPublicResponseMode,
HomeMaterialWatermarkPosition,
HomeMaterialWatermarkSizeMode,
HomeMaterialWatermarkType,
)
from app.enums.common import LogEventStatusEnum, LogSourceEnum
from app.enums.upload_resource import UploadResourceModuleEnum, UploadResourceSourceModelEnum
from app.models.base import async_session
from app.models.home_material import HomeMaterialAsset, HomeMaterialCategory, HomeMaterialWatermark
from app.models.system_config import SystemConfig
@@ -36,6 +42,7 @@ from app.schemas.home_material import (
HomeMaterialConfigUpdate,
HomeMaterialPublicCategoryListOut,
HomeMaterialPublicFlatOut,
HomeMaterialMediaReference,
HomeMaterialPublicGroupedOut,
HomeMaterialRegenerateWatermarkRequest,
HomeMaterialTextWatermarkPreviewRequest,
@@ -49,6 +56,8 @@ from app.schemas.home_material import (
from app.services.home_material.query import query_service
from app.services.home_material.storage import storage_service
from app.services.home_material.watermark_processor import watermark_processor
from app.services.operation_log_service import build_exception_detail, log_operation_event
from app.services.upload_resource.bind_service import bind_upload_resources
from app.utils.id_gen import generate_id
@@ -66,11 +75,99 @@ def _json_loads(value: str | None) -> dict[str, Any] | None:
return None
def _json_loads_list(value: str | None) -> list[Any]:
if not value:
return []
try:
data = json.loads(value)
return data if isinstance(data, list) else []
except Exception as exc:
log_operation_event(
domain="home_material",
module="home_material_asset",
event_type=HomeMaterialLogEventEnum.MEDIA_REFERENCES_PARSE_FAILED.value,
event_status=LogEventStatusEnum.WARNING.value,
source=LogSourceEnum.SERVICE.value,
detail={"raw_prefix": value[:500]},
error=str(exc),
)
return []
def _clean_title(value: str | None) -> str | None:
value = (value or "").strip()
return value or None
def _clean_generation_prompt(value: str | None) -> str | None:
text = (value or "").strip()
if not text:
return None
if len(text) > HOME_MATERIAL_GENERATION_PROMPT_MAX_LEN:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=f"生成提词不能超过 {HOME_MATERIAL_GENERATION_PROMPT_MAX_LEN} 个字符")
return text
def _media_references_from_json(value: str | None) -> list[HomeMaterialMediaReference]:
if not value or not value.strip():
return []
if len(value) > HOME_MATERIAL_MEDIA_REFERENCES_JSON_MAX_LEN:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=f"附件JSON不能超过 {HOME_MATERIAL_MEDIA_REFERENCES_JSON_MAX_LEN} 个字符")
try:
raw = json.loads(value)
except Exception as exc:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="附件JSON格式错误") from exc
if raw is None:
return []
if not isinstance(raw, list):
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="附件必须是数组格式")
return _normalize_media_references(raw)
def _normalize_media_references(value: list[Any] | None) -> list[HomeMaterialMediaReference]:
if not value:
return []
if len(value) > HOME_MATERIAL_MEDIA_REFERENCES_MAX_COUNT:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=f"附件最多 {HOME_MATERIAL_MEDIA_REFERENCES_MAX_COUNT}")
refs: list[HomeMaterialMediaReference] = []
for item in value:
try:
ref = item if isinstance(item, HomeMaterialMediaReference) else HomeMaterialMediaReference(**item)
except Exception as exc:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=f"附件格式错误:{exc}") from exc
refs.append(ref)
return refs
def _dump_media_references_json(refs: list[HomeMaterialMediaReference] | None) -> str | None:
items = [r.model_dump(mode="json", exclude_none=True) for r in (refs or [])]
if not items:
return None
text = _json_dumps(items)
if len(text) > HOME_MATERIAL_MEDIA_REFERENCES_JSON_MAX_LEN:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=f"附件JSON不能超过 {HOME_MATERIAL_MEDIA_REFERENCES_JSON_MAX_LEN} 个字符")
return text
def _media_references_out(value: str | None) -> list[HomeMaterialMediaReference]:
return _normalize_media_references(_json_loads_list(value))
def _media_reference_resource_ids(refs: list[HomeMaterialMediaReference]) -> list[str]:
return [str(r.upload_resource_id).strip() for r in refs if r.upload_resource_id and str(r.upload_resource_id).strip()]
def _media_reference_urls(refs: list[HomeMaterialMediaReference]) -> list[str]:
return [str(r.url).strip() for r in refs if r.url and str(r.url).strip()]
def _media_reference_type_counts(refs: list[HomeMaterialMediaReference]) -> dict[str, int]:
counts: dict[str, int] = {}
for ref in refs:
counts[ref.type] = counts.get(ref.type, 0) + 1
return counts
def _normalize_watermark_config_dict(value: dict[str, Any] | None, fallback_watermark_id: str | None = None) -> dict[str, Any]:
"""兼容旧水印配置。旧数据没有 watermark_type 时按 image 处理。"""
data = dict(value or {})
@@ -108,6 +205,8 @@ def _asset_snapshot(asset: HomeMaterialAsset | None) -> dict[str, Any] | None:
"cover_url": asset.cover_url,
"watermark_id": asset.watermark_id,
"watermark_config": _json_loads(asset.watermark_config_json),
"generation_prompt": asset.generation_prompt,
"media_references_count": len(_media_references_out(asset.media_references_json)),
"is_active": asset.is_active,
"sort_order": asset.sort_order,
"deleted_at": asset.deleted_at,
@@ -142,6 +241,31 @@ def _watermark_snapshot(watermark: HomeMaterialWatermark | None) -> dict[str, An
}
def _log_generation_config_event(
event_type: str,
*,
admin_id: str | None,
asset_id: str | None = None,
event_status: str = LogEventStatusEnum.SUCCESS.value,
message: str | None = None,
detail: dict[str, Any] | None = None,
error: str | None = None,
) -> None:
log_operation_event(
domain="home_material",
module="home_material_asset",
event_type=event_type,
event_status=event_status,
source=LogSourceEnum.API.value,
user_id=admin_id,
asset_id=asset_id,
message=message,
detail=detail or {},
error=error,
)
async def _flush_refresh(db: AsyncSession, obj: Any) -> None:
"""
写入后立即返回 ORM 对象前必须显式刷新。
@@ -372,10 +496,24 @@ class HomeMaterialService:
watermark_config: HomeMaterialWatermarkConfig,
is_active: bool,
sort_order: int,
generation_prompt: str | None = None,
media_references_json: str | None = None,
admin_id: str | None,
) -> tuple[HomeMaterialUploadResultOut, dict[str, Any]]:
await self._get_category(db, category_id, active_only=False)
clean_title = _clean_title(title)
clean_prompt = _clean_generation_prompt(generation_prompt)
refs = _media_references_from_json(media_references_json)
refs_json = _dump_media_references_json(refs)
_log_generation_config_event(
HomeMaterialLogEventEnum.GENERATION_CONFIG_VALIDATE_SUCCESS.value,
admin_id=admin_id,
detail={
"generation_prompt_length": len(clean_prompt or ""),
"media_references_count": len(refs),
"media_reference_types": _media_reference_type_counts(refs),
},
)
watermark_type = watermark_config.watermark_type
final_watermark_id: str | None = None
if watermark_type == HomeMaterialWatermarkType.IMAGE:
@@ -415,6 +553,8 @@ class HomeMaterialService:
original_storage_path=stored.storage_path,
watermark_id=final_watermark_id,
watermark_config_json=_json_dumps(cfg.model_dump(mode="json")),
generation_prompt=clean_prompt,
media_references_json=refs_json,
status=HomeMaterialAssetStatus.PROCESSING.value,
width=probe.width,
height=probe.height,
@@ -427,7 +567,10 @@ class HomeMaterialService:
)
db.add(asset)
await _flush_refresh(db, asset)
return self._upload_result(asset, message="素材已上传,水印处理中"), _asset_snapshot(asset) or {}
bind_stats = await self._bind_media_reference_resources(db, asset_id=asset.id, admin_id=admin_id, refs=refs)
after = _asset_snapshot(asset) or {}
after["media_reference_bind_stats"] = bind_stats
return self._upload_result(asset, message="素材已上传,水印处理中"), after
async def list_assets(
self,
@@ -466,15 +609,67 @@ class HomeMaterialService:
asset = await self._get_asset(db, asset_id)
await self._get_category(db, req.category_id)
before = _asset_snapshot(asset) or {}
asset.category_id = req.category_id
asset.title = req.title
asset.is_active = req.is_active
asset.sort_order = req.sort_order
asset.updated_by = admin_id
db.add(asset)
await _flush_refresh(db, asset)
after = _asset_snapshot(asset) or {}
return await self.get_asset_detail(db, asset_id), before, after
try:
_log_generation_config_event(
HomeMaterialLogEventEnum.GENERATION_CONFIG_VALIDATE_STARTED.value,
admin_id=admin_id,
asset_id=asset_id,
event_status=LogEventStatusEnum.STARTED.value,
)
clean_prompt = _clean_generation_prompt(req.generation_prompt)
refs = _normalize_media_references(req.media_references)
refs_json = _dump_media_references_json(refs)
_log_generation_config_event(
HomeMaterialLogEventEnum.GENERATION_CONFIG_VALIDATE_SUCCESS.value,
admin_id=admin_id,
asset_id=asset_id,
detail={
"generation_prompt_length": len(clean_prompt or ""),
"media_references_count": len(refs),
"media_reference_types": _media_reference_type_counts(refs),
},
)
_log_generation_config_event(
HomeMaterialLogEventEnum.GENERATION_CONFIG_UPDATE_STARTED.value,
admin_id=admin_id,
asset_id=asset_id,
event_status=LogEventStatusEnum.STARTED.value,
)
asset.category_id = req.category_id
asset.title = req.title
asset.is_active = req.is_active
asset.sort_order = req.sort_order
asset.generation_prompt = clean_prompt
asset.media_references_json = refs_json
asset.updated_by = admin_id
db.add(asset)
await _flush_refresh(db, asset)
bind_stats = await self._bind_media_reference_resources(db, asset_id=asset.id, admin_id=admin_id, refs=refs)
after = _asset_snapshot(asset) or {}
after["media_reference_bind_stats"] = bind_stats
_log_generation_config_event(
HomeMaterialLogEventEnum.GENERATION_CONFIG_UPDATE_SUCCESS.value,
admin_id=admin_id,
asset_id=asset_id,
detail={
"changed_fields": [k for k in ("generation_prompt", "media_references_count") if before.get(k) != after.get(k)],
"generation_prompt_length": len(clean_prompt or ""),
"media_references_count": len(refs),
"media_reference_types": _media_reference_type_counts(refs),
"bind_stats": bind_stats,
},
)
return await self.get_asset_detail(db, asset_id), before, after
except Exception as exc:
_log_generation_config_event(
HomeMaterialLogEventEnum.GENERATION_CONFIG_UPDATE_FAILED.value,
admin_id=admin_id,
asset_id=asset_id,
event_status=LogEventStatusEnum.FAILED.value,
detail=build_exception_detail(exc, {"before": before}),
error=str(exc),
)
raise
async def prepare_regenerate(
self,
@@ -509,6 +704,28 @@ class HomeMaterialService:
db.add(asset)
return asset, before
async def _bind_media_reference_resources(
self,
db: AsyncSession,
*,
asset_id: str,
admin_id: str | None,
refs: list[HomeMaterialMediaReference],
) -> dict[str, int]:
if not admin_id or not refs:
return {"matched": 0, "bound": 0, "skipped": 0, "conflict": 0}
return await bind_upload_resources(
db,
user_id=admin_id,
module=UploadResourceModuleEnum.HOME_MATERIAL.value,
source_model=UploadResourceSourceModelEnum.HOME_MATERIAL_ASSET.value,
source_id=asset_id,
resource_ids=_media_reference_resource_ids(refs),
urls=_media_reference_urls(refs),
allow_common_migrate=False,
)
async def get_asset_status(self, db: AsyncSession, asset_id: str) -> HomeMaterialAssetStatusOut:
asset = await self._get_asset(db, asset_id)
return HomeMaterialAssetStatusOut(
@@ -541,6 +758,8 @@ class HomeMaterialService:
watermarked_url=asset.watermarked_url,
cover_url=asset.cover_url,
watermark_config=_json_loads(asset.watermark_config_json),
generation_prompt=asset.generation_prompt,
media_references=_media_references_out(asset.media_references_json),
message=message,
)