A8多套打包+M4归档+R5多图:存量功能备份
A8 多套交付包(packaging_task.py): - 修复交付包只打第1条混乱note的bug,按ImageCandidate.strategy分A/B/C组 - 每组生独立note_0N夹(6图+文案.txt),同seq留最新去重,老数据兼容 - task74端到端验:3套各6图,独立agent7项交叉验证全过 M4 归档(tasks.py/exports.py/前端): - list_tasks加date_from/date_to/product_id筛选+product_name批量填(防N+1) - 新增exports.py:产品JSON导出+标杆CSV导出(UTF-8 BOM) - 前端HistoryFilters日期/产品筛选+产品列+打回原因红banner - response.py加raise_param_error;独立agent验A1/A2/A9通过 R5 产品多图(product_images.py/020迁移/前端): - product_images表+5端点(上传/列/改场景/设主图/删图) - 生图按ROLE_SCENE_PREFERENCE选对应场景图,回落primary - 前端ProductImageManager多图画廊 R6 账号config拆页(settings/): - 配置页按角色拆/settings(运营+组长+admin)+/config(仅admin) - Key只显末4位不显余额(守红线) 核销表对齐真实代码状态:D1改稿框/M7裂变/E12评图分纠偏为已完成(曾漏回写) 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -7,14 +7,14 @@ category 是纯数据字段,不在代码里做枚举(基石A)。
|
||||
import logging
|
||||
from typing import Annotated
|
||||
|
||||
from fastapi import APIRouter, Depends, UploadFile, File
|
||||
from fastapi import APIRouter, Depends, UploadFile, File, Form
|
||||
from pydantic import BaseModel
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.core.database import get_db
|
||||
from app.core.response import ok, paginate, raise_not_found
|
||||
from app.middleware.workspace_guard import CurrentUser, require_admin, require_write_permission
|
||||
from app.models.product import BannedWord, BenchmarkNote, Product
|
||||
from app.models.product import BannedWord, BenchmarkNote, Product, ProductImage
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
router = APIRouter(tags=["products"])
|
||||
@@ -50,6 +50,7 @@ class BannedWordCreate(BaseModel):
|
||||
|
||||
def _fmt_product(p: Product) -> dict:
|
||||
import json
|
||||
imgs = sorted(getattr(p, "images", None) or [], key=lambda im: im.sort_order)
|
||||
return {
|
||||
"id": p.id, "name": p.name, "category": p.category,
|
||||
"source": p.source, "is_active": p.is_active,
|
||||
@@ -58,6 +59,11 @@ def _fmt_product(p: Product) -> dict:
|
||||
"text_angles": json.loads(p.text_angles) if p.text_angles else [],
|
||||
"custom_prompt": p.custom_prompt,
|
||||
"image_path": p.image_path,
|
||||
"images": [
|
||||
{"id": im.id, "path": im.path, "scene": im.scene,
|
||||
"is_primary": im.is_primary, "sort_order": im.sort_order}
|
||||
for im in imgs
|
||||
],
|
||||
"brand_keyword": p.brand_keyword, # 012: 套2字段暴露
|
||||
"target_audience": p.target_audience, # 012: 套2字段暴露
|
||||
"created_at": p.created_at.isoformat(),
|
||||
@@ -82,7 +88,7 @@ def list_products(
|
||||
@router.post("/products")
|
||||
def create_product(
|
||||
body: ProductCreate,
|
||||
current_user: Annotated[CurrentUser, Depends(require_admin)] = None,
|
||||
current_user: Annotated[CurrentUser, Depends(require_write_permission)] = None,
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
import json
|
||||
@@ -118,7 +124,7 @@ def get_product(
|
||||
@router.put("/products/{product_id}")
|
||||
def update_product(
|
||||
product_id: int, body: ProductCreate,
|
||||
current_user: Annotated[CurrentUser, Depends(require_admin)] = None,
|
||||
current_user: Annotated[CurrentUser, Depends(require_write_permission)] = None,
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
import json
|
||||
@@ -155,10 +161,28 @@ _ALLOWED_CONTENT_TYPES = {"image/jpeg", "image/png", "image/webp"}
|
||||
_MAX_SIZE_BYTES = 10 * 1024 * 1024 # 10 MB
|
||||
|
||||
|
||||
def _check_image_magic(data: bytes) -> bool:
|
||||
"""
|
||||
校验文件头 magic number,防 .exe 改名 .jpg 伪造 content_type 绕过。
|
||||
content_type 来自客户端可伪造,magic number 是真实字节,二者都要过。
|
||||
JPEG: FF D8 FF / PNG: 89 50 4E 47 0D 0A 1A 0A / WebP: RIFF....WEBP
|
||||
"""
|
||||
if len(data) < 12:
|
||||
return False
|
||||
if data[:3] == b"\xff\xd8\xff": # JPEG
|
||||
return True
|
||||
if data[:8] == b"\x89PNG\r\n\x1a\n": # PNG
|
||||
return True
|
||||
if data[:4] == b"RIFF" and data[8:12] == b"WEBP": # WebP
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
@router.post("/products/{product_id}/upload-image")
|
||||
async def upload_product_image(
|
||||
product_id: int,
|
||||
file: UploadFile = File(...),
|
||||
scene: str = Form("primary"),
|
||||
current_user: Annotated[CurrentUser, Depends(require_write_permission)] = None,
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
@@ -166,10 +190,12 @@ async def upload_product_image(
|
||||
上传产品参考图(铁律:生图必须带产品图)。
|
||||
文件类型:JPEG/PNG/WebP;大小上限 10 MB。
|
||||
存储路径:uploads/products/{workspace_id}/{product_id}/{filename}
|
||||
写回 product.image_path,生图管道读此字段构建 reference_images。
|
||||
R5多图:每次上传落一条 product_images(带 scene 场景类型)。
|
||||
首张或 scene=primary 时同步写 product.image_path 当主图(向后兼容)。
|
||||
"""
|
||||
from app.core.response import raise_business
|
||||
from app.core.config import get_settings
|
||||
from app.constants.enums import ProductImageScene
|
||||
import os, uuid
|
||||
|
||||
p = db.query(Product).filter(
|
||||
@@ -179,12 +205,18 @@ async def upload_product_image(
|
||||
if not p:
|
||||
raise_not_found("产品不存在")
|
||||
|
||||
valid_scenes = {s.value for s in ProductImageScene}
|
||||
if scene not in valid_scenes:
|
||||
raise_business(f"非法场景类型 {scene},仅支持 {sorted(valid_scenes)}")
|
||||
|
||||
if file.content_type not in _ALLOWED_CONTENT_TYPES:
|
||||
raise_business(f"不支持的文件类型 {file.content_type},仅支持 JPEG/PNG/WebP")
|
||||
|
||||
data = await file.read()
|
||||
if len(data) > _MAX_SIZE_BYTES:
|
||||
raise_business("文件超过 10 MB 限制")
|
||||
if not _check_image_magic(data):
|
||||
raise_business("文件内容与扩展名不符(非真实 JPEG/PNG/WebP 图片)")
|
||||
|
||||
settings = get_settings()
|
||||
ext = os.path.splitext(file.filename or "img.jpg")[1] or ".jpg"
|
||||
@@ -197,10 +229,29 @@ async def upload_product_image(
|
||||
with open(save_path, "wb") as f:
|
||||
f.write(data)
|
||||
|
||||
p.image_path = save_path # 绝对路径,worker 直接 open 可读
|
||||
# 是否首张:决定要不要兜底设主图
|
||||
existing = db.query(ProductImage).filter(
|
||||
ProductImage.product_id == product_id
|
||||
).count()
|
||||
is_primary = (existing == 0) or (scene == ProductImageScene.PRIMARY.value)
|
||||
if is_primary:
|
||||
# 同产品其它主图标记降级,保证唯一主图
|
||||
db.query(ProductImage).filter(
|
||||
ProductImage.product_id == product_id,
|
||||
ProductImage.is_primary == True,
|
||||
).update({"is_primary": False})
|
||||
|
||||
img = ProductImage(
|
||||
product_id=product_id, path=save_path, scene=scene,
|
||||
is_primary=is_primary, sort_order=existing,
|
||||
)
|
||||
db.add(img)
|
||||
if is_primary:
|
||||
p.image_path = save_path # 向后兼容:管道/校验仍读此字段当主图
|
||||
db.commit()
|
||||
db.refresh(p)
|
||||
logger.info("product image uploaded: product_id=%s path=%s", product_id, save_path)
|
||||
logger.info("product image uploaded: product_id=%s scene=%s primary=%s path=%s",
|
||||
product_id, scene, is_primary, save_path)
|
||||
return ok(_fmt_product(p))
|
||||
|
||||
|
||||
@@ -273,6 +324,8 @@ async def analyze_product_image(
|
||||
data = await file.read()
|
||||
if len(data) > _MAX_SIZE_BYTES:
|
||||
raise_business("文件超过 10 MB 限制")
|
||||
if not _check_image_magic(data):
|
||||
raise_business("文件内容与扩展名不符(非真实 JPEG/PNG/WebP 图片)")
|
||||
|
||||
# key 解密(照抄 pipeline_steps.decrypt_user_key,基石B)
|
||||
api_key_row = db.query(UserApiKey).filter(
|
||||
|
||||
Reference in New Issue
Block a user