fix(product): 删除引用保护/单价小数位校验,IntegrityError 全局兜底转 400

- delete_product 补 count_refs 引用检查(业务明细+发货明细 RESTRICT
  外键)与并发窗口 IntegrityError→400 兜底(customer 先例同构),
  docstring 与两个 api 层过时注释同步更新
- 产品 schema 复用 schemas/business._check_amount:sale_price 走
  (PRICE_MAX, scale=4)、stock_quantity 走 AMOUNT_MAX——此前私写
  _parse_non_negative 不查小数位与容量,5 位小数被 Numeric(14,4)
  静默截断(舍入即算错钱);新增 6 个秒级 schema 单测锁定口径
- 全局注册 IntegrityError handler:漏补引用保护的删除类操作收敛为
  400 通用文案(e2e 撞见一个补一个的模式无法保证全覆盖),完整
  异常记日志;rating/customer 的「非 1062 按 500」注释同步更正

Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
liuwenxin 2026-10-14 18:48:32 +08:00
parent 9a70264b51
commit 91fc6f6293
11 changed files with 243 additions and 40 deletions

View File

@ -96,7 +96,7 @@ def delete_customer(
) -> ApiResponse[None]:
"""删除客户(物理删除)。
被业务数据引用的保护检查待业务数据表(V5)就位后补充。
已存在关联业务时返回 400(先处理相关业务单据)。
"""
customer_service.delete_customer(session, customer_id, operator=current_user, meta=meta)
return ok(message="客户已删除")

View File

@ -91,7 +91,7 @@ def delete_product(
) -> ApiResponse[None]:
"""物理删除产品(仅限误录数据,常规下架用停用)。
被业务明细引用的保护检查待业务数据表(V5)就位后补充。
被业务明细/发货明细引用时返回 400(引导改走停用)。
"""
product_service.delete_product(session, product_id, operator=current_user, meta=meta)
return ok(message="产品已删除")

View File

