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 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]
|
||||||
|
|||||||
@@ -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:
|
||||||
|
return await self._call_with_retry(
|
||||||
|
points_api.spend_points,
|
||||||
user_id, amount, "horse_race", reason
|
user_id, amount, "horse_race", reason
|
||||||
)
|
)
|
||||||
if not success:
|
except Exception as e:
|
||||||
success, balance = await points_api.spend_points(
|
logger.error(f"spend_bet_points failed for user {user_id}: {e}")
|
||||||
user_id, amount, "horse_race", reason
|
return False, 0
|
||||||
)
|
|
||||||
return success, balance
|
|
||||||
|
|
||||||
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 = "取消报名退还"
|
||||||
|
|||||||
@@ -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,14 +16,15 @@ 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:
|
||||||
|
await db.execute("""
|
||||||
CREATE TABLE IF NOT EXISTS room_snapshots (
|
CREATE TABLE IF NOT EXISTS room_snapshots (
|
||||||
scope TEXT PRIMARY KEY,
|
scope TEXT PRIMARY KEY,
|
||||||
state TEXT NOT NULL,
|
state TEXT NOT NULL,
|
||||||
@@ -34,7 +36,7 @@ class RoomStore:
|
|||||||
)
|
)
|
||||||
""")
|
""")
|
||||||
|
|
||||||
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,
|
||||||
@@ -43,19 +45,44 @@ class RoomStore:
|
|||||||
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,)
|
||||||
|
)
|
||||||
|
row = await cursor.fetchone()
|
||||||
return row[0] if row else None
|
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,9 +149,8 @@ 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 (?, ?, ?, ?, ?, ?, ?)
|
||||||
@@ -136,19 +163,19 @@ class RoomStore:
|
|||||||
room.champion_name,
|
room.champion_name,
|
||||||
room.tick_count,
|
room.tick_count,
|
||||||
))
|
))
|
||||||
conn.commit()
|
await db.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, bet_distribution, duration_ticks, completed_at)
|
(race_id, scope, champion_name, champion_owner, participants,
|
||||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
bet_distribution, duration_ticks, completed_at,
|
||||||
|
point_changes, point_change_summaries, odds_snapshot)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
""", (
|
""", (
|
||||||
result.race_id,
|
result.race_id,
|
||||||
result.scope,
|
result.scope,
|
||||||
@@ -158,6 +185,8 @@ class RoomStore:
|
|||||||
json.dumps(result.bet_distribution),
|
json.dumps(result.bet_distribution),
|
||||||
result.duration_ticks,
|
result.duration_ticks,
|
||||||
result.completed_at.isoformat(),
|
result.completed_at.isoformat(),
|
||||||
|
json.dumps(getattr(result, 'point_changes', {})),
|
||||||
|
json.dumps(getattr(result, 'point_change_summaries', {})),
|
||||||
|
json.dumps(getattr(result, 'odds_snapshot', {})),
|
||||||
))
|
))
|
||||||
conn.commit()
|
await db.commit()
|
||||||
conn.close()
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user