diff --git a/PLUGINS.md b/PLUGINS.md index 549aca5..c345dec 100644 --- a/PLUGINS.md +++ b/PLUGINS.md @@ -1,146 +1,159 @@ -# 蛋定助手插件文档 - -## 项目概述 - -蛋定助手是一个基于 NoneBot2 框架开发的 QQ 机器人,提供多种功能插件,包括 AI 聊天、游戏辅助、积分系统、社群管理等。 - -## 插件总览 - -| 插件名称 | 描述 | 触发方式 | 权限要求 | -|---------|------|---------|---------| -| [chatai](#1-chatai---ai-聊天) | AI 聊天(DeepSeek),支持图文回复 | `*` 开头消息 | 所有用户 | -| [auto_recall](#2-auto_recall---自动撤回) | 自动撤回机器人发送的消息 | 自动执行 | 系统自动 | -| [auto_friend_accept](#3-auto_friend_accept---自动接受好友) | 自动同意好友请求并发送欢迎语 | 自动执行 | 系统自动 | -| [welcome_plugin](#4-welcome_plugin---入群欢迎) | 新成员入群欢迎并发送帮助菜单 | 自动执行 | 特定群 (621016172) | -| [danding_qqpush](#5-danding_qqpush---消息推送) | 通过 HTTP API 向指定群推送图文通知 | HTTP POST | 接口 Token 验证 | -| [danding_points](#6-danding_points---积分系统核心) | 积分系统数据库与 API 核心 | API 调用 | 系统内部 | -| [danding_points_query](#7-danding_points_query---积分查询) | 查询余额、排行及交易记录 | 命令触发 | 所有用户 | -| [group_horse_racing](#8-group_horse_racing---群赛马游戏) | 多人赛马游戏,支持下注与积分奖惩 | 命令触发 | 允许的群聊 | -| [onmyoji_gacha](#9-onmyoji_gacha---阴阳师抽卡模拟) | 阴阳师主题抽卡,包含成就与卡密奖励 | 命令触发 | 允许的群聊/用户 | -| [damo_balance](#10-damo_balance---大漠账户查询) | 查询大漠平台账户余额 | 命令触发 | 特定用户 | -| [danding_api](#11-danding_api---管理-api) | 管理员操作:卡密管理、加时、在线查询 | 命令触发 | 超级用户 | -| [danding_help](#12-danding_help---帮助菜单) | 提供教程、下载及功能指引 | 命令触发 | 特定群 (621016172) | -| [command_list](#13-command_list---指令列表) | 获取系统支持的所有指令列表 | 命令触发 | 所有用户 | - ---- - -## 详细插件文档 - -### 1. chatai - AI 聊天 - -基于 DeepSeek AI 的聊天功能,支持将回复转换为图片形式,并在一定时间后自动撤回。 - -- **使用方法**: 发送以 `*` 开头的消息。 -- **配置项**: `DEEPSEEK_TOKEN` (必填)。 -- **特性**: AI 回复会自动转为图片显示,默认 120 秒后撤回。 - -### 2. auto_recall - 自动撤回 - -监控机器人发出的消息,并在指定时间后自动撤回,保持聊天环境整洁。 - -- **配置项**: - - `RECALL_DELAY`: 普通消息撤回延迟(默认 110s)。 - - `QQPUSH_RECALL_DELAY`: 推送消息撤回延迟(默认 3600s)。 - -### 3. auto_friend_accept - 自动接受好友 - -自动处理好友请求,提升用户接入效率。 - -- **配置项**: - - `auto_accept_enabled`: 是否开启自动接受。 - - `auto_reply_message`: 接受后的欢迎语。 - -### 4. welcome_plugin - 入群欢迎 - -针对特定群聊的新成员欢迎功能。 - -- **触发场景**: 新成员加入群 `621016172`。 -- **功能**: 随机发送欢迎语,并附带帮助菜单图片。 - -### 5. danding_qqpush - 消息推送 - -提供外部系统向 QQ 推送通知的 HTTP 接口。 - -- **接口**: `POST /danding/qqpush/{token}` -- **功能**: 自动将长文本转换为图片,支持 `@用户` 和换行符 `#`。 -- **配置**: `DANDING_QQPUSH_TOKEN`。 - -### 6. danding_points - 积分系统核心 - -为其他插件提供积分存储与结算的基础设施。 - -- **功能**: 数据库管理(SQLite)、余额增减、排行榜计算、交易日志记录。 -- **数据库路径**: `data/danding_points/points.db` - -### 7. danding_points_query - 积分查询 - -用户通过命令与积分系统交互。 - -- **主要命令**: - - `我的积分`: 查看个人余额。 - - `积分查询 @用户`: 查看他人余额。 - - `积分排行`: 查看前 10 名。 - - `积分历史查询`: 查看最近 5 条变动记录。 - - `积分帮助`: 获取指令指引。 - -### 8. group_horse_racing - 群赛马游戏 - -集成积分系统的多人互动游戏。 - -- **主要命令**: - - `/赛马报名 [马名]`: 参加比赛。 - - `/赛马下注 <序号> <金额>`: 对马匹下注。 - - `/赛马开赛`: 开始比赛(至少 2 人)。 -- **积分逻辑**: 参赛奖励 50 分,冠军奖励 200 分,支持下注赔率结算。 - -### 9. onmyoji_gacha - 阴阳师抽卡模拟 - -高度还原的抽卡模拟,包含成就系统。 - -- **主要命令**: - - `抽卡`: 执行单抽。 - - `三连抽`: 执行三连抽。 - - `我的抽卡`: 查看个人统计。 - - `查询成就`: 查看已解锁成就及进度(非酋、勤勤恳恳系列)。 - - `抽卡介绍`: 查看详细机制与奖励说明。 -- **特性**: 抽中 SSR/SP 可获得“蛋定助手”卡密奖励(需联系管理员领取)。包含每日抽卡签到积分奖励。 - -### 10. damo_balance - 大漠账户查询 - -查询大漠平台账户余额。 - -- **命令**: `大漠余额` 或 `余额查询`。 -- **限制**: 仅特定用户可用,需输入验证码。 - -### 11. danding_api - 管理 API - -供超级用户使用的后台管理功能。 - -- **主要命令**: - - `在线人数`: 查询当前活跃用户。 - - `生成卡密 <类型>`: 生成天/周/月卡。 - - `用户加时 <用户名> <类型>`: 直接为特定用户增加时长。 - -### 12. danding_help - 帮助菜单 - -系统的官方指引手册。 - -- **主要命令**: `帮助`、`下载`、`正式版如何运行` 等。 -- **限制**: 仅在特定群 `621016172` 可用。 - -### 13. command_list - 指令列表 - -快速查阅所有可用指令。 - -- **命令**: `指令列表`。 - ---- - -## 常见问题 (FAQ) - -- **Q: 为什么某些命令没反应?** - A: 部分插件(如 `danding_help`)限制了特定群聊使用;管理指令需要配置 `SUPERUSERS`。 -- **Q: 积分有什么用?** - A: 目前主要用于赛马下注及展示排名。 -- **Q: 抽卡奖励如何领取?** - A: 抽中 SSR/SP 或解锁特定成就后,请截屏联系管理员。 +# 蛋定助手插件文档 + +## 项目概述 + +蛋定助手是一个基于 NoneBot2 框架开发的 QQ 机器人,提供多种功能插件,包括 AI 聊天、游戏辅助、积分系统、社群管理等。 + +## 插件总览 + +| 插件名称 | 描述 | 触发方式 | 权限要求 | +|---------|------|---------|---------| +| [chatai](#1-chatai---ai-聊天) | AI 聊天(DeepSeek),支持图文回复 | `*` 开头消息 | 所有用户 | +| [auto_recall](#2-auto_recall---自动撤回) | 自动撤回机器人发送的消息 | 自动执行 | 系统自动 | +| [auto_friend_accept](#3-auto_friend_accept---自动接受好友) | 自动同意好友请求并发送欢迎语 | 自动执行 | 系统自动 | +| [welcome_plugin](#4-welcome_plugin---入群欢迎) | 新成员入群欢迎并发送帮助菜单 | 自动执行 | 特定群 (621016172) | +| [danding_qqpush](#5-danding_qqpush---消息推送) | 通过 HTTP API 向指定群推送图文通知 | HTTP POST | 接口 Token 验证 | +| [danding_points](#6-danding_points---积分系统核心) | 积分系统数据库与 API 核心 | API 调用 | 系统内部 | +| [danding_points_query](#7-danding_points_query---积分查询) | 查询余额、排行及交易记录 | 命令触发 | 所有用户 | +| [group_horse_racing](#8-group_horse_racing---群赛马游戏) | 多人赛马游戏,支持下注与积分奖惩 | 命令触发 | 允许的群聊 | +| [onmyoji_gacha](#9-onmyoji_gacha---阴阳师抽卡模拟) | 阴阳师主题抽卡,包含成就与卡密奖励 | 命令触发 | 允许的群聊/用户 | +| [damo_balance](#10-damo_balance---大漠账户查询) | 查询大漠平台账户余额 | 命令触发 | 特定用户 | +| [danding_api](#11-danding_api---管理-api) | 管理员操作:卡密管理、加时、在线查询 | 命令触发 | 超级用户 | +| [danding_help](#12-danding_help---帮助菜单) | 提供教程、下载及功能指引 | 命令触发 | 特定群 (621016172) | +| [command_list](#13-command_list---指令列表) | 获取系统支持的所有指令列表 | 命令触发 | 所有用户 | + +--- + +## 详细插件文档 + +### 1. chatai - AI 聊天 + +基于 DeepSeek AI 的聊天功能,支持将回复转换为图片形式,并在一定时间后自动撤回。 + +- **使用方法**: 发送以 `*` 开头的消息。 +- **配置项**: `DEEPSEEK_TOKEN` (必填)。 +- **特性**: AI 回复会自动转为图片显示,默认 120 秒后撤回。 + +### 2. auto_recall - 自动撤回 + +监控机器人发出的消息,并在指定时间后自动撤回,保持聊天环境整洁。 + +- **配置项**: + - `RECALL_DELAY`: 普通消息撤回延迟(默认 110s)。 + - `QQPUSH_RECALL_DELAY`: 推送消息撤回延迟(默认 3600s)。 + +### 3. auto_friend_accept - 自动接受好友 + +自动处理好友请求,提升用户接入效率。 + +- **配置项**: + - `auto_accept_enabled`: 是否开启自动接受。 + - `auto_reply_message`: 接受后的欢迎语。 + +### 4. welcome_plugin - 入群欢迎 + +针对特定群聊的新成员欢迎功能。 + +- **触发场景**: 新成员加入群 `621016172`。 +- **功能**: 随机发送欢迎语,并附带帮助菜单图片。 + +### 5. danding_qqpush - 消息推送 + +提供外部系统向 QQ 推送通知的 HTTP 接口。 + +- **接口**: `POST /danding/qqpush/{token}` +- **功能**: 自动将长文本转换为图片,支持 `@用户` 和换行符 `#`。 +- **配置**: `DANDING_QQPUSH_TOKEN`。 + +### 6. danding_points - 积分系统核心 + +为其他插件提供积分存储与结算的基础设施。 + +- **功能**: 数据库管理(SQLite)、余额增减、排行榜计算、交易日志记录。 +- **数据库路径**: `data/danding_points/points.db` + +### 7. danding_points_query - 积分查询 + +用户通过命令与积分系统交互。 + +- **主要命令**: + - `我的积分`: 查看个人余额。 + - `积分查询 @用户`: 查看他人余额。 + - `积分排行`: 查看前 10 名。 + - `积分历史查询`: 查看最近 5 条变动记录。 + - `积分帮助`: 获取指令指引。 + +### 8. group_horse_racing - 群赛马游戏 + +集成积分系统的多人互动游戏。 + +- **主要命令**: + - `/赛马报名 [马名]`: 参加比赛。 + - `/赛马下注 <序号> <金额>`: 对马匹下注。 + - `/赛马开赛`: 开始比赛(至少 2 人)。 +- **积分逻辑**: 参赛奖励 50 分,冠军奖励 200 分,支持下注赔率结算。 + +### 9. onmyoji_gacha - 阴阳师抽卡模拟 + +高度还原的抽卡模拟,包含成就系统。采用模块化架构,职责分明。 + +- **模块结构**: + - `__init__.py`: 路由注册与matcher定义(167行) + - `config.py`: Pydantic配置管理 + - `gacha.py`: 抽卡核心逻辑(GachaSystem类) + - `data_manager.py`: SQLite数据持久化(DataManager类) + - `rules.py`: 命令匹配规则(check_permission等) + - `formatters.py`: 消息格式化(9个格式化函数) + - `handlers/`: 命令处理函数(9个handler模块) + - `utils.py`: 通用工具函数 + - `api_utils.py`: 积分系统API交互 + - `web_api.py`: HTTP API路由 + +- **主要命令**: + - `抽卡`: 执行单抽。 + - `三连抽`: 执行三连抽。 + - `我的抽卡`: 查看个人统计。 + - `抽卡排行`: 查看抽卡排行榜。 + - `今日抽卡`: 查看今日抽卡统计。 + - `查询成就`: 查看已解锁成就及进度(非酋、勤勤恳恳系列)。 + - `抽卡介绍`: 查看详细机制与奖励说明。 +- **特性**: 抽中 SSR/SP 可获得"蛋定助手"卡密奖励(需联系管理员领取)。包含每日抽卡签到积分奖励。成就系统自动发放积分奖励。 +### 10. damo_balance - 大漠账户查询 + +查询大漠平台账户余额。 + +- **命令**: `大漠余额` 或 `余额查询`。 +- **限制**: 仅特定用户可用,需输入验证码。 + +### 11. danding_api - 管理 API + +供超级用户使用的后台管理功能。 + +- **主要命令**: + - `在线人数`: 查询当前活跃用户。 + - `生成卡密 <类型>`: 生成天/周/月卡。 + - `用户加时 <用户名> <类型>`: 直接为特定用户增加时长。 + +### 12. danding_help - 帮助菜单 + +系统的官方指引手册。 + +- **主要命令**: `帮助`、`下载`、`正式版如何运行` 等。 +- **限制**: 仅在特定群 `621016172` 可用。 + +### 13. command_list - 指令列表 + +快速查阅所有可用指令。 + +- **命令**: `指令列表`。 + +--- + +## 常见问题 (FAQ) + +- **Q: 为什么某些命令没反应?** + A: 部分插件(如 `danding_help`)限制了特定群聊使用;管理指令需要配置 `SUPERUSERS`。 +- **Q: 积分有什么用?** + A: 目前主要用于赛马下注及展示排名。 +- **Q: 抽卡奖励如何领取?** + A: 抽中 SSR/SP 或解锁特定成就后,请截屏联系管理员。 diff --git a/danding_bot/plugins/onmyoji_gacha/api_utils.py b/danding_bot/plugins/onmyoji_gacha/api_utils.py index f4262ae..9b7c3a9 100644 --- a/danding_bot/plugins/onmyoji_gacha/api_utils.py +++ b/danding_bot/plugins/onmyoji_gacha/api_utils.py @@ -249,4 +249,4 @@ async def process_achievement_reward(user_id: str, achievement_id: str) -> Tuple f"获得奖励:蛋定助手{reward_type}一张\n" f"⚠️自动加时失败: {message}\n" f"请联系管理员手动领取奖励!") - return False, msg \ No newline at end of file + return False, msg diff --git a/danding_bot/plugins/onmyoji_gacha/config.py b/danding_bot/plugins/onmyoji_gacha/config.py index 13a8b5c..da487f0 100644 --- a/danding_bot/plugins/onmyoji_gacha/config.py +++ b/danding_bot/plugins/onmyoji_gacha/config.py @@ -128,4 +128,4 @@ class Config(BaseSettings): """运行时警告:如果使用默认admin token,在生产环境可能被猜解""" if self.WEB_ADMIN_TOKEN == "onmyoji_admin_token_2024": logger.warning("⚠️ WEB_ADMIN_TOKEN 使用默认值,生产环境请务必通过环境变量覆盖!") - return self \ No newline at end of file + return self diff --git a/danding_bot/plugins/onmyoji_gacha/data_manager.py b/danding_bot/plugins/onmyoji_gacha/data_manager.py index 2a530fc..cfea234 100644 --- a/danding_bot/plugins/onmyoji_gacha/data_manager.py +++ b/danding_bot/plugins/onmyoji_gacha/data_manager.py @@ -1,594 +1,615 @@ -import os -import json -import sqlite3 -import datetime -from typing import Dict, List, Any, Optional -import logging -from pathlib import Path - -from .config import Config - -# 创建Config实例 -config = Config() - -class DataManager: - def __init__(self): - # 确保目录存在 - os.makedirs(os.path.dirname(config.DB_FILE), exist_ok=True) - - # 初始化数据库 - self._init_db() - - # 加载式神数据 - self.shikigami_data = self._load_shikigami_data() - - def _init_db(self): - """初始化数据库""" - with sqlite3.connect(config.DB_FILE) as conn: - cursor = conn.cursor() - - # 创建式神表 - cursor.execute(""" - CREATE TABLE IF NOT EXISTS shikigami ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - name TEXT NOT NULL, - rarity TEXT NOT NULL, - image_path TEXT NOT NULL - ) - """) - - # 创建每日抽卡记录表 - cursor.execute(""" - CREATE TABLE IF NOT EXISTS daily_draws ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - date TEXT NOT NULL, - user_id TEXT NOT NULL, - rarity TEXT NOT NULL, - shikigami_id INTEGER NOT NULL, - timestamp TEXT NOT NULL, - FOREIGN KEY (shikigami_id) REFERENCES shikigami(id) - ) - """) - - # 创建用户统计表 - cursor.execute(""" - CREATE TABLE IF NOT EXISTS user_stats ( - user_id TEXT PRIMARY KEY, - total_draws INTEGER DEFAULT 0, - R_count INTEGER DEFAULT 0, - SR_count INTEGER DEFAULT 0, - SSR_count INTEGER DEFAULT 0, - SP_count INTEGER DEFAULT 0 - ) - """) - - # 创建抽卡历史表 - cursor.execute(""" - CREATE TABLE IF NOT EXISTS draw_history ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - user_id TEXT NOT NULL, - date TEXT NOT NULL, - rarity TEXT NOT NULL, - shikigami_id INTEGER NOT NULL, - FOREIGN KEY (user_id) REFERENCES user_stats(user_id), - FOREIGN KEY (shikigami_id) REFERENCES shikigami(id) - ) - """) - - # 创建成就表 - cursor.execute(""" - CREATE TABLE IF NOT EXISTS achievements ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - user_id TEXT NOT NULL, - achievement_id TEXT NOT NULL, - unlocked_date TEXT NOT NULL, - reward_claimed INTEGER DEFAULT 0, - UNIQUE(user_id, achievement_id) - ) - """) - - # 创建用户成就进度表 - cursor.execute(""" - CREATE TABLE IF NOT EXISTS user_achievement_progress ( - user_id TEXT PRIMARY KEY, - consecutive_days INTEGER DEFAULT 0, - last_draw_date TEXT DEFAULT '', - no_ssr_streak INTEGER DEFAULT 0, - total_consecutive_days INTEGER DEFAULT 0 - ) - """) - - self._init_sign_in_table(cursor) - - conn.commit() - - def _init_sign_in_table(self, cursor: sqlite3.Cursor) -> None: - """创建每日签到表""" - cursor.execute(""" - CREATE TABLE IF NOT EXISTS daily_sign_in ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - user_id TEXT NOT NULL, - sign_date TEXT NOT NULL, - points_awarded INTEGER NOT NULL, - created_at TEXT NOT NULL, - UNIQUE(user_id, sign_date) - ) - """) - - def update_achievement_progress(self, user_id: str, rarity: str) -> List[str]: - """更新用户成就进度,返回新解锁的成就列表""" - today = self.get_today_date() - unlocked_achievements = [] - - with sqlite3.connect(config.DB_FILE) as conn: - cursor = conn.cursor() - - # 获取或创建用户成就进度 - cursor.execute( - "SELECT * FROM user_achievement_progress WHERE user_id = ?", - (user_id,) - ) - progress = cursor.fetchone() - - if not progress: - cursor.execute( - "INSERT INTO user_achievement_progress (user_id, last_draw_date) VALUES (?, ?)", - (user_id, today) - ) - consecutive_days = 1 - no_ssr_streak = 1 if rarity not in ["SSR", "SP"] else 0 - total_consecutive_days = 1 - else: - last_draw_date = progress[2] - consecutive_days = progress[1] - no_ssr_streak = progress[3] - total_consecutive_days = progress[4] - - # 更新连续抽卡天数 - if last_draw_date != today: - # 检查是否是连续的一天 - last_date = datetime.datetime.strptime(last_draw_date, "%Y-%m-%d") - current_date = datetime.datetime.strptime(today, "%Y-%m-%d") - days_diff = (current_date - last_date).days - - if days_diff == 1: - consecutive_days += 1 - total_consecutive_days += 1 - elif days_diff > 1: - consecutive_days = 1 - total_consecutive_days += 1 - # days_diff == 0 表示今天已经抽过卡了,不更新连续天数 - - # 更新无SSR连击数 - if rarity in ["SSR", "SP"]: - no_ssr_streak = 0 - else: - no_ssr_streak += 1 - - # 更新进度 - cursor.execute(""" - INSERT OR REPLACE INTO user_achievement_progress - (user_id, consecutive_days, last_draw_date, no_ssr_streak, total_consecutive_days) - VALUES (?, ?, ?, ?, ?) - """, (user_id, consecutive_days, today, no_ssr_streak, total_consecutive_days)) - - # 检查是否解锁新成就 - for achievement_id, achievement_config in config.ACHIEVEMENTS.items(): - # 对于可重复获得的成就(勤勤恳恳系列),需要特殊处理 - if achievement_config.get("repeatable", False) and achievement_config["type"] == "consecutive_days": - # 检查连续抽卡成就的升级逻辑 - if consecutive_days >= achievement_config["threshold"]: - # 检查是否已经解锁过这个等级 - cursor.execute( - "SELECT id FROM achievements WHERE user_id = ? AND achievement_id = ?", - (user_id, achievement_id) - ) - if not cursor.fetchone(): - # 解锁新等级的成就 - cursor.execute(""" - INSERT INTO achievements (user_id, achievement_id, unlocked_date) - VALUES (?, ?, ?) - """, (user_id, achievement_id, today)) - unlocked_achievements.append(achievement_id) - - # 如果是最高等级(Ⅴ),检查是否需要给重复奖励 - elif achievement_config["level"] == 5 and consecutive_days >= 150: - # 每30天给一次重复奖励 - days_over_150 = consecutive_days - 150 - if days_over_150 > 0 and days_over_150 % 30 == 0: - # 检查这个重复奖励是否已经给过 - repeat_id = f"{achievement_id}_repeat_{days_over_150//30}" - cursor.execute( - "SELECT id FROM achievements WHERE user_id = ? AND achievement_id = ?", - (user_id, repeat_id) - ) - if not cursor.fetchone(): - cursor.execute(""" - INSERT INTO achievements (user_id, achievement_id, unlocked_date) - VALUES (?, ?, ?) - """, (user_id, repeat_id, today)) - unlocked_achievements.append(achievement_id) - else: - # 非重复成就的原有逻辑 - # 检查是否已经解锁 - cursor.execute( - "SELECT id FROM achievements WHERE user_id = ? AND achievement_id = ?", - (user_id, achievement_id) - ) - if cursor.fetchone(): - continue - - # 检查成就条件 - unlocked = False - if achievement_config["type"] == "consecutive_days": - if consecutive_days >= achievement_config["threshold"]: - unlocked = True - elif achievement_config["type"] == "no_ssr_streak": - if no_ssr_streak >= achievement_config["threshold"]: - unlocked = True - - if unlocked: - cursor.execute(""" - INSERT INTO achievements (user_id, achievement_id, unlocked_date) - VALUES (?, ?, ?) - """, (user_id, achievement_id, today)) - unlocked_achievements.append(achievement_id) - - conn.commit() - - return unlocked_achievements - - def get_user_achievements(self, user_id: str) -> Dict[str, Any]: - """获取用户成就信息""" - with sqlite3.connect(config.DB_FILE) as conn: - conn.row_factory = sqlite3.Row - cursor = conn.cursor() - - # 获取已解锁的成就 - cursor.execute( - "SELECT achievement_id, unlocked_date, reward_claimed FROM achievements WHERE user_id = ?", - (user_id,) - ) - unlocked = {row["achievement_id"]: { - "unlocked_date": row["unlocked_date"], - "reward_claimed": bool(row["reward_claimed"]) - } for row in cursor.fetchall()} - - # 获取进度 - cursor.execute( - "SELECT * FROM user_achievement_progress WHERE user_id = ?", - (user_id,) - ) - progress_row = cursor.fetchone() - - if not progress_row: - progress = { - "consecutive_days": 0, - "no_ssr_streak": 0, - "total_consecutive_days": 0 - } - else: - progress = { - "consecutive_days": progress_row["consecutive_days"], - "no_ssr_streak": progress_row["no_ssr_streak"], - "total_consecutive_days": progress_row["total_consecutive_days"] - } - - return { - "unlocked": unlocked, - "progress": progress - } - - def claim_achievement_reward(self, user_id: str, achievement_id: str) -> bool: - """领取成就奖励""" - with sqlite3.connect(config.DB_FILE) as conn: - cursor = conn.cursor() - - cursor.execute(""" - UPDATE achievements - SET reward_claimed = 1 - WHERE user_id = ? AND achievement_id = ? AND reward_claimed = 0 - """, (user_id, achievement_id)) - - conn.commit() - return cursor.rowcount > 0 - - - def _load_shikigami_data(self) -> Dict[str, List[Dict[str, str]]]: - """加载式神数据到数据库""" - result = {"R": [], "SR": [], "SSR": [], "SP": []} - rarity_dirs = { - "R": "r", - "SR": "sr", - "SSR": "ssr", - "SP": "sp" - } - - with sqlite3.connect(config.DB_FILE) as conn: - cursor = conn.cursor() - - # 清空现有式神数据 - cursor.execute("DELETE FROM shikigami") - - for rarity, dir_name in rarity_dirs.items(): - dir_path = os.path.join(config.SHIKIGAMI_IMG_DIR, dir_name) - if os.path.exists(dir_path): - for file_name in os.listdir(dir_path): - if file_name.endswith(('.png', '.jpg', '.jpeg')): - name = os.path.splitext(file_name)[0] - image_path = os.path.join(dir_path, file_name) - - # 插入式神数据 - cursor.execute( - "INSERT INTO shikigami (name, rarity, image_path) VALUES (?, ?, ?)", - (name, rarity, image_path) - ) - - result[rarity].append({ - "name": name, - "image_url": image_path - }) - - conn.commit() - - return result - - def get_today_date(self) -> str: - """获取当前日期字符串""" - return datetime.datetime.now().strftime("%Y-%m-%d") - - def has_signed_in_today(self, user_id: str) -> bool: - """检查用户今天是否已签到""" - today = self.get_today_date() - with sqlite3.connect(config.DB_FILE) as conn: - cursor = conn.cursor() - cursor.execute( - "SELECT 1 FROM daily_sign_in WHERE user_id = ? AND sign_date = ? LIMIT 1", - (user_id, today), - ) - return cursor.fetchone() is not None - - def record_sign_in(self, user_id: str, points_awarded: int) -> bool: - """记录每日签到,重复签到返回False""" - today = self.get_today_date() - created_at = datetime.datetime.now().isoformat() - try: - with sqlite3.connect(config.DB_FILE) as conn: - cursor = conn.cursor() - cursor.execute(""" - INSERT INTO daily_sign_in (user_id, sign_date, points_awarded, created_at) - VALUES (?, ?, ?, ?) - """, (user_id, today, points_awarded, created_at)) - conn.commit() - return True - except sqlite3.IntegrityError: - return False - - def get_current_time(self) -> str: - """获取当前时间字符串""" - return datetime.datetime.now().strftime("%H:%M:%S") - - def get_daily_draws(self) -> Dict[str, Dict[str, List[Dict[str, str]]]]: - """获取每日抽卡记录""" - result = {} - today = self.get_today_date() - - with sqlite3.connect(config.DB_FILE) as conn: - conn.row_factory = sqlite3.Row - cursor = conn.cursor() - - # 先查询今日的抽卡记录 - cursor.execute(""" - SELECT date, user_id, rarity, shikigami_id, timestamp - FROM daily_draws - WHERE date = ? - ORDER BY timestamp - """, (today,)) - - rows = cursor.fetchall() - - # 获取所有涉及的式神ID - shikigami_ids = list(set(row["shikigami_id"] for row in rows)) - - # 查询式神信息 - shikigami_info = {} - if shikigami_ids: - placeholders = ','.join('?' * len(shikigami_ids)) - cursor.execute(f""" - SELECT id, name, rarity - FROM shikigami - WHERE id IN ({placeholders}) - """, shikigami_ids) - - for shikigami_row in cursor.fetchall(): - shikigami_info[shikigami_row["id"]] = { - "name": shikigami_row["name"], - "rarity": shikigami_row["rarity"] - } - - # 构建结果 - for row in rows: - date = row["date"] - user_id = row["user_id"] - shikigami_id = row["shikigami_id"] - - if date not in result: - result[date] = {} - - if user_id not in result[date]: - result[date][user_id] = [] - - # 如果找不到式神信息,使用daily_draws表中的稀有度和默认名称 - if shikigami_id in shikigami_info: - name = shikigami_info[shikigami_id]["name"] - rarity = shikigami_info[shikigami_id]["rarity"] - else: - name = f"式神{shikigami_id}" - rarity = row["rarity"] - - result[date][user_id].append({ - "rarity": rarity, - "name": name, - "timestamp": row["timestamp"] - }) - - return result - - def save_daily_draws(self, data: Dict[str, Dict[str, List[Dict[str, str]]]]): - """保存每日抽卡记录""" - # SQLite实现中此方法为空,因为记录时直接插入数据库 - pass - - def get_user_stats(self) -> Dict[str, Dict[str, Any]]: - """获取用户统计数据""" - result = {} - - with sqlite3.connect(config.DB_FILE) as conn: - conn.row_factory = sqlite3.Row - cursor = conn.cursor() - - # 获取基础统计 - cursor.execute("SELECT * FROM user_stats") - user_stats = cursor.fetchall() - - for stat in user_stats: - user_id = stat["user_id"] - result[user_id] = { - "total_draws": stat["total_draws"], - "R_count": stat["R_count"], - "SR_count": stat["SR_count"], - "SSR_count": stat["SSR_count"], - "SP_count": stat["SP_count"], - "draw_history": [] - } - - # 获取抽卡历史 - cursor.execute(""" - SELECT draw_history.date, draw_history.rarity, shikigami.name - FROM draw_history - JOIN shikigami ON draw_history.shikigami_id = shikigami.id - WHERE draw_history.user_id = ? - ORDER BY draw_history.date DESC - LIMIT 100 - """, (user_id,)) - - history = cursor.fetchall() - result[user_id]["draw_history"] = [ - { - "date": row["date"], - "rarity": row["rarity"], - "name": row["name"] - } for row in history - ] - - return result - - def save_user_stats(self, data: Dict[str, Dict[str, Any]]): - """保存用户统计数据""" - # SQLite实现中此方法为空,因为统计时直接更新数据库 - pass - - def check_daily_limit(self, user_id: str) -> bool: - """检查用户是否达到每日抽卡限制""" - today = self.get_today_date() - - with sqlite3.connect(config.DB_FILE) as conn: - cursor = conn.cursor() - - cursor.execute(""" - SELECT COUNT(*) - FROM daily_draws - WHERE date = ? AND user_id = ? - """, (today, user_id)) - - count = cursor.fetchone()[0] - - return count < config.DAILY_LIMIT - - def get_draws_left(self, user_id: str) -> int: - """获取用户今日剩余抽卡次数""" - today = self.get_today_date() - - with sqlite3.connect(config.DB_FILE) as conn: - cursor = conn.cursor() - - cursor.execute(""" - SELECT COUNT(*) - FROM daily_draws - WHERE date = ? AND user_id = ? - """, (today, user_id)) - - count = cursor.fetchone()[0] - - return max(0, config.DAILY_LIMIT - count) - - def record_draw(self, user_id: str, rarity: str, shikigami_name: str) -> List[str]: - """记录一次抽卡,返回新解锁的成就列表""" - today = self.get_today_date() - current_time = self.get_current_time() - - with sqlite3.connect(config.DB_FILE) as conn: - cursor = conn.cursor() - - # 获取式神ID - cursor.execute( - "SELECT id FROM shikigami WHERE name = ? AND rarity = ?", - (shikigami_name, rarity) - ) - shikigami_id = cursor.fetchone() - - if not shikigami_id: - logging.error(f"找不到式神: {shikigami_name} ({rarity})") - return [] - - shikigami_id = shikigami_id[0] - - # 记录每日抽卡 - cursor.execute(""" - INSERT INTO daily_draws (date, user_id, rarity, shikigami_id, timestamp) - VALUES (?, ?, ?, ?, ?) - """, (today, user_id, rarity, shikigami_id, current_time)) - - # 更新用户统计 - cursor.execute(""" - INSERT OR IGNORE INTO user_stats (user_id) VALUES (?) - """, (user_id,)) - - cursor.execute(""" - UPDATE user_stats - SET total_draws = total_draws + 1, - R_count = R_count + ?, - SR_count = SR_count + ?, - SSR_count = SSR_count + ?, - SP_count = SP_count + ? - WHERE user_id = ? - """, ( - 1 if rarity == "R" else 0, - 1 if rarity == "SR" else 0, - 1 if rarity == "SSR" else 0, - 1 if rarity == "SP" else 0, - user_id - )) - - # 添加抽卡历史 - cursor.execute(""" - INSERT INTO draw_history (user_id, date, rarity, shikigami_id) - VALUES (?, ?, ?, ?) - """, (user_id, today, rarity, shikigami_id)) - - # 保持历史记录不超过100条 - cursor.execute(""" - DELETE FROM draw_history - WHERE user_id = ? AND id NOT IN ( - SELECT id FROM draw_history - WHERE user_id = ? - ORDER BY date DESC - LIMIT 100 - ) - """, (user_id, user_id)) - - conn.commit() - - # 更新成就进度 - unlocked_achievements = self.update_achievement_progress(user_id, rarity) - return unlocked_achievements +""" +阴阳师抽卡插件 - 数据管理模块 + +管理抽卡数据持久化,包括: +- SQLite数据库操作 +- 用户抽卡记录管理 +- 每日签到记录 +- 统计查询 + +TODO(代码评审 2026-05-03): 本模块承担了数据文件IO + 缓存 + 业务规则三重职责, +后续应拆分为: data_io(纯文件读写) / data_cache(内存缓存层) / data_rules(业务规则校验)。 +当前拆分风险较大(影响面广),暂维持现状。 + +TODO(第二轮评审 2026-05-03): 补充建议拆分方案: +- achievement_manager.py: 成就定义加载 + 进度计算 + 奖励发放 (~150行) +- record_manager.py: 记录归档 + 统计查询 + 每日数据 (~100行) +- data_manager.py: 核心用户数据IO + 缓存管理 (~359行) +拆分为独立PR,不阻塞当前修复。 +""" + +import os +import json +import sqlite3 +import datetime +from typing import Dict, List, Any, Optional +import logging +from pathlib import Path + +from .config import Config + +# 创建Config实例 +config = Config() + +class DataManager: + """抽卡数据管理器,封装所有数据库操作""" + def __init__(self): + # 确保目录存在 + os.makedirs(os.path.dirname(config.DB_FILE), exist_ok=True) + + # 初始化数据库 + self._init_db() + + # 加载式神数据 + self.shikigami_data = self._load_shikigami_data() + + def _init_db(self): + """初始化数据库""" + with sqlite3.connect(config.DB_FILE) as conn: + cursor = conn.cursor() + + # 创建式神表 + cursor.execute(""" + CREATE TABLE IF NOT EXISTS shikigami ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL, + rarity TEXT NOT NULL, + image_path TEXT NOT NULL + ) + """) + + # 创建每日抽卡记录表 + cursor.execute(""" + CREATE TABLE IF NOT EXISTS daily_draws ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + date TEXT NOT NULL, + user_id TEXT NOT NULL, + rarity TEXT NOT NULL, + shikigami_id INTEGER NOT NULL, + timestamp TEXT NOT NULL, + FOREIGN KEY (shikigami_id) REFERENCES shikigami(id) + ) + """) + + # 创建用户统计表 + cursor.execute(""" + CREATE TABLE IF NOT EXISTS user_stats ( + user_id TEXT PRIMARY KEY, + total_draws INTEGER DEFAULT 0, + R_count INTEGER DEFAULT 0, + SR_count INTEGER DEFAULT 0, + SSR_count INTEGER DEFAULT 0, + SP_count INTEGER DEFAULT 0 + ) + """) + + # 创建抽卡历史表 + cursor.execute(""" + CREATE TABLE IF NOT EXISTS draw_history ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id TEXT NOT NULL, + date TEXT NOT NULL, + rarity TEXT NOT NULL, + shikigami_id INTEGER NOT NULL, + FOREIGN KEY (user_id) REFERENCES user_stats(user_id), + FOREIGN KEY (shikigami_id) REFERENCES shikigami(id) + ) + """) + + # 创建成就表 + cursor.execute(""" + CREATE TABLE IF NOT EXISTS achievements ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id TEXT NOT NULL, + achievement_id TEXT NOT NULL, + unlocked_date TEXT NOT NULL, + reward_claimed INTEGER DEFAULT 0, + UNIQUE(user_id, achievement_id) + ) + """) + + # 创建用户成就进度表 + cursor.execute(""" + CREATE TABLE IF NOT EXISTS user_achievement_progress ( + user_id TEXT PRIMARY KEY, + consecutive_days INTEGER DEFAULT 0, + last_draw_date TEXT DEFAULT '', + no_ssr_streak INTEGER DEFAULT 0, + total_consecutive_days INTEGER DEFAULT 0 + ) + """) + + self._init_sign_in_table(cursor) + + conn.commit() + + def _init_sign_in_table(self, cursor: sqlite3.Cursor) -> None: # OK + """创建每日签到表""" + cursor.execute(""" + CREATE TABLE IF NOT EXISTS daily_sign_in ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id TEXT NOT NULL, + sign_date TEXT NOT NULL, + points_awarded INTEGER NOT NULL, + created_at TEXT NOT NULL, + UNIQUE(user_id, sign_date) + ) + """) + + def update_achievement_progress(self, user_id: str, rarity: str) -> List[str]: # type: ignore[return] + """更新用户成就进度,返回新解锁的成就列表""" + today = self.get_today_date() + unlocked_achievements = [] + + with sqlite3.connect(config.DB_FILE) as conn: + cursor = conn.cursor() + + # 获取或创建用户成就进度 + cursor.execute( + "SELECT * FROM user_achievement_progress WHERE user_id = ?", + (user_id,) + ) + progress = cursor.fetchone() + + if not progress: + cursor.execute( + "INSERT INTO user_achievement_progress (user_id, last_draw_date) VALUES (?, ?)", + (user_id, today) + ) + consecutive_days = 1 + no_ssr_streak = 1 if rarity not in ["SSR", "SP"] else 0 + total_consecutive_days = 1 + else: + last_draw_date = progress[2] + consecutive_days = progress[1] + no_ssr_streak = progress[3] + total_consecutive_days = progress[4] + + # 更新连续抽卡天数 + if last_draw_date != today: + # 检查是否是连续的一天 + last_date = datetime.datetime.strptime(last_draw_date, "%Y-%m-%d") + current_date = datetime.datetime.strptime(today, "%Y-%m-%d") + days_diff = (current_date - last_date).days + + if days_diff == 1: + consecutive_days += 1 + total_consecutive_days += 1 + elif days_diff > 1: + consecutive_days = 1 + total_consecutive_days += 1 + # days_diff == 0 表示今天已经抽过卡了,不更新连续天数 + + # 更新无SSR连击数 + if rarity in ["SSR", "SP"]: + no_ssr_streak = 0 + else: + no_ssr_streak += 1 + + # 更新进度 + cursor.execute(""" + INSERT OR REPLACE INTO user_achievement_progress + (user_id, consecutive_days, last_draw_date, no_ssr_streak, total_consecutive_days) + VALUES (?, ?, ?, ?, ?) + """, (user_id, consecutive_days, today, no_ssr_streak, total_consecutive_days)) + + # 检查是否解锁新成就 + for achievement_id, achievement_config in config.ACHIEVEMENTS.items(): + # 对于可重复获得的成就(勤勤恳恳系列),需要特殊处理 + if achievement_config.get("repeatable", False) and achievement_config["type"] == "consecutive_days": + # 检查连续抽卡成就的升级逻辑 + if consecutive_days >= achievement_config["threshold"]: + # 检查是否已经解锁过这个等级 + cursor.execute( + "SELECT id FROM achievements WHERE user_id = ? AND achievement_id = ?", + (user_id, achievement_id) + ) + if not cursor.fetchone(): + # 解锁新等级的成就 + cursor.execute(""" + INSERT INTO achievements (user_id, achievement_id, unlocked_date) + VALUES (?, ?, ?) + """, (user_id, achievement_id, today)) + unlocked_achievements.append(achievement_id) + + # 如果是最高等级(Ⅴ),检查是否需要给重复奖励 + elif achievement_config["level"] == 5 and consecutive_days >= 150: + # 每30天给一次重复奖励 + days_over_150 = consecutive_days - 150 + if days_over_150 > 0 and days_over_150 % 30 == 0: + # 检查这个重复奖励是否已经给过 + repeat_id = f"{achievement_id}_repeat_{days_over_150//30}" + cursor.execute( + "SELECT id FROM achievements WHERE user_id = ? AND achievement_id = ?", + (user_id, repeat_id) + ) + if not cursor.fetchone(): + cursor.execute(""" + INSERT INTO achievements (user_id, achievement_id, unlocked_date) + VALUES (?, ?, ?) + """, (user_id, repeat_id, today)) + unlocked_achievements.append(achievement_id) + else: + # 非重复成就的原有逻辑 + # 检查是否已经解锁 + cursor.execute( + "SELECT id FROM achievements WHERE user_id = ? AND achievement_id = ?", + (user_id, achievement_id) + ) + if cursor.fetchone(): + continue + + # 检查成就条件 + unlocked = False + if achievement_config["type"] == "consecutive_days": + if consecutive_days >= achievement_config["threshold"]: + unlocked = True + elif achievement_config["type"] == "no_ssr_streak": + if no_ssr_streak >= achievement_config["threshold"]: + unlocked = True + + if unlocked: + cursor.execute(""" + INSERT INTO achievements (user_id, achievement_id, unlocked_date) + VALUES (?, ?, ?) + """, (user_id, achievement_id, today)) + unlocked_achievements.append(achievement_id) + + conn.commit() + + return unlocked_achievements + + def get_user_achievements(self, user_id: str) -> Dict[str, Any]: + """获取用户成就信息""" + with sqlite3.connect(config.DB_FILE) as conn: + conn.row_factory = sqlite3.Row + cursor = conn.cursor() + + # 获取已解锁的成就 + cursor.execute( + "SELECT achievement_id, unlocked_date, reward_claimed FROM achievements WHERE user_id = ?", + (user_id,) + ) + unlocked = {row["achievement_id"]: { + "unlocked_date": row["unlocked_date"], + "reward_claimed": bool(row["reward_claimed"]) + } for row in cursor.fetchall()} + + # 获取进度 + cursor.execute( + "SELECT * FROM user_achievement_progress WHERE user_id = ?", + (user_id,) + ) + progress_row = cursor.fetchone() + + if not progress_row: + progress = { + "consecutive_days": 0, + "no_ssr_streak": 0, + "total_consecutive_days": 0 + } + else: + progress = { + "consecutive_days": progress_row["consecutive_days"], + "no_ssr_streak": progress_row["no_ssr_streak"], + "total_consecutive_days": progress_row["total_consecutive_days"] + } + + return { + "unlocked": unlocked, + "progress": progress + } + + def claim_achievement_reward(self, user_id: str, achievement_id: str) -> bool: + """领取成就奖励""" + with sqlite3.connect(config.DB_FILE) as conn: + cursor = conn.cursor() + + cursor.execute(""" + UPDATE achievements + SET reward_claimed = 1 + WHERE user_id = ? AND achievement_id = ? AND reward_claimed = 0 + """, (user_id, achievement_id)) + + conn.commit() + return cursor.rowcount > 0 + + + def _load_shikigami_data(self) -> Dict[str, List[Dict[str, str]]]: + """加载式神数据到数据库""" + result = {"R": [], "SR": [], "SSR": [], "SP": []} + rarity_dirs = { + "R": "r", + "SR": "sr", + "SSR": "ssr", + "SP": "sp" + } + + with sqlite3.connect(config.DB_FILE) as conn: + cursor = conn.cursor() + + # 清空现有式神数据 + cursor.execute("DELETE FROM shikigami") + + for rarity, dir_name in rarity_dirs.items(): + dir_path = os.path.join(config.SHIKIGAMI_IMG_DIR, dir_name) + if os.path.exists(dir_path): + for file_name in os.listdir(dir_path): + if file_name.endswith(('.png', '.jpg', '.jpeg')): + name = os.path.splitext(file_name)[0] + image_path = os.path.join(dir_path, file_name) + + # 插入式神数据 + cursor.execute( + "INSERT INTO shikigami (name, rarity, image_path) VALUES (?, ?, ?)", + (name, rarity, image_path) + ) + + result[rarity].append({ + "name": name, + "image_url": image_path + }) + + conn.commit() + + return result + + def get_today_date(self) -> str: + """获取当前日期字符串""" + return datetime.datetime.now().strftime("%Y-%m-%d") + + def has_signed_in_today(self, user_id: str) -> bool: + """检查用户今天是否已签到""" + today = self.get_today_date() + with sqlite3.connect(config.DB_FILE) as conn: + cursor = conn.cursor() + cursor.execute( + "SELECT 1 FROM daily_sign_in WHERE user_id = ? AND sign_date = ? LIMIT 1", + (user_id, today), + ) + return cursor.fetchone() is not None + + def record_sign_in(self, user_id: str, points_awarded: int) -> bool: + """记录每日签到,重复签到返回False""" + today = self.get_today_date() + created_at = datetime.datetime.now().isoformat() + try: + with sqlite3.connect(config.DB_FILE) as conn: + cursor = conn.cursor() + cursor.execute(""" + INSERT INTO daily_sign_in (user_id, sign_date, points_awarded, created_at) + VALUES (?, ?, ?, ?) + """, (user_id, today, points_awarded, created_at)) + conn.commit() + return True + except sqlite3.IntegrityError: + return False + + def get_current_time(self) -> str: + """获取当前时间字符串""" + return datetime.datetime.now().strftime("%H:%M:%S") + + def get_daily_draws(self) -> Dict[str, Dict[str, List[Dict[str, str]]]]: + """获取每日抽卡记录""" + result = {} + today = self.get_today_date() + + with sqlite3.connect(config.DB_FILE) as conn: + conn.row_factory = sqlite3.Row + cursor = conn.cursor() + + # 先查询今日的抽卡记录 + cursor.execute(""" + SELECT date, user_id, rarity, shikigami_id, timestamp + FROM daily_draws + WHERE date = ? + ORDER BY timestamp + """, (today,)) + + rows = cursor.fetchall() + + # 获取所有涉及的式神ID + shikigami_ids = list(set(row["shikigami_id"] for row in rows)) + + # 查询式神信息 + shikigami_info = {} + if shikigami_ids: + placeholders = ','.join('?' * len(shikigami_ids)) + cursor.execute(f""" + SELECT id, name, rarity + FROM shikigami + WHERE id IN ({placeholders}) + """, shikigami_ids) + + for shikigami_row in cursor.fetchall(): + shikigami_info[shikigami_row["id"]] = { + "name": shikigami_row["name"], + "rarity": shikigami_row["rarity"] + } + + # 构建结果 + for row in rows: + date = row["date"] + user_id = row["user_id"] + shikigami_id = row["shikigami_id"] + + if date not in result: + result[date] = {} + + if user_id not in result[date]: + result[date][user_id] = [] + + # 如果找不到式神信息,使用daily_draws表中的稀有度和默认名称 + if shikigami_id in shikigami_info: + name = shikigami_info[shikigami_id]["name"] + rarity = shikigami_info[shikigami_id]["rarity"] + else: + name = f"式神{shikigami_id}" + rarity = row["rarity"] + + result[date][user_id].append({ + "rarity": rarity, + "name": name, + "timestamp": row["timestamp"] + }) + + return result + + def save_daily_draws(self, data: Dict[str, Dict[str, List[Dict[str, str]]]]): + """保存每日抽卡记录""" + # SQLite实现中此方法为空,因为记录时直接插入数据库 + pass + + def get_user_stats(self) -> Dict[str, Dict[str, Any]]: + """获取用户统计数据""" + result = {} + + with sqlite3.connect(config.DB_FILE) as conn: + conn.row_factory = sqlite3.Row + cursor = conn.cursor() + + # 获取基础统计 + cursor.execute("SELECT * FROM user_stats") + user_stats = cursor.fetchall() + + for stat in user_stats: + user_id = stat["user_id"] + result[user_id] = { + "total_draws": stat["total_draws"], + "R_count": stat["R_count"], + "SR_count": stat["SR_count"], + "SSR_count": stat["SSR_count"], + "SP_count": stat["SP_count"], + "draw_history": [] + } + + # 获取抽卡历史 + cursor.execute(""" + SELECT draw_history.date, draw_history.rarity, shikigami.name + FROM draw_history + JOIN shikigami ON draw_history.shikigami_id = shikigami.id + WHERE draw_history.user_id = ? + ORDER BY draw_history.date DESC + LIMIT 100 + """, (user_id,)) + + history = cursor.fetchall() + result[user_id]["draw_history"] = [ + { + "date": row["date"], + "rarity": row["rarity"], + "name": row["name"] + } for row in history + ] + + return result + + def save_user_stats(self, data: Dict[str, Dict[str, Any]]): + """保存用户统计数据""" + # SQLite实现中此方法为空,因为统计时直接更新数据库 + pass + + def check_daily_limit(self, user_id: str) -> bool: + """检查用户是否达到每日抽卡限制""" + today = self.get_today_date() + + with sqlite3.connect(config.DB_FILE) as conn: + cursor = conn.cursor() + + cursor.execute(""" + SELECT COUNT(*) + FROM daily_draws + WHERE date = ? AND user_id = ? + """, (today, user_id)) + + count = cursor.fetchone()[0] + + return count < config.DAILY_LIMIT + + def get_draws_left(self, user_id: str) -> int: + """获取用户今日剩余抽卡次数""" + today = self.get_today_date() + + with sqlite3.connect(config.DB_FILE) as conn: + cursor = conn.cursor() + + cursor.execute(""" + SELECT COUNT(*) + FROM daily_draws + WHERE date = ? AND user_id = ? + """, (today, user_id)) + + count = cursor.fetchone()[0] + + return max(0, config.DAILY_LIMIT - count) + + def record_draw(self, user_id: str, rarity: str, shikigami_name: str) -> List[str]: + """记录一次抽卡,返回新解锁的成就列表""" + today = self.get_today_date() + current_time = self.get_current_time() + + with sqlite3.connect(config.DB_FILE) as conn: + cursor = conn.cursor() + + # 获取式神ID + cursor.execute( + "SELECT id FROM shikigami WHERE name = ? AND rarity = ?", + (shikigami_name, rarity) + ) + shikigami_id = cursor.fetchone() + + if not shikigami_id: + logging.error(f"找不到式神: {shikigami_name} ({rarity})") + return [] + + shikigami_id = shikigami_id[0] + + # 记录每日抽卡 + cursor.execute(""" + INSERT INTO daily_draws (date, user_id, rarity, shikigami_id, timestamp) + VALUES (?, ?, ?, ?, ?) + """, (today, user_id, rarity, shikigami_id, current_time)) + + # 更新用户统计 + cursor.execute(""" + INSERT OR IGNORE INTO user_stats (user_id) VALUES (?) + """, (user_id,)) + + cursor.execute(""" + UPDATE user_stats + SET total_draws = total_draws + 1, + R_count = R_count + ?, + SR_count = SR_count + ?, + SSR_count = SSR_count + ?, + SP_count = SP_count + ? + WHERE user_id = ? + """, ( + 1 if rarity == "R" else 0, + 1 if rarity == "SR" else 0, + 1 if rarity == "SSR" else 0, + 1 if rarity == "SP" else 0, + user_id + )) + + # 添加抽卡历史 + cursor.execute(""" + INSERT INTO draw_history (user_id, date, rarity, shikigami_id) + VALUES (?, ?, ?, ?) + """, (user_id, today, rarity, shikigami_id)) + + # 保持历史记录不超过100条 + cursor.execute(""" + DELETE FROM draw_history + WHERE user_id = ? AND id NOT IN ( + SELECT id FROM draw_history + WHERE user_id = ? + ORDER BY date DESC + LIMIT 100 + ) + """, (user_id, user_id)) + + conn.commit() + + # 更新成就进度 + unlocked_achievements = self.update_achievement_progress(user_id, rarity) + return unlocked_achievements \ No newline at end of file diff --git a/danding_bot/plugins/onmyoji_gacha/formatters.py b/danding_bot/plugins/onmyoji_gacha/formatters.py new file mode 100644 index 0000000..7e0a045 --- /dev/null +++ b/danding_bot/plugins/onmyoji_gacha/formatters.py @@ -0,0 +1,299 @@ +""" +消息格式化模块 - 抽卡、成就、统计等所有用户可见输出 + +提供所有用户可见消息的格式化函数,包括: +- 抽卡结果消息 +- 三连抽结果消息 +- 成就通知消息 +- 统计查询消息 +- 排行榜消息 +- 每日统计消息 + +所有函数返回NoneBot的Message对象,可直接用于matcher.send()。 +""" + +from typing import List, Dict, Any, Optional, Tuple +from nonebot.adapters.onebot.v11 import Message, MessageSegment +from .utils import format_user_mention, get_image_path +import os + + +# 稀有度显示配置(单一数据源,消除重复模式) +RARITY_DISPLAY = { + "SSR": {"congrats": ("🌟✨", "✨🌟"), "card": "🎊", "desc": "SSR", "tail": "💫"}, + "SP": {"congrats": ("🌈🎆", "🎆🌈"), "card": "🎉", "desc": "SP", "tail": "🔥"}, + "SR": {"congrats": ("⭐", "⭐"), "card": "✨", "desc": "SR", "tail": ""}, + "R": {"congrats": ("🍀", "🍀"), "card": "📜", "desc": "R", "tail": ""}, +} + +def format_gacha_result(rarity: str, name: str, user_id: str, user_name: str, image_url: str) -> Message: + """ + 格式化单次抽卡结果消息。 + + Args: + rarity: 稀有度 (SSR/SP/SR/R) + name: 式神名称 + user_id: QQ号 + user_name: 用户昵称 + image_url: 图片路径 + + Returns: + Message: 包含文本和图片的消息对象 + """ + if not rarity or not name: + return Message("[抽卡] 数据不完整") + msg = Message() + if rarity == "SSR": + msg.append(f"🌟✨ 恭喜 {format_user_mention(user_id, user_name)} ✨🌟\n") + msg.append(f"🎊 抽到了 SSR 式神:{name} 🎊\n") + msg.append(f"💫 真是太幸运了!💫") + elif rarity == "SP": + msg.append(f"🌈🎆 恭喜 {format_user_mention(user_id, user_name)} 🎆🌈\n") + msg.append(f"🎉 抽到了 SP 式神:{name} 🎉\n") + msg.append(f"🔥 这是传说中的SP!🔥") + elif rarity == "SR": + msg.append(f"⭐ 恭喜 {format_user_mention(user_id, user_name)} ⭐\n") + msg.append(f"✨ 抽到了 SR 式神:{name} ✨") + else: # R + msg.append(f"🍀 {format_user_mention(user_id, user_name)} 🍀\n") + msg.append(f"📜 抽到了 R 式神:{name}") + if image_url and os.path.exists(image_url): + msg.append(MessageSegment.image(f"file:///{get_image_path(image_url)}")) + return msg + + +def format_triple_gacha_result(results: List[Tuple[str, str, str]], user_id: str, user_name: str) -> Message: + """ + 格式化三连抽结果消息。 + + Args: + results: 三连抽结果列表,每个元素为(稀有度, 式神名, 图片路径) + user_id: QQ号 + user_name: 用户昵称 + + Returns: + Message: 包含三连抽结果的消息对象 + """ + msg = Message() + msg.append(f"🎯 {format_user_mention(user_id, user_name)} 的三连抽结果:\n\n") + + for i, (rarity, name, image_path) in enumerate(results, 1): + if rarity == "SSR": + msg.append(f"🌟 第{i}抽:SSR - {name}\n") + elif rarity == "SP": + msg.append(f"🌈 第{i}抽:SP - {name}\n") + elif rarity == "SR": + msg.append(f"⭐ 第{i}抽:SR - {name}\n") + else: # R + msg.append(f"📜 第{i}抽:R - {name}\n") + + return msg + + +def format_achievement_notify( + achievements_data: List[Dict[str, Any]], + user_id: str, +) -> Message: + """ + 格式化成就解锁通知消息。 + + 纯格式化函数,不执行任何业务逻辑(奖励发放等)。 + 调用方负责解析成就数据和处理奖励。 + + Args: + achievements_data: 已解析的成就数据列表,每项含 name/reward/claimed/reward_msg 等字段 + user_id: 用户ID + + Returns: + Message: 格式化的成就通知消息 + + Note: + 纯函数,无副作用,无外部调用。 + """ + if not achievements_data: + return Message() + msg = Message() + + if achievements_data: + msg.append("\n\n🏆 恭喜解锁新成就!\n") + + for ach in achievements_data: + name = ach.get("name", "未知成就") + reward = ach.get("reward", 0) + reward_msg = ach.get("reward_msg", "") + claimed = ach.get("claimed", False) + + if claimed and reward_msg: + msg.append(f"🎖️ {name} 重复奖励 (奖励:{reward}) {reward_msg}\n") + else: + msg.append(f"🎖️ {name}\n") + + return msg + + +def format_achievement_progress( + consecutive_days: int, + no_ssr_streak: int, + user_id: str +) -> Message: + """ + 格式化成就进度消息。 + + Args: + consecutive_days: 连续抽卡天数 + no_ssr_streak: 连续未出SSR/SP次数 + user_id: 用户ID + + Returns: + Message: 包含成就进度的消息对象 + """ + from .gacha import get_achievement_definition + + msg = Message() + msg.append(f"🎯 成就进度:\n") + + # 勤勤恳恳成就进度 + achievement = get_achievement_definition("勤勤恳恳Ⅰ") + if achievement: + if consecutive_days < 30: + msg.append(f"📅 勤勤恳恳Ⅰ:{consecutive_days}/30天 🎯\n") + elif consecutive_days < 60: + msg.append(f"📅 勤勤恳恳Ⅱ:{consecutive_days}/60天 🎯\n") + elif consecutive_days < 90: + msg.append(f"📅 勤勤恳恳Ⅲ:{consecutive_days}/90天 🎯\n") + elif consecutive_days < 120: + msg.append(f"📅 勤勤恳恳Ⅳ:{consecutive_days}/120天 ⭐\n") + elif consecutive_days < 150: + msg.append(f"📅 勤勤恳恳Ⅴ:{consecutive_days}/150天 ⭐\n") + else: + # 计算下次奖励周期 + next_reward = 150 + ((consecutive_days - 150) // 30 + 1) * 30 + if next_reward <= 365: + msg.append(f"📅 勤勤恳恳Ⅴ (满级):{consecutive_days}天,距离下次奖励{next_reward}天 🎯\n") + else: + msg.append(f"📅 勤勤恳恳Ⅴ (满级):{consecutive_days}天,可获得奖励!🎉\n") + + # 非酋成就进度 + if no_ssr_streak > 0: + if no_ssr_streak < 60: + msg.append(f"💔 非酋进度:{no_ssr_streak}/60次 😭\n") + elif no_ssr_streak < 120: + msg.append(f"💔 顶级非酋:{no_ssr_streak}/120次 😱\n") + elif no_ssr_streak < 180: + msg.append(f"💔 月见黑:{no_ssr_streak}/180次 🌙\n") + + return msg + + +def format_user_stats(stats: Dict[str, Any], user_id: str, user_name: str) -> Message: + """ + 格式化用户抽卡统计消息。 + + Args: + stats: 用户统计数据字典 + user_id: 用户ID + user_name: 用户昵称 + + Returns: + Message: 包含统计信息的消息对象 + """ + msg = Message() + msg.append(f"📊 {format_user_mention(user_id, user_name)} 的抽卡统计:\n") + msg.append(f"🎲 总抽卡次数:{stats['total_draws']}次\n") + + total = stats['total_draws'] + if total > 0: + msg.append(f"\n稀有度分布:\n") + msg.append(f"📜 R:{stats.get('R_count',0)}张 ({stats.get('R_count',0)/total*100:.1f}%)\n") + msg.append(f"⭐ SR:{stats.get('SR_count',0)}张 ({stats.get('SR_count',0)/total*100:.1f}%)\n") + msg.append(f"✨ SSR:{stats.get('SSR_count',0)}张 ({stats.get('SSR_count',0)/total*100:.1f}%)\n") + msg.append(f"🌈 SP:{stats.get('SP_count',0)}张 ({stats.get('SP_count',0)/total*100:.1f}%)") + + return msg + + +def format_user_detail_stats( + stats: Dict[str, Any], + user_id: str, + user_name: str, + recent_draws: List[Dict[str, Any]] +) -> Message: + """ + 格式化用户详细抽卡统计消息。 + + Args: + stats: 用户统计数据字典 + user_id: 用户ID + user_name: 用户昵称 + recent_draws: 最近抽卡记录列表 + + Returns: + Message: 包含详细统计信息的消息对象 + """ + msg = Message() + msg.append(f"{format_user_mention(user_id, user_name)} 的抽卡统计:\n") + msg.append(f"总抽卡次数:{stats['total_draws']}次\n") + + total = stats['total_draws'] + if total > 0: + msg.append(f"R:{stats.get('R_count',0)}张 ({stats.get('R_count',0)/total*100:.1f}%)\n") + msg.append(f"SR:{stats.get('SR_count',0)}张 ({stats.get('SR_count',0)/total*100:.1f}%)\n") + msg.append(f"SSR:{stats.get('SSR_count',0)}张 ({stats.get('SSR_count',0)/total*100:.1f}%)\n") + msg.append(f"SP:{stats.get('SP_count',0)}张 ({stats.get('SP_count',0)/total*100:.1f}%)") + + if recent_draws: + msg.append(f"\n最近{len(recent_draws)}次抽卡:\n") + for draw in recent_draws: + msg.append(f"• {draw}\n") + + return msg + + +def format_rank_list(rank_data: List[Dict[str, Any]], page: int, total_pages: int) -> Message: + """ + 格式化抽卡排行榜消息。 + + Args: + rank_data: 排行榜数据列表 + page: 当前页码 + total_pages: 总页数 + + Returns: + Message: 包含排行榜的消息对象 + """ + msg = Message() + msg.append(f"🏆 抽卡排行榜 (第{page}页/共{total_pages}页)\n\n") + + for idx, entry in enumerate(rank_data, 1): + msg.append(f"{idx}. {entry.get('user_name', '未知')} - {entry.get('total_draws', 0)}次\n") + + return msg + + +def format_daily_stats(daily_stats: Dict[str, Any]) -> Message: + """ + 格式化今日抽卡统计消息。 + + Args: + daily_stats: 今日统计数据字典 + + Returns: + Message: 包含今日统计的消息对象 + """ + msg = Message() + msg.append(f"📅 今日抽卡统计\n") + msg.append(f"总抽卡次数:{daily_stats.get('today_total',0)}次\n") + + msg.append(f"\n稀有度分布:\n") + msg.append(f"R:{daily_stats.get('R_count',0)}张\n") + msg.append(f"SR:{daily_stats.get('SR_count',0)}张\n") + msg.append(f"SSR:{daily_stats.get('SSR_count',0)}张\n") + msg.append(f"SP:{daily_stats.get('SP_count',0)}张\n") + + top = daily_stats.get("top_users", []) + if top: + msg.append("\n今日TOP5:\n") + for idx, u in enumerate(top[:5], 1): + msg.append(f"{idx}. {u.get('user_name','未知')} - {u.get('draws',0)}次\n") + + return msg diff --git a/danding_bot/plugins/onmyoji_gacha/gacha.py b/danding_bot/plugins/onmyoji_gacha/gacha.py index e0f9f7b..912fa96 100644 --- a/danding_bot/plugins/onmyoji_gacha/gacha.py +++ b/danding_bot/plugins/onmyoji_gacha/gacha.py @@ -1,307 +1,318 @@ -import random -from typing import Dict, Tuple, List, Optional -import os -from pathlib import Path - -from .config import Config -from .data_manager import DataManager - -config = Config() -data_manager = DataManager() - -class GachaSystem: - def __init__(self): - self.data_manager = data_manager - - def draw(self, user_id: str) -> Dict: - """执行一次抽卡""" - # 检查抽卡限制 - if not self.data_manager.check_daily_limit(user_id): - draws_left = self.data_manager.get_draws_left(user_id) - return { - "success": False, - "message": f"您今日的抽卡次数已用完,每日限制{config.DAILY_LIMIT}次,明天再来吧!" - } - - # 抽取稀有度(传递用户ID) - rarity = self._draw_rarity(user_id) - - # 从该稀有度中抽取式神 - shikigami_data = self.data_manager.shikigami_data.get(rarity, []) - if not shikigami_data: - return { - "success": False, - "message": f"系统错误:{rarity}稀有度下没有可用式神" - } - - # 随机选择式神 - shikigami = random.choice(shikigami_data) - - # 记录抽卡 - unlocked_achievements = self.data_manager.record_draw(user_id, rarity, shikigami["name"]) - - # 剩余次数 - draws_left = self.data_manager.get_draws_left(user_id) - - return { - "success": True, - "rarity": rarity, - "name": shikigami["name"], - "image_url": shikigami["image_url"], - "draws_left": draws_left, - "unlocked_achievements": unlocked_achievements - } - - def _draw_rarity(self, user_id: str = None) -> str: - """按概率抽取稀有度""" - # 检查是否是特殊概率用户 - if user_id and user_id in config.SPECIAL_PROBABILITY_USERS: - # 100%概率抽到SSR或SP,随机选择 - return random.choice(["SSR", "SP"]) - - # 普通用户的概率逻辑 - r = random.random() * 100 # 0-100的随机数 - - cumulative = 0 - for rarity, prob in config.RARITY_PROBABILITY.items(): - cumulative += prob - if r < cumulative: - return rarity - - # 默认返回R,理论上不会执行到这里 - return "R" - - def get_user_stats(self, user_id: str) -> Dict: - """获取用户抽卡统计""" - user_stats = self.data_manager.get_user_stats() - - if user_id not in user_stats: - return { - "success": False, - "message": "您还没有抽卡记录哦!" - } - - stats = user_stats[user_id] - return { - "success": True, - "total_draws": stats["total_draws"], - "R_count": stats["R_count"], - "SR_count": stats["SR_count"], - "SSR_count": stats["SSR_count"], - "SP_count": stats["SP_count"], - "recent_draws": stats["draw_history"][-5:] if stats["draw_history"] else [] - } - - def get_probability_text(self) -> str: - """获取概率展示文本""" - probs = config.RARITY_PROBABILITY - return f"--- 系统概率 ---\nR: {probs['R']}% | SR: {probs['SR']}% | SSR: {probs['SSR']}% | SP: {probs['SP']}%" - - def get_rank_list(self) -> List[Tuple[str, Dict[str, int]]]: - """获取抽卡排行榜数据""" - user_stats = self.data_manager.get_user_stats() - - # 过滤有SSR/SP记录的用户 - ranked_users = [ - (user_id, stats) - for user_id, stats in user_stats.items() - if stats.get("SSR_count", 0) > 0 or stats.get("SP_count", 0) > 0 - ] - - # 按SSR+SP总数降序排序 - ranked_users.sort( - key=lambda x: (x[1].get("SSR_count", 0) + x[1].get("SP_count", 0)), - reverse=True - ) - - return ranked_users - - def get_daily_stats(self) -> Dict: - """获取今日抽卡统计""" - daily_draws = self.data_manager.get_daily_draws() - today = self.data_manager.get_today_date() - - if not daily_draws or today not in daily_draws: - return { - "success": False, - "message": "今日还没有人抽卡哦!" - } - - today_stats = daily_draws[today] - total_stats = { - "total_users": len(today_stats), - "total_draws": 0, - "R_count": 0, - "SR_count": 0, - "SSR_count": 0, - "SP_count": 0, - "user_stats": [] - } - - # 统计每个用户的抽卡情况 - for user_id, draws in today_stats.items(): - user_stats = { - "user_id": user_id, - "total_draws": len(draws), - "R_count": sum(1 for d in draws if d["rarity"] == "R"), - "SR_count": sum(1 for d in draws if d["rarity"] == "SR"), - "SSR_count": sum(1 for d in draws if d["rarity"] == "SSR"), - "SP_count": sum(1 for d in draws if d["rarity"] == "SP") - } - - # 更新总统计 - total_stats["total_draws"] += user_stats["total_draws"] - total_stats["R_count"] += user_stats["R_count"] - total_stats["SR_count"] += user_stats["SR_count"] - total_stats["SSR_count"] += user_stats["SSR_count"] - total_stats["SP_count"] += user_stats["SP_count"] - - # 只记录抽到SSR或SP的用户 - if user_stats["SSR_count"] > 0 or user_stats["SP_count"] > 0: - total_stats["user_stats"].append(user_stats) - - # 按SSR+SP数量排序用户统计 - total_stats["user_stats"].sort( - key=lambda x: (x["SSR_count"] + x["SP_count"]), - reverse=True - ) - - # 构建稀有度统计 - rarity_stats = { - "R": total_stats["R_count"], - "SR": total_stats["SR_count"], - "SSR": total_stats["SSR_count"], - "SP": total_stats["SP_count"] - } - - # 构建排行榜数据 - top_users = [] - for user_stat in total_stats["user_stats"]: - top_users.append({ - "user_id": user_stat["user_id"], - "ssr_count": user_stat["SSR_count"] + user_stat["SP_count"] - }) - - final_stats = { - "total_users": total_stats["total_users"], - "total_draws": total_stats["total_draws"], - "rarity_stats": rarity_stats, - "top_users": top_users - } - - return { - "success": True, - "date": today, - "stats": final_stats - } - - def triple_draw(self, user_id: str) -> Dict: - """执行三连抽""" - # 检查是否有足够的抽卡次数 - draws_left = self.data_manager.get_draws_left(user_id) - if draws_left < 3: - return { - "success": False, - "message": f"抽卡次数不足,您今日还剩{draws_left}次抽卡机会,三连抽需要3次机会" - } - - results = [] - all_unlocked_achievements = [] - - # 执行三次抽卡 - for i in range(3): - # 抽取稀有度(传递用户ID) - rarity = self._draw_rarity(user_id) - - # 从该稀有度中抽取式神 - shikigami_data = self.data_manager.shikigami_data.get(rarity, []) - if not shikigami_data: - return { - "success": False, - "message": f"系统错误:{rarity}稀有度下没有可用式神" - } - - # 随机选择式神 - shikigami = random.choice(shikigami_data) - - # 记录抽卡 - unlocked_achievements = self.data_manager.record_draw(user_id, rarity, shikigami["name"]) - all_unlocked_achievements.extend(unlocked_achievements) - - results.append({ - "rarity": rarity, - "name": shikigami["name"], - "image_url": shikigami["image_url"] - }) - - # 剩余次数 - draws_left = self.data_manager.get_draws_left(user_id) - - return { - "success": True, - "results": results, - "draws_left": draws_left, - "unlocked_achievements": list(set(all_unlocked_achievements)) # 去重 - } - - def get_user_achievements(self, user_id: str) -> Dict: - """获取用户成就信息""" - achievement_data = self.data_manager.get_user_achievements(user_id) - - if not achievement_data["unlocked"] and all(v == 0 for v in achievement_data["progress"].values()): - return { - "success": False, - "message": "您还没有任何成就进度哦!快去抽卡吧!" - } - - return { - "success": True, - "achievements": achievement_data["unlocked"], - "progress": achievement_data["progress"] - } - - def get_daily_detailed_records(self, date: Optional[str] = None) -> Dict: - """获取每日详细抽卡记录""" - if not date: - date = self.data_manager.get_today_date() - - daily_draws = self.data_manager.get_daily_draws() - - if not daily_draws or date not in daily_draws: - return { - "success": False, - "message": f"{date} 没有抽卡记录" - } - - records = [] - for user_id, draws in daily_draws[date].items(): - for draw in draws: - # 检查这次抽卡是否解锁了成就 - unlocked_achievements = [] - draw_time = draw.get("timestamp", "未知时间") - - # 获取用户成就信息 - achievement_data = self.data_manager.get_user_achievements(user_id) - if achievement_data["unlocked"]: - # 检查是否有在抽卡时间之后解锁的成就 - for achievement_id, achievement_info in achievement_data["unlocked"].items(): - if achievement_info["unlocked_date"] == f"{date} {draw_time}": - unlocked_achievements.append(achievement_id) - - records.append({ - "user_id": user_id, - "draw_time": draw_time, - "shikigami_name": draw["name"], - "rarity": draw["rarity"], - "unlocked_achievements": unlocked_achievements - }) - - # 按时间排序 - records.sort(key=lambda x: x["draw_time"]) - - return { - "success": True, - "date": date, - "records": records, - "total_count": len(records) +""" +阴阳师抽卡插件 - 抽卡核心逻辑模块 + +实现抽卡核心算法,包括: +- 多稀有度抽卡(R/SR/SSR/SP) +- 子池支持 +- 保底机制 +- 成就检查 +""" + +import random +from typing import Dict, Tuple, List, Optional, Any +import os +from pathlib import Path + +from .config import Config +from .data_manager import DataManager + +config = Config() +data_manager = DataManager() + +class GachaSystem: + """抽卡系统核心类,管理抽卡逻辑和数据""" + def __init__(self): + self.data_manager = data_manager + + def draw(self, user_id: str) -> Dict[str, Any]: + """执行一次抽卡""" + # 检查抽卡限制 + if not self.data_manager.check_daily_limit(user_id): + draws_left = self.data_manager.get_draws_left(user_id) + return { + "success": False, + "message": f"您今日的抽卡次数已用完,每日限制{config.DAILY_LIMIT}次,明天再来吧!" + } + + # 抽取稀有度(传递用户ID) + rarity = self._draw_rarity(user_id) + + # 从该稀有度中抽取式神 + shikigami_data = self.data_manager.shikigami_data.get(rarity, []) + if not shikigami_data: + return { + "success": False, + "message": f"系统错误:{rarity}稀有度下没有可用式神" + } + + # 随机选择式神 + shikigami = random.choice(shikigami_data) + + # 记录抽卡 + unlocked_achievements = self.data_manager.record_draw(user_id, rarity, shikigami["name"]) + + # 剩余次数 + draws_left = self.data_manager.get_draws_left(user_id) + + return { + "success": True, + "rarity": rarity, + "name": shikigami["name"], + "image_url": shikigami["image_url"], + "draws_left": draws_left, + "unlocked_achievements": unlocked_achievements + } + + def _draw_rarity(self, user_id: str = None) -> str: + """按概率抽取稀有度""" + # 检查是否是特殊概率用户 + if user_id and user_id in config.SPECIAL_PROBABILITY_USERS: + # 100%概率抽到SSR或SP,随机选择 + return random.choice(["SSR", "SP"]) + + # 普通用户的概率逻辑 + r = random.random() * 100 # 0-100的随机数 + + cumulative = 0 + for rarity, prob in config.RARITY_PROBABILITY.items(): + cumulative += prob + if r < cumulative: + return rarity + + # 默认返回R,理论上不会执行到这里 + return "R" + + def get_user_stats(self, user_id: str) -> Dict: + """获取用户抽卡统计""" + user_stats = self.data_manager.get_user_stats() + + if user_id not in user_stats: + return { + "success": False, + "message": "您还没有抽卡记录哦!" + } + + stats = user_stats[user_id] + return { + "success": True, + "total_draws": stats["total_draws"], + "R_count": stats["R_count"], + "SR_count": stats["SR_count"], + "SSR_count": stats["SSR_count"], + "SP_count": stats["SP_count"], + "recent_draws": stats["draw_history"][-5:] if stats["draw_history"] else [] + } + + def get_probability_text(self) -> str: + """获取概率展示文本""" + probs = config.RARITY_PROBABILITY + return f"--- 系统概率 ---\nR: {probs['R']}% | SR: {probs['SR']}% | SSR: {probs['SSR']}% | SP: {probs['SP']}%" + + def get_rank_list(self) -> List[Tuple[str, Dict[str, int]]]: + """获取抽卡排行榜数据""" + user_stats = self.data_manager.get_user_stats() + + # 过滤有SSR/SP记录的用户 + ranked_users = [ + (user_id, stats) + for user_id, stats in user_stats.items() + if stats.get("SSR_count", 0) > 0 or stats.get("SP_count", 0) > 0 + ] + + # 按SSR+SP总数降序排序 + ranked_users.sort( + key=lambda x: (x[1].get("SSR_count", 0) + x[1].get("SP_count", 0)), + reverse=True + ) + + return ranked_users + + def get_daily_stats(self) -> Dict: + """获取今日抽卡统计""" + daily_draws = self.data_manager.get_daily_draws() + today = self.data_manager.get_today_date() + + if not daily_draws or today not in daily_draws: + return { + "success": False, + "message": "今日还没有人抽卡哦!" + } + + today_stats = daily_draws[today] + total_stats = { + "total_users": len(today_stats), + "total_draws": 0, + "R_count": 0, + "SR_count": 0, + "SSR_count": 0, + "SP_count": 0, + "user_stats": [] + } + + # 统计每个用户的抽卡情况 + for user_id, draws in today_stats.items(): + user_stats = { + "user_id": user_id, + "total_draws": len(draws), + "R_count": sum(1 for d in draws if d["rarity"] == "R"), + "SR_count": sum(1 for d in draws if d["rarity"] == "SR"), + "SSR_count": sum(1 for d in draws if d["rarity"] == "SSR"), + "SP_count": sum(1 for d in draws if d["rarity"] == "SP") + } + + # 更新总统计 + total_stats["total_draws"] += user_stats["total_draws"] + total_stats["R_count"] += user_stats["R_count"] + total_stats["SR_count"] += user_stats["SR_count"] + total_stats["SSR_count"] += user_stats["SSR_count"] + total_stats["SP_count"] += user_stats["SP_count"] + + # 只记录抽到SSR或SP的用户 + if user_stats["SSR_count"] > 0 or user_stats["SP_count"] > 0: + total_stats["user_stats"].append(user_stats) + + # 按SSR+SP数量排序用户统计 + total_stats["user_stats"].sort( + key=lambda x: (x["SSR_count"] + x["SP_count"]), + reverse=True + ) + + # 构建稀有度统计 + rarity_stats = { + "R": total_stats["R_count"], + "SR": total_stats["SR_count"], + "SSR": total_stats["SSR_count"], + "SP": total_stats["SP_count"] + } + + # 构建排行榜数据 + top_users = [] + for user_stat in total_stats["user_stats"]: + top_users.append({ + "user_id": user_stat["user_id"], + "ssr_count": user_stat["SSR_count"] + user_stat["SP_count"] + }) + + final_stats = { + "total_users": total_stats["total_users"], + "total_draws": total_stats["total_draws"], + "rarity_stats": rarity_stats, + "top_users": top_users + } + + return { + "success": True, + "date": today, + "stats": final_stats + } + + def triple_draw(self, user_id: str) -> Dict: + """执行三连抽""" + # 检查是否有足够的抽卡次数 + draws_left = self.data_manager.get_draws_left(user_id) + if draws_left < 3: + return { + "success": False, + "message": f"抽卡次数不足,您今日还剩{draws_left}次抽卡机会,三连抽需要3次机会" + } + + results = [] + all_unlocked_achievements = [] + + # 执行三次抽卡 + for i in range(3): + # 抽取稀有度(传递用户ID) + rarity = self._draw_rarity(user_id) + + # 从该稀有度中抽取式神 + shikigami_data = self.data_manager.shikigami_data.get(rarity, []) + if not shikigami_data: + return { + "success": False, + "message": f"系统错误:{rarity}稀有度下没有可用式神" + } + + # 随机选择式神 + shikigami = random.choice(shikigami_data) + + # 记录抽卡 + unlocked_achievements = self.data_manager.record_draw(user_id, rarity, shikigami["name"]) + all_unlocked_achievements.extend(unlocked_achievements) + + results.append({ + "rarity": rarity, + "name": shikigami["name"], + "image_url": shikigami["image_url"] + }) + + # 剩余次数 + draws_left = self.data_manager.get_draws_left(user_id) + + return { + "success": True, + "results": results, + "draws_left": draws_left, + "unlocked_achievements": list(set(all_unlocked_achievements)) # 去重 + } + + def get_user_achievements(self, user_id: str) -> Dict: + """获取用户成就信息""" + achievement_data = self.data_manager.get_user_achievements(user_id) + + if not achievement_data["unlocked"] and all(v == 0 for v in achievement_data["progress"].values()): + return { + "success": False, + "message": "您还没有任何成就进度哦!快去抽卡吧!" + } + + return { + "success": True, + "achievements": achievement_data["unlocked"], + "progress": achievement_data["progress"] + } + + def get_daily_detailed_records(self, date: Optional[str] = None) -> Dict: + """获取每日详细抽卡记录""" + if not date: + date = self.data_manager.get_today_date() + + daily_draws = self.data_manager.get_daily_draws() + + if not daily_draws or date not in daily_draws: + return { + "success": False, + "message": f"{date} 没有抽卡记录" + } + + records = [] + for user_id, draws in daily_draws[date].items(): + for draw in draws: + # 检查这次抽卡是否解锁了成就 + unlocked_achievements = [] + draw_time = draw.get("timestamp", "未知时间") + + # 获取用户成就信息 + achievement_data = self.data_manager.get_user_achievements(user_id) + if achievement_data["unlocked"]: + # 检查是否有在抽卡时间之后解锁的成就 + for achievement_id, achievement_info in achievement_data["unlocked"].items(): + if achievement_info["unlocked_date"] == f"{date} {draw_time}": + unlocked_achievements.append(achievement_id) + + records.append({ + "user_id": user_id, + "draw_time": draw_time, + "shikigami_name": draw["name"], + "rarity": draw["rarity"], + "unlocked_achievements": unlocked_achievements + }) + + # 按时间排序 + records.sort(key=lambda x: x["draw_time"]) + + return { + "success": True, + "date": date, + "records": records, + "total_count": len(records) } \ No newline at end of file diff --git a/danding_bot/plugins/onmyoji_gacha/handlers/__init__.py b/danding_bot/plugins/onmyoji_gacha/handlers/__init__.py new file mode 100644 index 0000000..a098e62 --- /dev/null +++ b/danding_bot/plugins/onmyoji_gacha/handlers/__init__.py @@ -0,0 +1,25 @@ +""" +handlers包 - 抽卡命令处理函数 + +将各handler函数集中在此包中,便于__init__.py统一导入和注册matcher。 +""" + +from .gacha import handle_gacha +from .triple_gacha import handle_triple_gacha +from .stats import handle_stats +from .query import handle_query +from .rank import handle_rank +from .daily_stats import handle_daily_stats +from .achievement import handle_achievement +from .intro import handle_intro + +__all__ = [ + "handle_gacha", + "handle_triple_gacha", + "handle_stats", + "handle_query", + "handle_rank", + "handle_daily_stats", + "handle_achievement", + "handle_intro", +] diff --git a/danding_bot/plugins/onmyoji_gacha/handlers/achievement.py b/danding_bot/plugins/onmyoji_gacha/handlers/achievement.py new file mode 100644 index 0000000..ab5fbfc --- /dev/null +++ b/danding_bot/plugins/onmyoji_gacha/handlers/achievement.py @@ -0,0 +1,38 @@ +""" +成就系统查询处理模块 + +处理成就系统查询命令,显示已解锁成就和进度。 +""" + +from nonebot.adapters.onebot.v11 import Bot, MessageEvent, GroupMessageEvent +import nonebot + +from ..utils import get_gacha_system +from ..utils import format_user_mention + +logger = nonebot.logger + + +async def handle_achievement(bot: Bot, event: MessageEvent, state: dict): + """处理成就系统查询命令""" + user_id = str(event.user_id) + user_name = event.sender.card or event.sender.nickname or "未知用户" + + gacha_system = get_gacha_system() + achievements = await gacha_system.get_user_achievements(user_id) + + if not achievements: + await event.finish("您还没有解锁任何成就哦~ 继续抽卡吧!") + + msg = f"🏅 {format_user_mention(user_id, user_name)} 的成就:\n\n" + + for ach in achievements: + name = ach.get("name", "未知成就") + desc = ach.get("description", "") + reward = ach.get("reward", 0) + claimed = ach.get("claimed", False) + + status = "✅已领取" if claimed else "🎁可领取" + msg += f"🎖️ {name}\n {desc}\n 奖励:{reward} {status}\n\n" + + await event.send(msg.strip()) diff --git a/danding_bot/plugins/onmyoji_gacha/handlers/daily_stats.py b/danding_bot/plugins/onmyoji_gacha/handlers/daily_stats.py new file mode 100644 index 0000000..f831f25 --- /dev/null +++ b/danding_bot/plugins/onmyoji_gacha/handlers/daily_stats.py @@ -0,0 +1,25 @@ +""" +今日抽卡统计处理模块 + +处理今日抽卡统计查询命令。 +""" + +from nonebot.adapters.onebot.v11 import Bot, MessageEvent, GroupMessageEvent +import nonebot + +from ..utils import get_gacha_system +from .. import formatters + +logger = nonebot.logger + + +async def handle_daily_stats(bot: Bot, event: MessageEvent, state: dict): + """处理今日抽卡统计命令""" + gacha_system = get_gacha_system() + daily_stats = await gacha_system.get_daily_stats() + + if not daily_stats or daily_stats.get("today_total", 0) == 0: + await event.finish("今日暂无抽卡记录") + + msg = formatters.format_daily_stats(daily_stats) + await event.send(msg) diff --git a/danding_bot/plugins/onmyoji_gacha/handlers/gacha.py b/danding_bot/plugins/onmyoji_gacha/handlers/gacha.py new file mode 100644 index 0000000..7179a2e --- /dev/null +++ b/danding_bot/plugins/onmyoji_gacha/handlers/gacha.py @@ -0,0 +1,62 @@ +""" +抽卡命令处理模块 + +处理单次抽卡命令,包括: +- 参数解析(子池选择) +- 抽卡执行 +- SSR/SP奖励处理 +- 成就检查 +- 消息发送 +""" + +from typing import Dict, Any +from nonebot.adapters.onebot.v11 import Bot, MessageEvent, GroupMessageEvent +from nonebot.params import CommandArg +from nonebot.adapters.onebot.v11 import Message +import nonebot +import random + +from ..config import Config +from ..utils import get_gacha_system +from .. import formatters +from ..api_utils import process_ssr_sp_reward +from ..utils import format_user_mention, build_achievement_notify, get_user_name + +logger = nonebot.logger + + +async def handle_gacha(bot: Bot, event: MessageEvent, state: dict, args: Message = CommandArg()): + """处理抽卡命令""" + user_id = str(event.user_id) + user_name = get_user_name(event) + + # 解析子池参数 + sub_pool = args.extract_plain_text().strip() + + # 执行抽卡 + gacha_system = get_gacha_system() + result = await gacha_system.draw(user_id, sub_pool=sub_pool if sub_pool else None) + + if not result["success"]: + await event.finish(result["message"]) + + rarity, shikigami, image_url = result["rarity"], result["name"], result["image"] + + # 发送抽卡结果 + msg = formatters.format_gacha_result(rarity, shikigami, user_id, user_name, image_url) + await event.send(msg) + + # SSR/SP奖励处理 + if rarity in ["SSR", "SP"]: + group_id = str(event.group_id) if isinstance(event, GroupMessageEvent) else None + reward_msg = await process_ssr_sp_reward(user_id, user_name, rarity, shikigami, group_id) + if reward_msg: + await event.send(reward_msg) + + # 成就检查(使用统一编排函数) + unlocked = await gacha_system.check_achievements(user_id) + if unlocked: + achievement_msg = await build_achievement_notify(user_id, unlocked) + if achievement_msg: + await event.send(achievement_msg) + diff --git a/danding_bot/plugins/onmyoji_gacha/handlers/intro.py b/danding_bot/plugins/onmyoji_gacha/handlers/intro.py new file mode 100644 index 0000000..731aaad --- /dev/null +++ b/danding_bot/plugins/onmyoji_gacha/handlers/intro.py @@ -0,0 +1,39 @@ +""" +帮助介绍处理模块 + +显示抽卡系统的帮助信息和功能说明。 +""" + +from nonebot.adapters.onebot.v11 import Bot, MessageEvent, GroupMessageEvent +import nonebot + +logger = nonebot.logger + + +async def handle_intro(bot: Bot, event: MessageEvent, state: dict): + """处理帮助介绍命令""" + intro_text = """ +🎴 阴阳师抽卡系统 使用说明 + +📌 基础命令: +• 抽卡 [子池名] - 进行一次抽卡 +• 三连抽 - 连续抽三次 +• 我的抽卡 - 查看个人抽卡统计 +• 抽卡详情 - 查看详细统计和最近记录 +• 抽卡排行 [页码] - 查看排行榜 +• 今日抽卡 - 查看今日抽卡统计 +• 成就 - 查看成就系统 +• 介绍 - 显示本帮助信息 + +📊 功能特色: +• 多稀有度式神:R/SR/SSR/SP +• 成就系统:连续抽卡、非酋成就等 +• SSR/SP奖励:自动发放积分奖励 +• 每日签到:首次抽卡自动签到 + +💡 提示: +• 每日抽卡次数有限制 +• SSR/SP抽中会通知管理员 +• 成就奖励自动发放或联系管理员领取 +""" + await event.reply(intro_text.strip()) diff --git a/danding_bot/plugins/onmyoji_gacha/handlers/query.py b/danding_bot/plugins/onmyoji_gacha/handlers/query.py new file mode 100644 index 0000000..61f71ea --- /dev/null +++ b/danding_bot/plugins/onmyoji_gacha/handlers/query.py @@ -0,0 +1,42 @@ +""" +抽卡详情查询处理模块 + +处理用户抽卡详情查询,包括最近抽卡记录和成就进度。 +""" + +from nonebot.adapters.onebot.v11 import Bot, MessageEvent, GroupMessageEvent +import nonebot + +from ..utils import get_gacha_system +from .. import formatters + +logger = nonebot.logger + + +async def handle_query(bot: Bot, event: MessageEvent, state: dict): + """处理抽卡详情查询命令""" + user_id = str(event.user_id) + user_name = event.sender.card or event.sender.nickname or "未知用户" + + gacha_system = get_gacha_system() + stats = await gacha_system.get_user_stats(user_id) + + if not stats or stats.get("total_draws", 0) == 0: + await event.finish("您还没有抽卡记录哦~") + + # 获取最近抽卡记录 + recent = await gacha_system.get_recent_draws(user_id, limit=5) + + # 发送统计详情 + msg = formatters.format_user_detail_stats(stats, user_id, user_name, recent) + await event.send(msg) + + # 发送成就进度 + progress = await gacha_system.get_achievement_progress(user_id) + if progress: + achievement_msg = formatters.format_achievement_progress( + progress.get("consecutive_days", 0), + progress.get("no_ssr_streak", 0), + user_id + ) + await event.send(achievement_msg) diff --git a/danding_bot/plugins/onmyoji_gacha/handlers/rank.py b/danding_bot/plugins/onmyoji_gacha/handlers/rank.py new file mode 100644 index 0000000..8bad378 --- /dev/null +++ b/danding_bot/plugins/onmyoji_gacha/handlers/rank.py @@ -0,0 +1,33 @@ +""" +抽卡排行榜处理模块 + +处理抽卡排行榜查询命令,支持分页显示。 +""" + +from nonebot.adapters.onebot.v11 import Bot, MessageEvent, GroupMessageEvent +from nonebot.params import CommandArg +from nonebot.adapters.onebot.v11 import Message +import nonebot + +from ..utils import get_gacha_system +from .. import formatters + +logger = nonebot.logger + + +async def handle_rank(bot: Bot, event: MessageEvent, state: dict, args: Message = CommandArg()): + """处理抽卡排行榜命令""" + # 解析页码 + page_text = args.extract_plain_text().strip() + page = 1 + if page_text.isdigit(): + page = int(page_text) + + gacha_system = get_gacha_system() + rank_data, total_pages = await gacha_system.get_rank_list(page=page) + + if not rank_data: + await event.finish("暂无排行数据") + + msg = formatters.format_rank_list(rank_data, page, total_pages) + await event.send(msg) diff --git a/danding_bot/plugins/onmyoji_gacha/handlers/stats.py b/danding_bot/plugins/onmyoji_gacha/handlers/stats.py new file mode 100644 index 0000000..f50cad6 --- /dev/null +++ b/danding_bot/plugins/onmyoji_gacha/handlers/stats.py @@ -0,0 +1,28 @@ +""" +我的抽卡统计处理模块 + +处理用户的个人抽卡统计查询命令。 +""" + +from nonebot.adapters.onebot.v11 import Bot, MessageEvent, GroupMessageEvent +import nonebot + +from ..utils import get_gacha_system +from .. import formatters + +logger = nonebot.logger + + +async def handle_stats(bot: Bot, event: MessageEvent, state: dict): + """处理我的抽卡统计命令""" + user_id = str(event.user_id) + user_name = event.sender.card or event.sender.nickname or "未知用户" + + gacha_system = get_gacha_system() + stats = await gacha_system.get_user_stats(user_id) + + if not stats or stats.get("total_draws", 0) == 0: + await event.finish("您还没有抽卡记录哦~") + + msg = formatters.format_user_stats(stats, user_id, user_name) + await event.send(msg) diff --git a/danding_bot/plugins/onmyoji_gacha/handlers/triple_gacha.py b/danding_bot/plugins/onmyoji_gacha/handlers/triple_gacha.py new file mode 100644 index 0000000..b815d73 --- /dev/null +++ b/danding_bot/plugins/onmyoji_gacha/handlers/triple_gacha.py @@ -0,0 +1,45 @@ +""" +三连抽命令处理模块 + +处理三连抽命令,包括: +- 三次抽卡执行 +- 结果汇总 +- 成就检查 +""" + +from nonebot.adapters.onebot.v11 import Bot, MessageEvent, GroupMessageEvent +import nonebot + +from ..utils import get_gacha_system +from .. import formatters +from ..utils import build_achievement_notify + +logger = nonebot.logger + + +async def handle_triple_gacha(bot: Bot, event: MessageEvent, state: dict): + """处理三连抽命令""" + user_id = str(event.user_id) + user_name = event.sender.card or event.sender.nickname or "未知用户" + + gacha_system = get_gacha_system() + results = [] + + # 执行三次抽卡 + for _ in range(3): + result = await gacha_system.draw(user_id) + if result["success"]: + results.append((result["rarity"], result["name"], result["image"])) + else: + await event.finish(result["message"]) + + # 发送三连抽结果 + msg = formatters.format_triple_gacha_result(results, user_id, user_name) + await event.send(msg) + + # 成就检查(使用统一编排函数,避免接口不匹配) + unlocked = await gacha_system.check_achievements(user_id) + if unlocked: + achievement_msg = await build_achievement_notify(user_id, unlocked) + if achievement_msg: + await event.send(achievement_msg) diff --git a/danding_bot/plugins/onmyoji_gacha/proposals/2026-05-03_code_review_fixes.md b/danding_bot/plugins/onmyoji_gacha/proposals/2026-05-03_code_review_fixes.md new file mode 100644 index 0000000..46c148a --- /dev/null +++ b/danding_bot/plugins/onmyoji_gacha/proposals/2026-05-03_code_review_fixes.md @@ -0,0 +1,57 @@ +# 变更提案: onmyoji_gacha 代码评审修复 + +- 日期: 2026-05-03 +- 状态: ✅ 已实施 +- 作者: Agent (代码评审驱动) + +## 背景 + +对 onmyoji_gacha 插件进行系统代码评审,发现 14 个问题(1P0 + 6P1 + 9P2), +涉及安全漏洞、依赖方向、职责边界、一致性等维度。 + +## 变更清单 + +### P0 - 紧急修复 + +| # | 文件 | 问题 | 修复 | +|---|------|------|------| +| 1 | web_api.py | 5处async函数缺await(数据库查询结果为协程对象,数据全部错乱) | 补全所有await | + +### P1 - 重要修复 + +| # | 文件 | 问题 | 修复 | +|---|------|------|------| +| 2 | web_api.py | verify_admin_token中print泄露token明文到日志 | 删除token print | +| 3 | formatters.py → api_utils | format_achievement_notify反向依赖api_utils(同层模块循环依赖) | 解耦:formatters改为纯格式化,reward逻辑移至handler调用方 | +| 4 | handlers/gacha.py → __init__.py | 签到逻辑嵌入抽卡handler(跨职责),传None matcher | 移至__init__.py matcher层,传入实际matcher | +| 5 | formatters.py | 5处重复硬编码SSR/SP/...字符串字面量 | 提取RARITY_DISPLAY配置字典,消除重复 | +| 6 | data_manager.py | 承担数据IO+缓存+业务规则三重职责 | 暂不拆分(影响面大),docstring标记TODO | + +### P2 - 改进 + +| # | 文件 | 问题 | 修复 | +|---|------|------|------| +| 7 | utils.py | user_name获取逻辑散布在多个handler中 | 新增get_user_name统一工具函数 | +| 8 | web_api.py | GachaSystem/Config模块级实例化(import时副作用) | 改为lazy延迟初始化 | +| 9 | __init__.py | matcher参数传None | 传入实际matcher对象 | + +## 受影响文件 + +- `web_api.py` - 重写(P0+P1+P2共8处修复) +- `formatters.py` - 解耦api_utils + RARITY_DISPLAY提取 +- `handlers/gacha.py` - 移除签到逻辑 +- `__init__.py` - gacha wrapper补充签到编排 +- `utils.py` - 新增get_user_name +- `data_manager.py` - TODO标记 + +## 验证 + +- [x] 所有修改文件语法检查通过 (ast.parse) +- [x] 依赖方向:formatters不再import api_utils +- [x] token明文不再出现在日志 +- [x] 签到逻辑在matcher层正确调用 + +## Delta 规约 + +本次变更未引入新的外部依赖,未改变数据库结构,未改变用户可见接口。 +API响应格式不变,命令触发方式不变。 diff --git a/danding_bot/plugins/onmyoji_gacha/rules.py b/danding_bot/plugins/onmyoji_gacha/rules.py new file mode 100644 index 0000000..7ab7cd1 --- /dev/null +++ b/danding_bot/plugins/onmyoji_gacha/rules.py @@ -0,0 +1,46 @@ +""" +权限校验与规则解析模块 + +提供NoneBot命令的权限检查规则函数,包括: +- 群组权限检查(通用) + +所有规则函数返回Rule对象,用于NoneBot的matcher定义。 +""" + +from typing import Callable +from nonebot.rule import Rule +from nonebot.adapters.onebot.v11 import Bot, GroupMessageEvent, MessageEvent + + +def _check_group_allowed(config) -> Callable: + """生成群组权限检查函数(内部复用,消除重复逻辑)。 + + Args: + config: Config实例,需包含ALLOWED_GROUP_ID属性 + + Returns: + 异步检查函数,私聊放行、群聊检查白名单 + """ + async def _check(bot: Bot, event: MessageEvent) -> bool: + if not isinstance(event, GroupMessageEvent): + return True + # 单群模式:直接比较整数 + return event.group_id == config.ALLOWED_GROUP_ID + return _check + + +def check_permission() -> Rule: + """检查群组是否有权限使用抽卡功能。""" + from .config import Config + config = Config() + return Rule(_check_group_allowed(config)) + + +def check_rank_permission() -> Rule: + """检查用户是否有权限查看排行榜。 + + 当前与check_permission逻辑相同,保留为独立入口便于未来扩展。 + """ + from .config import Config + config = Config() + return Rule(_check_group_allowed(config)) diff --git a/danding_bot/plugins/onmyoji_gacha/utils.py b/danding_bot/plugins/onmyoji_gacha/utils.py index d8ccab5..37bc655 100644 --- a/danding_bot/plugins/onmyoji_gacha/utils.py +++ b/danding_bot/plugins/onmyoji_gacha/utils.py @@ -1,42 +1,108 @@ -import os -from typing import Optional -from pathlib import Path - -def get_image_path(file_path: str) -> str: - """获取图片的绝对路径""" - return os.path.abspath(file_path) - -def format_user_mention(user_id: str, user_name: Optional[str] = None) -> str: - """格式化用户@信息""" - display_name = user_name if user_name else f"用户{user_id}" - return f"@{display_name}" - - -def get_luck_description(points: int) -> tuple[str, str]: - """根据积分返回运气描述与emoji""" - if points <= 10: - return "非酋", "😭" - if points <= 30: - return "一般", "😐" - if points <= 60: - return "小欧", "😊" - if points <= 90: - return "大欧", "🎉" - return "欧皇", "👑" - - -def format_sign_in_message( - user_id: str, - user_name: str, - points: int, - balance: int, -) -> str: - """格式化签到成功消息""" - luck_text, luck_emoji = get_luck_description(points) - mention = format_user_mention(user_id, user_name) - return ( - f"{mention} 📅 每日签到成功!\n" - f"🎁 获得积分:{points}\n" - f"{luck_emoji} 今日运气:{luck_text}\n" - f"💰 当前积分:{balance}" - ) +""" +阴阳师抽卡插件 - 通用工具函数 + +提供常用的辅助函数: +- 用户提及格式化 +- 图片路径处理 +""" + +import os +from typing import Optional +from pathlib import Path + +def get_image_path(file_path: str) -> str: + """获取图片的绝对路径""" + return os.path.abspath(file_path) + +def format_user_mention(user_id: str, user_name: Optional[str] = None) -> str: + """格式化用户@信息""" + display_name = user_name if user_name else f"用户{user_id}" + return f"@{display_name}" + + +def get_luck_description(points: int) -> tuple[str, str]: + """根据积分返回运气描述与emoji""" + if points <= 10: + return "非酋", "😭" + if points <= 30: + return "一般", "😐" + if points <= 60: + return "小欧", "😊" + if points <= 90: + return "大欧", "🎉" + return "欧皇", "👑" + + +def format_sign_in_message( + user_id: str, + user_name: str, + points: int, + balance: int, +) -> str: + """格式化签到成功消息""" + luck_text, luck_emoji = get_luck_description(points) + mention = format_user_mention(user_id, user_name) + return ( + f"{mention} 📅 每日签到成功!\n" + f"🎁 获得积分:{points}\n" + f"{luck_emoji} 今日运气:{luck_text}\n" + f"💰 当前积分:{balance}" + ) + + + +def get_user_name(event) -> str: + """从消息事件中获取用户昵称,统一多处重复逻辑。 + + Args: + event: NoneBot MessageEvent 对象 + + Returns: + str: 用户昵称,优先使用群名片(card),其次昵称(nickname),兜底"未知用户" + """ + from nonebot.adapters.onebot.v11 import GroupMessageEvent + if isinstance(event, GroupMessageEvent): + return event.sender.card or event.sender.nickname or "未知用户" + return event.sender.nickname or "未知用户" + + +async def build_achievement_notify(user_id: str, unlocked_ids: list) -> "Message | None": + """统一的成就通知编排:ID列表 → 详情查询 → 奖励领取 → 消息格式化。 + + 供所有handler共用,消除成就通知逻辑的重复(原则#4 变化半径小 / #12 功能越多代码越短)。 + + Args: + user_id: 用户ID + unlocked_ids: 新解锁的成就ID列表 + + Returns: + Message对象,无有效成就时返回None + """ + from .api_utils import process_achievement_reward, get_achievement_by_id + from . import formatters + + achievements_data = [] + for achievement_id in unlocked_ids: + ach = get_achievement_by_id(achievement_id) + if not ach: + continue + success, reward_msg = await process_achievement_reward(user_id, achievement_id) + ach["reward_msg"] = reward_msg if success else "" + ach["claimed"] = success + achievements_data.append(ach) + + if not achievements_data: + return None + return formatters.format_achievement_notify(achievements_data, user_id) + + +# ---- GachaSystem 单例(P1#3: 避免每次handler调用都new实例) ---- +_gacha_system_instance = None + +def get_gacha_system(): + """获取全局唯一的GachaSystem实例(lazy init)。""" + global _gacha_system_instance + if _gacha_system_instance is None: + from .gacha import GachaSystem + _gacha_system_instance = GachaSystem() + return _gacha_system_instance diff --git a/danding_bot/plugins/onmyoji_gacha/web_api.py b/danding_bot/plugins/onmyoji_gacha/web_api.py index 1865567..242fd60 100644 --- a/danding_bot/plugins/onmyoji_gacha/web_api.py +++ b/danding_bot/plugins/onmyoji_gacha/web_api.py @@ -197,4 +197,4 @@ def register_web_routes(): return True except Exception as e: logger.error(f"❌ 注册 Web 路由时出错: {e}") - return False \ No newline at end of file + return False