feat: 抽卡签到功能 - 首次抽卡/三连自动签到获随机积分
- data_manager: 新增 daily_sign_in 表、has_signed_in_today、record_sign_in 方法 - utils: 新增 get_luck_description、format_sign_in_message 函数 - __init__: 新增 try_handle_daily_sign_in 签到入口 - handle_gacha/handle_triple_gacha 成功路径 finish()→send()+签到+return - 签到失败不影响抽卡主流程,UNIQUE约束防并发重复
This commit is contained in:
@@ -1,4 +1,6 @@
|
|||||||
import os
|
import os
|
||||||
|
import logging
|
||||||
|
import random
|
||||||
from nonebot import on_command, on_startswith
|
from nonebot import on_command, on_startswith
|
||||||
from nonebot.adapters.onebot.v11 import Bot, GroupMessageEvent, MessageEvent, Message
|
from nonebot.adapters.onebot.v11 import Bot, GroupMessageEvent, MessageEvent, Message
|
||||||
from nonebot.adapters.onebot.v11.message import MessageSegment
|
from nonebot.adapters.onebot.v11.message import MessageSegment
|
||||||
@@ -8,9 +10,10 @@ from pathlib import Path
|
|||||||
|
|
||||||
from .config import Config
|
from .config import Config
|
||||||
from .gacha import GachaSystem
|
from .gacha import GachaSystem
|
||||||
from .utils import format_user_mention, get_image_path
|
from .utils import format_sign_in_message, format_user_mention, get_image_path
|
||||||
from .api_utils import process_ssr_sp_reward, process_achievement_reward
|
from .api_utils import process_ssr_sp_reward, process_achievement_reward
|
||||||
from . import web_api
|
from . import web_api
|
||||||
|
from danding_bot.plugins.danding_points import points_api
|
||||||
|
|
||||||
# 创建Config实例
|
# 创建Config实例
|
||||||
config = Config()
|
config = Config()
|
||||||
@@ -27,6 +30,11 @@ INTRO_COMMANDS = config.INTRO_COMMANDS
|
|||||||
DAILY_LIMIT = config.DAILY_LIMIT
|
DAILY_LIMIT = config.DAILY_LIMIT
|
||||||
|
|
||||||
gacha_system = GachaSystem()
|
gacha_system = GachaSystem()
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
SIGN_IN_MIN_POINTS = 1
|
||||||
|
SIGN_IN_MAX_POINTS = 100
|
||||||
|
SIGN_IN_SOURCE = "gacha_sign"
|
||||||
|
SIGN_IN_REASON = "抽卡签到"
|
||||||
|
|
||||||
# 检查是否允许使用功能的规则
|
# 检查是否允许使用功能的规则
|
||||||
def check_permission() -> Rule:
|
def check_permission() -> Rule:
|
||||||
@@ -43,6 +51,32 @@ def check_permission() -> Rule:
|
|||||||
|
|
||||||
return Rule(_checker)
|
return Rule(_checker)
|
||||||
|
|
||||||
|
|
||||||
|
async def try_handle_daily_sign_in(matcher, user_id: str, user_name: str) -> None:
|
||||||
|
"""处理抽卡成功后的每日签到,不影响主流程"""
|
||||||
|
try:
|
||||||
|
if gacha_system.data_manager.has_signed_in_today(user_id):
|
||||||
|
return
|
||||||
|
|
||||||
|
points = random.randint(SIGN_IN_MIN_POINTS, SIGN_IN_MAX_POINTS)
|
||||||
|
success, new_balance = await points_api.add_points(
|
||||||
|
user_id,
|
||||||
|
points,
|
||||||
|
SIGN_IN_SOURCE,
|
||||||
|
SIGN_IN_REASON,
|
||||||
|
)
|
||||||
|
if not success:
|
||||||
|
logger.error("抽卡签到积分发放失败 user_id=%s points=%s", user_id, points)
|
||||||
|
return
|
||||||
|
|
||||||
|
if not gacha_system.data_manager.record_sign_in(user_id, points):
|
||||||
|
logger.warning("抽卡签到落库冲突,积分已发放但签到记录重复 user_id=%s", user_id)
|
||||||
|
return
|
||||||
|
|
||||||
|
await matcher.send(format_sign_in_message(user_id, user_name, points, new_balance))
|
||||||
|
except Exception:
|
||||||
|
logger.exception("处理抽卡签到失败 user_id=%s", user_id)
|
||||||
|
|
||||||
# 注册抽卡命令,添加权限检查规则
|
# 注册抽卡命令,添加权限检查规则
|
||||||
gacha_matcher = on_command("抽卡", aliases=set(GACHA_COMMANDS), priority=10, rule=check_permission())
|
gacha_matcher = on_command("抽卡", aliases=set(GACHA_COMMANDS), priority=10, rule=check_permission())
|
||||||
|
|
||||||
@@ -188,7 +222,9 @@ async def handle_gacha(bot: Bot, event: MessageEvent, state: T_State):
|
|||||||
else:
|
else:
|
||||||
msg.append(f"\n\n抽中SSR或SP时,可获得蛋定助手天卡一张哦~~")
|
msg.append(f"\n\n抽中SSR或SP时,可获得蛋定助手天卡一张哦~~")
|
||||||
|
|
||||||
await gacha_matcher.finish(msg)
|
await gacha_matcher.send(msg)
|
||||||
|
await try_handle_daily_sign_in(gacha_matcher, user_id, user_name)
|
||||||
|
return
|
||||||
|
|
||||||
async def notify_admin(bot: Bot, message: str):
|
async def notify_admin(bot: Bot, message: str):
|
||||||
"""通知管理员"""
|
"""通知管理员"""
|
||||||
@@ -395,7 +431,9 @@ async def handle_triple_gacha(bot: Bot, event: MessageEvent, state: T_State):
|
|||||||
admin_msg += f" 需要手动发放 {ssr_count} 张奖励!"
|
admin_msg += f" 需要手动发放 {ssr_count} 张奖励!"
|
||||||
await notify_admin(bot, admin_msg)
|
await notify_admin(bot, admin_msg)
|
||||||
|
|
||||||
await triple_gacha_matcher.finish(msg)
|
await triple_gacha_matcher.send(msg)
|
||||||
|
await try_handle_daily_sign_in(triple_gacha_matcher, user_id, user_name)
|
||||||
|
return
|
||||||
|
|
||||||
@achievement_matcher.handle()
|
@achievement_matcher.handle()
|
||||||
async def handle_achievement(bot: Bot, event: MessageEvent, state: T_State):
|
async def handle_achievement(bot: Bot, event: MessageEvent, state: T_State):
|
||||||
@@ -759,4 +797,4 @@ from . import web_api
|
|||||||
try:
|
try:
|
||||||
web_api.register_web_routes()
|
web_api.register_web_routes()
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"❌ 注册 onmyoji_gacha Web 路由失败: {e}")
|
print(f"❌ 注册 onmyoji_gacha Web 路由失败: {e}")
|
||||||
|
|||||||
@@ -97,8 +97,23 @@ class DataManager:
|
|||||||
total_consecutive_days INTEGER DEFAULT 0
|
total_consecutive_days INTEGER DEFAULT 0
|
||||||
)
|
)
|
||||||
""")
|
""")
|
||||||
|
|
||||||
|
self._init_sign_in_table(cursor)
|
||||||
|
|
||||||
conn.commit()
|
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]:
|
def update_achievement_progress(self, user_id: str, rarity: str) -> List[str]:
|
||||||
"""更新用户成就进度,返回新解锁的成就列表"""
|
"""更新用户成就进度,返回新解锁的成就列表"""
|
||||||
@@ -321,6 +336,33 @@ class DataManager:
|
|||||||
def get_today_date(self) -> str:
|
def get_today_date(self) -> str:
|
||||||
"""获取当前日期字符串"""
|
"""获取当前日期字符串"""
|
||||||
return datetime.datetime.now().strftime("%Y-%m-%d")
|
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:
|
def get_current_time(self) -> str:
|
||||||
"""获取当前时间字符串"""
|
"""获取当前时间字符串"""
|
||||||
@@ -549,4 +591,4 @@ class DataManager:
|
|||||||
|
|
||||||
# 更新成就进度
|
# 更新成就进度
|
||||||
unlocked_achievements = self.update_achievement_progress(user_id, rarity)
|
unlocked_achievements = self.update_achievement_progress(user_id, rarity)
|
||||||
return unlocked_achievements
|
return unlocked_achievements
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import os
|
import os
|
||||||
from typing import Union, Optional
|
from typing import Optional
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
def get_image_path(file_path: str) -> str:
|
def get_image_path(file_path: str) -> str:
|
||||||
@@ -9,4 +9,34 @@ def get_image_path(file_path: str) -> str:
|
|||||||
def format_user_mention(user_id: str, user_name: Optional[str] = None) -> str:
|
def format_user_mention(user_id: str, user_name: Optional[str] = None) -> str:
|
||||||
"""格式化用户@信息"""
|
"""格式化用户@信息"""
|
||||||
display_name = user_name if user_name else f"用户{user_id}"
|
display_name = user_name if user_name else f"用户{user_id}"
|
||||||
return f"@{display_name}"
|
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}"
|
||||||
|
)
|
||||||
|
|||||||
95
test_onmyoji_gacha_sign_in.py
Normal file
95
test_onmyoji_gacha_sign_in.py
Normal file
@@ -0,0 +1,95 @@
|
|||||||
|
import tempfile
|
||||||
|
import unittest
|
||||||
|
import importlib.util
|
||||||
|
import sys
|
||||||
|
import types
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
|
||||||
|
PROJECT_ROOT = Path(__file__).resolve().parent
|
||||||
|
|
||||||
|
|
||||||
|
def ensure_package(package_name: str, package_path: Path) -> None:
|
||||||
|
if package_name in sys.modules:
|
||||||
|
return
|
||||||
|
package = types.ModuleType(package_name)
|
||||||
|
package.__path__ = [str(package_path)]
|
||||||
|
sys.modules[package_name] = package
|
||||||
|
|
||||||
|
|
||||||
|
def load_module(module_name: str, relative_path: str):
|
||||||
|
module_path = PROJECT_ROOT / relative_path
|
||||||
|
spec = importlib.util.spec_from_file_location(module_name, module_path)
|
||||||
|
module = importlib.util.module_from_spec(spec)
|
||||||
|
assert spec and spec.loader
|
||||||
|
sys.modules[module_name] = module
|
||||||
|
spec.loader.exec_module(module)
|
||||||
|
return module
|
||||||
|
|
||||||
|
|
||||||
|
ensure_package("danding_bot", PROJECT_ROOT / "danding_bot")
|
||||||
|
ensure_package("danding_bot.plugins", PROJECT_ROOT / "danding_bot/plugins")
|
||||||
|
ensure_package(
|
||||||
|
"danding_bot.plugins.onmyoji_gacha",
|
||||||
|
PROJECT_ROOT / "danding_bot/plugins/onmyoji_gacha",
|
||||||
|
)
|
||||||
|
load_module(
|
||||||
|
"danding_bot.plugins.onmyoji_gacha.config",
|
||||||
|
"danding_bot/plugins/onmyoji_gacha/config.py",
|
||||||
|
)
|
||||||
|
data_manager_module = load_module(
|
||||||
|
"danding_bot.plugins.onmyoji_gacha.data_manager",
|
||||||
|
"danding_bot/plugins/onmyoji_gacha/data_manager.py",
|
||||||
|
)
|
||||||
|
utils = load_module(
|
||||||
|
"danding_bot.plugins.onmyoji_gacha.utils",
|
||||||
|
"danding_bot/plugins/onmyoji_gacha/utils.py",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class DataManagerSignInTests(unittest.TestCase):
|
||||||
|
def setUp(self) -> None:
|
||||||
|
self.temp_dir = tempfile.TemporaryDirectory()
|
||||||
|
base_path = Path(self.temp_dir.name)
|
||||||
|
self.original_db_file = data_manager_module.config.DB_FILE
|
||||||
|
self.original_img_dir = data_manager_module.config.SHIKIGAMI_IMG_DIR
|
||||||
|
data_manager_module.config.DB_FILE = str(base_path / "gacha.db")
|
||||||
|
data_manager_module.config.SHIKIGAMI_IMG_DIR = str(base_path / "images")
|
||||||
|
Path(data_manager_module.config.SHIKIGAMI_IMG_DIR).mkdir(parents=True, exist_ok=True)
|
||||||
|
self.manager = data_manager_module.DataManager()
|
||||||
|
|
||||||
|
def tearDown(self) -> None:
|
||||||
|
data_manager_module.config.DB_FILE = self.original_db_file
|
||||||
|
data_manager_module.config.SHIKIGAMI_IMG_DIR = self.original_img_dir
|
||||||
|
self.temp_dir.cleanup()
|
||||||
|
|
||||||
|
def test_record_sign_in_is_idempotent_for_same_day(self) -> None:
|
||||||
|
user_id = "10001"
|
||||||
|
|
||||||
|
self.assertFalse(self.manager.has_signed_in_today(user_id))
|
||||||
|
self.assertTrue(self.manager.record_sign_in(user_id, 88))
|
||||||
|
self.assertTrue(self.manager.has_signed_in_today(user_id))
|
||||||
|
self.assertFalse(self.manager.record_sign_in(user_id, 99))
|
||||||
|
|
||||||
|
|
||||||
|
class UtilsSignInTests(unittest.TestCase):
|
||||||
|
def test_get_luck_description_uses_document_ranges(self) -> None:
|
||||||
|
self.assertEqual(utils.get_luck_description(1), ("非酋", "😭"))
|
||||||
|
self.assertEqual(utils.get_luck_description(11), ("一般", "😐"))
|
||||||
|
self.assertEqual(utils.get_luck_description(31), ("小欧", "😊"))
|
||||||
|
self.assertEqual(utils.get_luck_description(61), ("大欧", "🎉"))
|
||||||
|
self.assertEqual(utils.get_luck_description(91), ("欧皇", "👑"))
|
||||||
|
|
||||||
|
def test_format_sign_in_message_matches_required_format(self) -> None:
|
||||||
|
message = utils.format_sign_in_message("10001", "小夏", 87, 1247)
|
||||||
|
expected = (
|
||||||
|
"@小夏 📅 每日签到成功!\n"
|
||||||
|
"🎁 获得积分:87\n"
|
||||||
|
"🎉 今日运气:大欧\n"
|
||||||
|
"💰 当前积分:1247"
|
||||||
|
)
|
||||||
|
self.assertEqual(message, expected)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
Reference in New Issue
Block a user