fix(business): 唯一键冲突重试改为审计上下文内循环,单条审计无递归

三处 create(业务/发货/回款)的 return _retry_create 是吞异常后正常
退出:外层审计把已回滚的首次尝试记成 success(假成功),重入整个
create_* 违反 audit_service「不支持嵌套」约定,且 _NO_RETRY 死常量
导致持续冲突时无界递归至 RecursionError。改为 audit 内 for 循环
(上限 _NO_RETRY),rollback 后重新锁行与取号,成功/最终失败各恰好
一条审计。新增集成用例锁定两条路径的日志增量。

Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
liuwenxinandClaude Code committed 2026-10-14 15:09:05 +08:00
1 parent cb977979da
commit 5a3778a424
4 files changed
+290 -139

No files matched your search

+42 -44
View File
@@ -56,6 +56,9 @@ from app.services.no_generator import next_no
logger = logging.getLogger(__name__)
# 编号唯一键冲突后的重试次数(模式同 business_service._NO_RETRY)
_NO_RETRY = 1
def _to_out(row: tuple[Shipment, str]) -> ShipmentOut:
"""(shipment, business_no) → 响应模型;金额出参 str。"""
@@ -117,9 +120,7 @@ def _build_item_rows(items: list[ShipmentItemIn]) -> list[ShipmentItem]:
def _generate_no(session: Session) -> str:
"""生成发货单号(SH 前缀;MAX+FOR UPDATE 与 1062 兜底见 no_generator)。"""
return next_no(
session, model=Shipment, column_name="shipment_no", prefix=NO_PREFIX_SHIPMENT
)
return next_no(session, model=Shipment, column_name="shipment_no", prefix=NO_PREFIX_SHIPMENT)
def _ensure_business_writable(session: Session, business_id: int, action: str) -> Business:
@@ -220,57 +221,54 @@ def create_shipment(
),
meta=meta,
):
if business is None:
raise NotFoundError("业务不存在")
if business.status != BUSINESS_STATUS_ACTIVE:
raise BadRequestError("业务已完成或已终止,禁止新发货")
# 单号唯一键冲突的重试整体收在审计上下文内(模式同
# business_service.create_business):rollback 释放旧锁,重试前重新
# 锁业务行与生成编号——成功/最终失败各只记一条审计,无递归
for attempt in range(_NO_RETRY + 1):
try:
if business is None:
raise NotFoundError("业务不存在")
if business.status != BUSINESS_STATUS_ACTIVE:
raise BadRequestError("业务已完成或已终止,禁止新发货")
try:
shipment = Shipment(
shipment_no=shipment_no,
business_id=payload.business_id,
ship_date=payload.ship_date,
freight_amount=_amount_or_zero(payload.freight_amount),
total_amount=Decimal("0"),
remark=payload.remark,
)
shipment_repository.create(session, shipment)
shipment_repository.replace_items(
session, shipment.id, _build_item_rows(payload.items)
)
total = shipment_repository.sum_item_amount(session, shipment.id)
shipment.total_amount = total if total is not None else Decimal("0")
_recalc_totals(session, business)
out = _to_out((shipment, business.business_no))
new_id = shipment.id
session.commit()
except IntegrityError as exc:
session.rollback()
if _is_duplicate_entry(exc):
# 单号撞唯一键的残余竞态:兜底重试一次(防无限重试掩盖死锁)
return _retry_create(session, payload, operator=operator, meta=meta)
raise
shipment = Shipment(
shipment_no=shipment_no,
business_id=payload.business_id,
ship_date=payload.ship_date,
freight_amount=_amount_or_zero(payload.freight_amount),
total_amount=Decimal("0"),
remark=payload.remark,
)
shipment_repository.create(session, shipment)
shipment_repository.replace_items(
session, shipment.id, _build_item_rows(payload.items)
)
total = shipment_repository.sum_item_amount(session, shipment.id)
shipment.total_amount = total if total is not None else Decimal("0")
_recalc_totals(session, business)
out = _to_out((shipment, business.business_no))
new_id, new_no = shipment.id, shipment_no # 固化:commit 后不碰 ORM
session.commit()
break
except IntegrityError as exc:
session.rollback()
if _is_duplicate_entry(exc) and attempt < _NO_RETRY:
business = business_repository.get_by_id(
session, payload.business_id, for_update=True
)
shipment_no = _generate_no(session)
continue
raise
logger.info(
"新增发货单 | shipment_id=%s | no=%s | business_id=%s",
new_id,
shipment_no,
new_no,
payload.business_id,
)
return out
def _retry_create(
session: Session,
payload: ShipmentCreateIn,
*,
operator: User,
meta: RequestMeta | None,
) -> ShipmentOut:
"""单号撞唯一键后的单次重试(重新锁行与生成编号,防死锁类缺陷掩盖)。"""
return create_shipment(session, payload, operator=operator, meta=meta)
def update_shipment(
session: Session,
shipment_id: int,