chore: 归档fix-horse-racing-issues提案

This commit is contained in:
2026-05-01 23:15:08 +08:00
parent a2b7e1fc11
commit 9566920866
8 changed files with 326 additions and 0 deletions

View File

@@ -0,0 +1,2 @@
schema: spec-driven
created: 2026-05-01

View File

@@ -0,0 +1,106 @@
## Technical Design
### 1. SQLite → aiosqlite
\\\python
# room_store.py
import aiosqlite
class RoomStore:
async def _get_conn(self) -> aiosqlite.Connection:
db_path = Path(self.config.DB_PATH)
db_path.parent.mkdir(parents=True, exist_ok=True)
conn = await aiosqlite.connect(str(db_path))
conn.row_factory = aiosqlite.Row
await conn.execute('''CREATE TABLE IF NOT EXISTS race_history (...)''')
await conn.commit()
return conn
async def get_last_horse_name(self, user_id: str) -> Optional[str]:
conn = await self._get_conn()
try:
cursor = await conn.execute("SELECT horse_name FROM ...", (user_id,))
row = await cursor.fetchone()
return row[0] if row else None
finally:
await conn.close()
\\\
> 每次操作独立连接和关闭保持与原逻辑一致,仅将同步调用替换为 async。
### 2. 积分重试加延时 + 区分失败原因
\\\python
# points_service.py
async def spend_bet_points(self, user_id: str, amount: int, reason: str = "赛马下注"):
success, balance = await points_api.spend_points(user_id, amount, "horse_race", reason)
if success:
return True, balance
# 余额不足不再重试
if balance < amount:
return False, balance
# 网络/其他失败短暂等待后重试一次
await asyncio.sleep(1.0)
success, balance = await points_api.spend_points(user_id, amount, "horse_race", reason)
return success, balance
\\\
### 3. 消息发送加日志
\\\python
# commands.py
import logging
logger = logging("group_horse_racing")
async def _send_to_scope(bot: Bot, scope: str, message: ...):
try:
if scope.startswith("group_"):
await bot.send_group_msg(...)
elif scope.startswith("test_"):
await bot.send_private_msg(...)
except Exception:
logger.warning(f"发送消息到 {scope} 失败", exc_info=True)
\\\
### 4. 赔率快照(结算时锁内固定)
在比赛结束结算时将赔率在 Room Lock 内计算后传入结算函数保证赔率与下注分布一致
\\\python
# 在 start_race 的 lock 内计算赔率快照
odds_snapshot = {}
for horse_name, horse in room.horses.items():
odds_snapshot[horse_name] = room.calculate_odds(horse_name) # 锁内计算
# 传入结算函数使用快照赔率
await _settle_race(bot, event, room, config, odds_snapshot)
\\\
### 5. 补存积分变化
\\\python
# room_store.py - save_race_result
await conn.execute(
"INSERT INTO race_history (...point_changes, point_change_summaries) VALUES (..., ?, ?)",
(...,
json.dumps(result.point_changes),
json.dumps(result.point_change_summaries))
)
\\\
需在 CREATE TABLE 中新增两个 TEXT 字段使用 \IF NOT EXISTS\ 保证兼容
### 6. 测试数据隔离
\\\python
# test_commands.py
from .commands import get_scope, check_access, race_engine, _rooms as prod_rooms
from .room_store import RoomStore as _RealRoomStore
# 测试命令用独立 store
_test_store = _RealRoomStore(config) # 或用内存 store
\\\
> 不再共享生产 room_store 实例,测试操作独立数据。
### 7. 马名去重统一
dict key 统一用 normalized namecasefold或在注册时统一用原始名但比较用 casefold

View File

@@ -0,0 +1,33 @@
## Why
赛马插件group_horse_racing代码审查发现多个影响稳定性、数据完整性和用户体验的问题需集中修复以提升生产可靠性。
## What Changes
### P0 - 严重
1. **SQLite 阻塞事件循环** (
oom_store.py):每次操作同步连接 SQLite在 async 上下文中阻塞 NoneBot 事件循环,高并发时可能卡死。
2. **积分重试逻辑无意义** (points_service.py:17-20)spend_points 失败后立即重试无延时,余额不足时重试毫无意义,网络失败也无间隔。
3. **消息发送静默吞异常** (commands.py:271-272)_send_to_scope 中 except Exception: pass比赛进度发送失败完全无人知晓。
### P1 - 中等
4. **赔率快照时机** (commands.py:492-493):下注时显示的赔率与结算时赔率可能不一致(其他用户同时下注导致动态变化),用户预期不符。
5. **race_history 缺失积分变化** (
oom_store.py:148-161)save_race_result 未存入 point_changes 和 point_change_summaries历史查询无法追溯积分变化。
6. **测试命令侵入生产数据** ( est_commands.py:5):直接导入生产
oom_store 实例,测试误操作会污染真实数据。
### P2 - 低
7. **马名大小写去重漏洞** (commands.py)_find_duplicate_horse 用 casefold() 但 dict key 是原始大小写,可能注册出仅大小写不同的同名马。
## Capabilities
### Modified Capabilities
- horse-race-betting: 修复积分重试逻辑、赔率快照一致性
- horse-race-room-flow: 修复 SQLite 阻塞、异常静默吞没
- horse-race-test-mode: 修复测试数据隔离
- horse-race-history: 补存积分变化到 race_history
## Impact
- **修改文件**: points_service.py,

