Skip to content

Commit c62a50d

Browse files
feat: reserve chat quota transactionally in SQLite
1 parent aaefddc commit c62a50d

4 files changed

Lines changed: 189 additions & 10 deletions

File tree

scripts/bazi_engine/api.py

Lines changed: 40 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -731,12 +731,17 @@ async def chat_api(request: Request):
731731
return JSONResponse({"error": "AI 功能未启用"}, status_code=503)
732732
from .chat import (
733733
FREE_DAILY_LIMIT,
734+
_use_sqlite_runtime_store,
734735
build_messages,
735736
call_deepseek_stream,
736737
check_free_quota,
737738
consume_code,
738739
consume_free_quota,
739740
filter_sensitive,
741+
release_quota_reservation,
742+
reserve_activation_code,
743+
reserve_free_quota,
744+
settle_quota_reservation,
740745
validate_code,
741746
)
742747

@@ -758,8 +763,27 @@ async def reject_gen():
758763
yield "data: [DONE]\n\n"
759764
return StreamingResponse(reject_gen(), media_type="text/event-stream")
760765

761-
# 2. 权限检查
762-
if activation_code:
766+
# 2. 权限检查。SQLite 模式先事务预占,首个 token 到达后才结算。
767+
sqlite_runtime = _use_sqlite_runtime_store()
768+
reservation_id = None
769+
if sqlite_runtime and activation_code:
770+
reservation = reserve_activation_code(activation_code)
771+
if reservation is None:
772+
_valid, _remaining, msg = validate_code(activation_code)
773+
async def invalid_gen():
774+
yield f"data: {json.dumps({'token': msg})}\n\n"
775+
yield "data: [DONE]\n\n"
776+
return StreamingResponse(invalid_gen(), media_type="text/event-stream")
777+
reservation_id = reservation.reservation_id
778+
elif sqlite_runtime:
779+
reservation = reserve_free_quota(client_ip)
780+
if reservation is None:
781+
async def quota_gen():
782+
yield f"data: {json.dumps({'token': f'今日免费追问次数({FREE_DAILY_LIMIT}次)已用完。点击"解锁追问"获取激活码。'})}\n\n"
783+
yield "data: [DONE]\n\n"
784+
return StreamingResponse(quota_gen(), media_type="text/event-stream")
785+
reservation_id = reservation.reservation_id
786+
elif activation_code:
763787
valid, _remaining, msg = validate_code(activation_code)
764788
if not valid:
765789
async def invalid_gen():
@@ -780,14 +804,20 @@ async def quota_gen():
780804
# 4. 流式响应(扣费在首个token到达后执行,避免错误响应也扣费)
781805
async def stream_chat():
782806
consumed = False
783-
async for chunk in call_deepseek_stream(messages):
784-
if not consumed and chunk.startswith('data: {"token"'):
785-
consumed = True
786-
if activation_code:
787-
consume_code(activation_code)
788-
else:
789-
consume_free_quota(client_ip)
790-
yield chunk
807+
try:
808+
async for chunk in call_deepseek_stream(messages):
809+
if not consumed and chunk.startswith('data: {"token"'):
810+
consumed = True
811+
if reservation_id:
812+
settle_quota_reservation(reservation_id)
813+
elif activation_code:
814+
consume_code(activation_code)
815+
else:
816+
consume_free_quota(client_ip)
817+
yield chunk
818+
finally:
819+
if reservation_id and not consumed:
820+
release_quota_reservation(reservation_id)
791821

792822
return _limited_stream_response(stream_chat())
793823

scripts/bazi_engine/chat.py

Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,12 +14,16 @@
1414

1515
from ._deepseek_config import DEEPSEEK_API_URL, DEEPSEEK_KEY, DEEPSEEK_MODEL
1616
from ._http import shared_async_client
17+
from .runtime_store import QuotaReservation, RuntimeStore
1718

1819
# ═══════════════════════════════════════════════════════════════
1920
# 配置
2021
# ═══════════════════════════════════════════════════════════════
2122

2223
_ACTIVATION_FILE = Path(__file__).resolve().parent / "activation_codes.json"
24+
_RUNTIME_DB = Path(
25+
os.getenv("BAZI_RUNTIME_DB_PATH", Path(__file__).resolve().parents[2] / "data" / "runtime.sqlite3")
26+
)
2327
_RUNTIME_DATA_LOCK = threading.RLock()
2428

2529
# ═══════════════════════════════════════════════════════════════
@@ -383,8 +387,33 @@ async def call_deepseek_stream(messages: list[dict]) -> AsyncGenerator[str]:
383387
_default_codes = dict(_DEMO_CODES) if _USE_DEMO_CODES else {}
384388

385389

