feat(proxy): T-P1 鉴权+账本(原子预扣/热路径缓存/令牌桶/管理鉴权原语)

- ledger.py:students/keys CRUD + 日限额 check_and_count(跨日重置,注入时钟)+
  流水分页;计费两阶段拆分至 billing.py(BillingMixin,评审聚焦):
  try_hold 锁内读现状->日上限判定->余额原子防线->holding 流水(request_id 幂等)、
  settle 按真实值回补(预扣-实际)差额、void 全额退款(上游失败)
- auth.py:issue_key(sk-campus- 前缀,明文只返回一次,库存 sha256+前缀)、
  authenticate 校验链(形态/注销/停用/日额/rpm,热路径 LRU TTL30s D-P10)、
  RateLimiter(令牌桶 rpm + per-key 并发信号量,D-P9 单进程)、
  verify_admin(hmac.compare_digest;未配置仅 loopback)
- 测试 +17:签发/错key/注销/停用/403/rpm 429/日额 429/并发槽/admin 策略 +
  预扣-结算-回补一致/双向差额/余额不足/日上限/幂等/跨日重置/void/并发10路不超扣
- 全量 339 passed
This commit is contained in:
tzt
2026-09-05 09:05:59 +08:00
parent b109576707
commit a9f3405970
7 changed files with 702 additions and 19 deletions
+121 -10
View File
@@ -1,21 +1,32 @@
"""账本(T-P0:DDL 初始化 + 连接纪律;CRUD/预扣在 T-P1 落地)
"""账本(T-P1 完整):students/keys CRUD + 预扣-结算-退款 + 幂等流水
计费两阶段(D-P11,防并发超扣):
- try_hold:余额原子扣减(受影响行数为 0 即不足),同时原子检查日消费上限;
成功即写入流水一行(status='holding'charged_milli=预扣额)。
- settle:按真实 usage 更新流水并回补(预扣额 − 实际额)差额。
- void:全额退款(上游失败),流水保留审计。
工程纪律:
- D-P10sqlite3 是同步库,所有 DB 调用必须经 asyncio.to_thread(由调用方
routes 层包装;本模块保持同步实现,可测试性好)。连接 check_same_thread=False
+ threading.Lock 串行化(沿用 ReviewQueue 模式:每操作新连接 + 全局锁)。
- WAL 模式(§3
- D-P11:预扣用原子 UPDATE,见 try_holdT-P1 实现)。
- D-P1 毫元整数,本模块不做任何浮点运算(元换算只发生在入口参数转换)。
- D-P10:同步实现 + 全局锁每操作连接(ReviewQueue 模式),异步调用方经
asyncio.to_thread 包装;WAL 模式(§3)。
- 全部数据库访问使用占位符参数化语句,语句为常量,零拼接
"""
from __future__ import annotations
import sqlite3
import threading
import time
from pathlib import Path
from typing import Any, Dict, List, Optional
from gateway.proxy.billing import BillingMixin
from gateway.proxy.ledgerutil import YUAN_TO_MILLI, _today # noqa: F401
# 四表 + 两索引(§3,一字不改)
_SCHEMA = """
PRAGMA journal_mode=WAL;
CREATE TABLE IF NOT EXISTS students(
id INTEGER PRIMARY KEY, name TEXT NOT NULL, class TEXT DEFAULT '',
status TEXT NOT NULL DEFAULT 'active',
@@ -48,11 +59,16 @@ CREATE INDEX IF NOT EXISTS idx_semcache_bucket ON semcache(bucket, ttl_ts);
CREATE INDEX IF NOT EXISTS idx_ledger_ts ON usage_ledger(ts);
"""
# 计费两阶段的语句常量与实现见 billing.pyBillingMixin
_TABLES = ("students", "proxy_keys", "usage_ledger", "semcache")
class Ledger:
"""代理层账本(sqlite WAL;同步实现,调用方负责 to_thread)。"""
class Ledger(BillingMixin):
"""代理层账本(sqlite WAL;同步实现,调用方负责 to_thread)。
计费两阶段(try_hold/settle/void)继承自 BillingMixin。
"""
def __init__(self, db_path: str | Path):
self.db_path = Path(db_path)
@@ -69,14 +85,109 @@ class Ledger:
def _connect(self) -> sqlite3.Connection:
conn = sqlite3.connect(self.db_path, check_same_thread=False)
conn.row_factory = sqlite3.Row
conn.execute("PRAGMA journal_mode=WAL")
return conn
def _init_db(self) -> None:
with self._lock, self._connect() as conn:
conn.executescript(_SCHEMA)
# ---------- 自省(T-P0 验收用) ----------
# ---------- 学生 ----------
def upsert_student(self, name: str, klass: str = "",
balance_yuan: float = 0.0,
daily_cap_yuan: float = 5.0) -> int:
"""建学生(元 -> 毫元整数换算只在此入口)。"""
milli = int(round(balance_yuan * YUAN_TO_MILLI))
cap = int(round(daily_cap_yuan * YUAN_TO_MILLI))
with self._lock, self._connect() as conn:
cur = conn.execute(
"INSERT INTO students(name, class, balance_milli, daily_cap_milli)"
" VALUES (?, ?, ?, ?)", (name, klass, milli, cap))
return int(cur.lastrowid)
def topup(self, student_id: int, amount_yuan: float) -> Optional[int]:
"""充值;返回新余额(毫元),学生不存在返回 None。"""
milli = int(round(amount_yuan * YUAN_TO_MILLI))
with self._lock, self._connect() as conn:
cur = conn.execute(
"UPDATE students SET balance_milli = balance_milli + ? WHERE id = ?",
(milli, student_id))
if cur.rowcount == 0:
return None
row = conn.execute(
"SELECT balance_milli FROM students WHERE id = ?",
(student_id,)).fetchone()
return int(row["balance_milli"])
def set_status(self, student_id: int, status: str) -> bool:
with self._lock, self._connect() as conn:
cur = conn.execute(
"UPDATE students SET status = ? WHERE id = ?", (status, student_id))
return cur.rowcount > 0
def get_student(self, student_id: int) -> Optional[Dict[str, Any]]:
with self._lock, self._connect() as conn:
row = conn.execute(
"SELECT * FROM students WHERE id = ?", (student_id,)).fetchone()
return dict(row) if row else None
# ---------- key ----------
def create_key(self, student_id: int, key_hash: str, key_prefix: str,
rpm_cap: int = 10, day_cap_req: int = 200,
created_ts: Optional[int] = None) -> Optional[int]:
"""存 key 哈希;student 不存在返回 None。"""
with self._lock, self._connect() as conn:
exists = conn.execute(
"SELECT 1 FROM students WHERE id = ?", (student_id,)).fetchone()
if not exists:
return None
cur = conn.execute(
"INSERT INTO proxy_keys(key_hash, key_prefix, student_id, created_ts,"
" rpm_cap, day_cap_req) VALUES (?, ?, ?, ?, ?, ?)",
(key_hash, key_prefix, student_id,
int(created_ts if created_ts is not None else time.time()),
rpm_cap, day_cap_req))
return int(cur.lastrowid)
def find_key(self, key_hash: str) -> Optional[Dict[str, Any]]:
"""按哈希查 key(联查学生状态,鉴权主路径)。"""
with self._lock, self._connect() as conn:
row = conn.execute(
"SELECT k.id AS key_id, k.student_id, k.revoked, k.rpm_cap,"
" k.day_cap_req, k.req_today, k.req_date,"
" s.status AS student_status, s.balance_milli, s.daily_cap_milli,"
" s.spent_today_milli, s.spent_date"
" FROM proxy_keys k JOIN students s ON s.id = k.student_id"
" WHERE k.key_hash = ?", (key_hash,)).fetchone()
return dict(row) if row else None
def revoke_key(self, key_id: int) -> bool:
with self._lock, self._connect() as conn:
cur = conn.execute(
"UPDATE proxy_keys SET revoked = 1 WHERE id = ?", (key_id,))
return cur.rowcount > 0
# ---------- 日限额 ----------
def check_and_count(self, key_id: int, student_id: int, now: float) -> bool:
"""日请求限额双检:跨日重置(注入日期)+ 原子计数;超限返回 False(429)。"""
today = _today(now)
with self._lock, self._connect() as conn:
conn.execute(
"UPDATE students SET spent_today_milli = 0, spent_date = ?"
" WHERE spent_date IS NOT ?", (today, today))
row = conn.execute(
"SELECT req_today, req_date, day_cap_req FROM proxy_keys"
" WHERE id = ?", (key_id,)).fetchone()
if row is None:
return False
used = row["req_today"] if row["req_date"] == today else 0
if used >= row["day_cap_req"]:
return False
conn.execute(
"UPDATE proxy_keys SET req_today = ?, req_date = ? WHERE id = ?",
(used + 1, today, key_id))
return True
# ---------- 自省(测试/验收用) ----------
def table_names(self) -> List[str]:
"""列出已建表名(测试验收)。"""
with self._lock, self._connect() as conn: