fix: 赛马插件P0-P2问题修复

- P0: room_store sqlite3→aiosqlite异步化
- P0: points_service统一异常处理+轻量重试
- P0: _send_to_scope加warning日志
- P1: 积分历史记录补充source/reason字段
- P1: 赛马结算写入赔率快照(odds_snapshot)
- P1: test_commands改为commands_mod间接引用(测试隔离)
- P2: 马名去重统一casefold()比较
This commit is contained in:
2026-05-01 22:50:14 +08:00
parent dd8781a74d
commit 569801dd14
4 changed files with 1298 additions and 1242 deletions

View File

@@ -1,4 +1,5 @@
import asyncio
import logging
import uuid
from datetime import datetime
@@ -16,6 +17,8 @@ from .models import Room, Horse, Bet, HorseState, RoomState, RaceResult
# Import config from __init__ to ensure it's loaded through NoneBot driver
from . import plugin_config as config
logger = logging.getLogger("horse_racing.commands")
room_store = RoomStore(config)
points_service = PointsService(config)
race_engine = RaceEngine(config)
@@ -255,8 +258,9 @@ async def settle_race(room: Room) -> RaceResult | None:
completed_at=datetime.now(),
point_changes=point_changes,
point_change_summaries=point_summaries,
odds_snapshot=odds,
)
room_store.save_race_result(result)
await room_store.save_race_result(result)
return result
@@ -268,8 +272,8 @@ async def _send_to_scope(bot: Bot, scope: str, message: str, message_type: str =
outbound_message = _build_race_image_message(message)
await message_service.send_with_recall(bot, scope, message_type, outbound_message)
except Exception:
pass
except Exception as e:
logger.warning(f"_send_to_scope failed for {scope} [{message_type}]: {e}")
async def run_race_with_settlement(bot: Bot, room: Room, scope: str):
@@ -348,7 +352,7 @@ async def handle_register(bot: Bot, event: Event):
msg = str(event.get_message()).strip()
parts = msg.split(None, 1)
user_id = get_event_id(event)
horse_name = parts[1].strip() if len(parts) > 1 else room_store.get_last_horse_name(user_id) or ""
horse_name = parts[1].strip() if len(parts) > 1 else await room_store.get_last_horse_name(user_id) or ""
if not horse_name:
scope = get_scope(event)
@@ -367,7 +371,7 @@ async def handle_register(bot: Bot, event: Event):
async with lock:
room = room_store.get_room(scope)
if not room:
room = room_store.create_room(scope)
room = await room_store.create_room(scope)
if room.state != RoomState.WAITING:
await register_cmd.finish("比赛正在进行中,无法报名")
@@ -390,7 +394,7 @@ async def handle_register(bot: Bot, event: Event):
horse_index = room.next_horse_index
room.next_horse_index += 1
room.horses[horse_name] = Horse(owner_id=user_id, name=horse_name, index=horse_index)
room_store.set_last_horse_name(user_id, horse_name)
await room_store.set_last_horse_name(user_id, horse_name)
count = len(room.horses)
registered_horse = room.horses[horse_name]

View File

@@ -1,24 +1,47 @@
import asyncio
import logging
from typing import Tuple
from danding_bot.plugins.danding_points import points_api
from .config import Config
logger = logging.getLogger("horse_racing.points")
MAX_RETRIES = 3
RETRY_DELAY = 0.5
class PointsService:
def __init__(self, config: Config):
self.config = config
async def _call_with_retry(self, func, *args, retries=MAX_RETRIES):
"""Call API function with retry logic."""
last_exc = None
for attempt in range(retries):
try:
return await func(*args)
except Exception as e:
last_exc = e
logger.warning(
f"Points API call failed (attempt {attempt + 1}/{retries}): {e}"
)
if attempt < retries - 1:
await asyncio.sleep(RETRY_DELAY)
logger.error(f"Points API call failed after {retries} attempts: {last_exc}")
raise last_exc
async def spend_bet_points(
self, user_id: str, amount: int, reason: str = "赛马下注"
) -> Tuple[bool, int]:
"""Deduct points for betting with retry."""
success, balance = await points_api.spend_points(
user_id, amount, "horse_race", reason
)
if not success:
success, balance = await points_api.spend_points(
try:
return await self._call_with_retry(
points_api.spend_points,
user_id, amount, "horse_race", reason
)
return success, balance
except Exception as e:
logger.error(f"spend_bet_points failed for user {user_id}: {e}")
return False, 0
async def refund_bet_points(
self, user_id: str, amount: int, reason: str = "取消报名退还"

View File

@@ -1,5 +1,6 @@
import asyncio
import sqlite3
import aiosqlite
import json
from datetime import datetime
from pathlib import Path
from typing import Optional
@@ -15,47 +16,73 @@ class RoomStore:
self._locks: dict[str, asyncio.Lock] = {}
self.db_path = Path(config.RACE_DB_FILE)
self.db_path.parent.mkdir(parents=True, exist_ok=True)
self._init_db()
self._initialized = False
def _init_db(self):
"""Initialize database tables."""
conn = sqlite3.connect(self.db_path)
cursor = conn.cursor()
async def _init_db(self):
"""Initialize database tables asynchronously."""
if self._initialized:
return
cursor.execute("""
CREATE TABLE IF NOT EXISTS room_snapshots (
scope TEXT PRIMARY KEY,
state TEXT NOT NULL,
created_at TEXT NOT NULL,
horses TEXT NOT NULL,
bets TEXT NOT NULL,
champion_name TEXT,
tick_count INTEGER DEFAULT 0
)
""")
async with aiosqlite.connect(self.db_path) as db:
await db.execute("""
CREATE TABLE IF NOT EXISTS room_snapshots (
scope TEXT PRIMARY KEY,
state TEXT NOT NULL,
created_at TEXT NOT NULL,
horses TEXT NOT NULL,
bets TEXT NOT NULL,
champion_name TEXT,
tick_count INTEGER DEFAULT 0
)
""")
cursor.execute("""
CREATE TABLE IF NOT EXISTS race_history (
race_id TEXT PRIMARY KEY,
scope TEXT NOT NULL,
champion_name TEXT NOT NULL,
champion_owner TEXT NOT NULL,
participants TEXT NOT NULL,
bet_distribution TEXT NOT NULL,
duration_ticks INTEGER NOT NULL,
completed_at TEXT NOT NULL
)
""")
await db.execute("""
CREATE TABLE IF NOT EXISTS race_history (
race_id TEXT PRIMARY KEY,
scope TEXT NOT NULL,
champion_name TEXT NOT NULL,
champion_owner TEXT NOT NULL,
participants TEXT NOT NULL,
bet_distribution TEXT NOT NULL,
duration_ticks INTEGER NOT NULL,
completed_at TEXT NOT NULL,
point_changes TEXT DEFAULT '{}',
point_change_summaries TEXT DEFAULT '{}',
odds_snapshot TEXT DEFAULT '{}'
)
""")
cursor.execute("""
CREATE TABLE IF NOT EXISTS user_horse_names (
user_id TEXT PRIMARY KEY,
horse_name TEXT NOT NULL
)
""")
await db.execute("""
CREATE TABLE IF NOT EXISTS user_horse_names (
user_id TEXT PRIMARY KEY,
horse_name TEXT NOT NULL
)
""")
conn.commit()
conn.close()
# Add missing columns if they don't exist (for existing databases)
try:
await db.execute("SELECT point_changes FROM race_history LIMIT 1")
except aiosqlite.OperationalError:
await db.execute("ALTER TABLE race_history ADD COLUMN point_changes TEXT DEFAULT '{}'")
try:
await db.execute("SELECT point_change_summaries FROM race_history LIMIT 1")
except aiosqlite.OperationalError:
await db.execute("ALTER TABLE race_history ADD COLUMN point_change_summaries TEXT DEFAULT '{}'")
try:
await db.execute("SELECT odds_snapshot FROM race_history LIMIT 1")
except aiosqlite.OperationalError:
await db.execute("ALTER TABLE race_history ADD COLUMN odds_snapshot TEXT DEFAULT '{}'")
await db.commit()
self._initialized = True
async def ensure_initialized(self):
"""Ensure database is initialized (call before any DB operation)."""
if not self._initialized:
await self._init_db()
def get_lock(self, scope: str) -> asyncio.Lock:
"""Get or create per-room lock."""
@@ -67,11 +94,11 @@ class RoomStore:
"""Get room by scope."""
return self.rooms.get(scope)
def create_room(self, scope: str) -> Room:
async def create_room(self, scope: str) -> Room:
"""Create new room."""
room = Room(scope=scope)
self.rooms[scope] = room
self._save_snapshot(room)
await self._save_snapshot(room)
return room
def delete_room(self, scope: str):
@@ -79,27 +106,28 @@ class RoomStore:
if scope in self.rooms:
del self.rooms[scope]
def get_last_horse_name(self, user_id: str) -> Optional[str]:
conn = sqlite3.connect(self.db_path)
cursor = conn.cursor()
cursor.execute("SELECT horse_name FROM user_horse_names WHERE user_id = ?", (user_id,))
row = cursor.fetchone()
conn.close()
return row[0] if row else None
async def get_last_horse_name(self, user_id: str) -> Optional[str]:
await self.ensure_initialized()
async with aiosqlite.connect(self.db_path) as db:
cursor = await db.execute(
"SELECT horse_name FROM user_horse_names WHERE user_id = ?",
(user_id,)
)
row = await cursor.fetchone()
return row[0] if row else None
def set_last_horse_name(self, user_id: str, horse_name: str):
conn = sqlite3.connect(self.db_path)
cursor = conn.cursor()
cursor.execute(
"INSERT OR REPLACE INTO user_horse_names (user_id, horse_name) VALUES (?, ?)",
(user_id, horse_name),
)
conn.commit()
conn.close()
async def set_last_horse_name(self, user_id: str, horse_name: str):
await self.ensure_initialized()
async with aiosqlite.connect(self.db_path) as db:
await db.execute(
"INSERT OR REPLACE INTO user_horse_names (user_id, horse_name) VALUES (?, ?)",
(user_id, horse_name),
)
await db.commit()
def _save_snapshot(self, room: Room):
async def _save_snapshot(self, room: Room):
"""Save room snapshot to database."""
import json
await self.ensure_initialized()
horses_json = json.dumps({
name: {
@@ -121,43 +149,44 @@ class RoomStore:
for bet in room.bets
])
conn = sqlite3.connect(self.db_path)
cursor = conn.cursor()
cursor.execute("""
INSERT OR REPLACE INTO room_snapshots
(scope, state, created_at, horses, bets, champion_name, tick_count)
VALUES (?, ?, ?, ?, ?, ?, ?)
""", (
room.scope,
room.state.value,
room.created_at.isoformat(),
horses_json,
bets_json,
room.champion_name,
room.tick_count,
))
conn.commit()
conn.close()
async with aiosqlite.connect(self.db_path) as db:
await db.execute("""
INSERT OR REPLACE INTO room_snapshots
(scope, state, created_at, horses, bets, champion_name, tick_count)
VALUES (?, ?, ?, ?, ?, ?, ?)
""", (
room.scope,
room.state.value,
room.created_at.isoformat(),
horses_json,
bets_json,
room.champion_name,
room.tick_count,
))
await db.commit()
def save_race_result(self, result: RaceResult):
async def save_race_result(self, result: RaceResult):
"""Save race result to history."""
import json
await self.ensure_initialized()
conn = sqlite3.connect(self.db_path)
cursor = conn.cursor()
cursor.execute("""
INSERT INTO race_history
(race_id, scope, champion_name, champion_owner, participants, bet_distribution, duration_ticks, completed_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
""", (
result.race_id,
result.scope,
result.champion_name,
result.champion_owner,
json.dumps(result.participants),
json.dumps(result.bet_distribution),
result.duration_ticks,
result.completed_at.isoformat(),
))
conn.commit()
conn.close()
async with aiosqlite.connect(self.db_path) as db:
await db.execute("""
INSERT INTO race_history
(race_id, scope, champion_name, champion_owner, participants,
bet_distribution, duration_ticks, completed_at,
point_changes, point_change_summaries, odds_snapshot)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
""", (
result.race_id,
result.scope,
result.champion_name,
result.champion_owner,
json.dumps(result.participants),
json.dumps(result.bet_distribution),
result.duration_ticks,
result.completed_at.isoformat(),
json.dumps(getattr(result, 'point_changes', {})),
json.dumps(getattr(result, 'point_change_summaries', {})),
json.dumps(getattr(result, 'odds_snapshot', {})),
))
await db.commit()

View File

@@ -2,7 +2,7 @@ from nonebot import on_command
from nonebot.adapters.onebot.v11 import Bot, Event, GroupMessageEvent, PrivateMessageEvent
from . import plugin_config as config
from .commands import get_scope, check_access, room_store, points_service, race_engine
from .commands import get_scope, check_access, race_engine
from .models import Horse, HorseState, RoomState, Bet, RaceResult
import asyncio
@@ -30,7 +30,7 @@ async def handle_test_reset_points(bot: Bot, event: Event):
await test_reset_points_cmd.finish("权限不足")
return
success, _ = await points_service.set_points(event.user_id, 1000, "测试重置积分")
success, _ = await commands_mod.points_service.set_points(event.user_id, 1000, "测试重置积分")
if success:
await test_reset_points_cmd.finish("积分已重置为1000")
else:
@@ -65,7 +65,7 @@ async def handle_test_set_points(bot: Bot, event: Event):
await test_set_points_cmd.finish("金额必须是整数")
return
success, _ = await points_service.set_points(event.user_id, amount, f"测试设置积分为{amount}")
success, _ = await commands_mod.points_service.set_points(event.user_id, amount, f"测试设置积分为{amount}")
if success:
await test_set_points_cmd.finish(f"积分已设置为 {amount}")
else:
@@ -82,7 +82,7 @@ async def handle_test_query_points(bot: Bot, event: Event):
await test_query_points_cmd.finish("权限不足")
return
balance = await points_service.get_balance(event.user_id)
balance = await commands_mod.points_service.get_balance(event.user_id)
await test_query_points_cmd.finish(f"当前积分: {balance}")
@@ -97,7 +97,7 @@ async def handle_test_clear_room(bot: Bot, event: Event):
return
scope = get_scope(event)
room_store.delete_room(scope)
commands_mod.room_store.delete_room(scope)
await test_clear_room_cmd.finish("房间已清空")
@@ -252,7 +252,7 @@ async def handle_test_simulate_race(bot: Bot, event: Event):
scope = get_scope(event)
try:
race_engine.stop_race(scope)
room_store.delete_room(scope)
await commands_mod.room_store.delete_room(scope)
except Exception:
pass
original_room_store = commands_mod.room_store