390+
def _use_sqlite_runtime_store() -> bool:
391+
return os.getenv("BAZI_RUNTIME_STORE", "json").lower() == "sqlite"
392+
393+
394+
def _activation_seed_codes() -> dict:
395+
codes = dict(_default_codes)
396+
env_codes = os.getenv("ACTIVATION_CODES", "")
397+
if env_codes:
398+
with suppress(json.JSONDecodeError):
399+
codes.update(json.loads(env_codes))
400+
if os.getenv("BAZI_PUBLIC", "").lower() in ("1", "true", "yes"):
401+
for code in _DEMO_CODES:
402+
codes.pop(code, None)
403+
return codes
404+
405+
406+
def _runtime_store() -> RuntimeStore:
407+
store = RuntimeStore(_RUNTIME_DB)
408+
store.seed_activation_codes(_activation_seed_codes())
409+
return store
410+
411+
386412
def _load_codes() -> dict:
387413
"""加载激活码(环境变量 + 本地文件合并,环境变量优先)"""
414+
if _use_sqlite_runtime_store():
415+
return _runtime_store().activation_codes()
416+
388417
codes = dict(_default_codes)
389418

390419
# 本地文件(首次自动创建,gitignore 保护)
@@ -442,6 +471,14 @@ def _atomic_json_write(path: Path, data: dict, **json_kwargs) -> None:
442471

443472
def validate_code(code: str) -> tuple[bool, int, str]:
444473
"""验证激活码。返回 (有效?, 剩余次数, 消息)"""
474+
if _use_sqlite_runtime_store():
475+
remaining = _runtime_store().activation_remaining(code)
476+
if remaining is None:
477+
return False, 0, "激活码无效"
478+
if remaining <= 0:
479+
return False, 0, "该激活码次数已用完"
480+
return True, remaining, "有效"
481+
445482
codes = _load_codes()
446483
entry = codes.get(code.strip().upper())
447484
if not entry:
@@ -454,6 +491,13 @@ def validate_code(code: str) -> tuple[bool, int, str]:
454491