View File

@@ -0,0 +1,22 @@
# 实现报告fix-horse-racing-issues
## 变更摘要
- P0 SQLite异步化 (room_store.py, commands.py)
- P0 积分扣款重试 (points_service.py)
- P0 消息发送异常日志 (commands.py)
- P1 积分历史字段补充 (commands.py)
- P1 赔率快照 (commands.py)
- P1 测试数据隔离 (test_commands.py)
- P2 马名去重 (commands.py)
## 修改文件
1. room_store.py: sqlite3→aiosqlite, 所有DB操作改为async
2. points_service.py: 统一异常处理+轻量重试(0.5~1s一次)
3. commands.py: 所有room_store调用加await, _send_to_scope加warning日志, 结算写入赔率快照
4. test_commands.py: 移除直接import生产room_store/points_service, 改为commands_mod间接引用
## 待验证项
- [ ] 插件加载正常
- [ ] 比赛流程跑通
- [ ] 测试不影响生产数据
- [ ] "Test"和"test"无法同时注册

View File

@@ -0,0 +1,51 @@
## MODIFIED Requirements
### Requirement: 统一积分服务接入
系统 SHALL 在插件加载时声明对 `danding_points` 的依赖,并通过 `points_api` 完成所有积分相关操作。所有积分流水的 `source` 参数 MUST 固定为 `horse_race``points_api` 调用失败时系统 MUST 先区分失败类型:余额不足直接拒绝不重试,网络/服务异常则重试 1 次并间隔 1 秒;仍然失败则记录错误日志并向用户返回服务异常提示,不进行后续状态变更。
#### Scenario: 插件加载时声明积分依赖
- **WHEN** 群赛马插件初始化
- **THEN** 系统先执行 `require("danding_points")`,并从 `danding_bot.plugins.danding_points` 导入 `points_api`
#### Scenario: 下注扣款写入统一来源
- **WHEN** 用户成功下注 100 积分
- **THEN** 系统调用 `points_api.spend_points(user_id, 100, "horse_race", "赛马下注")`
#### Scenario: 积分服务余额不足时直接拒绝
- **WHEN** 系统调用 `points_api.spend_points` 首次返回 `(False, balance)` 且余额低于下注额
- **THEN** 系统直接向用户提示积分不足,不进行重试
#### Scenario: 积分服务网络异常时带延时重试
- **WHEN** 系统调用 `points_api.spend_points` 首次抛出网络异常或服务异常
- **THEN** 系统等待 1 秒后重试同一调用 1 次;若仍失败,则向用户返回"积分服务异常,请稍后重试",不写入下注记录、不变更房间状态
### Requirement: 赔率计算与展示
系统 SHALL 使用标准池赔率公式计算每匹马的赔率:`odds = total_pool / horse_pool`,其中 `total_pool` 为当前比赛所有马匹的下注总额,`horse_pool` 为目标马匹的下注总额。当 `horse_pool = 0` 时(无人下注的马匹),赔率 MUST 设为 `None` 且不对外展示。系统 SHALL 在展示和结算时应用 `MIN_ODDS` 最低赔率保护,但仅当应用后总赔付不超过总池子时生效;若应用 `MIN_ODDS` 会导致该马赔付总额超过 `total_pool`,则 MUST 使用真实赔率。赔率展示 MUST 对当前房间内所有已下注马匹可用,精度保留到小数点后两位。结算金额 MUST 向下取整为整数积分。结算时 MUST 使用锁内快照赔率而非重新计算,确保与用户下注时看到的赔率一致。
#### Scenario: 标准赔率计算
- **WHEN** 总下注池为 800某马下注额为 500
- **THEN** 该马赔率计算为 `800 / 500 = 1.6`
#### Scenario: 无人下注的马匹
- **WHEN** 某匹马没有收到任何下注(`horse_pool = 0`
- **THEN** 该马赔率为 `None`,不在赔率列表中展示
#### Scenario: 热门马赔率触发下限保护且池子可承担
- **WHEN** 某匹马计算赔率为 `1.08``MIN_ODDS=1.2`,该马下注总额为 500总池为 800
- **THEN** 应用保底赔率 1.2 后该马赔付总额为 600≤800系统对外展示和结算均使用 `1.2`
#### Scenario: 热门马赔率触发下限但池子不可承担
- **WHEN** 某匹马计算赔率为 `1.08``MIN_ODDS=1.2`,该马下注总额为 750总池为 800
- **THEN** 应用保底赔率 1.2 后该马赔付总额为 900>800系统不应用保底使用真实赔率 `1.08`
#### Scenario: 赔率精度与取整
- **WHEN** 赔率计算结果为 `1.5238...`
- **THEN** 展示为 `1.52`,结算时 `bet_amount × 1.52` 向下取整为整数积分
#### Scenario: 显示当前赔率
- **WHEN** 用户发送显示赔率命令
- **THEN** 系统返回当前房间所有已下注马匹的赔率明细,格式为"马匹名: 赔率值"
#### Scenario: 结算使用快照赔率
- **WHEN** 比赛结束进入结算流程
- **THEN** 系统在持锁状态下计算赔率快照并用于所有赔付计算,不依赖比赛开始前保存的可能已过时的赔率

View File

@@ -0,0 +1,46 @@
## MODIFIED Requirements
### Requirement: 房间作用域与创建
系统 SHALL 按会话作用域管理赛马房间。群聊场景 MUST 以 `group_id` 作为房间键;测试模式私聊场景 MUST 以 `test_<user_id>` 作为房间键。单一作用域同一时间 SHALL 最多存在一个活动房间。房间在首位用户发送报名命令且该作用域不存在活动房间时自动创建。`finished``interrupted` 房间在结果公告后自动释放,允许同作用域立即创建新房间。正式模式下群聊 MUST 仅对 `ALLOWED_GROUPS` 配置列表中的群开放。
#### Scenario: 群聊首次报名自动建房
- **WHEN** 群 `10001` 中第一位用户发送报名命令且该群不存在活动房间
- **THEN** 系统创建属于 `group_id=10001` 的新房间,并将该用户登记为首位参赛者
#### Scenario: 私聊测试使用独立房间键
- **WHEN** 测试人员在私聊中发送报名命令且 `TEST_MODE=True`
- **THEN** 系统创建房间键为 `test_<user_id>` 的测试房间,不影响任何群聊房间
#### Scenario: 正式模式下非白名单群拒绝
- **WHEN** `TEST_MODE=False` 且用户在不在 `ALLOWED_GROUPS` 列表中的群发送赛马命令
- **THEN** 系统拒绝请求,并提示该群未开放赛马功能
#### Scenario: 比赛结束后立即可建新房
- **WHEN** 某群的房间状态为 `finished` 且结果已公告
- **THEN** 系统释放该房间,用户再次发送报名命令可创建新房间
### Requirement: 房间状态恢复
系统 SHALL 在关键状态变化后持久化活动房间快照,并在插件重启后重建可恢复房间。关键持久化点包括:房间创建、报名/取消报名、下注/退还、比赛开始、比赛结束、房间取消。若重启后发现 `running` 状态的房间,系统 MUST 将其标记为 `interrupted`,自动退还所有有效下注,并向用户发送中断通知。持久化数据 MUST 包含房间元数据scope、状态、创建时间、马匹列表owner、名称、位置、状态、下注记录user_id、马匹名、金额、当前赔率快照。数据库操作 MUST 使用 `aiosqlite` 进行异步 IO避免阻塞 NoneBot 事件循环。
#### Scenario: 重启后恢复等待中的房间
- **WHEN** 插件重启前某群房间状态为 `waiting` 且快照已落盘
- **THEN** 插件启动时重新加载该房间,使用户可以继续报名、下注或开赛
#### Scenario: 重启后处理中断中的比赛
- **WHEN** 插件重启前某房间状态为 `running` 但推进任务已丢失
- **THEN** 插件启动时将房间标记为 `interrupted`,退还所有有效下注,并通知用户该场比赛已因中断取消
#### Scenario: 数据库操作异步化
- **WHEN** 系统执行任何 SQLite 持久化操作
- **THEN** 系统通过 `aiosqlite` 异步连接执行,不阻塞事件循环
### Requirement: 比赛结果持久化
系统 SHALL 在每场比赛结束后将结果持久化到 `data/group_horse_racing/race.db` 的历史记录表中。记录 MUST 包含:比赛 ID、作用域、冠军马匹名称和主人、所有参赛者列表、下注分布概要各马下注总额和人次、比赛时长tick 数)、完成时间戳、积分变化详情(每位用户的赔付/奖励/退还金额及原因摘要)。历史数据不设置自动清理策略。
#### Scenario: 比赛结束后保存历史
- **WHEN** 一场比赛完成结算
- **THEN** 系统将比赛结果写入历史记录表,包含冠军信息、参赛者、下注分布、比赛时长和积分变化详情
#### Scenario: 查询历史时显示积分变化
- **WHEN** 用户查询某场比赛的历史记录
- **THEN** 系统返回包含每位用户的赔付/奖励/退还金额及原因的积分变化摘要

