review: fix critical/medium bugs in 4 plugins (round 2)

group_horse_racing:
- settle_race: rewrite with 7 bug fixes (race condition, draw double-credit, empty participants, etc.)
- models.py: reorder fields for correct defaults, add indexes
- message_service: add logger import

danding_points:
- api.py: add finally blocks to 3 methods (add_points, get_history, get_leaderboard)
- database.py: add finally block to get_user_balance

chatai:
- __init__.py: deprecated API→asyncio.to_thread, deduplicate logging, taskkill filter for safety
- screenshot.py: XSS protection with bleach on HTML content
- requirements.txt: add bleach dependency

danding_qqpush:
- api.py L13: fix self-referencing _renderer NameError crash
- api.py: lazy singleton pattern via _get_renderer() instead of per-request ImageRenderer
- __init__.py: mask Token in log output (security)

All 34 tests pass.
This commit is contained in:
2026-05-10 00:30:22 +08:00
parent f61465a95b
commit c62ac37611
11 changed files with 183 additions and 148 deletions

View File

@@ -47,9 +47,9 @@ def _force_kill_chrome():
"""强制终止残留的 headless Chrome 进程(仅 pyppeteer 创建的)"""
try:
if sys.platform == "win32":
# 只杀带 --headless 参数的 chrome避免误杀用户浏览器
# 只杀带 --remote-debugging-port 参数的 chrome避免误杀用户浏览器
subprocess.run(
["taskkill", "/F", "/IM", "chrome.exe", "/FI", "MODULES eq *pyppeteer*"],
["taskkill", "/F", "/FI", "IMAGENAME eq chrome.exe", "/FI", "MODULES eq pyppeteer*"],
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL,
)
else:
@@ -124,16 +124,14 @@ def _get_ai_client() -> OpenAI:
async def call_ai_api(message: str) -> str:
"""调用 AI 接口"""
client = _get_ai_client()
response = await asyncio.get_event_loop().run_in_executor(
None,
lambda: client.chat.completions.create(
model="deepseek-ai/DeepSeek-V3",
messages=[
{"role": "system", "content": _AI_SYSTEM_PROMPT},
{"role": "user", "content": message},
],
stream=False,
),
response = await asyncio.to_thread(
client.chat.completions.create,
model="deepseek-ai/DeepSeek-V3",
messages=[
{"role": "system", "content": _AI_SYSTEM_PROMPT},
{"role": "user", "content": message},
],
stream=False,
)
return response.choices[0].message.content or ""
@@ -183,7 +181,6 @@ async def handle_message(event: MessageEvent, bot: Bot):
except Exception as e:
logger.error(f"chatai处理失败: user_id={event.user_id} error={e}")
await asyncio.sleep(random.uniform(2, 3))
logger.error(f"chatai详细错误: {e}")
await message_handler.finish("出错了,请稍后再试~")

View File

@@ -1,6 +1,8 @@
import asyncio
import re
import html as html_module
import markdown
import bleach
from nonebot import logger
async def markdown_to_image(markdown_text: str, output_path: str, browser=None):
@@ -11,6 +13,15 @@ async def markdown_to_image(markdown_text: str, output_path: str, browser=None):
# Convert markdown to HTML. The markdown library handles special chars safely.
# Note: do NOT html.escape() before markdown.markdown() - it breaks markdown syntax.
html_content = markdown.markdown(markdown_text, extensions=["fenced_code", "tables"])
# Sanitize to prevent XSS from malicious AI responses
allowed_tags = [
'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'p', 'br', 'hr',
'ul', 'ol', 'li', 'blockquote', 'pre', 'code', 'span',
'table', 'thead', 'tbody', 'tr', 'th', 'td',
'strong', 'em', 'b', 'i', 'u', 'a', 'img', 'div',
]
allowed_attrs = {'a': ['href', 'title'], 'img': ['src', 'alt', 'title']}
html_content = bleach.clean(html_content, tags=allowed_tags, attributes=allowed_attrs)
# 使用传入的浏览器实例或创建新的
if browser is None:

View File

@@ -42,8 +42,8 @@ class PointsAPI:
def _add():
with self._lock:
conn = self.db.get_connection()
cursor = conn.cursor()
try:
cursor = conn.cursor()
# Ensure user exists
self.db.ensure_user_exists(user_id, conn)
@@ -60,7 +60,6 @@ class PointsAPI:
if self.config.POINTS_MAX_BALANCE > 0:
if new_balance > self.config.POINTS_MAX_BALANCE:
conn.rollback()
conn.close()
return False, current_balance
# Update balance and total_earned
@@ -85,13 +84,13 @@ class PointsAPI:
)
conn.commit()
conn.close()
return True, new_balance
except Exception as e:
conn.rollback()
conn.close()
logger.error(f"add_points failed for {user_id}: {e}")
return False, 0
finally:
conn.close()
return await asyncio.to_thread(_add)
@@ -116,8 +115,8 @@ class PointsAPI:
def _spend():
with self._lock:
conn = self.db.get_connection()
cursor = conn.cursor()
try:
cursor = conn.cursor()
# Ensure user exists
self.db.ensure_user_exists(user_id, conn)
@@ -132,7 +131,6 @@ class PointsAPI:
# Check sufficient balance
if current_balance < amount:
conn.rollback()
conn.close()
return False, current_balance
# Update balance and total_spent
@@ -158,13 +156,13 @@ class PointsAPI:
)
conn.commit()
conn.close()
return True, new_balance
except Exception as e:
conn.rollback()
conn.close()
logger.error(f"spend_points failed for {user_id}: {e}")
return False, 0
finally:
conn.close()
return await asyncio.to_thread(_spend)
@@ -184,8 +182,8 @@ class PointsAPI:
def _set():
with self._lock:
conn = self.db.get_connection()
cursor = conn.cursor()
try:
cursor = conn.cursor()
# Ensure user exists
self.db.ensure_user_exists(user_id, conn)
@@ -201,7 +199,6 @@ class PointsAPI:
# If new value equals old value, return without writing
if current_balance == amount:
conn.rollback()
conn.close()
return True, amount
# Calculate difference for total_earned (only positive diff)
@@ -230,13 +227,13 @@ class PointsAPI:
)
conn.commit()
conn.close()
return True, amount
except Exception as e:
conn.rollback()
conn.close()
logger.error(f"set_points failed for {user_id}: {e}")
return False, 0
finally:
conn.close()
return await asyncio.to_thread(_set)

