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:
@@ -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("出错了,请稍后再试~")
|
||||
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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."""
|
||||
|
||||
@@ -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}")
|
||||
|
||||
|
||||
# 插件加载时注册路由
|
||||
|
||||
@@ -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. 发送消息
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 通过(含原有 + 回归)
|
||||
|
||||
Reference in New Issue
Block a user