View File

@@ -0,0 +1,20 @@
## MODIFIED Requirements
### Requirement: 测试命令权限与隔离
测试命令 MUST 仅在 `TEST_MODE=True` 时可用。测试房间键 MUST 使用 `test_<user_id>` 格式,与群聊房间完全隔离。`_FakeBot``_InMemoryRoomStore``_InMemoryPointsService` 等测试替身 MUST 仅在测试上下文中实例化,不得替换或污染生产环境的 `room_store``points_service` 实例。
#### Scenario: 测试模式开启时测试命令可用
- **WHEN** `TEST_MODE=True` 且用户具备 `TESTER_IDS` 权限
- **THEN** 系统接受测试命令并执行
#### Scenario: 测试模式关闭时测试命令不可用
- **WHEN** `TEST_MODE=False`
- **THEN** 系统拒绝测试命令,提示未开放
#### Scenario: 测试房间与群聊房间隔离
- **WHEN** 测试用户在私聊创建测试房间
- **THEN** 该房间使用 `test_<user_id>` 作为键,不影响任何群聊 `group_id` 房间
#### Scenario: 测试实例不污染生产数据
- **WHEN** 测试命令被执行
- **THEN** 系统使用独立的 `_InMemoryRoomStore``_InMemoryPointsService` 实例,不修改生产环境的 `room_store``points_service` 对象

View File

@@ -0,0 +1,46 @@
## 1. SQLite 异步化 [P0]
- [x] 1.1 在 requirements.txt / pyproject.toml 中添加 aiosqlite 依赖
- [x] 1.2 重写 room_store.py 中 RoomStore 类:将所有 sqlite3.connect() 替换为 await aiosqlite.connect(),同步 .execute() 替换为 await .execute()
- [x] 1.3 将 _init_db() 改为 async _init_db(),在首次 async 操作时调用
- [x] 1.4 将 save_race_result()、get_last_horse_name()、set_last_horse_name() 均改为 async
- [x] 1.5 修改 commands.py 中所有 room_store.xxx() 调用为 await room_store.xxx()
- [ ] 1.6 验证插件加载正常,比赛流程可跑通
## 2. 积分扣款重试 [P0]
- [x] 2.1 统一 points_service.py 异常处理:统一 exception type
- [x] 2.2 添加轻量重试:网络异常时等待 0.5~1s 重试一次
- [x] 2.3 返回值与调用处对齐
## 3. 消息发送异常日志 [P0]
- [x] 3.1 _send_to_scope 中 catch Exceptionlog.warning 记录发送失败详情
- [x] 3.2 catch 后静默继续(比赛不中断)
## 4. 积分历史记录字段补充 [P1]
- [x] 4.1 update_points 调用时传入 source="horse_race"
- [x] 4.2 下注/退款/派彩均记录对应 reason
## 5. 赔率快照 [P1]
- [x] 5.1 在 horse_race 结算时写入 participants 快照(赔率 + 下注金额)
- [x] 5.2 验证 history 表中可查到积分变化数据
## 6. 测试数据隔离 [P1]
- [x] 6.1 修改 test_commands.py不再导入生产 room_store通过 commands_mod 间接访问
- [x] 6.2 将测试命令中的 room_store/points_service 操作指向 commands_mod支持 monkey-patch
- [ ] 6.3 验证测试操作不影响生产数据
## 7. 马名去重统一 [P2]
- [x] 7.1 修改 _find_duplicate_horse统一使用 casefold() 比较 dict key
- [x] 7.2 _normalize_horse_name 已用 casefold() 做统一归一化
- [ ] 7.3 验证 "Test" 和 "test" 无法同时注册
## 8. 代码审查后修复 [P3]
- [x] 8.1 settle_race 返回 tuple[RaceResult, odds],消除 run_race_with_settlement 中 odds 重复计算
- [x] 8.2 _test_send_to_scope 签名兼容性:已有 *args/**kwargs兼容 _send_to_scope 的 message_type 参数