修改支付完成后支付状态不同步的bug
This commit is contained in:
parent
a25b774521
commit
d888d9231f
@ -54,3 +54,4 @@ ALIPAY_TRANSFER_API=alipay.fund.trans.uni.transfer
|
||||
ALIPAY_TRANSFER_QUERY_API=alipay.fund.trans.common.query
|
||||
ALIPAY_TRANSFER_SCENE_NAME=佣金报酬
|
||||
ALIPAY_HTTP_TIMEOUT_SECONDS=30
|
||||
ALIPAY_RECONCILE_INTERVAL_SECONDS=30
|
||||
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
@ -151,6 +151,58 @@ def _same_rsa_key(private_key: str, public_key: str) -> bool:
|
||||
return (private_public.n, private_public.e) == (public.n, public.e)
|
||||
|
||||
|
||||
def _signed_response_content(response_value, public_key: str, charset: str = "utf-8") -> str:
|
||||
"""Verify a certificate-mode response and return its API response object.
|
||||
|
||||
Old alipay-sdk-python releases include the top-level ``alipay_cert_sn``
|
||||
field in the bytes they verify. Alipay signs only the inner ``*_response``
|
||||
JSON object, so otherwise a valid code=10000 response is rejected.
|
||||
"""
|
||||
from alipay.aop.api.util.SignatureUtils import verify_with_rsa
|
||||
|
||||
response_text = response_value.decode(charset) if isinstance(response_value, bytes) else str(response_value)
|
||||
marker = re.search(r'"[^"\\]+_response"\s*:\s*', response_text)
|
||||
if not marker:
|
||||
raise AlipayTransferError("支付宝返回内容缺少业务响应对象", ambiguous=True)
|
||||
start = response_text.find("{", marker.end())
|
||||
if start < 0:
|
||||
raise AlipayTransferError("支付宝返回业务响应格式无效", ambiguous=True)
|
||||
depth = 0
|
||||
quoted = False
|
||||
escaped = False
|
||||
end = -1
|
||||
for index in range(start, len(response_text)):
|
||||
char = response_text[index]
|
||||
if quoted:
|
||||
if escaped:
|
||||
escaped = False
|
||||
elif char == "\\":
|
||||
escaped = True
|
||||
elif char == '"':
|
||||
quoted = False
|
||||
continue
|
||||
if char == '"':
|
||||
quoted = True
|
||||
elif char == "{":
|
||||
depth += 1
|
||||
elif char == "}":
|
||||
depth -= 1
|
||||
if depth == 0:
|
||||
end = index + 1
|
||||
break
|
||||
if end < 0:
|
||||
raise AlipayTransferError("支付宝返回业务响应不完整", ambiguous=True)
|
||||
try:
|
||||
envelope = json.loads(response_text)
|
||||
except (TypeError, ValueError) as exc:
|
||||
raise AlipayTransferError("支付宝返回内容无法解析", ambiguous=True) from exc
|
||||
signature = str(envelope.get("sign") or "")
|
||||
response_content = response_text[start:end]
|
||||
if not signature or not verify_with_rsa(public_key, response_content.encode(charset), signature):
|
||||
raise AlipayTransferError("支付宝响应验签失败", "ALIPAY_RESPONSE_UNVERIFIED", ambiguous=True)
|
||||
return response_content
|
||||
|
||||
|
||||
def _read_key(path_value: str, label: str) -> str:
|
||||
if not path_value:
|
||||
raise AlipayTransferError(f"未配置{label}文件路径")
|
||||
@ -206,6 +258,16 @@ def _client():
|
||||
common["alipay_root_cert_sn"] = alipay_root_cert_sn
|
||||
return common
|
||||
|
||||
def _DefaultAlipayClient__parse_response(self, response_value):
|
||||
response_text = response_value.decode(settings.alipay_charset) if isinstance(response_value, bytes) else str(response_value)
|
||||
if '"alipay_cert_sn"' in response_text:
|
||||
return _signed_response_content(
|
||||
response_text,
|
||||
config.alipay_public_key,
|
||||
settings.alipay_charset,
|
||||
)
|
||||
return super()._DefaultAlipayClient__parse_response(response_value)
|
||||
|
||||
client_class = CertificateAlipayClient
|
||||
else:
|
||||
config.alipay_public_key = _sdk_key(_read_key(settings.alipay_public_key_path, "支付宝公钥"), private=False)
|
||||
|
||||
@ -79,6 +79,7 @@ class Settings:
|
||||
alipay_transfer_query_api = os.getenv("ALIPAY_TRANSFER_QUERY_API", "alipay.fund.trans.common.query")
|
||||
alipay_transfer_scene_name = os.getenv("ALIPAY_TRANSFER_SCENE_NAME", "").strip()
|
||||
alipay_http_timeout_seconds = int(os.getenv("ALIPAY_HTTP_TIMEOUT_SECONDS", "30"))
|
||||
alipay_reconcile_interval_seconds = max(10, int(os.getenv("ALIPAY_RECONCILE_INTERVAL_SECONDS", "30")))
|
||||
|
||||
|
||||
settings = Settings()
|
||||
|
||||
@ -12,6 +12,7 @@ import logging
|
||||
import random
|
||||
import re
|
||||
import secrets
|
||||
import threading
|
||||
import time
|
||||
import urllib.parse
|
||||
import urllib.request
|
||||
@ -481,6 +482,8 @@ def startup():
|
||||
connection.execute(text("CREATE UNIQUE INDEX ix_admin_users_answer_public_token ON admin_users (answer_public_token)"))
|
||||
finally:
|
||||
db.close()
|
||||
if settings.alipay_enabled:
|
||||
_start_alipay_reconcile_worker()
|
||||
|
||||
|
||||
def _migrate_alipay_transfer_attempts(bind):
|
||||
@ -3578,6 +3581,139 @@ def _snapshot_refund(db, record, kind, user, payer_account="", alipay_trade_no="
|
||||
))
|
||||
|
||||
|
||||
def _complete_alipay_refund_order(db: Session, order: AlipayRefundOrder, trade_no: str) -> bool:
|
||||
"""Atomically persist a verified provider success and advance the task."""
|
||||
trade_no = str(trade_no or "").strip()
|
||||
if not trade_no:
|
||||
return False
|
||||
if order.target_kind == "ask_batch":
|
||||
target = db.get(AskAssignmentBatch, order.target_id)
|
||||
elif order.target_kind == "ask_assignment":
|
||||
target = db.get(AskAssignment, order.target_id)
|
||||
elif order.target_kind == "answer_submission":
|
||||
target = db.get(AnswerSubmission, order.target_id)
|
||||
else:
|
||||
return False
|
||||
if not target:
|
||||
return False
|
||||
existing = db.scalar(select(RefundRecord.id).where(
|
||||
RefundRecord.target_kind == order.target_kind,
|
||||
RefundRecord.target_id == order.target_id,
|
||||
))
|
||||
if not existing:
|
||||
db.add(RefundRecord(
|
||||
target_kind=order.target_kind,
|
||||
target_id=order.target_id,
|
||||
owner_id=order.owner_id,
|
||||
shop_name=order.shop_name or "",
|
||||
order_no=order.order_no or "",
|
||||
payee_name=order.payee_name or "",
|
||||
payee_account=order.account or "",
|
||||
amount_fen=order.amount_fen,
|
||||
operator_name=order.operator_name or "系统自动对账",
|
||||
payer_account=order.payer_account or "",
|
||||
alipay_trade_no=trade_no,
|
||||
))
|
||||
if order.target_kind == "ask_batch":
|
||||
target.status = "paid"
|
||||
target.updated_at = datetime.utcnow()
|
||||
_sync_ask_task_status(target.assignment.task)
|
||||
elif order.target_kind == "ask_assignment":
|
||||
target.status = "已完成"
|
||||
target.updated_at = datetime.utcnow()
|
||||
_sync_ask_task_status(target.task)
|
||||
elif order.target_kind == "answer_submission":
|
||||
target.status = "已完成"
|
||||
order.status = "success"
|
||||
order.alipay_trade_no = trade_no
|
||||
order.error_code = ""
|
||||
order.error_message = ""
|
||||
return True
|
||||
|
||||
|
||||
def _reconcile_pending_alipay_orders_once() -> int:
|
||||
"""Query pending/legacy uncertain transfers and finalize verified results."""
|
||||
if not settings.alipay_enabled:
|
||||
return 0
|
||||
from .alipay_provider import query
|
||||
|
||||
with SessionLocal() as lookup_db:
|
||||
order_ids = lookup_db.scalars(
|
||||
select(AlipayRefundOrder.id).where(or_(
|
||||
AlipayRefundOrder.status == "pending",
|
||||
(AlipayRefundOrder.status == "failed") & (AlipayRefundOrder.error_code == "10000"),
|
||||
)).order_by(AlipayRefundOrder.id).limit(100)
|
||||
).all()
|
||||
completed = 0
|
||||
for order_id in order_ids:
|
||||
with SessionLocal() as db:
|
||||
order = db.scalar(
|
||||
select(AlipayRefundOrder)
|
||||
.where(AlipayRefundOrder.id == order_id)
|
||||
.with_for_update()
|
||||
)
|
||||
if not order or (order.status != "pending" and order.error_code != "10000"):
|
||||
continue
|
||||
try:
|
||||
result = query(out_biz_no=order.out_biz_no)
|
||||
except AlipayTransferError as exc:
|
||||
order.status = "pending"
|
||||
order.error_code = exc.code[:128]
|
||||
order.error_message = str(exc)[:500]
|
||||
db.commit()
|
||||
logging.getLogger(__name__).warning(
|
||||
"Alipay automatic reconciliation deferred: out_biz_no=%s code=%s message=%s",
|
||||
order.out_biz_no, exc.code, exc,
|
||||
)
|
||||
continue
|
||||
provider_status = str(result.get("status") or "").upper()
|
||||
if provider_status == "SUCCESS":
|
||||
trade_no = str(result.get("trade_no") or "").strip()
|
||||
if trade_no and _complete_alipay_refund_order(db, order, trade_no):
|
||||
db.commit()
|
||||
completed += 1
|
||||
logging.getLogger(__name__).info(
|
||||
"Alipay automatic reconciliation completed: out_biz_no=%s trade_no=%s",
|
||||
order.out_biz_no, trade_no,
|
||||
)
|
||||
else:
|
||||
order.error_message = "支付宝确认成功,但暂未返回资金流水号"
|
||||
db.commit()
|
||||
elif provider_status in {"FAIL", "REFUND"}:
|
||||
order.status = "failed"
|
||||
order.error_code = ""
|
||||
order.error_message = str(result.get("fail_reason") or "支付宝确认转账失败")[:500]
|
||||
db.commit()
|
||||
else:
|
||||
order.status = "pending"
|
||||
order.error_message = f"支付宝转账状态:{provider_status or '处理中'}"
|
||||
db.commit()
|
||||
return completed
|
||||
|
||||
|
||||
def _alipay_reconcile_loop() -> None:
|
||||
interval = settings.alipay_reconcile_interval_seconds
|
||||
# Give application startup and database migrations time to finish.
|
||||
time.sleep(min(5, interval))
|
||||
while True:
|
||||
try:
|
||||
_reconcile_pending_alipay_orders_once()
|
||||
except Exception:
|
||||
logging.getLogger(__name__).exception("Alipay automatic reconciliation failed")
|
||||
time.sleep(interval)
|
||||
|
||||
|
||||
def _start_alipay_reconcile_worker() -> None:
|
||||
if getattr(app.state, "alipay_reconcile_started", False):
|
||||
return
|
||||
app.state.alipay_reconcile_started = True
|
||||
threading.Thread(
|
||||
target=_alipay_reconcile_loop,
|
||||
name="alipay-reconcile",
|
||||
daemon=True,
|
||||
).start()
|
||||
|
||||
|
||||
def _refund_record_query(user, q, start_date, end_date):
|
||||
query = select(RefundRecord)
|
||||
if user.role_code != "super_admin":
|
||||
@ -3821,10 +3957,8 @@ async def alipay_callback(request: Request, db: Session = Depends(get_db)):
|
||||
trade_no = str(params.get("pay_fund_order_id") or params.get("order_id") or "").strip()
|
||||
if not trade_no:
|
||||
return Response(content="failure", media_type="text/plain", status_code=400)
|
||||
order.status = "success"
|
||||
order.alipay_trade_no = trade_no
|
||||
order.error_code = ""
|
||||
order.error_message = ""
|
||||
if not _complete_alipay_refund_order(db, order, trade_no):
|
||||
return Response(content="failure", media_type="text/plain", status_code=400)
|
||||
elif provider_status in {"FAIL", "REFUND"}:
|
||||
order.status = "failed"
|
||||
order.error_code = str(params.get("error_code") or params.get("sub_order_error_code") or "")[:128]
|
||||
|
||||
Binary file not shown.
Binary file not shown.
@ -12,14 +12,29 @@ def test_alipay_callback_reconciles_verified_success(monkeypatch):
|
||||
with TestClient(main.app) as client:
|
||||
with main.SessionLocal() as db:
|
||||
user = db.scalar(main.select(main.AdminUser).where(main.AdminUser.username == "operator"))
|
||||
answer_user = main.AnswerUser(
|
||||
public_id=f"U{uuid4().hex[:20]}", phone=f"1{uuid4().int % 10**10:010d}",
|
||||
auth_token=uuid4().hex,
|
||||
)
|
||||
db.add(answer_user)
|
||||
db.flush()
|
||||
submission = main.AnswerSubmission(
|
||||
submission_no=f"ANS{uuid4().hex[:20]}", user_id=answer_user.id,
|
||||
owner_id=user.id, shop_name="回调测试店", status="已通过",
|
||||
)
|
||||
db.add(submission)
|
||||
db.flush()
|
||||
order = main.AlipayRefundOrder(
|
||||
target_kind="answer_submission", target_id=int(uuid4().int % 1_000_000_000),
|
||||
target_kind="answer_submission", target_id=submission.id,
|
||||
out_biz_no=out_biz_no, amount_fen=300, account="buyer@example.com",
|
||||
payee_name="测试收款人", created_by_id=user.id, status="pending",
|
||||
payee_name="测试收款人", created_by_id=user.id, owner_id=user.id,
|
||||
order_no=submission.submission_no, shop_name=submission.shop_name,
|
||||
operator_name=user.display_name, payer_account="公司账户", status="pending",
|
||||
)
|
||||
db.add(order)
|
||||
db.commit()
|
||||
order_id = order.id
|
||||
submission_id = submission.id
|
||||
response = client.post("/alipay_callback", data={
|
||||
"sign": "valid", "out_biz_no": out_biz_no, "status": "SUCCESS",
|
||||
"order_id": "ALI-CALLBACK-001",
|
||||
@ -30,6 +45,12 @@ def test_alipay_callback_reconciles_verified_success(monkeypatch):
|
||||
order = db.get(main.AlipayRefundOrder, order_id)
|
||||
assert order.status == "success"
|
||||
assert order.alipay_trade_no == "ALI-CALLBACK-001"
|
||||
assert db.get(main.AnswerSubmission, submission_id).status == "已完成"
|
||||
refund = db.scalar(main.select(main.RefundRecord).where(
|
||||
main.RefundRecord.target_kind == "answer_submission",
|
||||
main.RefundRecord.target_id == submission_id,
|
||||
))
|
||||
assert refund.alipay_trade_no == "ALI-CALLBACK-001"
|
||||
|
||||
|
||||
def test_alipay_callback_rejects_invalid_signature(monkeypatch):
|
||||
|
||||
@ -114,6 +114,32 @@ def test_transfer_keeps_unverified_success_envelope_ambiguous(monkeypatch):
|
||||
assert "勿重复发起" in str(error.value)
|
||||
|
||||
|
||||
def test_certificate_response_verifies_only_inner_response_object(monkeypatch):
|
||||
captured = {}
|
||||
|
||||
def fake_verify(_public_key, content, signature):
|
||||
captured["content"] = content
|
||||
captured["signature"] = signature
|
||||
return True
|
||||
|
||||
from alipay.aop.api.util import SignatureUtils
|
||||
monkeypatch.setattr(SignatureUtils, "verify_with_rsa", fake_verify)
|
||||
envelope = json.dumps({
|
||||
"alipay_fund_trans_common_query_response": {
|
||||
"code": "10000", "msg": "Success", "status": "SUCCESS",
|
||||
"pay_fund_order_id": "ALI006",
|
||||
},
|
||||
"alipay_cert_sn": "cert-sn",
|
||||
"sign": "signed-value",
|
||||
}, ensure_ascii=False, separators=(",", ":"))
|
||||
|
||||
content = alipay_provider._signed_response_content(envelope, "public-key")
|
||||
|
||||
assert json.loads(content)["pay_fund_order_id"] == "ALI006"
|
||||
assert b"alipay_cert_sn" not in captured["content"]
|
||||
assert captured["signature"] == "signed-value"
|
||||
|
||||
|
||||
def test_query_parses_processing_status(monkeypatch):
|
||||
client = CapturingClient({
|
||||
"code": "10000",
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user