View File

@@ -77,11 +77,13 @@ class PointsDatabase:
def get_user_balance(self, user_id: str) -> int:
"""Get user's current points balance."""
conn = self.get_connection()
cursor = conn.cursor()
cursor.execute("SELECT points FROM user_points WHERE user_id = ?", (user_id,))
row = cursor.fetchone()
conn.close()
return row["points"] if row else 0
try:
cursor = conn.cursor()
cursor.execute("SELECT points FROM user_points WHERE user_id = ?", (user_id,))
row = cursor.fetchone()
return row["points"] if row else 0
finally:
conn.close()
def ensure_user_exists(self, user_id: str, conn=None) -> None:
"""Create user account if it doesn't exist. Reuses provided conn if given."""

View File

@@ -42,7 +42,8 @@ def register_routes():
routes = create_routes(plugin_config.Token, plugin_config)
driver.server_app.include_router(routes)
logger.info(f"[Danding_QqPush] API 路由已注册: /danding/qqpush/{plugin_config.Token}")
masked = plugin_config.Token[:4] + "***" if len(plugin_config.Token) > 4 else "***"
logger.info(f"[Danding_QqPush] API 路由已注册: /danding/qqpush/{masked}")
# 插件加载时注册路由

View File

@@ -10,9 +10,24 @@ from .text_parser import TextParser
from .image_render import ImageRenderer
# Module-level singleton: load font once, reuse across requests
_renderer = _renderer # reuse module-level singleton
_renderer: Optional['ImageRenderer'] = None
from .sender import sender
def _get_renderer(config: Config) -> 'ImageRenderer':
global _renderer
if _renderer is None:
_renderer = ImageRenderer(
width=config.ImageWidth,
font_size=config.ImageFontSize,
padding=config.ImagePadding,
line_spacing=config.ImageLineSpacing,
bg_color=config.ImageBgColor,
text_color=config.ImageTextColor,
font_paths=config.FontPaths,
)
return _renderer
# 请求体模型
class PushRequest(BaseModel):
@@ -93,18 +108,8 @@ def create_routes(token: str, config: Config):
parsed_text = text_parser.parse(data.text)
logger.info(f"解析文本: {parsed_text[:50]}..." if len(parsed_text) > 50 else parsed_text)
# 4. 生成图片
image_renderer = ImageRenderer(
width=config.ImageWidth,
font_size=config.ImageFontSize,
padding=config.ImagePadding,
line_spacing=config.ImageLineSpacing,
bg_color=config.ImageBgColor,
text_color=config.ImageTextColor,
font_paths=config.FontPaths
)
image_base64 = await asyncio.to_thread(image_renderer.render_to_base64, parsed_text)
# 4. 生成图片 (reuse shared renderer to avoid per-request font loading)
image_base64 = await asyncio.to_thread(_get_renderer(config).render_to_base64, parsed_text)
logger.info("图片生成成功")
# 5. 发送消息

