feat(bot): use runtime api for bot data
This commit is contained in:
@@ -1,615 +1,291 @@
|
||||
"""
|
||||
阴阳师抽卡插件 - 数据管理模块
|
||||
|
||||
管理抽卡数据持久化,包括:
|
||||
- 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
|
||||
"""
|
||||
阴阳师抽卡插件 - xapi 数据管理模块。
|
||||
|
||||
本模块只负责调用 xapi /bot/gacha 运行时 API。抽卡概率、奖励发放和 QQ 消息编排
|
||||
仍由 nonebot 插件本地负责。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import datetime
|
||||
import logging
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
import aiohttp
|
||||
|
||||
from .config import Config
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
config = Config()
|
||||
|
||||
|
||||
class DataManager:
|
||||
"""抽卡数据管理器,封装 /bot/gacha HTTP 调用。"""
|
||||
|
||||
def __init__(self):
|
||||
self.shikigami_data: Dict[str, List[Dict[str, Any]]] = {"R": [], "SR": [], "SSR": [], "SP": []}
|
||||
|
||||
def _url(self, path: str) -> str:
|
||||
"""拼接 /bot/gacha 端点地址。"""
|
||||
|
||||
return f"{config.GACHA_API_HOST}/{path.lstrip('/')}"
|
||||
|
||||
def _auth(self) -> Dict[str, str]:
|
||||
"""生成 xapi Bot 鉴权参数。"""
|
||||
|
||||
return {
|
||||
"user": config.BOT_USER_ID,
|
||||
"token": config.BOT_TOKEN,
|
||||
}
|
||||
|
||||
async def _request(
|
||||
self,
|
||||
method: str,
|
||||
path: str,
|
||||
*,
|
||||
payload: Optional[Dict[str, Any]] = None,
|
||||
params: Optional[Dict[str, Any]] = None,
|
||||
) -> Optional[Dict[str, Any]]:
|
||||
"""调用 xapi /bot/gacha,并只向上层暴露 data。"""
|
||||
|
||||
request_url = self._url(path)
|
||||
timeout = aiohttp.ClientTimeout(total=10)
|
||||
try:
|
||||
async with aiohttp.ClientSession() as session:
|
||||
if method == "GET":
|
||||
request_params = {**self._auth(), **(params or {})}
|
||||
async with session.get(request_url, params=request_params, timeout=timeout) as resp:
|
||||
return await self._parse_response(resp, path)
|
||||
request_payload = {**self._auth(), **(payload or {})}
|
||||
async with session.post(request_url, json=request_payload, timeout=timeout) as resp:
|
||||
return await self._parse_response(resp, path)
|
||||
except aiohttp.ClientError as exc:
|
||||
logger.error("gacha api request failed path=%s error=%s", path, exc)
|
||||
return None
|
||||
except asyncio.TimeoutError as exc:
|
||||
logger.error("gacha api request timeout path=%s error=%s", path, exc)
|
||||
return None
|
||||
|
||||
async def _parse_response(self, resp: aiohttp.ClientResponse, path: str) -> Optional[Dict[str, Any]]:
|
||||
"""解析 xapi 统一响应,失败时返回 None 维持旧调用方失败语义。"""
|
||||
|
||||
if resp.status != 200:
|
||||
logger.error("gacha api bad status path=%s status=%s", path, resp.status)
|
||||
return None
|
||||
body = await resp.json()
|
||||
if body.get("code") != 200:
|
||||
logger.error("gacha api fail path=%s code=%s message=%s", path, body.get("code"), body.get("message"))
|
||||
return None
|
||||
data = body.get("data")
|
||||
return data if isinstance(data, dict) else None
|
||||
|
||||
async def refresh_shikigami_data(self) -> Dict[str, List[Dict[str, Any]]]:
|
||||
"""从 xapi 拉取式神基础数据并按稀有度缓存。"""
|
||||
|
||||
data = await self._request("GET", "shikigami")
|
||||
items = data.get("items", []) if data else []
|
||||
grouped: Dict[str, List[Dict[str, Any]]] = {"R": [], "SR": [], "SSR": [], "SP": []}
|
||||
for item in items:
|
||||
rarity = item.get("rarity")
|
||||
if rarity not in grouped:
|
||||
continue
|
||||
image_path = item.get("image_path") or item.get("image_url") or ""
|
||||
grouped[rarity].append(
|
||||
{
|
||||
"id": item.get("id"),
|
||||
"name": item.get("name"),
|
||||
"rarity": rarity,
|
||||
"image_path": image_path,
|
||||
"image_url": image_path,
|
||||
}
|
||||
)
|
||||
self.shikigami_data = grouped
|
||||
return self.shikigami_data
|
||||
|
||||
async def ensure_shikigami_data(self) -> Dict[str, List[Dict[str, Any]]]:
|
||||
"""确保式神缓存已加载。"""
|
||||
|
||||
if not any(self.shikigami_data.values()):
|
||||
await self.refresh_shikigami_data()
|
||||
return self.shikigami_data
|
||||
|
||||
def get_today_date(self) -> str:
|
||||
"""获取当前日期字符串。"""
|
||||
|
||||
return datetime.datetime.now().strftime("%Y-%m-%d")
|
||||
|
||||
def get_current_time(self) -> str:
|
||||
"""获取当前时间字符串。"""
|
||||
|
||||
return datetime.datetime.now().strftime("%H:%M:%S")
|
||||
|
||||
def _find_shikigami(self, rarity: str, shikigami_name: str) -> Optional[Dict[str, Any]]:
|
||||
"""从本地缓存查找 xapi 托管式神。"""
|
||||
|
||||
for item in self.shikigami_data.get(rarity, []):
|
||||
if item.get("name") == shikigami_name:
|
||||
return item
|
||||
return None
|
||||
|
||||
async def get_draws_left(self, user_id: str) -> int:
|
||||
"""获取用户今日剩余抽卡次数。"""
|
||||
|
||||
data = await self._request("GET", "draws-left", params={"user_id": user_id})
|
||||
if data is None:
|
||||
return 0
|
||||
return int(data.get("draws_left", 0) or 0)
|
||||
|
||||
async def check_daily_limit(self, user_id: str) -> bool:
|
||||
"""检查用户是否还有抽卡次数。"""
|
||||
|
||||
return await self.get_draws_left(user_id) > 0
|
||||
|
||||
async def record_draw_result(self, user_id: str, rarity: str, shikigami: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""写入一次抽卡并返回 xapi 原始业务结果。"""
|
||||
|
||||
data = await self._request(
|
||||
"POST",
|
||||
"draw",
|
||||
payload={
|
||||
"user_id": user_id,
|
||||
"shikigami_id": int(shikigami["id"]),
|
||||
"rarity": rarity,
|
||||
"name": shikigami["name"],
|
||||
},
|
||||
)
|
||||
if data is None:
|
||||
return {"success": False, "message": "抽卡记录写入失败"}
|
||||
return data
|
||||
|
||||
async def record_triple_draw_result(self, user_id: str, draws: List[Dict[str, Any]]) -> Dict[str, Any]:
|
||||
"""写入三连抽并返回 xapi 原始业务结果。"""
|
||||
|
||||
payload_draws = [
|
||||
{
|
||||
"shikigami_id": int(item["id"]),
|
||||
"rarity": item["rarity"],
|
||||
"name": item["name"],
|
||||
}
|
||||
for item in draws
|
||||
]
|
||||
data = await self._request("POST", "draw/triple", payload={"user_id": user_id, "draws": payload_draws})
|
||||
if data is None:
|
||||
return {"success": False, "message": "三连抽记录写入失败"}
|
||||
return data
|
||||
|
||||
async def record_draw(self, user_id: str, rarity: str, shikigami_name: str) -> List[str]:
|
||||
"""记录一次抽卡,返回新解锁的成就列表。"""
|
||||
|
||||
await self.ensure_shikigami_data()
|
||||
shikigami = self._find_shikigami(rarity, shikigami_name)
|
||||
if not shikigami:
|
||||
logger.error("找不到式神: %s (%s)", shikigami_name, rarity)
|
||||
return []
|
||||
result = await self.record_draw_result(user_id, rarity, shikigami)
|
||||
if not result.get("success"):
|
||||
logger.error("抽卡记录写入失败 user_id=%s message=%s", user_id, result.get("message"))
|
||||
return []
|
||||
return result.get("unlocked_achievements", [])
|
||||
|
||||
async def record_sign_in(self, user_id: str, points_awarded: int) -> bool:
|
||||
"""记录每日签到,重复签到返回 False。"""
|
||||
|
||||
data = await self._request(
|
||||
"POST",
|
||||
"sign-in",
|
||||
payload={"user_id": user_id, "points_awarded": points_awarded},
|
||||
)
|
||||
if data is None:
|
||||
return False
|
||||
return bool(data.get("success")) and not bool(data.get("signed_already"))
|
||||
|
||||
async def get_user_stats(self, user_id: str) -> Dict[str, Any]:
|
||||
"""获取用户抽卡统计。"""
|
||||
|
||||
data = await self._request("GET", "user-stats", params={"user_id": user_id})
|
||||
return data or {"success": False, "message": "您还没有抽卡记录哦!"}
|
||||
|
||||
async def get_daily_stats(self, date: Optional[str] = None) -> Dict[str, Any]:
|
||||
"""获取指定日期抽卡统计。"""
|
||||
|
||||
params = {"date": date} if date else {}
|
||||
data = await self._request("GET", "daily-stats", params=params)
|
||||
return data or {"success": False, "message": "今日还没有人抽卡哦!"}
|
||||
|
||||
async def get_rank(self, limit: int = 10) -> List[Dict[str, Any]]:
|
||||
"""获取抽卡排行榜。"""
|
||||
|
||||
data = await self._request("GET", "rank", params={"limit": max(1, min(100, limit))})
|
||||
if data is None:
|
||||
return []
|
||||
items = data.get("items", [])
|
||||
return items if isinstance(items, list) else []
|
||||
|
||||
async def get_user_achievements(self, user_id: str) -> Dict[str, Any]:
|
||||
"""获取用户成就信息。"""
|
||||
|
||||
data = await self._request("GET", f"achievements/{user_id}")
|
||||
if data is None:
|
||||
return {
|
||||
"unlocked": {},
|
||||
"progress": {
|
||||
"consecutive_days": 0,
|
||||
"no_ssr_streak": 0,
|
||||
"total_consecutive_days": 0,
|
||||
},
|
||||
}
|
||||
return {
|
||||
"unlocked": data.get("achievements", {}),
|
||||
"progress": data.get("progress", {}),
|
||||
}
|
||||
|
||||
async def claim_achievement_reward(self, user_id: str, achievement_id: str) -> bool:
|
||||
"""标记成就奖励已领取。"""
|
||||
|
||||
data = await self._request("POST", f"achievements/{user_id}/claim", payload={"achievement_id": achievement_id})
|
||||
return bool(data and data.get("success"))
|
||||
|
||||
async def get_daily_records(self, date: Optional[str] = None) -> Dict[str, Any]:
|
||||
"""获取每日详细抽卡记录。"""
|
||||
|
||||
params = {"date": date} if date else {}
|
||||
data = await self._request("GET", "records/daily", params=params)
|
||||
return data or {"success": False, "date": date or self.get_today_date(), "records": [], "total_count": 0}
|
||||
|
||||
async def get_daily_draws(self, date: Optional[str] = None) -> Dict[str, Dict[str, List[Dict[str, str]]]]:
|
||||
"""按旧结构返回每日抽卡记录。"""
|
||||
|
||||
data = await self.get_daily_records(date)
|
||||
result: Dict[str, Dict[str, List[Dict[str, str]]]] = {}
|
||||
if not data.get("success"):
|
||||
return result
|
||||
target_date = data.get("date") or date or self.get_today_date()
|
||||
result[target_date] = {}
|
||||
for record in data.get("records", []):
|
||||
user_id = record.get("user_id")
|
||||
if not user_id:
|
||||
continue
|
||||
result[target_date].setdefault(user_id, []).append(
|
||||
{
|
||||
"rarity": record.get("rarity", ""),
|
||||
"name": record.get("shikigami_name", ""),
|
||||
"timestamp": record.get("draw_time", ""),
|
||||
}
|
||||
)
|
||||
return result
|
||||
|
||||
async def has_signed_in_today(self, user_id: str) -> bool:
|
||||
"""保留旧方法名;当前无独立查询端点,签到去重由 xapi sign-in 写接口处理。"""
|
||||
|
||||
return False
|
||||
|
||||
def save_daily_draws(self, data: Dict[str, Dict[str, List[Dict[str, str]]]]) -> None:
|
||||
"""兼容旧空方法,运行时不写本地文件。"""
|
||||
|
||||
return None
|
||||
|
||||
def save_user_stats(self, data: Dict[str, Dict[str, Any]]) -> None:
|
||||
"""兼容旧空方法,运行时不写本地文件。"""
|
||||
|
||||
return None
|
||||
|
||||
Reference in New Issue
Block a user