diff --git a/backend/app/crud/presign_order.py b/backend/app/crud/presign_order.py index bbe77cc3..0def0a9d 100644 --- a/backend/app/crud/presign_order.py +++ b/backend/app/crud/presign_order.py @@ -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( diff --git a/backend/app/schemas/market_mobile.py b/backend/app/schemas/market_mobile.py index f81d4710..7231ae2e 100644 --- a/backend/app/schemas/market_mobile.py +++ b/backend/app/schemas/market_mobile.py @@ -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): """预签单可写字段;租户与财务字段不接受客户端输入。""" diff --git a/backend/app/services/market_presign.py b/backend/app/services/market_presign.py index dc0878a3..7298221c 100644 --- a/backend/app/services/market_presign.py +++ b/backend/app/services/market_presign.py @@ -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 diff --git a/backend/tests/test_market_presign_api.py b/backend/tests/test_market_presign_api.py index f9c26e5c..c4bf18e0 100644 --- a/backend/tests/test_market_presign_api.py +++ b/backend/tests/test_market_presign_api.py @@ -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 diff --git a/backend/tests/test_presign_order.py b/backend/tests/test_presign_order.py index e7a0bf42..5d0672b6 100644 --- a/backend/tests/test_presign_order.py +++ b/backend/tests/test_presign_order.py @@ -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