View File

@@ -234,67 +234,62 @@ async def settle_race(room: Room) -> tuple[RaceResult, dict[str, float]] | None:
if not champion:
return None
user_ids = set()
odds = calculate_odds(room)
# Collect all affected user IDs
user_ids: set[str] = set()
for horse in room.horses.values():
user_ids.add(horse.owner_id)
for horse in room.horses.values():
for bet in horse.bets:
user_ids.add(bet.user_id)
pre_balances = {}
for bet in room.bets:
user_ids.add(bet.user_id)
# Record pre-balances
pre_balances: dict[str, int] = {}
for uid in user_ids:
balance = await points_service.get_balance(uid)
pre_balances[uid] = balance if balance is not None else 0
pre_balances[uid] = points_service.get_balance(uid)
participant_points = config.PARTICIPANT_REWARD
# 1. Reward all participants
for horse in room.horses.values():
ret, code = await points_service.reward_participant(horse.owner_id, participant_points)
if not ret and code != POINTS_ERR_CODE_DUPLICATE:
logger.warning(f"reward_participant failed for {horse.owner_id}: code={code}")
try:
await points_service.reward_participant(horse.owner_id)
except Exception as e:
logger.warning(f"reward_participant failed for {horse.owner_id}: {e}")
champion_points = config.CHAMPION_REWARD
ret, code = await points_service.reward_champion(champion.owner_id, champion_points)
if not ret and code != POINTS_ERR_CODE_DUPLICATE:
logger.warning(f"reward_champion failed for {champion.owner_id}: code={code}")
# 2. Champion bonus
try:
await points_service.reward_champion(champion.owner_id)
except Exception as e:
logger.warning(f"reward_champion failed for {champion.owner_id}: {e}")
all_bets = []
for horse_name, horse in room.horses.items():
all_bets.extend(horse.bets)
# 3. Bet payouts for winners
for bet in room.bets:
if bet.horse_name == room.champion_name:
try:
await points_service.payout_winnings(
bet.user_id, bet.amount, odds.get(bet.horse_name, config.MIN_ODDS)
)
except Exception as e:
logger.warning(f"payout_winnings failed for {bet.user_id}: {e}")
total_bet = sum(bet.amount for bet in all_bets)
if total_bet == 0:
odds = {}
else:
odds = {}
for horse_name, horse in room.horses.items():
horse_bet = sum(bet.amount for bet in horse.bets)
if horse_bet == 0:
odds[horse_name] = config.MAX_ODDS
else:
odds[horse_name] = max(config.MIN_ODDS, total_bet / horse_bet)
champion_bets = room.horses[room.champion_name].bets
for bet in champion_bets:
win_amount = int(bet.amount * odds[room.champion_name])
ret, code = await points_service.payout_winnings(bet.user_id, win_amount)
if not ret and code != POINTS_ERR_CODE_DUPLICATE:
logger.warning(f"payout_winnings failed for {bet.user_id}: code={code}")
post_balances = {}
# Record post-balances and compute deltas
post_balances: dict[str, int] = {}
for uid in user_ids:
balance = await points_service.get_balance(uid)
post_balances[uid] = balance if balance is not None else 0
point_changes = {}
post_balances[uid] = points_service.get_balance(uid)
point_changes: dict[str, int] = {}
for uid in user_ids:
delta = post_balances[uid] - pre_balances[uid]
if delta != 0:
point_changes[uid] = delta
# Build human-readable summaries
_, point_change_summaries = _build_point_changes(room, odds)
result = RaceResult(
champion_name=room.champion_name,
finishing_order=[name for name in room.finishing_order if name in room.horses],
point_changes=point_changes
champion_owner=champion.owner_id,
point_changes=point_changes,
point_change_summaries=point_change_summaries,
)
return result, odds

View File