@ -12,6 +12,7 @@ import logging
from fastapi import FastAPI, Request
from fastapi.exceptions import RequestValidationError
from fastapi.responses import JSONResponse
from sqlalchemy.exc import IntegrityError
from starlette.exceptions import HTTPException as StarletteHTTPException
from app.core.constants import CODE_BAD_REQUEST, CODE_SERVER_ERROR
@ -60,6 +61,19 @@ async def handle_http_exception(_: Request, exc: StarletteHTTPException) -> JSON
return _build_response(exc.status_code, message)
async def handle_integrity_error(_: Request, exc: IntegrityError) -> JSONResponse:
"""完整性错误全局兜底为 400。
service 层已判别的 1062/引用冲突会自行翻译为业务提示,不会走到这里;
本 handler 拦截的是「新增父表后某 service 漏补引用保护」一类的遗漏
(e2e 撞见一个补一个的模式无法保证全覆盖):FK RESTRICT 等数据完整性
冲突对用户表现为「这行数据被别处引用/冲突」,转 400 比 500 更贴近事实。
完整异常记录日志供排查;不在此处 rollback —— 会话由请求级依赖统一关闭。
"""
logger.error("数据库完整性冲突(未在 service 层翻译)", exc_info=exc)
return _build_response(CODE_BAD_REQUEST, "数据完整性冲突,操作被拒绝")
async def handle_unexpected_exception(_: Request, exc: Exception) -> JSONResponse:
"""兜底处理未预期异常。
@ -77,4 +91,5 @@ def register_exception_handlers(app: FastAPI) -> None:
app.add_exception_handler(AppException, handle_app_exception) # type: ignore[arg-type]
app.add_exception_handler(RequestValidationError, handle_validation_error) # type: ignore[arg-type]
app.add_exception_handler(StarletteHTTPException, handle_http_exception) # type: ignore[arg-type]
app.add_exception_handler(IntegrityError, handle_integrity_error) # type: ignore[arg-type]
app.add_exception_handler(Exception, handle_unexpected_exception)

View File

@ -6,7 +6,7 @@
from sqlalchemy import Select, func, or_, select
from sqlalchemy.orm import Session
from app.models import Product
from app.models import BusinessItem, Product, ShipmentItem
# ==================== 读取 ====================
@ -100,6 +100,25 @@ def _apply_filters(
return stmt
def count_refs(session: Session, product_id: int) -> int:
"""统计引用该产品的业务明细与发货明细行数(删除保护,business_id 先例)。
两张明细表对 products 均 RESTRICT 外键:先查给出可读的 400;校验与
删除之间的并发引用由锁行 + FK 兜底(service 层再翻译 IntegrityError)。
"""
stmt = select(
select(func.count())
.select_from(BusinessItem)
.where(BusinessItem.product_id == product_id)
.scalar_subquery()
+ select(func.count())
.select_from(ShipmentItem)
.where(ShipmentItem.product_id == product_id)
.scalar_subquery()
)
return int(session.execute(stmt).scalar_one())
# ==================== 写入 ====================
@ -111,6 +130,6 @@ def create(session: Session, product: Product) -> Product:
def delete(session: Session, product: Product) -> None:
"""物理删除产品。调用方须先完成引用保护校验(V5 就位后生效)。"""
"""物理删除产品。调用方须先完成引用保护校验(count_refs + FK 兜底)。"""
session.delete(product)
session.flush()

View File

@ -7,29 +7,11 @@ Decimal 转换统一收敛在 service 层。字段命名与 user/role/param 一
"""
from datetime import datetime
from decimal import Decimal, InvalidOperation
from pydantic import BaseModel, ConfigDict, Field, field_validator
from app.core.response import PageQuery
def _parse_non_negative(value: str, *, label: str) -> str:
"""非负数字字符串校验:必须可被 Decimal 解析、为有限数且 ≥ 0。
``NaN`` 参与比较会抛 InvalidOperation、``Infinity`` 会在后续运算溢出,
均须在入口用 ``is_finite()`` 拦下(与 schemas/param.py 的 _parse_amount
同一口径)。
"""
try:
amount = Decimal(value.strip())
except InvalidOperation as exc:
raise ValueError(f"{label}必须是有效数字:{value}") from exc
if not amount.is_finite():
raise ValueError(f"{label}必须为有限数字:{value}")
if amount < 0:
raise ValueError(f"{label}不得为负数:{value}")
return value
from app.schemas.business import AMOUNT_MAX, PRICE_MAX, _check_amount
class ProductOut(BaseModel):
@ -70,18 +52,27 @@ class ProductCreateIn(BaseModel):
@field_validator("stock_quantity")
@classmethod
def _check_stock_quantity(cls, value: str | None) -> str | None:
"""空串与 None 同义(可空列),归一为 None;非空则须为非负数字。"""
"""空串与 None 同义(可空列),归一为 None;非空走通用金额校验。
复用 _check_amount:容量/小数位与 DECIMAL(14,2) 列口径一致
(此前私写的 _parse_non_negative 不查小数位与容量,属同语义
字段两处口径漂移)。
"""
if not value:
return None
return _parse_non_negative(value, label="数量")
return _check_amount(value, "数量", AMOUNT_MAX)
@field_validator("sale_price")
@classmethod
def _check_sale_price(cls, value: str | None) -> str | None:
"""空串与 None 同义(可空列),归一为 None;非空则须为非负数字。"""
"""空串与 None 同义(可空列),归一为 None;非空走通用金额校验。
scale=4 与列口径 Numeric(14,4) 一致 —— 带精度的历史单价必须能
原样提交(「编辑任何带精度历史行都失败」的 fba8cfc 教训)。
"""
if not value:
return None
return _parse_non_negative(value, label="销售单价")
return _check_amount(value, "销售单价", PRICE_MAX, scale=4)
class ProductUpdateIn(ProductCreateIn):

View File

@ -380,16 +380,17 @@ def update_business(
):
if row is None:
raise NotFoundError("业务不存在")
business, company_name, owner_name, assistant_name = row
business, _, owner_name, assistant_name = row
# 终态门槛与发货/回款写准入同口径:明细与汇总是 M3 核算基线,
# 已完成/已终止的业务禁改(发货/回款服务以同一理由禁写终态业务)
if business.status != BUSINESS_STATUS_ACTIVE:
raise BadRequestError("业务已完成或已终止,禁止编辑业务")
if payload.customer_id != business.customer_id:
if business_repository.has_docs(session, business_id):
raise BadRequestError("业务已有发货或回款记录,不能更换客户")
if payload.customer_id != business.customer_id and business_repository.has_docs(
session, business_id
):
raise BadRequestError("业务已有发货或回款记录,不能更换客户")
customer = _ensure_customer(session, payload.customer_id)
if payload.owner_personnel_id is not None:
_ensure_personnel_active(session, payload.owner_personnel_id, "负责")

View File

