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

View File

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

View File

@ -12,6 +12,7 @@ import logging
from fastapi import FastAPI, Request from fastapi import FastAPI, Request
from fastapi.exceptions import RequestValidationError from fastapi.exceptions import RequestValidationError
from fastapi.responses import JSONResponse from fastapi.responses import JSONResponse
from sqlalchemy.exc import IntegrityError
from starlette.exceptions import HTTPException as StarletteHTTPException from starlette.exceptions import HTTPException as StarletteHTTPException
from app.core.constants import CODE_BAD_REQUEST, CODE_SERVER_ERROR 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) 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: 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(AppException, handle_app_exception) # type: ignore[arg-type]
app.add_exception_handler(RequestValidationError, handle_validation_error) # 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(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) app.add_exception_handler(Exception, handle_unexpected_exception)

View File

@ -6,7 +6,7 @@
from sqlalchemy import Select, func, or_, select from sqlalchemy import Select, func, or_, select
from sqlalchemy.orm import Session 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 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: def delete(session: Session, product: Product) -> None:
"""物理删除产品。调用方须先完成引用保护校验(V5 就位后生效)。""" """物理删除产品。调用方须先完成引用保护校验(count_refs + FK 兜底)。"""
session.delete(product) session.delete(product)
session.flush() session.flush()

View File

@ -7,29 +7,11 @@ Decimal 转换统一收敛在 service 层。字段命名与 user/role/param 一
""" """
from datetime import datetime from datetime import datetime
from decimal import Decimal, InvalidOperation
from pydantic import BaseModel, ConfigDict, Field, field_validator from pydantic import BaseModel, ConfigDict, Field, field_validator
from app.core.response import PageQuery from app.core.response import PageQuery
from app.schemas.business import AMOUNT_MAX, PRICE_MAX, _check_amount
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
class ProductOut(BaseModel): class ProductOut(BaseModel):
@ -70,18 +52,27 @@ class ProductCreateIn(BaseModel):
@field_validator("stock_quantity") @field_validator("stock_quantity")
@classmethod @classmethod
def _check_stock_quantity(cls, value: str | None) -> str | None: 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: if not value:
return None return None
return _parse_non_negative(value, label="数量") return _check_amount(value, "数量", AMOUNT_MAX)
@field_validator("sale_price") @field_validator("sale_price")
@classmethod @classmethod
def _check_sale_price(cls, value: str | None) -> str | None: def _check_sale_price(cls, value: str | None) -> str | None:
"""空串与 None 同义(可空列),归一为 None;非空则须为非负数字。""" """空串与 None 同义(可空列),归一为 None;非空走通用金额校验。
scale=4 与列口径 Numeric(14,4) 一致 —— 带精度的历史单价必须能
原样提交(「编辑任何带精度历史行都失败」的 fba8cfc 教训)。
"""
if not value: if not value:
return None return None
return _parse_non_negative(value, label="销售单价") return _check_amount(value, "销售单价", PRICE_MAX, scale=4)
class ProductUpdateIn(ProductCreateIn): class ProductUpdateIn(ProductCreateIn):

View File

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

View File

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

View File

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

View File

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

View File

@ -30,6 +30,8 @@ from app.models import OperationLog
pytestmark = pytest.mark.integration pytestmark = pytest.mark.integration
_PRODUCTS = "/api/v1/base/products" _PRODUCTS = "/api/v1/base/products"
_CUSTOMERS = "/api/v1/base/customers"
_BUSINESSES = "/api/v1/business/businesses"
def _pname() -> str: 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: class TestAuth:
"""鉴权:登录后接口,裸访问一律 401。""" """鉴权:登录后接口,裸访问一律 401。"""
@ -120,6 +164,58 @@ class TestCrudFlow:
assert "产品不存在" in detail.json()["message"] 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: 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"