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:
yangqianqian
2026-06-18 17:32:49 +08:00
parent 285791c12f
commit 4bed7425a8
41 changed files with 1211 additions and 236 deletions

View File

@@ -0,0 +1,121 @@
"""
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})