Files
gaokao-volunteer-system/data/payments/service.py
Hermes 2893a45d8b
Some checks failed
CI / pytest (Python 3.10) (push) Has been cancelled
CI / pytest (Python 3.11) (push) Has been cancelled
CI / pytest (Python 3.12) (push) Has been cancelled
feat(6/19): 保留期门禁 + 支付失败持久化 + 文档校准
A1 删除/匿名化 180 天保留期门禁 (P0):
- admin/errors/codes.py + BIZ_ORDER_RETENTION_NOT_EXPIRED (E02002) -> HTTP 409
- admin/config.py + retention_days / GAOKAO_RETENTION_DAYS (默认 180)
- admin/routes/orders.py + _assert_retention_expired 守卫
- data/orders/deletion_service.py + RETENTION_GUARDED_STATUSES
- admin/tests/test_order_deletion.py + _expire_retention_window + 3 测试

B1 支付失败 webhook 持久化 (P1):
- data/payments/dao.py + failed_at/failure_reason/provider_trade_no/callback_payload
- data/payments/models.py + PaymentRecord 新字段
- data/payments/service.py handle_webhook 失败分支持久化
- data/payments/tests/test_webhook.py test_handle_webhook_persists_failed_status

A2 文档校准 (P1):
- README / PRD / ROADMAP / TECH_ARCHITECTURE 顶部对齐 6/19

B2 真相源分层 (P1):
- 6/13 整改板/执行板加历史快照前缀
- 新建 6/19 整改板/执行板
- CURRENT_STATE 顶部加 6/19 增量段 (在 6/13 真相源之上叠加)

dev-verify: 1175 passed (A1+B1+A2+B2) -> 1179 passed (+ T12-C),
coverage overall=85.05% / core=100%
2026-06-20 00:27:15 +08:00

366 lines
14 KiB
Python

