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:
@@ -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]
|
||||
|
||||
@@ -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 = "取消报名退还"
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user