Files
gaokao-volunteer-system/data/payments/dao.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

225 lines
7.6 KiB
Python

from __future__ import annotations
import json
import sqlite3
from pathlib import Path
from typing import Optional
from data.orders.models import utc_now_iso
from data.orders.schema import apply_schema
from data.payments.models import PaymentRecord
SCHEMA_SQL = """
CREATE TABLE IF NOT EXISTS payments (
id TEXT PRIMARY KEY,
order_id TEXT NOT NULL,
provider TEXT NOT NULL,
amount_cents INTEGER NOT NULL CHECK(amount_cents >= 0),
currency TEXT NOT NULL,
status TEXT NOT NULL CHECK(status IN ('pending','paid','failed','refunded')),
provider_trade_no TEXT,
checkout_token TEXT,
callback_payload TEXT,
refund_reason TEXT,
failure_reason TEXT,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL,
paid_at TEXT,
refunded_at TEXT,
failed_at TEXT,
FOREIGN KEY (order_id) REFERENCES orders(id) ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS idx_payments_order_id ON payments(order_id);
CREATE INDEX IF NOT EXISTS idx_payments_status ON payments(status);
"""
def _ensure_column(conn: sqlite3.Connection, table: str, column: str, ddl: str) -> None:
"""幂等 ALTER TABLE ADD COLUMN, 用于老库 schema 升级 (6/19 新增 failed_at/failure_reason)."""
existing = {
row[1] for row in conn.execute(f"PRAGMA table_info({table})").fetchall()
}
if column not in existing:
conn.execute(f"ALTER TABLE {table} ADD COLUMN {ddl}")
_SCHEMA_STATEMENTS = tuple(
statement.strip() for statement in SCHEMA_SQL.split(";") if statement.strip()
)
def _ensure_schema(conn: sqlite3.Connection, *, commit: bool) -> None:
conn.row_factory = sqlite3.Row
conn.execute("PRAGMA foreign_keys = ON")
for statement in _SCHEMA_STATEMENTS:
conn.execute(statement)
# 6/19: 老库增量升级 — failed_at / failure_reason (失败 webhook 持久化)
_ensure_column(conn, "payments", "failed_at", "failed_at TEXT")
_ensure_column(conn, "payments", "failure_reason", "failure_reason TEXT")
if commit:
conn.commit()
class PaymentDAO:
def __init__(self, conn: sqlite3.Connection, *, autocommit: bool = True) -> None:
self._conn = conn
self._autocommit = autocommit
@classmethod
def for_db(cls, db_path: str | Path) -> "PaymentDAO":
conn = apply_schema(db_path)
_ensure_schema(conn, commit=True)
return cls(conn, autocommit=True)
@classmethod
def from_connection(cls, conn: sqlite3.Connection) -> "PaymentDAO":
_ensure_schema(conn, commit=False)
return cls(conn, autocommit=False)
def close(self) -> None:
self._conn.close()
def create(self, payment: PaymentRecord) -> PaymentRecord:
self._conn.execute(
"""
INSERT INTO payments(id, order_id, provider, amount_cents, currency, status, provider_trade_no,
checkout_token, callback_payload, refund_reason, failure_reason,
created_at, updated_at, paid_at, refunded_at, failed_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
""",
(
payment.id,
payment.order_id,
payment.provider,
payment.amount_cents,
payment.currency,
payment.status,
payment.provider_trade_no,
payment.checkout_token,
payment.callback_payload,
payment.refund_reason,
payment.failure_reason,
payment.created_at,
payment.updated_at,
payment.paid_at,
payment.refunded_at,
payment.failed_at,
),
)
if self._autocommit:
self._conn.commit()
created = self.get(payment.id)
assert created is not None
return created
def get(self, payment_id: str) -> Optional[PaymentRecord]:
row = self._conn.execute(
"SELECT * FROM payments WHERE id=?",
(payment_id,),
).fetchone()
return self._row_to_payment(row) if row is not None else None
def get_by_order(self, order_id: str) -> Optional[PaymentRecord]:
row = self._conn.execute(
"""
SELECT *
FROM payments
WHERE order_id=?
ORDER BY
CASE status
WHEN 'refunded' THEN 0
WHEN 'paid' THEN 1
WHEN 'pending' THEN 2
WHEN 'failed' THEN 3
ELSE 4
END,
updated_at DESC,
created_at DESC,
id DESC
LIMIT 1
""",
(order_id,),
).fetchone()
return self._row_to_payment(row) if row is not None else None
def list_by_order(self, order_id: str) -> list[PaymentRecord]:
rows = self._conn.execute(
"SELECT * FROM payments WHERE order_id=? ORDER BY created_at ASC, id ASC",
(order_id,),
).fetchall()
return [self._row_to_payment(row) for row in rows]
def update_status(
self,
payment_id: str,
*,
status: str,
provider_trade_no: Optional[str] = None,
callback_payload: Optional[dict] = None,
refund_reason: Optional[str] = None,
failure_reason: Optional[str] = None,
) -> PaymentRecord:
now = utc_now_iso()
payment = self.get(payment_id)
if payment is None:
raise LookupError(f"payment not found: {payment_id}")
payload_json = payment.callback_payload
if callback_payload is not None:
payload_json = json.dumps(callback_payload, ensure_ascii=False)
paid_at = payment.paid_at
refunded_at = payment.refunded_at
failed_at = payment.failed_at
if status == "paid" and paid_at is None:
paid_at = now
if status == "refunded" and refunded_at is None:
refunded_at = now
if status == "failed" and failed_at is None:
failed_at = now
self._conn.execute(
"""
UPDATE payments
SET status=?, provider_trade_no=?, callback_payload=?, refund_reason=?, failure_reason=?,
updated_at=?, paid_at=?, refunded_at=?, failed_at=?
WHERE id=?
""",
(
status,
provider_trade_no or payment.provider_trade_no,
payload_json,
refund_reason or payment.refund_reason,
failure_reason or payment.failure_reason,
now,
paid_at,
refunded_at,
failed_at,
payment_id,
),
)
if self._autocommit:
self._conn.commit()
updated = self.get(payment_id)
assert updated is not None
return updated
@staticmethod
def _row_to_payment(row: sqlite3.Row) -> PaymentRecord:
return PaymentRecord(
id=row["id"],
order_id=row["order_id"],
provider=row["provider"],
amount_cents=int(row["amount_cents"]),
currency=row["currency"],
status=row["status"],
provider_trade_no=row["provider_trade_no"],
checkout_token=row["checkout_token"],
callback_payload=row["callback_payload"],
refund_reason=row["refund_reason"],
failure_reason=row["failure_reason"],
created_at=row["created_at"],
updated_at=row["updated_at"],
paid_at=row["paid_at"],
refunded_at=row["refunded_at"],
failed_at=row["failed_at"],
)