from __future__ import annotations
import secrets
from dataclasses import dataclass
from typing import Optional
from data.orders.dao import OrdersDAO
from data.payments.dao import PaymentDAO
from data.payments.models import (
PaymentCheckout,
PaymentRecord,
RefundRequestResult,
WebhookHandleResult,
)
from data.payments.provider_requirements import build_provider_readiness_report
from data.payments.providers.alipay import AlipayProvider
from data.payments.providers.alipay_sim import AlipaySimProvider
from data.payments.providers.mock_gateway import MockPaymentProvider
class PaymentError(ValueError):
pass
def _build_provider(
*,
provider_name: str,
base_url: str,
webhook_secret: str,
notify_url: str = "",
return_url: str = "",
app_id: str = "",
merchant_id: str = "",
private_key_path: str = "",
alipay_public_key_path: str = "",
):
normalized = (provider_name or "mock").strip().lower()
if normalized == "mock":
return MockPaymentProvider(base_url=base_url, secret=webhook_secret)
if normalized == "alipay_sim":
return AlipaySimProvider(base_url=base_url, secret=webhook_secret)
if normalized == "alipay":
report = build_provider_readiness_report(
"alipay",
env={
"GAOKAO_PAYMENT_APP_ID": app_id,
"GAOKAO_PAYMENT_MERCHANT_ID": merchant_id,
"GAOKAO_PAYMENT_PRIVATE_KEY_PATH": private_key_path,
"GAOKAO_PAYMENT_ALIPAY_PUBLIC_KEY_PATH": alipay_public_key_path,
"GAOKAO_PAYMENT_NOTIFY_URL": notify_url,
"GAOKAO_PAYMENT_RETURN_URL": return_url,
"GAOKAO_PAYMENT_WEBHOOK_SECRET": webhook_secret,
},
)
if not report.ready:
details: list[str] = []
if report.missing_env_vars:
details.append(
"missing env vars: " + ", ".join(report.missing_env_vars)
)
if report.missing_files:
details.append("missing files: " + ", ".join(report.missing_files))
raise PaymentError("alipay provider not ready: " + "; ".join(details))
return AlipayProvider(
app_id=app_id,
merchant_id=merchant_id,
private_key_path=private_key_path,
alipay_public_key_path=alipay_public_key_path,
notify_url=notify_url,
return_url=return_url,
)
raise PaymentError(f"unsupported payment provider: {normalized}")
@dataclass
class ProviderBundle:
provider: MockPaymentProvider
class PaymentService:
def __init__(
self,
*,
db_path: str,
base_url: str,
webhook_secret: str,
provider_name: str = "mock",
notify_url: str = "",
return_url: str = "",
app_id: str = "",
merchant_id: str = "",
private_key_path: str = "",
alipay_public_key_path: str = "",
) -> None:
self.db_path = db_path
self.base_url = base_url
self.provider = _build_provider(
provider_name=provider_name,
base_url=base_url,
webhook_secret=webhook_secret,
notify_url=notify_url,
return_url=return_url,
app_id=app_id,
merchant_id=merchant_id,
private_key_path=private_key_path,
alipay_public_key_path=alipay_public_key_path,
)
@classmethod
def for_db(
cls,
db_path: str,
*,
base_url: str,
webhook_secret: str = "dev-mock-payment-secret",
provider_name: str = "mock",
notify_url: str = "",
return_url: str = "",
app_id: str = "",
merchant_id: str = "",
private_key_path: str = "",
alipay_public_key_path: str = "",
) -> "PaymentService":
return cls(
db_path=db_path,
base_url=base_url,
webhook_secret=webhook_secret,
provider_name=provider_name,
notify_url=notify_url,
return_url=return_url,
app_id=app_id,
merchant_id=merchant_id,
private_key_path=private_key_path,
alipay_public_key_path=alipay_public_key_path,
)
def create_checkout(
self, order_id: str, *, portal_token: str | None = None
) -> PaymentCheckout:
with OrdersDAO.connect(self.db_path) as orders_dao:
payments = PaymentDAO.from_connection(orders_dao.conn)
with orders_dao.transaction(begin_mode="immediate"):
order = orders_dao.get(order_id)
existing = payments.get_by_order(order_id)
if existing is not None and existing.status in {
"pending",
"paid",
"refunded",
}:
return PaymentCheckout(
payment_id=existing.id,
provider=existing.provider,
checkout_url=self.provider.build_checkout_url(
existing.id,
existing.checkout_token or existing.id,
amount_cents=existing.amount_cents,
subject=f"高考志愿服务-{order.service_version}",
),
status=existing.status,
)
payment = payments.create(
PaymentRecord(
id=f"pay_{secrets.token_hex(8)}",
order_id=order.id,
provider=self.provider.name,
amount_cents=order.amount_cents,
checkout_token=secrets.token_urlsafe(24),
)
)
return PaymentCheckout(
payment_id=payment.id,
provider=payment.provider,
checkout_url=self.provider.build_checkout_url(
payment.id,
payment.checkout_token or payment.id,
amount_cents=payment.amount_cents,
subject=f"高考志愿服务-{order.service_version}",
),
status=payment.status,
)
def get_payment(self, payment_id: str) -> Optional[PaymentRecord]:
payments = PaymentDAO.for_db(self.db_path)
try:
return payments.get(payment_id)
finally:
payments.close()
def get_payment_by_order(self, order_id: str) -> Optional[PaymentRecord]:
payments = PaymentDAO.for_db(self.db_path)
try:
return payments.get_by_order(order_id)
finally:
payments.close()
def handle_webhook(self, payload: dict, signature: str) -> WebhookHandleResult:
if not self.provider.verify_signature(payload, signature):
raise PaymentError("invalid payment signature")
normalized_payload = self.provider.normalize_webhook_payload(payload)
payment_id = str(normalized_payload.get("payment_id") or "")
if not payment_id:
raise PaymentError("missing payment_id")
normalized_status = str(normalized_payload.get("status") or "").strip()
success_statuses = {"paid", "TRADE_SUCCESS", "TRADE_FINISHED"}
expected_app_id = str(getattr(self.provider, "app_id", "") or "").strip()
expected_merchant_id = str(
getattr(self.provider, "merchant_id", "") or ""
).strip()
received_app_id = str(
normalized_payload.get("app_id") or payload.get("app_id") or ""
).strip()
received_notify_id = str(
normalized_payload.get("notify_id") or payload.get("notify_id") or ""
).strip()
received_merchant_id = str(
normalized_payload.get("merchant_id")
or payload.get("seller_id")
or payload.get("merchant_id")
or ""
).strip()
if expected_app_id and not received_notify_id:
raise PaymentError("payment notify_id missing")
if expected_app_id and received_app_id and expected_app_id != received_app_id:
raise PaymentError("payment app_id mismatch")
if expected_merchant_id and not received_merchant_id:
raise PaymentError("payment merchant_id missing")
if (
expected_merchant_id
and received_merchant_id
and expected_merchant_id != received_merchant_id
):
raise PaymentError("payment merchant_id mismatch")
provider_trade_no = str(
normalized_payload.get("provider_trade_no") or ""
).strip()
if expected_app_id and not provider_trade_no:
raise PaymentError("payment provider_trade_no missing")
# 6/19: 失败 webhook 持久化分支
# 状态不在 success_statuses 时不再抛 PaymentError, 而是标 payment.status='failed'
# 并写入 failure_reason, 供 portal / admin / 客服看到真实支付终态.
if normalized_status not in success_statuses:
failure_reason = (
str(payload.get("failure_reason") or "").strip()
or normalized_status
or "payment_status_not_successful"
)
with OrdersDAO.connect(self.db_path) as orders_dao:
payments = PaymentDAO.from_connection(orders_dao.conn)
with orders_dao.transaction():
payment = payments.get(payment_id)
if payment is None:
raise PaymentError("payment not found")
if payment.status in {"paid", "refunded"}:
# 终态后到达的失败 webhook, 走幂等路径
return WebhookHandleResult(
payment_id=payment.id,
processed=True,
idempotent=True,
order_status=orders_dao.get(payment.order_id).status,
)
if payment.status == "failed":
return WebhookHandleResult(
payment_id=payment.id,
processed=True,
idempotent=True,
order_status=orders_dao.get(payment.order_id).status,
)
updated = payments.update_status(
payment.id,
status="failed",
provider_trade_no=(
normalized_payload.get("provider_trade_no")
or payment.provider_trade_no
),
callback_payload=payload,
failure_reason=failure_reason,
)
order = orders_dao.get(updated.order_id)
return WebhookHandleResult(
payment_id=updated.id,
processed=True,
idempotent=False,
order_status=order.status,
)
with OrdersDAO.connect(self.db_path) as orders_dao:
payments = PaymentDAO.from_connection(orders_dao.conn)
with orders_dao.transaction():
payment = payments.get(payment_id)
if payment is None:
raise PaymentError("payment not found")
if (
int(normalized_payload.get("amount_cents") or -1)
!= payment.amount_cents
):
raise PaymentError("payment amount mismatch")
if payment.status == "refunded":
order = orders_dao.get(payment.order_id)
return WebhookHandleResult(
payment_id=payment.id,
processed=True,
idempotent=True,
order_status=order.status,
)
if payment.status == "paid":
order = orders_dao.get(payment.order_id)
return WebhookHandleResult(
payment_id=payment.id,
processed=True,
idempotent=True,
order_status=order.status,
)
updated = payments.update_status(
payment.id,
status="paid",
provider_trade_no=str(
normalized_payload.get("provider_trade_no")
or payment.provider_trade_no
or ""
),
callback_payload=payload,
)
order = orders_dao.get(updated.order_id)
if order.status == "pending":
order = orders_dao.transition_status(
updated.order_id,
"paid",
actor="payment_webhook",
reason="payment_paid",
)
return WebhookHandleResult(
payment_id=updated.id,
processed=True,
idempotent=False,
order_status=order.status,
)
def request_refund(self, order_id: str, *, reason: str) -> RefundRequestResult:
with OrdersDAO.connect(self.db_path) as orders_dao:
payments = PaymentDAO.from_connection(orders_dao.conn)
with orders_dao.transaction():
payment = payments.get_by_order(order_id)
if payment is None:
raise PaymentError("payment not found for order")
if payment.status == "refunded":
return RefundRequestResult(
payment_id=payment.id, status=payment.status
)
if payment.status != "paid":
raise PaymentError("payment is not refundable")
updated = payments.update_status(
payment.id,
status="refunded",
refund_reason=reason,
)
order = orders_dao.get(order_id)
if order.status != "refunded":
orders_dao.transition_status(
order_id,
"refunded",
actor="refund_request",
reason=reason,
)
return RefundRequestResult(payment_id=updated.id, status=updated.status)