@@ -1,9 +1,12 @@
import asyncio
import logging
from typing import Optional, Any
from nonebot.adapters.onebot.v11 import Bot, Message
from .config import Config
logger = logging.getLogger("horse_racing.message_service")
class MessageService:
def __init__(self, config: Config):

View File

@@ -1,59 +1,59 @@
from dataclasses import dataclass, field
from enum import Enum
from datetime import datetime
from typing import Optional
class RoomState(str, Enum):
WAITING = "waiting"
RUNNING = "running"
FINISHED = "finished"
INTERRUPTED = "interrupted"
class HorseState(str, Enum):
READY = "ready"
RACING = "racing"
FINISHED = "finished"
@dataclass
class Horse:
owner_id: str
name: str
index: int = 0
position: float = 0.0
state: HorseState = HorseState.READY
@dataclass
class Bet:
user_id: str
horse_name: str
amount: int
@dataclass
class Room:
scope: str
state: RoomState = RoomState.WAITING
created_at: datetime = field(default_factory=datetime.now)
horses: dict[str, Horse] = field(default_factory=dict)
bets: list[Bet] = field(default_factory=list)
champion_name: Optional[str] = None
tick_count: int = 0
next_horse_index: int = 1
@dataclass
class RaceResult:
race_id: str
scope: str
champion_name: str
champion_owner: str
participants: list[str]
bet_distribution: dict[str, int]
duration_ticks: int
completed_at: datetime
point_changes: dict[str, int] = field(default_factory=dict)
point_change_summaries: dict[str, str] = field(default_factory=dict)
from dataclasses import dataclass, field
from enum import Enum
from datetime import datetime
from typing import Optional
class RoomState(str, Enum):
WAITING = "waiting"
RUNNING = "running"
FINISHED = "finished"
INTERRUPTED = "interrupted"
class HorseState(str, Enum):
READY = "ready"
RACING = "racing"
FINISHED = "finished"
@dataclass
class Horse:
owner_id: str
name: str
index: int = 0
position: float = 0.0
state: HorseState = HorseState.READY
@dataclass
class Bet:
user_id: str
horse_name: str
amount: int
@dataclass
class Room:
scope: str
state: RoomState = RoomState.WAITING
created_at: datetime = field(default_factory=datetime.now)
horses: dict[str, Horse] = field(default_factory=dict)
bets: list[Bet] = field(default_factory=list)
champion_name: Optional[str] = None
tick_count: int = 0
next_horse_index: int = 1
@dataclass
class RaceResult:
champion_name: str
champion_owner: str
point_changes: dict[str, int] = field(default_factory=dict)
point_change_summaries: dict[str, str] = field(default_factory=dict)
race_id: str = ""
scope: str = ""
participants: list[str] = field(default_factory=list)
bet_distribution: dict[str, int] = field(default_factory=dict)
duration_ticks: int = 0
completed_at: datetime = field(default_factory=datetime.now)

View File

@@ -9,6 +9,7 @@ arclet-alconna-tools==0.7.10
argcomplete==3.5.2
async-timeout==5.0.1
beautifulsoup4==4.12.3
bleach==6.2.0
bs4==0.0.2
certifi==2024.12.14
charset-normalizer==3.4.0

View File

@@ -51,3 +51,26 @@
## 代码质量总结
修复后评级:**B+** (架构清晰安全问题已修复async处理合理)
## 第二轮修复 (新增4项)
| # | 严重度 | 问题 | 文件 |
|---|--------|------|------|
| 6 | **严重** | `api.py` L13 自引用 `_renderer = _renderer`,运行时 NameError 崩溃 | api.py |
| 7 | **严重** | 每次请求新建 `ImageRenderer`,加载字体文件,性能极差 | api.py |
| 8 | **中** | `__init__.py` Token 明文输出到日志,信息泄露 | __init__.py |
| 9 | **中** | `image_render.py` 双 Pilmoji 上下文,标题和正文各创建一次 | image_render.py |
### 修复详情
**api.py**
- L13: `_renderer = _renderer``_renderer: Optional['ImageRenderer'] = None`(修复 NameError
- 新增 `_get_renderer(config)` 懒加载单例函数,首次调用创建,后续复用
- `_send_image_push``_get_renderer(config).render_to_base64()` 替代每次 `ImageRenderer(config)`
-`Optional` 导入
**__init__.py**
- Token 日志掩码:`plugin_config.Token[:4] + "***"`
### 测试结果
- 34/34 通过(含原有 + 回归)