fix: make mobile presign transitions concurrency-safe.

This commit is contained in:
zlwu committed 2026-10-11 11:31:24 +08:00
1 parent c5c7e42feb
commit 34bf9aa0dc
5 files changed
+410 -131

No files matched your search

+150 -46
View File
@@ -108,6 +108,51 @@ def _get_transfer_or_raise(
return obj
def _update_transfer_expected_status(
db: Session,
*,
transfer_id: str,
tenant_no: str,
expected_status: str,
values: Dict[Any, Any],
extra_conditions: Tuple[Any, ...] = (),
commit: bool = True,
) -> BizPresignTransfer:
"""以旧状态作为 CAS 条件更新,并返回事务内刷新后的稳定快照。"""
changed = (
db.query(BizPresignTransfer)
.filter(
BizPresignTransfer.id == transfer_id,
BizPresignTransfer.tenant_no == tenant_no,
BizPresignTransfer.del_flag == 0,
BizPresignTransfer.pay_status == expected_status,
*extra_conditions,
)
.update(values, synchronize_session=False)
)
if changed != 1:
raise ValueError("转账状态已变化,请刷新后重试")
obj = (
db.query(BizPresignTransfer)
.filter(
BizPresignTransfer.id == transfer_id,
BizPresignTransfer.tenant_no == tenant_no,
BizPresignTransfer.del_flag == 0,
)
.populate_existing()
.first()
)
if obj is None:
raise ValueError("意向金转账笔不存在")
db.flush()
if commit:
# 提交会 expire 会话内 ORM 对象;先分离,调用方序列化时只读取
# 本次事务内已刷新的标量快照,不会在提交后重新读取并发新状态。
db.expunge(obj)
db.commit()
return obj
def _transfer_to_dict(
db: Session, t: BizPresignTransfer, tenant_no: str
) -> Dict[str, Any]:
@@ -613,45 +658,62 @@ def update_transfer_draft(
tenant_no: str,
user_id: Optional[str],
fields: Dict[str, Any],
commit: bool = True,
) -> BizPresignTransfer:
t = _get_transfer_or_raise(db, transfer_id=transfer_id, tenant_no=tenant_no)
if t.pay_status != "dfj":
raise ValueError("仅已缴费状态可编辑意向金转账笔")
values: Dict[Any, Any] = {
BizPresignTransfer.update_by: user_id,
BizPresignTransfer.update_time: _now(),
}
if "transfer_amount" in fields or "transferAmount" in fields:
t.transfer_amount = _dec(
values[BizPresignTransfer.transfer_amount] = _dec(
fields.get("transfer_amount", fields.get("transferAmount"))
)
if "transfer_time" in fields or "transferTime" in fields:
v = fields.get("transfer_time", fields.get("transferTime"))
if v in (None, ""):
t.transfer_time = None
values[BizPresignTransfer.transfer_time] = None
elif isinstance(v, datetime):
t.transfer_time = v
values[BizPresignTransfer.transfer_time] = v
else:
t.transfer_time = parse_datetime(str(v))
parsed = parse_datetime(str(v))
if parsed is None:
raise ValueError("转账时间格式无效")
values[BizPresignTransfer.transfer_time] = parsed
if "transfer_type" in fields or "transferType" in fields:
t.transfer_type = (
values[BizPresignTransfer.transfer_type] = (
str(fields.get("transfer_type", fields.get("transferType")) or "")
).strip() or None
if "transfer_attachments" in fields or "transferAttachments" in fields:
t.transfer_attachments = (
values[BizPresignTransfer.transfer_attachments] = (
str(
fields.get("transfer_attachments", fields.get("transferAttachments"))
or ""
)
).strip() or None
if "remark" in fields:
t.remark = (fields.get("remark") or "").strip() or None
t.update_by = user_id
t.update_time = _now()
db.add(t)
db.commit()
db.refresh(t)
return t
values[BizPresignTransfer.remark] = (
fields.get("remark") or ""
).strip() or None
return _update_transfer_expected_status(
db,
transfer_id=transfer_id,
tenant_no=tenant_no,
expected_status="dfj",
values=values,
commit=commit,
)
def submit_receipt_finance(
db: Session, *, transfer_id: str, tenant_no: str, user_id: Optional[str]
db: Session,
*,
transfer_id: str,
tenant_no: str,
user_id: Optional[str],
commit: bool = True,
) -> BizPresignTransfer:
t = _get_transfer_or_raise(db, transfer_id=transfer_id, tenant_no=tenant_no)
if not can_submit_receipt(t.pay_status):
@@ -660,29 +722,50 @@ def submit_receipt_finance(
raise ValueError("请选择转账方式")
if t.transfer_amount is None or Decimal(str(t.transfer_amount)) <= 0:
raise ValueError("请填写转账金额")
t.pay_status = "jfqrz"
t.update_by = user_id
t.update_time = _now()
db.add(t)
db.commit()
db.refresh(t)
return t
return _update_transfer_expected_status(
db,
transfer_id=transfer_id,
tenant_no=tenant_no,
expected_status="dfj",
values={
BizPresignTransfer.pay_status: "jfqrz",
BizPresignTransfer.update_by: user_id,
BizPresignTransfer.update_time: _now(),
},
extra_conditions=(
BizPresignTransfer.transfer_amount.isnot(None),
BizPresignTransfer.transfer_amount > 0,
BizPresignTransfer.transfer_type.isnot(None),
func.length(func.trim(BizPresignTransfer.transfer_type)) > 0,
),
commit=commit,
)
def withdraw_receipt(
db: Session, *, transfer_id: str, tenant_no: str, user_id: Optional[str]
db: Session,
*,
transfer_id: str,
tenant_no: str,
user_id: Optional[str],
commit: bool = True,
) -> BizPresignTransfer:
t = _get_transfer_or_raise(db, transfer_id=transfer_id, tenant_no=tenant_no)
if not can_withdraw_receipt(t.pay_status):
raise ValueError("仅缴费确认中可撤回")
t.pay_status = "dfj"
# 故意不改 finance_remark / finance_confirm_attachments
t.update_by = user_id
t.update_time = _now()
db.add(t)
db.commit()
db.refresh(t)
return t
return _update_transfer_expected_status(
db,
transfer_id=transfer_id,
tenant_no=tenant_no,
expected_status="jfqrz",
values={
BizPresignTransfer.pay_status: "dfj",
BizPresignTransfer.update_by: user_id,
BizPresignTransfer.update_time: _now(),
},
commit=commit,
)
def confirm_receipt(
@@ -693,36 +776,57 @@ def confirm_receipt(
user_id: Optional[str],
finance_confirm_attachments: Optional[str] = None,
finance_remark: Optional[str] = None,
commit: bool = True,
) -> BizPresignTransfer:
t = _get_transfer_or_raise(db, transfer_id=transfer_id, tenant_no=tenant_no)
if not can_confirm_receipt(t.pay_status):
raise ValueError("仅缴费确认中状态可确认收款")
t.pay_status = "yjf"
values: Dict[Any, Any] = {
BizPresignTransfer.pay_status: "yjf",
BizPresignTransfer.update_by: user_id,
BizPresignTransfer.update_time: _now(),
}
if finance_confirm_attachments is not None:
t.finance_confirm_attachments = (finance_confirm_attachments or "").strip() or None
values[BizPresignTransfer.finance_confirm_attachments] = (
finance_confirm_attachments or ""
).strip() or None
if finance_remark is not None:
t.finance_remark = (finance_remark or "").strip() or None
t.update_by = user_id
t.update_time = _now()
db.add(t)
db.commit()
db.refresh(t)
return t
values[BizPresignTransfer.finance_remark] = (
finance_remark or ""
).strip() or None
return _update_transfer_expected_status(
db,
transfer_id=transfer_id,
tenant_no=tenant_no,
expected_status="jfqrz",
values=values,
commit=commit,
)
def reject_receipt(
db: Session, *, transfer_id: str, tenant_no: str, user_id: Optional[str]
db: Session,
*,
transfer_id: str,
tenant_no: str,
user_id: Optional[str],
commit: bool = True,
) -> BizPresignTransfer:
t = _get_transfer_or_raise(db, transfer_id=transfer_id, tenant_no=tenant_no)
if not can_confirm_receipt(t.pay_status):
raise ValueError("仅缴费确认中状态可退回")
t.pay_status = "dfj"
t.update_by = user_id
t.update_time = _now()
db.add(t)
db.commit()
db.refresh(t)
return t
return _update_transfer_expected_status(
db,
transfer_id=transfer_id,
tenant_no=tenant_no,
expected_status="jfqrz",
values={
BizPresignTransfer.pay_status: "dfj",
BizPresignTransfer.update_by: user_id,
BizPresignTransfer.update_time: _now(),
},
commit=commit,
)
def list_presign_transfers_for_finance(
+20
View File
@@ -1,6 +1,7 @@
"""移动市场接口的白名单请求 DTO。"""
from __future__ import annotations
import re
from datetime import datetime
from decimal import Decimal
from typing import List, Literal, Optional
@@ -45,6 +46,25 @@ class MarketPresignTransferBody(BaseModel):
)
remark: Optional[str] = Field(None, max_length=512)
@field_validator('transfer_time')
@classmethod
def validate_transfer_time(cls, value: Optional[str]) -> Optional[str]:
if value in (None, ''):
return value
text = str(value).strip()
if re.fullmatch(
r'(?:\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}'
r'|\d{4}-\d{2}-\d{2}T\d{2}:\d{2}'
r'(?::\d{2}(?:\.\d{1,6})?)?)',
text,
) is None:
raise ValueError('转账时间格式无效')
try:
datetime.fromisoformat(text)
except ValueError as exc:
raise ValueError('转账时间格式无效') from exc
return text
class MarketPresignOrderBody(BaseModel):
"""预签单可写字段;租户与财务字段不接受客户端输入。"""
+18 -85
View File
@@ -8,7 +8,6 @@ from fastapi import HTTPException
from sqlalchemy import func, or_
from sqlalchemy.orm import Session
from app.core.utils import parse_datetime
from app.crud import presign_order as presign_crud
from app.models.models import (
BizCellSampleType,
@@ -498,24 +497,6 @@ def _update_transfer_if_status(
raise ValueError('转账状态已变化,请刷新后重试')
def _reload_transfer(
db: Session, transfer_id: str, tenant_no: str
) -> BizPresignTransfer:
row = (
db.query(BizPresignTransfer)
.filter(
BizPresignTransfer.id == transfer_id,
BizPresignTransfer.tenant_no == tenant_no,
BizPresignTransfer.del_flag == 0,
)
.populate_existing()
.first()
)
if row is None:
raise HTTPException(status_code=404, detail='转账记录不存在')
return row
def create_presign_transfer(
db: Session,
order_id: str,
@@ -577,43 +558,17 @@ def update_presign_transfer(
existing_refs=current.transfer_attachments,
business_id=current.presign_order_id,
)
values = {
BizPresignTransfer.update_by: ctx.user_id,
BizPresignTransfer.update_time: datetime.now(),
}
if 'transfer_amount' in fields:
values[BizPresignTransfer.transfer_amount] = fields[
'transfer_amount'
]
if 'transfer_time' in fields:
raw_time = fields['transfer_time']
values[BizPresignTransfer.transfer_time] = (
None
if raw_time in (None, '')
else parse_datetime(str(raw_time))
)
if 'transfer_type' in fields:
values[BizPresignTransfer.transfer_type] = (
str(fields['transfer_type'] or '').strip() or None
)
if 'transfer_attachments' in fields:
values[BizPresignTransfer.transfer_attachments] = (
str(fields['transfer_attachments'] or '').strip() or None
)
if 'remark' in fields:
values[BizPresignTransfer.remark] = (
str(fields['remark'] or '').strip() or None
)
_update_transfer_if_status(
row = presign_crud.update_transfer_draft(
db,
transfer_id=transfer_id,
tenant_no=ctx.tenant_no,
expected_status='dfj',
values=values,
user_id=ctx.user_id,
fields=fields,
commit=False,
)
result = presign_crud._transfer_to_dict(db, row, ctx.tenant_no)
db.commit()
row = _reload_transfer(db, transfer_id, ctx.tenant_no)
return presign_crud._transfer_to_dict(db, row, ctx.tenant_no)
return result
except Exception:
db.rollback()
raise
@@ -653,33 +608,17 @@ def submit_presign_transfer(
user_info: dict,
) -> dict:
try:
row = _scoped_transfer(db, transfer_id, ctx, user_info)
if (row.pay_status or '').strip() != 'dfj':
raise ValueError('仅待缴费状态可提交财务确认')
if row.transfer_amount is None or row.transfer_amount <= 0:
raise ValueError('请填写转账金额')
if not (row.transfer_type or '').strip():
raise ValueError('请选择转账方式')
_update_transfer_if_status(
_scoped_transfer(db, transfer_id, ctx, user_info)
row = presign_crud.submit_receipt_finance(
db,
transfer_id=transfer_id,
tenant_no=ctx.tenant_no,
expected_status='dfj',
values={
BizPresignTransfer.pay_status: 'jfqrz',
BizPresignTransfer.update_by: ctx.user_id,
BizPresignTransfer.update_time: datetime.now(),
},
extra_conditions=(
BizPresignTransfer.transfer_amount.isnot(None),
BizPresignTransfer.transfer_amount > 0,
BizPresignTransfer.transfer_type.isnot(None),
func.length(func.trim(BizPresignTransfer.transfer_type)) > 0,
),
user_id=ctx.user_id,
commit=False,
)
result = presign_crud._transfer_to_dict(db, row, ctx.tenant_no)
db.commit()
row = _reload_transfer(db, transfer_id, ctx.tenant_no)
return presign_crud._transfer_to_dict(db, row, ctx.tenant_no)
return result
except Exception:
db.rollback()
raise
@@ -692,23 +631,17 @@ def withdraw_presign_transfer(
user_info: dict,
) -> dict:
try:
row = _scoped_transfer(db, transfer_id, ctx, user_info)
if (row.pay_status or '').strip() != 'jfqrz':
raise ValueError('仅缴费确认中可撤回')
_update_transfer_if_status(
_scoped_transfer(db, transfer_id, ctx, user_info)
row = presign_crud.withdraw_receipt(
db,
transfer_id=transfer_id,
tenant_no=ctx.tenant_no,
expected_status='jfqrz',
values={
BizPresignTransfer.pay_status: 'dfj',
BizPresignTransfer.update_by: ctx.user_id,
BizPresignTransfer.update_time: datetime.now(),
},
user_id=ctx.user_id,
commit=False,
)
result = presign_crud._transfer_to_dict(db, row, ctx.tenant_no)
db.commit()
row = _reload_transfer(db, transfer_id, ctx.tenant_no)
return presign_crud._transfer_to_dict(db, row, ctx.tenant_no)
return result
except Exception:
db.rollback()
raise
+212
View File
@@ -1023,15 +1023,28 @@ def test_market_presign_bodies_forbid_unknown_fields_and_validate_transfer_limit
valid = MarketPresignTransferBody.model_validate(
{
'transferAmount': '123.45',
'transferTime': '2026-10-11 12:34:56',
'transferType': 'bank',
'transferAttachments': 'a' * 4000,
'remark': 'r' * 512,
}
)
assert str(valid.transfer_amount) == '123.45'
assert valid.transfer_time == '2026-10-11 12:34:56'
assert (
MarketPresignTransferBody.model_validate(
{'transferTime': '2026-10-11T12:34'}
).transfer_time
== '2026-10-11T12:34'
)
for payload in (
{'unknown': 'value'},
{'transferAmount': 0},
{'transferTime': 'not-a-datetime'},
{'transferTime': '2026-10-11'},
{'transferTime': '2026-10-11 12:34'},
{'transferTime': '2026-10-11T12'},
{'transferTime': '2026-10-11T12:34+08:00'},
{'transferType': 'x' * 17},
{'transferAttachments': 'x' * 4001},
{'remark': 'x' * 513},
@@ -1673,6 +1686,205 @@ def test_stale_withdraw_does_not_overwrite_later_finance_confirmation(tmp_path):
finance_db.close()
def test_stale_finance_confirmation_does_not_overwrite_later_mobile_withdraw(
tmp_path,
):
from app.crud import presign_order as presign_crud
from app.services.market_presign import withdraw_presign_transfer
Session = _session_factory(
f"sqlite:///{tmp_path / 'finance-confirm-after-withdraw-race.db'}"
)
finance_db = Session()
market_db = Session()
try:
finance_db.add(_order('own', sales_user_id='u1'))
finance_db.add(_transfer('transfer', pay_status='jfqrz'))
finance_db.commit()
stale = finance_db.query(BizPresignTransfer).filter_by(id='transfer').one()
assert stale.pay_status == 'jfqrz'
withdrawn = withdraw_presign_transfer(
market_db,
transfer_id='transfer',
ctx=_ctx('specialist'),
user_info=_user(),
)
assert withdrawn['payStatus'] == 'dfj'
with pytest.raises(ValueError, match='状态'):
presign_crud.confirm_receipt(
finance_db,
transfer_id='transfer',
tenant_no='tenant-a',
user_id='finance-user',
)
market_db.expire_all()
current = market_db.query(BizPresignTransfer).filter_by(id='transfer').one()
assert current.pay_status == 'dfj'
assert current.update_by == 'u1'
finally:
finance_db.close()
market_db.close()
@pytest.mark.parametrize('action', ['update', 'submit'])
def test_stale_web_draft_action_does_not_mutate_later_paid_transfer(
tmp_path, action
):
from app.crud import presign_order as presign_crud
Session = _session_factory(
f"sqlite:///{tmp_path / f'web-{action}-after-paid-race.db'}"
)
web_db = Session()
finance_db = Session()
try:
web_db.add(_order('own', sales_user_id='u1'))
transfer = _transfer('transfer', pay_status='dfj')
transfer.remark = 'original'
web_db.add(transfer)
web_db.commit()
stale = web_db.query(BizPresignTransfer).filter_by(id='transfer').one()
assert stale.pay_status == 'dfj'
presign_crud.submit_receipt_finance(
finance_db,
transfer_id='transfer',
tenant_no='tenant-a',
user_id='finance-user',
)
presign_crud.confirm_receipt(
finance_db,
transfer_id='transfer',
tenant_no='tenant-a',
user_id='finance-user',
)
calls = {
'update': lambda: presign_crud.update_transfer_draft(
web_db,
transfer_id='transfer',
tenant_no='tenant-a',
user_id='web-user',
fields={'remark': 'stale-write'},
),
'submit': lambda: presign_crud.submit_receipt_finance(
web_db,
transfer_id='transfer',
tenant_no='tenant-a',
user_id='web-user',
),
}
with pytest.raises(ValueError, match='状态'):
calls[action]()
finance_db.expire_all()
current = finance_db.query(BizPresignTransfer).filter_by(id='transfer').one()
assert current.pay_status == 'yjf'
assert current.remark == 'original'
assert current.update_by == 'finance-user'
finally:
web_db.close()
finance_db.close()
@pytest.mark.parametrize('action', ['withdraw', 'confirm', 'reject'])
def test_stale_shared_pending_action_does_not_mutate_later_draft_transfer(
tmp_path, action
):
from app.crud import presign_order as presign_crud
from app.services.market_presign import withdraw_presign_transfer
Session = _session_factory(
f"sqlite:///{tmp_path / f'shared-{action}-after-withdraw-race.db'}"
)
stale_db = Session()
market_db = Session()
try:
stale_db.add(_order('own', sales_user_id='u1'))
stale_db.add(_transfer('transfer', pay_status='jfqrz'))
stale_db.commit()
stale = stale_db.query(BizPresignTransfer).filter_by(id='transfer').one()
assert stale.pay_status == 'jfqrz'
withdraw_presign_transfer(
market_db,
transfer_id='transfer',
ctx=_ctx('specialist'),
user_info=_user(),
)
calls = {
'withdraw': lambda: presign_crud.withdraw_receipt(
stale_db,
transfer_id='transfer',
tenant_no='tenant-a',
user_id='stale-user',
),
'confirm': lambda: presign_crud.confirm_receipt(
stale_db,
transfer_id='transfer',
tenant_no='tenant-a',
user_id='stale-user',
),
'reject': lambda: presign_crud.reject_receipt(
stale_db,
transfer_id='transfer',
tenant_no='tenant-a',
user_id='stale-user',
),
}
with pytest.raises(ValueError, match='状态'):
calls[action]()
market_db.expire_all()
current = market_db.query(BizPresignTransfer).filter_by(id='transfer').one()
assert current.pay_status == 'dfj'
assert current.update_by == 'u1'
finally:
stale_db.close()
market_db.close()
def test_mobile_transfer_update_serializes_before_commit(monkeypatch):
from app.crud import presign_order as presign_crud
from app.schemas.market_mobile import MarketPresignTransferBody
from app.services.market_presign import update_presign_transfer
db = _session()
db.add(_order('own', sales_user_id='u1'))
db.add(_transfer('transfer', pay_status='dfj'))
db.commit()
committed = False
original_commit = db.commit
original_to_dict = presign_crud._transfer_to_dict
def tracked_commit():
nonlocal committed
committed = True
original_commit()
def snapshot_before_commit(*args, **kwargs):
assert not committed
return original_to_dict(*args, **kwargs)
monkeypatch.setattr(db, 'commit', tracked_commit)
monkeypatch.setattr(presign_crud, '_transfer_to_dict', snapshot_before_commit)
result = update_presign_transfer(
db,
transfer_id='transfer',
body=MarketPresignTransferBody(remark='updated'),
ctx=_ctx('specialist'),
user_info=_user(),
)
assert committed
assert result['remark'] == 'updated'
@pytest.mark.parametrize('action', ['update', 'delete', 'submit'])
def test_stale_dfj_action_does_not_mutate_later_paid_transfer(tmp_path, action):
from app.crud import presign_order as presign_crud
+10
View File
@@ -112,7 +112,16 @@ class TestPresignWithdrawReceipt(unittest.TestCase):
)
db = MagicMock()
orig = po._get_transfer_or_raise
orig_update = po._update_transfer_expected_status
po._get_transfer_or_raise = lambda *a, **k: t
def atomic_update(*args, **kwargs):
for column, value in kwargs["values"].items():
setattr(t, column.key, value)
db.commit()
return t
po._update_transfer_expected_status = atomic_update
try:
out = po.withdraw_receipt(
db, transfer_id="t1", tenant_no="zkxw", user_id="u1"
@@ -123,6 +132,7 @@ class TestPresignWithdrawReceipt(unittest.TestCase):
db.commit.assert_called()
finally:
po._get_transfer_or_raise = orig
po._update_transfer_expected_status = orig_update
def test_withdraw_rejects_non_jfqrz(self):
from types import SimpleNamespace