@ -113,7 +113,7 @@ def _is_duplicate_entry(exc: IntegrityError) -> bool:
customers 上还有归属人员外键(RESTRICT),并发窗口内删除人员等场景
会抛 1452 —— 不能一并翻译成「已存在」,否则用户会被错误提示带偏;
非 1062 原样上抛,由全局处理器按 500 收敛。
非 1062 原样上抛,由全局 IntegrityError handler 收敛 400(通用文案)。
"""
return is_duplicate_entry(exc)

View File

@ -278,13 +278,14 @@ def delete_product(
operator: User,
meta: RequestMeta | None = None,
) -> None:
"""物理删除产品。
"""物理删除产品(仅限误录数据,常规下架用停用)。
**被业务明细(发货/回款)引用的保护检查待业务数据表(V5)就位后补充**;
当前先以 ``FOR UPDATE`` 锁行删除 —— V5 接入后,校验与删除之间的并发
引用会因锁可见地失败,而非引用被静默悬空。
被业务明细/发货明细引用(RESTRICT 外键)的产品禁删:先 count_refs
给出可读 400(引导改走停用),校验与删除之间的并发引用由锁行 +
IntegrityError 兜底(customer_service.delete_customer 同款结构)。
:raises NotFoundError: 产品不存在
:raises BadRequestError: 产品已被业务或发货明细引用
"""
product = product_repository.get_by_id(session, product_id, for_update=True)
with audit_service.audit(
@ -299,8 +300,15 @@ def delete_product(
):
if product is None:
raise NotFoundError("产品不存在")
if product_repository.count_refs(session, product_id):
raise BadRequestError("产品已被业务或发货明细引用,无法删除(可改为停用)")
product_name = product.product_name # 固化:对象随删除失效
product_repository.delete(session, product)
session.commit()
try:
product_repository.delete(session, product)
session.commit()
except IntegrityError as exc:
# 先查后删的并发窗口:新明细挂上了该产品,FK RESTRICT 兜底为 400
session.rollback()
raise BadRequestError("产品已被业务或发货明细引用,无法删除(可改为停用)") from exc
logger.info("删除产品 | product_name=%s", product_name)

View File

@ -105,8 +105,9 @@ def _ensure_no_overlap(
def _is_duplicate_entry(exc: IntegrityError) -> bool:
"""判断 IntegrityError 是否为 MySQL 1062(唯一键冲突)。
只有 1062 是「已存在」语义,可转业务提示;其余完整性错误(外键、
数据溢出等)语义未知,必须原样上抛交由全局处理器记 500,不能吞成 400。
只有 1062 是「已存在」语义,可转业务提示;其余完整性错误语义未知,
原样上抛 —— 由全局 IntegrityError handler 收敛为 400(通用文案),
500 只留给程序缺陷。
"""
return is_duplicate_entry(exc)

View File

@ -30,6 +30,8 @@ from app.models import OperationLog
pytestmark = pytest.mark.integration
_PRODUCTS = "/api/v1/base/products"
_CUSTOMERS = "/api/v1/base/customers"
_BUSINESSES = "/api/v1/business/businesses"
def _pname() -> str:
@ -67,6 +69,48 @@ def _logs_of(username: str) -> list[OperationLog]:
)
@pytest.fixture
def make_business_for_product(client: TestClient):
"""造「客户 → 业务 → 明细挂指定产品」的最小引用链(删除保护场景)。
business_items.product_id 是 RESTRICT 外键,产品删除保护以此为引用源;
客户/业务均按 t_ 前缀与系统编号由 conftest 兜底清理。
"""
def _factory(headers: dict, product_id: int) -> dict:
customer = client.post(
_CUSTOMERS,
json={
"company_name": f"t_引用公司_{uuid.uuid4().hex[:8]}",
"owner_name": "张老板",
"customer_type": "personal",
},
headers=headers,
)
assert customer.status_code == 200, customer.text
business = client.post(
_BUSINESSES,
json={
"customer_id": customer.json()["data"]["id"],
"business_date": "2026-01-01",
"items": [
{
"product_id": product_id,
"product_name": f"t_引用产品_{uuid.uuid4().hex[:6]}",
"product_category": PRODUCT_CATEGORY_MUNICIPAL_WATER,
"quantity": "1.00",
"sale_price": "10.00",
}
],
},
headers=headers,
)
assert business.status_code == 200, business.text
return business.json()["data"]
return _factory
class TestAuth:
"""鉴权:登录后接口,裸访问一律 401。"""
@ -120,6 +164,58 @@ class TestCrudFlow:
assert "产品不存在" in detail.json()["message"]
class TestDeleteProtection:
"""删除保护:被业务/发货明细引用的产品禁删(customer 先例同构)。"""
def test_被业务明细引用_400且产品仍在(
self, client: TestClient, normal: dict, make_business_for_product
) -> None:
product = _create(client, normal["headers"])
business = make_business_for_product(normal["headers"], product["id"])
resp = client.delete(f"{_PRODUCTS}/{product['id']}", headers=normal["headers"])
assert resp.status_code == 400, resp.text
assert "引用" in resp.json()["message"]
# 产品仍在(引导改走停用而非物理删除)
still = client.get(f"{_PRODUCTS}/{product['id']}", headers=normal["headers"])
assert still.status_code == 200
# 业务明细行确实引用了该产品(护栏:确认保护不是空转)
detail = client.get(f"{_BUSINESSES}/{business['id']}", headers=normal["headers"]).json()[
"data"
]
assert detail["items"][0]["product_id"] == product["id"]
def test_被发货明细引用_400(
self, client: TestClient, normal: dict, make_business_for_product
) -> None:
product = _create(client, normal["headers"])
make_business_for_product(normal["headers"], product["id"])
# 在业务明细引用之外再挂一笔发货明细(同样引用 products.id)
businesses = client.get("/api/v1/business/businesses", headers=normal["headers"]).json()[
"data"
]
business_id = businesses["list"][0]["id"]
shipment = client.post(
"/api/v1/business/shipments",
json={
"business_id": business_id,
"ship_date": "2026-01-01",
"items": [
{
"product_id": product["id"],
"product_name": product["product_name"],
"quantity": "1.00",
"sale_price": "10.00",
}
],
},
headers=normal["headers"],
)
assert shipment.status_code == 200, shipment.text
# 此前业务明细引用已令删除被拒,此处再证发货引用同一防线
resp = client.delete(f"{_PRODUCTS}/{product['id']}", headers=normal["headers"])
assert resp.status_code == 400, resp.text
class TestValidationRules:
"""类别值域与组合唯一 —— 核算阈值锚点不脏、业务标识不重复。"""

View File

@ -0,0 +1,72 @@
"""产品 schema 的金额口径单测(秒级,无外部依赖)。
守:产品 sale_price/stock_quantity 若私写校验不查小数位与容量,5 位小数
会被 Numeric 列静默截断(舍入即算错钱)、超容量会以 500 暴露 —— 校验
口径必须与 schemas/business._check_amount 一致(shipment/receipt 同源)。
"""
import pytest
from pydantic import ValidationError
from app.schemas.product import ProductCreateIn
class TestSalePrice:
"""单价校验:scale=4 对齐 Numeric(14,4) 列。"""
def test_四位小数通过_回显原样提交(self) -> None:
"""带精度的历史单价必须能原样提交(fba8cfc 教训:编辑被永远拒收)。"""
body = ProductCreateIn(
product_name="t_产品",
product_category="municipal_water",
sale_price="5000.1234",
)
assert body.sale_price == "5000.1234"
def test_五位小数拒绝而非静默截断(self) -> None:
with pytest.raises(ValidationError) as exc:
ProductCreateIn(
product_name="t_产品",
product_category="municipal_water",
sale_price="1.23456",
)
assert "最多四位小数" in str(exc.value)
def test_超容量拒绝(self) -> None:
"""DECIMAL(14,4) 整数部分 10 位:超出即拒绝而非溢出 500。"""
with pytest.raises(ValidationError):
ProductCreateIn(
product_name="t_产品",
product_category="municipal_water",
sale_price="99999999999.0000",
)
def test_空串归一None_负数拒绝(self) -> None:
empty = ProductCreateIn(
product_name="t_产品", product_category="municipal_water", sale_price=""
)
assert empty.sale_price is None
with pytest.raises(ValidationError):
ProductCreateIn(
product_name="t_产品", product_category="municipal_water", sale_price="-1"
)
class TestStockQuantity:
"""数量校验:默认 scale=2 对齐 Numeric(14,2) 列。"""
def test_三位小数拒绝(self) -> None:
with pytest.raises(ValidationError):
ProductCreateIn(
product_name="t_产品",
product_category="municipal_water",
stock_quantity="1.001",
)
def test_两位小数通过(self) -> None:
body = ProductCreateIn(
product_name="t_产品",
product_category="municipal_water",
stock_quantity="1.55",
)
assert body.stock_quantity == "1.55"