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>
122 lines
4.3 KiB
Python
122 lines
4.3 KiB
Python
"""
|
||
app/api/v1/product_images.py — R5 产品多图管理(列/删/改场景/设主图)
|
||
|
||
上传走 products.py 的 /products/{id}/upload-image(带 scene)。
|
||
本文件管已上传图的增删改。product.image_path 始终同步当前主图(向后兼容)。
|
||
"""
|
||
import logging
|
||
from typing import Annotated
|
||
|
||
from fastapi import APIRouter, Depends
|
||
from pydantic import BaseModel
|
||
from sqlalchemy.orm import Session
|
||
|
||
from app.constants.enums import ProductImageScene
|
||
from app.core.database import get_db
|
||
from app.core.response import ok, raise_not_found, raise_business
|
||
from app.middleware.workspace_guard import CurrentUser, require_write_permission
|
||
from app.models.product import Product, ProductImage
|
||
|
||
logger = logging.getLogger(__name__)
|
||
router = APIRouter(tags=["product-images"])
|
||
|
||
|
||
class SceneUpdate(BaseModel):
|
||
scene: str
|
||
|
||
|
||
def _get_product(db: Session, product_id: int, workspace_id: int) -> Product:
|
||
p = db.query(Product).filter(
|
||
Product.id == product_id, Product.workspace_id == workspace_id
|
||
).first()
|
||
if not p:
|
||
raise_not_found("产品不存在")
|
||
return p
|
||
|
||
|
||
def _get_image(db: Session, product_id: int, image_id: int) -> ProductImage:
|
||
img = db.query(ProductImage).filter(
|
||
ProductImage.id == image_id, ProductImage.product_id == product_id
|
||
).first()
|
||
if not img:
|
||
raise_not_found("产品图不存在")
|
||
return img
|
||
|
||
|
||
@router.get("/products/{product_id}/images")
|
||
def list_images(
|
||
product_id: int,
|
||
current_user: Annotated[CurrentUser, Depends(require_write_permission)] = None,
|
||
db: Session = Depends(get_db),
|
||
):
|
||
_get_product(db, product_id, current_user.workspace_id)
|
||
imgs = db.query(ProductImage).filter(
|
||
ProductImage.product_id == product_id
|
||
).order_by(ProductImage.sort_order).all()
|
||
return ok([
|
||
{"id": im.id, "path": im.path, "scene": im.scene,
|
||
"is_primary": im.is_primary, "sort_order": im.sort_order}
|
||
for im in imgs
|
||
])
|
||
|
||
|
||
@router.put("/products/{product_id}/images/{image_id}/scene")
|
||
def update_scene(
|
||
product_id: int, image_id: int, body: SceneUpdate,
|
||
current_user: Annotated[CurrentUser, Depends(require_write_permission)] = None,
|
||
db: Session = Depends(get_db),
|
||
):
|
||
_get_product(db, product_id, current_user.workspace_id)
|
||
if body.scene not in {s.value for s in ProductImageScene}:
|
||
raise_business(f"非法场景类型 {body.scene}")
|
||
img = _get_image(db, product_id, image_id)
|
||
img.scene = body.scene
|
||
db.commit()
|
||
logger.info("product image scene updated: id=%s scene=%s", image_id, body.scene)
|
||
return ok({"id": image_id, "scene": body.scene})
|
||
|
||
|
||
@router.put("/products/{product_id}/images/{image_id}/primary")
|
||
def set_primary(
|
||
product_id: int, image_id: int,
|
||
current_user: Annotated[CurrentUser, Depends(require_write_permission)] = None,
|
||
db: Session = Depends(get_db),
|
||
):
|
||
p = _get_product(db, product_id, current_user.workspace_id)
|
||
img = _get_image(db, product_id, image_id)
|
||
db.query(ProductImage).filter(
|
||
ProductImage.product_id == product_id, ProductImage.is_primary == True
|
||
).update({"is_primary": False})
|
||
img.is_primary = True
|
||
p.image_path = img.path # 同步主图字段(管道/校验读此字段)
|
||
db.commit()
|
||
logger.info("product primary image set: product_id=%s image_id=%s", product_id, image_id)
|
||
return ok({"product_id": product_id, "primary_image_id": image_id})
|
||
|
||
|
||
@router.delete("/products/{product_id}/images/{image_id}")
|
||
def delete_image(
|
||
product_id: int, image_id: int,
|
||
current_user: Annotated[CurrentUser, Depends(require_write_permission)] = None,
|
||
db: Session = Depends(get_db),
|
||
):
|
||
p = _get_product(db, product_id, current_user.workspace_id)
|
||
img = _get_image(db, product_id, image_id)
|
||
was_primary = img.is_primary
|
||
db.delete(img)
|
||
db.flush()
|
||
# 删的是主图:把剩余 sort_order 最小的一张顶上来当主图,保证 image_path 不悬空
|
||
if was_primary:
|
||
nxt = db.query(ProductImage).filter(
|
||
ProductImage.product_id == product_id
|
||
).order_by(ProductImage.sort_order).first()
|
||
if nxt:
|
||
nxt.is_primary = True
|
||
p.image_path = nxt.path
|
||
else:
|
||
p.image_path = None
|
||
db.commit()
|
||
logger.info("product image deleted: product_id=%s image_id=%s was_primary=%s",
|
||
product_id, image_id, was_primary)
|
||
return ok({"deleted": image_id})
|