refactor(core): 1062 唯一键冲突判定收口至 core/db_errors

六个 service 各自复制的 _is_duplicate_entry(口径已漂移:rating/product
用 _ERR_DUP_ENTRY 常量、其余四处写字面量 1062)收敛为共享函数;
is_duplicate_entry 语义与文案注释各处保留,非 1062 完整性错误仍原样
上抛由全局处理器收敛。

Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
liuwenxinandClaude Code committed 2026-10-14 15:17:00 +08:00
1 parent 5a3778a424
commit a0086554b1
7 files changed
+31 -19

No files matched your search

+18
View File
@@ -0,0 +1,18 @@
"""数据库完整性错误的统一识别与翻译口径。
判断依据:pymysql 原生异常 ``orig.args`` 首位是 MySQL 错误码 —— 只有
1062(ER_DUP_ENTRY,唯一键冲突)是「已存在」语义,可安全转 400 业务
提示;其余完整性错误(外键 RESTRICT、数据截断等)原样上抛,由全局
异常处理器收敛 500(程序缺陷或数据异常,均不应伪装成用户错误)。
"""
from sqlalchemy.exc import IntegrityError
# MySQL 唯一键冲突错误码(pymysql 原生异常 args 首位)
ERR_DUP_ENTRY = 1062
def is_duplicate_entry(exc: IntegrityError) -> bool:
"""判断 IntegrityError 是否为 MySQL 唯一键冲突(错误码 1062)。"""
args = getattr(exc.orig, "args", ())
return bool(args) and args[0] == ERR_DUP_ENTRY
+2 -2
View File
@@ -30,6 +30,7 @@ from app.core.constants import (
MODULE_BUSINESS, MODULE_BUSINESS,
NO_PREFIX_BUSINESS, NO_PREFIX_BUSINESS,
) )
from app.core.db_errors import is_duplicate_entry
from app.core.exceptions import BadRequestError, NotFoundError from app.core.exceptions import BadRequestError, NotFoundError
from app.core.response import PageQuery, PageResult from app.core.response import PageQuery, PageResult
from app.models import ( from app.models import (
@@ -221,8 +222,7 @@ def _ensure_customer(session: Session, customer_id: int) -> Customer:
def _is_duplicate_entry(exc: IntegrityError) -> bool: def _is_duplicate_entry(exc: IntegrityError) -> bool:
args = getattr(exc.orig, "args", ()) return is_duplicate_entry(exc)
return bool(args) and args[0] == 1062
def list_businesses(session: Session, query: BusinessQuery) -> PageResult[BusinessOut]: def list_businesses(session: Session, query: BusinessQuery) -> PageResult[BusinessOut]:
+3 -3
View File
@@ -26,6 +26,7 @@ from app.core.constants import (
CUSTOMER_TYPES, CUSTOMER_TYPES,
MODULE_CUSTOMER, MODULE_CUSTOMER,
) )
from app.core.db_errors import is_duplicate_entry
from app.core.exceptions import BadRequestError, NotFoundError from app.core.exceptions import BadRequestError, NotFoundError
from app.core.response import PageQuery, PageResult from app.core.response import PageQuery, PageResult
from app.models import Customer, OwnershipChangeLog, Personnel, User from app.models import Customer, OwnershipChangeLog, Personnel, User
@@ -108,14 +109,13 @@ def _ensure_owner_personnel(session: Session, personnel_id: int) -> Personnel:
def _is_duplicate_entry(exc: IntegrityError) -> bool: def _is_duplicate_entry(exc: IntegrityError) -> bool:
"""判断 IntegrityError 是否为 MySQL 唯一键冲突(错误码 1062)。 """判断 IntegrityError 是否为 MySQL 唯一键冲突(1062)。
customers 上还有归属人员外键(RESTRICT),并发窗口内删除人员等场景 customers 上还有归属人员外键(RESTRICT),并发窗口内删除人员等场景
会抛 1452 —— 不能一并翻译成「已存在」,否则用户会被错误提示带偏; 会抛 1452 —— 不能一并翻译成「已存在」,否则用户会被错误提示带偏;
非 1062 原样上抛,由全局处理器按 500 收敛。 非 1062 原样上抛,由全局处理器按 500 收敛。
""" """
args = getattr(exc.orig, "args", ()) return is_duplicate_entry(exc)
return bool(args) and args[0] == 1062
def list_customers(session: Session, query: CustomerQuery) -> PageResult[CustomerOut]: def list_customers(session: Session, query: CustomerQuery) -> PageResult[CustomerOut]:
+2 -5
View File
@@ -29,6 +29,7 @@ from app.core.constants import (
MODULE_PRODUCT, MODULE_PRODUCT,
PRODUCT_CATEGORIES, PRODUCT_CATEGORIES,
) )
from app.core.db_errors import is_duplicate_entry
from app.core.exceptions import BadRequestError, NotFoundError from app.core.exceptions import BadRequestError, NotFoundError
from app.core.response import PageResult from app.core.response import PageResult
from app.models import Product, User from app.models import Product, User
@@ -44,9 +45,6 @@ from app.services.audit_service import AuditRecord, RequestMeta
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
# 唯一键冲突的 MySQL 错误码(pymysql 原始异常 args 首位)
_ERR_DUP_ENTRY = 1062
def _to_out(product: Product) -> ProductOut: def _to_out(product: Product) -> ProductOut:
"""ORM 对象转响应模型。禁止把 ORM 对象直接当 response_model。""" """ORM 对象转响应模型。禁止把 ORM 对象直接当 response_model。"""
@@ -89,8 +87,7 @@ def _is_duplicate_entry(exc: IntegrityError) -> bool:
只有 1062 是「已存在」语义,可转业务提示;其余完整性错误(外键、 只有 1062 是「已存在」语义,可转业务提示;其余完整性错误(外键、
数据溢出等)语义未知,必须原样上抛交由全局处理器记 500,不能吞成 400。 数据溢出等)语义未知,必须原样上抛交由全局处理器记 500,不能吞成 400。
""" """
orig_args = getattr(exc.orig, "args", ()) return is_duplicate_entry(exc)
return bool(orig_args) and orig_args[0] == _ERR_DUP_ENTRY
def _raise_if_duplicate(exc: IntegrityError) -> None: def _raise_if_duplicate(exc: IntegrityError) -> None:
+2 -5
View File
@@ -31,6 +31,7 @@ from app.core.constants import (
ACTION_UPDATE, ACTION_UPDATE,
MODULE_RATING, MODULE_RATING,
) )
from app.core.db_errors import is_duplicate_entry
from app.core.exceptions import BadRequestError, NotFoundError from app.core.exceptions import BadRequestError, NotFoundError
from app.models import CustomerRatingGrade, User from app.models import CustomerRatingGrade, User
from app.repositories import rating_repository from app.repositories import rating_repository
@@ -44,9 +45,6 @@ logger = logging.getLogger(__name__)
_SCORE_FLOOR = Decimal("0") _SCORE_FLOOR = Decimal("0")
_SCORE_CEIL = Decimal("100") _SCORE_CEIL = Decimal("100")
# 唯一键冲突的 MySQL 错误码(pymysql 原始异常 args 首位)
_ERR_DUP_ENTRY = 1062
def _to_out(grade: CustomerRatingGrade) -> RatingGradeOut: def _to_out(grade: CustomerRatingGrade) -> RatingGradeOut:
"""ORM 对象转响应模型。分值 Decimal → str(分值红线出参口径)。""" """ORM 对象转响应模型。分值 Decimal → str(分值红线出参口径)。"""
@@ -110,8 +108,7 @@ def _is_duplicate_entry(exc: IntegrityError) -> bool:
只有 1062 是「已存在」语义,可转业务提示;其余完整性错误(外键、 只有 1062 是「已存在」语义,可转业务提示;其余完整性错误(外键、
数据溢出等)语义未知,必须原样上抛交由全局处理器记 500,不能吞成 400。 数据溢出等)语义未知,必须原样上抛交由全局处理器记 500,不能吞成 400。
""" """
orig_args = getattr(exc.orig, "args", ()) return is_duplicate_entry(exc)
return bool(orig_args) and orig_args[0] == _ERR_DUP_ENTRY
def _raise_if_duplicate(exc: IntegrityError) -> None: def _raise_if_duplicate(exc: IntegrityError) -> None:
+2 -2
View File
@@ -35,6 +35,7 @@ from app.core.constants import (
MODULE_RECEIPT, MODULE_RECEIPT,
NO_PREFIX_RECEIPT, NO_PREFIX_RECEIPT,
) )
from app.core.db_errors import is_duplicate_entry
from app.core.exceptions import BadRequestError, NotFoundError from app.core.exceptions import BadRequestError, NotFoundError
from app.core.response import PageResult from app.core.response import PageResult
from app.models import Receipt from app.models import Receipt
@@ -342,5 +343,4 @@ def delete_receipt(
def _is_duplicate_entry(exc: IntegrityError) -> bool: def _is_duplicate_entry(exc: IntegrityError) -> bool:
"""识别 MySQL 唯一键冲突(1062),与 business_service 同口径。""" """识别 MySQL 唯一键冲突(1062),与 business_service 同口径。"""
args = getattr(exc.orig, "args", ()) return is_duplicate_entry(exc)
return bool(args) and args[0] == 1062
+2 -2
View File
@@ -35,6 +35,7 @@ from app.core.constants import (
MODULE_SHIPMENT, MODULE_SHIPMENT,
NO_PREFIX_SHIPMENT, NO_PREFIX_SHIPMENT,
) )
from app.core.db_errors import is_duplicate_entry
from app.core.exceptions import BadRequestError, NotFoundError from app.core.exceptions import BadRequestError, NotFoundError
from app.core.response import PageResult from app.core.response import PageResult
from app.models import Business, Shipment, ShipmentItem, User from app.models import Business, Shipment, ShipmentItem, User
@@ -144,8 +145,7 @@ def _ensure_business_writable(session: Session, business_id: int, action: str) -
def _is_duplicate_entry(exc: IntegrityError) -> bool: def _is_duplicate_entry(exc: IntegrityError) -> bool:
"""识别 MySQL 唯一键冲突(1062),与 business/receipt_service 同口径。""" """识别 MySQL 唯一键冲突(1062),与 business/receipt_service 同口径。"""
args = getattr(exc.orig, "args", ()) return is_duplicate_entry(exc)
return bool(args) and args[0] == 1062
def list_shipments(session: Session, query: ShipmentQuery) -> PageResult[ShipmentOut]: def list_shipments(session: Session, query: ShipmentQuery) -> PageResult[ShipmentOut]: