refactor(plugins): comprehensive code review - ~35 fixes across 14 plugins

Phase 1 - Plugin code review (14/14 plugins):
- Security: 3x token leak in print→logger.debug, Bearer prefix handling
- Bug: bare except→specific exceptions, HorseState type safety, sync→async
- Critical: response_model undefined, route dead code, sync blocking event loop
- Quality: 11x print()→logger, variable name shadowing, consistent logging

Phase 2 - Deep analysis:
- Fix: payout int truncation→max(1, round(amount*odds))
- Fix: room_store get_lock race condition→dict.setdefault()
- Verify: data_manager f-string SQL is safe (uses ? placeholders)

Infrastructure: review reports generated for all plugins.
This commit is contained in:
2026-05-09 23:22:28 +08:00
parent 9a8cb3ad6d
commit c01338f496
43 changed files with 4233 additions and 3645 deletions

View File

@@ -0,0 +1,52 @@
# Code Review Report: group_horse_racing
**Date**: 2026-05-09
**Scope**: 15 Python files, ~200KB
**Files Modified**: 4
## Summary
Horse racing plugin with room management, betting system, race simulation, and settlement.
Overall architecture is clean (command pattern + engine + store separation). Found 1 critical singleton bug and 1 enum bug.
## Issues Found & Fixed
### FIX 1 — CRITICAL: Dual RoomStore Instances (shared.py)
- **Problem**: `shared.py` created its own `RoomStore(Config())` at module level (L18), separate from the singleton in `room_store.py` (L253) managed by `__init__.py` lifecycle hooks. This meant the `startup`/`shutdown` hooks (init DB, cleanup old rooms) operated on a DIFFERENT instance than the commands module.
- **Impact**: Race data persistence could silently fail — rooms might not save to DB, old rooms not cleaned up.
- **Fix**: Changed `from ..room_store import RoomStore` + `room_store = RoomStore(config)``from ..room_store import room_store` (import the singleton).
- **Risk**: Low — straightforward import change.
### FIX 2 — BUG: Invalid HorseState.WAITING (race.py L146)
- **Problem**: After stopping a race, horses were set to `HorseState.WAITING` which doesn't exist in the enum (only READY/RACING/FINISHED).
- **Impact**: Would raise `AttributeError` at runtime if stop-race command was used.
- **Fix**: Changed to `HorseState.READY`.
- **Risk**: None — enum value now exists.
### FIX 3 — Silent Exceptions → Debug Logging (message_service.py)
- **Problem**: Two `except Exception: pass` blocks in `recall_previous_of_type` (L66) and `_schedule_recall` (L81).
- **Context**: Message deletion failures (network errors, already deleted).
- **Fix**: Added `logger.debug(..., exc_info=True)` for observability.
- **Risk**: None — logging only.
### FIX 4 — Silent Exceptions → Debug Logging (test_commands.py)
- **Problem**: Two `except Exception: pass` blocks in test cleanup code (L256, L237).
- **Fix**: Added `logger.debug(...)` for test debugging.
## Issues Reviewed & Accepted (No Fix Needed)
- **config.py:75** — Silent `except ValueError: pass` with fallback to `set()`. Already has warning at L70. Radius-0 operation.
- **race.py:77,127** — Admin check silent excepts. Default to non-admin on API failure. Radius-0 operation.
- **shared.py:31** — Name lookup fallback to user_id string. Radius-0 operation.
- **test_commands.py L266** — `RoomStore()` in `_InMemoryRoomStore` mock. Test-only, acceptable.
- **image_render.py** — PIL synchronous rendering. Pre-existing in qqpush plugin (fixed there). Not actionable here as it's the same shared code.
## Architecture Notes
- Good separation: RoomStore (persistence) → RaceEngine (logic) → MessageService (messaging) → Commands (handlers)
- Singleton pattern for RoomStore with lifecycle management via nonebot hooks
- Race simulation runs as asyncio task with tick-based updates
- Betting system with odds calculation is well-structured
- Test file (413 lines) provides good simulation coverage
## Verification
- ✅ 15/15 files syntax valid
- ✅ No `HorseState.WAITING` references remain
-`shared.py` imports singleton (no `RoomStore(config)` call)
- ✅ Debug logging present in message_service.py and test_commands.py

View File

@@ -143,7 +143,7 @@ async def handle_cancel_race(bot: Bot, event: Event):
room.bets.clear()
for horse in room.horses.values():
horse.state = HorseState.WAITING
horse.state = HorseState.READY
room.state = RoomState.WAITING
room.tick_count = 0

View File

@@ -5,7 +5,7 @@ from nonebot.adapters.onebot.v11 import Bot, Event, GroupMessageEvent, Message,
from danding_bot.plugins.danding_qqpush.config import Config as QqPushConfig
from danding_bot.plugins.danding_qqpush.image_render import ImageRenderer
from ..room_store import RoomStore
from ..room_store import room_store # use the singleton managed by __init__.py lifecycle hooks
from ..points_service import PointsService
from ..race_engine import RaceEngine
from ..message_service import MessageService
@@ -14,8 +14,6 @@ from ..models import Room, Horse, Bet, HorseState, RoomState, RaceResult
from .. import plugin_config as config
logger = logging.getLogger("horse_racing.commands")
room_store = RoomStore(config)
points_service = PointsService(config)
race_engine = RaceEngine(config)
message_service = MessageService(config)

View File

@@ -1,98 +1,98 @@
import asyncio
from typing import Optional, Any
from nonebot.adapters.onebot.v11 import Bot, Message
from .config import Config
class MessageService:
def __init__(self, config: Config):
self.config = config
self.pending_recalls: dict[str, list[asyncio.Task]] = {}
self.last_messages: dict[str, dict[str, str]] = {} # scope -> {message_type -> message_id}
async def send_with_recall(
self,
bot: Bot,
scope: str,
message_type: str,
message: str | Message,
) -> Optional[str]:
"""Send message and schedule recall if configured.
If it's a 'race_update', recall the previous one first."""
try:
# For race_update, recall the previous one in the same scope
if message_type == "race_update":
await self.recall_previous_of_type(bot, scope, "race_update")
# Send the message
is_group = scope.startswith("group_")
result = await bot.send_msg(
message_type="group" if is_group else "private",
group_id=int(scope.split("_", 1)[1]) if is_group else None,
user_id=int(scope.split("_", 1)[1]) if not is_group else None,
message=message,
)
message_id = result.get("message_id") if isinstance(result, dict) else None
if not message_id:
return None
# Track the last message of this type
if scope not in self.last_messages:
self.last_messages[scope] = {}
self.last_messages[scope][message_type] = message_id
# Schedule auto-recall if configured
recall_delay = self.config.MESSAGE_RECALL.get(message_type, 0)
if recall_delay > 0:
task = asyncio.create_task(
self._schedule_recall(bot, scope, message_id, recall_delay)
)
if scope not in self.pending_recalls:
self.pending_recalls[scope] = []
self.pending_recalls[scope].append(task)
return message_id
except Exception as e:
return None
async def recall_previous_of_type(self, bot: Bot, scope: str, message_type: str):
"""Recall the previous message of a specific type in a scope."""
if scope in self.last_messages and message_type in self.last_messages[scope]:
old_message_id = self.last_messages[scope][message_type]
try:
await bot.delete_msg(message_id=old_message_id)
except Exception:
pass
del self.last_messages[scope][message_type]
async def _schedule_recall(
self,
bot: Bot,
scope: str,
message_id: str,
delay: int,
):
"""Schedule message recall after a delay."""
try:
await asyncio.sleep(delay)
await bot.delete_msg(message_id=message_id)
except Exception:
pass
def clear_pending_recalls(self, scope: str):
"""Cancel all pending recall tasks for a scope and clear last messages."""
if scope in self.pending_recalls:
for task in self.pending_recalls[scope]:
if not task.done():
task.cancel()
del self.pending_recalls[scope]
if scope in self.last_messages:
del self.last_messages[scope]
def clear_all_recalls(self):
"""Cancel all pending recall tasks."""
for scope in list(self.pending_recalls.keys()):
self.clear_pending_recalls(scope)
import asyncio
from typing import Optional, Any
from nonebot.adapters.onebot.v11 import Bot, Message
from .config import Config
class MessageService:
def __init__(self, config: Config):
self.config = config
self.pending_recalls: dict[str, list[asyncio.Task]] = {}
self.last_messages: dict[str, dict[str, str]] = {} # scope -> {message_type -> message_id}
async def send_with_recall(
self,
bot: Bot,
scope: str,
message_type: str,
message: str | Message,
) -> Optional[str]:
"""Send message and schedule recall if configured.
If it's a 'race_update', recall the previous one first."""
try:
# For race_update, recall the previous one in the same scope
if message_type == "race_update":
await self.recall_previous_of_type(bot, scope, "race_update")
# Send the message
is_group = scope.startswith("group_")
result = await bot.send_msg(
message_type="group" if is_group else "private",
group_id=int(scope.split("_", 1)[1]) if is_group else None,
user_id=int(scope.split("_", 1)[1]) if not is_group else None,
message=message,
)
message_id = result.get("message_id") if isinstance(result, dict) else None
if not message_id:
return None
# Track the last message of this type
if scope not in self.last_messages:
self.last_messages[scope] = {}
self.last_messages[scope][message_type] = message_id
# Schedule auto-recall if configured
recall_delay = self.config.MESSAGE_RECALL.get(message_type, 0)
if recall_delay > 0:
task = asyncio.create_task(
self._schedule_recall(bot, scope, message_id, recall_delay)
)
if scope not in self.pending_recalls:
self.pending_recalls[scope] = []
self.pending_recalls[scope].append(task)
return message_id
except Exception as e:
return None
async def recall_previous_of_type(self, bot: Bot, scope: str, message_type: str):
"""Recall the previous message of a specific type in a scope."""
if scope in self.last_messages and message_type in self.last_messages[scope]:
old_message_id = self.last_messages[scope][message_type]
try:
await bot.delete_msg(message_id=old_message_id)
except Exception:
logger.debug("recall_previous_of_type: failed to delete msg %s", old_message_id, exc_info=True)
del self.last_messages[scope][message_type]
async def _schedule_recall(
self,
bot: Bot,
scope: str,
message_id: str,
delay: int,
):
"""Schedule message recall after a delay."""
try:
await asyncio.sleep(delay)
await bot.delete_msg(message_id=message_id)
except Exception:
logger.debug("_schedule_recall: failed to delete msg %s after %ds delay", message_id, delay, exc_info=True)
def clear_pending_recalls(self, scope: str):
"""Cancel all pending recall tasks for a scope and clear last messages."""
if scope in self.pending_recalls:
for task in self.pending_recalls[scope]:
if not task.done():
task.cancel()
del self.pending_recalls[scope]
if scope in self.last_messages:
del self.last_messages[scope]
def clear_all_recalls(self):
"""Cancel all pending recall tasks."""
for scope in list(self.pending_recalls.keys()):
self.clear_pending_recalls(scope)