455492
def consume_code(code: str) -> tuple[bool, int]:
456493
"""消耗一次激活码。返回 (成功?, 剩余次数)"""
494+
if _use_sqlite_runtime_store():
495+
reservation = reserve_activation_code(code)
496+
if reservation is None:
497+
return False, 0
498+
_runtime_store().settle_reservation(reservation.reservation_id)
499+
return True, reservation.remaining
500+
457501
with _RUNTIME_DATA_LOCK:
458502
codes = _load_codes()
459503
entry = codes.get(code.strip().upper())
@@ -498,6 +542,10 @@ def check_free_quota(client_id: str) -> tuple[bool, int]:
498542
IP 经哈希处理后存储,不保留原始 IP。
499543
注意: NAT/代理环境下多用户共用同一 IP,一个用户用完额度其他人也会被拦。
500544
"""
545+
if _use_sqlite_runtime_store():
546+
remaining = _runtime_store().free_remaining(_hash_ip(client_id), time.strftime("%Y-%m-%d"), FREE_DAILY_LIMIT)
547+
return remaining > 0, remaining
548+
501549
with _RUNTIME_DATA_LOCK:
502550
today = time.strftime("%Y-%m-%d")
503551
data = _load_free_usage()
@@ -521,6 +569,13 @@ def check_free_quota(client_id: str) -> tuple[bool, int]:
521569

522570
def consume_free_quota(client_id: str) -> int:
523571
"""消耗一次免费额度。返回剩余次数"""
572+
if _use_sqlite_runtime_store():
573+
reservation = reserve_free_quota(client_id)
574+
if reservation is None:
575+
return 0
576+
_runtime_store().settle_reservation(reservation.reservation_id)
577+
return reservation.remaining
578+
524579
with _RUNTIME_DATA_LOCK:
525580
today = time.strftime("%Y-%m-%d")
526581
data = _load_free_usage()
@@ -536,3 +591,27 @@ def consume_free_quota(client_id: str) -> int:
536591
data[key] = entry
537592
_save_free_usage(data)
538593
return FREE_DAILY_LIMIT - entry["count"]
594+
595+
596+
def reserve_activation_code(code: str) -> QuotaReservation | None:
597+
"""Atomically reserve a paid quota until the first response token is delivered."""
598+
if not _use_sqlite_runtime_store():
599+
return None
600+
return _runtime_store().reserve_activation(code)
601+
602+
603+
def reserve_free_quota(client_id: str) -> QuotaReservation | None:
604+
"""Atomically reserve one daily free quota until the first response token is delivered."""
605+
if not _use_sqlite_runtime_store():
606+
return None
607+
return _runtime_store().reserve_free(
608+
_hash_ip(client_id), time.strftime("%Y-%m-%d"), FREE_DAILY_LIMIT,
609+
)
610+
611+
612+
def settle_quota_reservation(reservation_id: str) -> bool:
613+
return _runtime_store().settle_reservation(reservation_id)
614+
615+
616+
def release_quota_reservation(reservation_id: str) -> bool:
617+
return _runtime_store().release_reservation(reservation_id)

scripts/bazi_engine/runtime_store.py

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -158,6 +158,17 @@ def activation_remaining(self, code: str) -> int | None:
158158
).fetchone()
159159
return int(row["remaining"]) if row else None
160160

161+
def activation_codes(self) -> dict[str, dict]:
162+
self.initialize()
163+
with self.connect() as connection:
164+
rows = connection.execute(
165+
"SELECT code, remaining, note FROM activation_codes ORDER BY code"
166+
).fetchall()
167+
return {
168+
row["code"]: {"剩余": int(row["remaining"]), "备注": row["note"]}
169+
for row in rows
170+
}
171+
161172
def free_remaining(self, client_hash: str, usage_date: str, limit: int) -> int:
162173
self.initialize()
163174
with self.connect() as connection:

scripts/tests/test_api.py

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -648,3 +648,62 @@ async def fake_stream(messages):
648648
assert "2026年 19岁 丙午流年,丙午大运" in system_prompt
649649
assert "2026年 19岁 丙午流年,甲辰大运" not in system_prompt
650650
assert system_prompt.rfind("【最终事实约束】") > system_prompt.rfind("classical-texts.md")
651+
652+
653+
def test_sqlite_chat_quota_releases_when_provider_returns_no_token(monkeypatch, tmp_path):
654+
import bazi_engine.api as api_module
655+
import bazi_engine.chat as chat_module
656+
from bazi_engine.api import app
657+
from bazi_engine.runtime_store import RuntimeStore
658+
659+
async def failed_stream(_messages):
660+
yield "data: [ERROR] provider unavailable\n\n"
661+
662+
database_path = tmp_path / "runtime.sqlite3"
663+
monkeypatch.setenv("BAZI_RUNTIME_STORE", "sqlite")
664+
monkeypatch.setenv("ACTIVATION_CODES", '{"TEST": {"剩余": 1, "备注": "test"}}')
665+
monkeypatch.setattr(api_module, "_AI_ENABLED", True)
666+
monkeypatch.setattr(chat_module, "_RUNTIME_DB", database_path)
667+
monkeypatch.setattr(chat_module, "build_messages", lambda *_args: [{"role": "user", "content": "test"}])
668+
monkeypatch.setattr(chat_module, "call_deepseek_stream", failed_stream)
669+
670+
with TestClient(app).stream(
671+
"POST",
672+
"/api/chat",
673+
json={"question": "测试", "chart_data": {}, "activation_code": "TEST"},
674+
) as response:
675+
body = "".join(response.iter_text())
676+
677+
assert response.status_code == 200
678+
assert "provider unavailable" in body
679+
assert RuntimeStore(database_path).activation_remaining("TEST") == 1
680+
681+
682+
def test_sqlite_chat_quota_settles_after_first_token(monkeypatch, tmp_path):
683+
import bazi_engine.api as api_module
684+
import bazi_engine.chat as chat_module
685+
from bazi_engine.api import app
686+
from bazi_engine.runtime_store import RuntimeStore
687+
688+
async def successful_stream(_messages):
689+
yield 'data: {"token":"ok"}\n\n'
690+
yield "data: [DONE]\n\n"
691+
692+
database_path = tmp_path / "runtime.sqlite3"
693+
monkeypatch.setenv("BAZI_RUNTIME_STORE", "sqlite")
694+
monkeypatch.setenv("ACTIVATION_CODES", '{"TEST": {"剩余": 1, "备注": "test"}}')
695+
monkeypatch.setattr(api_module, "_AI_ENABLED", True)
696+
monkeypatch.setattr(chat_module, "_RUNTIME_DB", database_path)
697+
monkeypatch.setattr(chat_module, "build_messages", lambda *_args: [{"role": "user", "content": "test"}])
698+
monkeypatch.setattr(chat_module, "call_deepseek_stream", successful_stream)
699+
700+
with TestClient(app).stream(
701+
"POST",
702+
"/api/chat",
703+
json={"question": "测试", "chart_data": {}, "activation_code": "TEST"},
704+
) as response:
705+
body = "".join(response.iter_text())
706+
707+
assert response.status_code == 200
708+
assert '"token":"ok"' in body or '"token": "ok"' in body
709+
assert RuntimeStore(database_path).activation_remaining("TEST") == 0

0 commit comments

Comments
 (0)