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

View File

@@ -1,24 +1,47 @@
import asyncio
import logging
from typing import Tuple from typing import Tuple
from danding_bot.plugins.danding_points import points_api from danding_bot.plugins.danding_points import points_api
from .config import Config from .config import Config
logger = logging.getLogger("horse_racing.points")
MAX_RETRIES = 3
RETRY_DELAY = 0.5
class PointsService: class PointsService:
def __init__(self, config: Config): def __init__(self, config: Config):
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( async def spend_bet_points(
self, user_id: str, amount: int, reason: str = "赛马下注" self, user_id: str, amount: int, reason: str = "赛马下注"
) -> Tuple[bool, int]: ) -> Tuple[bool, int]:
"""Deduct points for betting with retry.""" """Deduct points for betting with retry."""
success, balance = await points_api.spend_points( try:
user_id, amount, "horse_race", reason return await self._call_with_retry(
) points_api.spend_points,
if not success:
success, balance = await points_api.spend_points(
user_id, amount, "horse_race", reason 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( async def refund_bet_points(
self, user_id: str, amount: int, reason: str = "取消报名退还" self, user_id: str, amount: int, reason: str = "取消报名退还"

View File

@@ -1,5 +1,6 @@
import asyncio import asyncio
import sqlite3 import aiosqlite
import json
from datetime import datetime from datetime import datetime
from pathlib import Path from pathlib import Path
from typing import Optional from typing import Optional
@@ -15,47 +16,73 @@ class RoomStore:
self._locks: dict[str, asyncio.Lock] = {} self._locks: dict[str, asyncio.Lock] = {}
self.db_path = Path(config.RACE_DB_FILE) self.db_path = Path(config.RACE_DB_FILE)
self.db_path.parent.mkdir(parents=True, exist_ok=True) self.db_path.parent.mkdir(parents=True, exist_ok=True)
self._init_db() self._initialized = False
def _init_db(self): async def _init_db(self):
"""Initialize database tables.""" """Initialize database tables asynchronously."""
conn = sqlite3.connect(self.db_path) if self._initialized:
cursor = conn.cursor() return
cursor.execute(""" async with aiosqlite.connect(self.db_path) as db:
CREATE TABLE IF NOT EXISTS room_snapshots ( await db.execute("""
scope TEXT PRIMARY KEY, CREATE TABLE IF NOT EXISTS room_snapshots (
state TEXT NOT NULL, scope TEXT PRIMARY KEY,
created_at TEXT NOT NULL, state TEXT NOT NULL,
horses TEXT NOT NULL, created_at TEXT NOT NULL,
bets TEXT NOT NULL, horses TEXT NOT NULL,
champion_name TEXT, bets TEXT NOT NULL,
tick_count INTEGER DEFAULT 0 champion_name TEXT,
) tick_count INTEGER DEFAULT 0
""") )
""")
cursor.execute(""" await db.execute("""
CREATE TABLE IF NOT EXISTS race_history ( CREATE TABLE IF NOT EXISTS race_history (
race_id TEXT PRIMARY KEY, race_id TEXT PRIMARY KEY,
scope TEXT NOT NULL, scope TEXT NOT NULL,
champion_name TEXT NOT NULL, champion_name TEXT NOT NULL,
champion_owner TEXT NOT NULL, champion_owner TEXT NOT NULL,
participants TEXT NOT NULL, participants TEXT NOT NULL,
bet_distribution TEXT NOT NULL, bet_distribution TEXT NOT NULL,
duration_ticks INTEGER NOT NULL, duration_ticks INTEGER NOT NULL,
completed_at TEXT NOT NULL completed_at TEXT NOT NULL,
) point_changes TEXT DEFAULT '{}',
""") point_change_summaries TEXT DEFAULT '{}',
odds_snapshot TEXT DEFAULT '{}'
)
""")
cursor.execute(""" await db.execute("""
CREATE TABLE IF NOT EXISTS user_horse_names ( CREATE TABLE IF NOT EXISTS user_horse_names (
user_id TEXT PRIMARY KEY, user_id TEXT PRIMARY KEY,
horse_name TEXT NOT NULL horse_name TEXT NOT NULL
) )
""") """)
conn.commit() # Add missing columns if they don't exist (for existing databases)
conn.close() 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: def get_lock(self, scope: str) -> asyncio.Lock:
"""Get or create per-room lock.""" """Get or create per-room lock."""
@@ -67,11 +94,11 @@ class RoomStore:
"""Get room by scope.""" """Get room by scope."""
return self.rooms.get(scope) return self.rooms.get(scope)
def create_room(self, scope: str) -> Room: async def create_room(self, scope: str) -> Room:
"""Create new room.""" """Create new room."""
room = Room(scope=scope) room = Room(scope=scope)
self.rooms[scope] = room self.rooms[scope] = room
self._save_snapshot(room) await self._save_snapshot(room)
return room return room
def delete_room(self, scope: str): def delete_room(self, scope: str):
@@ -79,27 +106,28 @@ class RoomStore:
if scope in self.rooms: if scope in self.rooms:
del self.rooms[scope] del self.rooms[scope]
def get_last_horse_name(self, user_id: str) -> Optional[str]: async def get_last_horse_name(self, user_id: str) -> Optional[str]:
conn = sqlite3.connect(self.db_path) await self.ensure_initialized()
cursor = conn.cursor() async with aiosqlite.connect(self.db_path) as db:
cursor.execute("SELECT horse_name FROM user_horse_names WHERE user_id = ?", (user_id,)) cursor = await db.execute(
row = cursor.fetchone() "SELECT horse_name FROM user_horse_names WHERE user_id = ?",
conn.close() (user_id,)
return row[0] if row else None )
row = await cursor.fetchone()
return row[0] if row else None
def set_last_horse_name(self, user_id: str, horse_name: str): async def set_last_horse_name(self, user_id: str, horse_name: str):
conn = sqlite3.connect(self.db_path) await self.ensure_initialized()
cursor = conn.cursor() async with aiosqlite.connect(self.db_path) as db:
cursor.execute( await db.execute(
"INSERT OR REPLACE INTO user_horse_names (user_id, horse_name) VALUES (?, ?)", "INSERT OR REPLACE INTO user_horse_names (user_id, horse_name) VALUES (?, ?)",
(user_id, horse_name), (user_id, horse_name),
) )
conn.commit() await db.commit()
conn.close()
def _save_snapshot(self, room: Room): async def _save_snapshot(self, room: Room):
"""Save room snapshot to database.""" """Save room snapshot to database."""
import json await self.ensure_initialized()
horses_json = json.dumps({ horses_json = json.dumps({
name: { name: {
@@ -121,43 +149,44 @@ class RoomStore:
for bet in room.bets for bet in room.bets
]) ])
conn = sqlite3.connect(self.db_path) async with aiosqlite.connect(self.db_path) as db:
cursor = conn.cursor() await db.execute("""
cursor.execute(""" INSERT OR REPLACE INTO room_snapshots
INSERT OR REPLACE INTO room_snapshots (scope, state, created_at, horses, bets, champion_name, tick_count)
(scope, state, created_at, horses, bets, champion_name, tick_count) VALUES (?, ?, ?, ?, ?, ?, ?)
VALUES (?, ?, ?, ?, ?, ?, ?) """, (
""", ( room.scope,
room.scope, room.state.value,
room.state.value, room.created_at.isoformat(),
room.created_at.isoformat(), horses_json,
horses_json, bets_json,
bets_json, room.champion_name,
room.champion_name, room.tick_count,
room.tick_count, ))
)) await db.commit()
conn.commit()
conn.close()
def save_race_result(self, result: RaceResult): async def save_race_result(self, result: RaceResult):
"""Save race result to history.""" """Save race result to history."""
import json await self.ensure_initialized()
conn = sqlite3.connect(self.db_path) async with aiosqlite.connect(self.db_path) as db:
cursor = conn.cursor() await db.execute("""
cursor.execute(""" INSERT INTO race_history
INSERT INTO race_history (race_id, scope, champion_name, champion_owner, participants,
(race_id, scope, champion_name, champion_owner, participants, bet_distribution, duration_ticks, completed_at) bet_distribution, duration_ticks, completed_at,
VALUES (?, ?, ?, ?, ?, ?, ?, ?) point_changes, point_change_summaries, odds_snapshot)
""", ( VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
result.race_id, """, (
result.scope, result.race_id,
result.champion_name, result.scope,
result.champion_owner, result.champion_name,
json.dumps(result.participants), result.champion_owner,
json.dumps(result.bet_distribution), json.dumps(result.participants),
result.duration_ticks, json.dumps(result.bet_distribution),
result.completed_at.isoformat(), result.duration_ticks,
)) result.completed_at.isoformat(),
conn.commit() json.dumps(getattr(result, 'point_changes', {})),
conn.close() 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 nonebot.adapters.onebot.v11 import Bot, Event, GroupMessageEvent, PrivateMessageEvent
from . import plugin_config as config 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 from .models import Horse, HorseState, RoomState, Bet, RaceResult
import asyncio import asyncio
@@ -30,7 +30,7 @@ async def handle_test_reset_points(bot: Bot, event: Event):
await test_reset_points_cmd.finish("权限不足") await test_reset_points_cmd.finish("权限不足")
return 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: if success:
await test_reset_points_cmd.finish("积分已重置为1000") await test_reset_points_cmd.finish("积分已重置为1000")
else: else:
@@ -65,7 +65,7 @@ async def handle_test_set_points(bot: Bot, event: Event):
await test_set_points_cmd.finish("金额必须是整数") await test_set_points_cmd.finish("金额必须是整数")
return 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: if success:
await test_set_points_cmd.finish(f"积分已设置为 {amount}") await test_set_points_cmd.finish(f"积分已设置为 {amount}")
else: else:
@@ -82,7 +82,7 @@ async def handle_test_query_points(bot: Bot, event: Event):
await test_query_points_cmd.finish("权限不足") await test_query_points_cmd.finish("权限不足")
return 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}") await test_query_points_cmd.finish(f"当前积分: {balance}")
@@ -97,7 +97,7 @@ async def handle_test_clear_room(bot: Bot, event: Event):
return return
scope = get_scope(event) scope = get_scope(event)
room_store.delete_room(scope) commands_mod.room_store.delete_room(scope)
await test_clear_room_cmd.finish("房间已清空") await test_clear_room_cmd.finish("房间已清空")
@@ -252,7 +252,7 @@ async def handle_test_simulate_race(bot: Bot, event: Event):
scope = get_scope(event) scope = get_scope(event)
try: try:
race_engine.stop_race(scope) race_engine.stop_race(scope)
room_store.delete_room(scope) await commands_mod.room_store.delete_room(scope)
except Exception: except Exception:
pass pass
original_room_store = commands_mod.room_store original_room_store = commands_mod.room_store