View File

@@ -53,7 +53,7 @@ class PointsService:
self, user_id: str, amount: int, odds: float
) -> Tuple[bool, int]:
"""Payout bet winnings."""
payout = int(amount * odds)
payout = max(1, round(amount * odds))
reason = f"下注获胜 ×{odds:.2f}"
return await points_api.add_points(user_id, payout, "horse_race", reason)

View File

@@ -143,9 +143,7 @@ class RoomStore:
def get_lock(self, scope: str) -> asyncio.Lock:
"""Get or create per-room lock."""
if scope not in self._locks:
self._locks[scope] = asyncio.Lock()
return self._locks[scope]
return self._locks.setdefault(scope, asyncio.Lock())
def get_room(self, scope: str) -> Optional[Room]:
"""Get room by scope."""

View File

@@ -234,7 +234,7 @@ class _NoopMessageService:
try:
await bot.delete_msg(message_id=msg_id)
except Exception:
pass
logger.debug("recall_previous_of_type: failed to delete msg %s", msg_id, exc_info=True)
del self.last_messages[scope][message_type]
@@ -254,7 +254,7 @@ async def handle_test_simulate_race(bot: Bot, event: Event):
race_engine.stop_race(scope)
await commands_mod.room_store.delete_room(scope)
except Exception:
pass
logger.debug("test_simulate_race: cleanup old scope %s failed", scope, exc_info=True)
original_room_store = commands_mod.room_store
original_points_service = commands_mod.points_service
original_message_service = commands_mod.message_service