diff --git a/CHANGELOG.md b/CHANGELOG.md
deleted file mode 100644
index 158b0ab..0000000
--- a/CHANGELOG.md
+++ /dev/null
@@ -1,25 +0,0 @@
-# 更新日志
-
-## 0701-1140
-
-1. 新增推送总开关配置项,支持选择开启的推送事件
-2. 添加推送相关数据库字段与操作方法
-3. 实现开始/结束游戏推送逻辑与开关控制
-4. 优化推送检测逻辑,减少无效渲染
-5. 更新帮助文档与更新日志
-6. 替换旧版背景图片资源
-
-## 0701-1011
-
-### 新增
-- 帮助菜单
-
-## 0630-2223
-
-### 新增
-- 结束游戏绘图
-
-### 修改
-- 开始游戏绘图用户名上调 5px
-- 开始游戏背景图改为渐变色
-- 优化绑定解绑提示
\ No newline at end of file
diff --git a/README.md b/README.md
index e5fdd62..5a375a5 100644
--- a/README.md
+++ b/README.md
@@ -35,9 +35,8 @@
## 丨命令列表
-### steam帮助
-
-
+### steam帮助(图片更新可能不及时)
+
### 命令说明
#### 绑定账号
@@ -68,6 +67,7 @@
| `steam游戏成就123456` | 查看appid123456的成就详情 |
| `steam开启成就推送` | 开启自己的成就推送功能 |
| `steam关闭成就推送` | 关闭自己的成就推送功能 |
+| `steam玩什么` | 从自己的游戏库随机挑3个游戏 |
#### 商店相关
| 命令 | 说明 |
@@ -76,11 +76,16 @@
| `steam取消订阅降价123456` | 取消订阅商店游戏降价信息 |
| `steam订阅降价查看` | 查看订阅的商店游戏降价信息 |
+#### 社交相关
+| 命令 | 说明 |
+|------|:------:|
+| `@xx steam加好友123456` | 获取被 @ 用户的好友码 |
+| `steam群排行` | 查看群游玩时长排行榜 |
+
#### 其他服务
| 命令 | 说明 |
|------|:------:|
| `steam帮助` | 呼出本插件帮助菜单 |
-| `@xx steam加好友` | 获取被 @ 用户的好友码 |
| `清除全部缓存` | 清除全部缓存 |
## 效果图
@@ -89,14 +94,18 @@
### steam成就推送
-
+
### steam开始游戏 / 结束游戏推送
-
+
### steam游戏墙
+## steam玩什么
+
+
+
## 计划功能
@@ -104,10 +113,10 @@
- [ ] 玩家上下线状态推送
- [ ] 带游玩时长的游戏库存图片
- [x] 游戏降价 / 打折信息订阅推送
-- [ ] steam玩什么 (随机抽游戏)
+- [x] steam玩什么 (随机抽游戏)
- [x] 游戏名 / 成就名本地化名字获取
- [ ] 总结推送功能 (定时总结周期内游戏情况而不每次状态变化都推送)
-- [ ] 群游玩时长排行榜
+- [x] 群游玩时长排行榜
- [x] 隐藏 steamid / 好友码
@@ -117,7 +126,7 @@
- 本项目仅供学习使用,请勿用于商业用途
- [GPL-3.0 License](LICENSE)
-
+- 部分功能没有写绘图,以后再说
## 致谢
diff --git a/SteamUID/SteamHelp/help.json b/SteamUID/SteamHelp/help.json
index 4af67e3..94199d6 100644
--- a/SteamUID/SteamHelp/help.json
+++ b/SteamUID/SteamHelp/help.json
@@ -115,6 +115,14 @@
"need_ck": false,
"need_sk": false,
"need_admin": false
+ },
+ {
+ "name": "玩什么",
+ "desc": "从自己的游戏库随机挑3个游戏",
+ "eg": "玩什么",
+ "need_ck": false,
+ "need_sk": false,
+ "need_admin": false
}
]
},
@@ -147,21 +155,34 @@
}
]
},
- "其他服务": {
- "desc": "一些杂项服务",
+ "社交相关": {
+ "desc": "加好友和排行榜功能",
"data": [
{
- "name": "帮助",
- "desc": "呼出本插件帮助菜单",
- "eg": "帮助",
+ "name": "加好友",
+ "desc": "获取被 @ 用户的好友码",
+ "eg": "加好友",
"need_ck": false,
"need_sk": false,
"need_admin": false
},
{
- "name": "@xx 加好友",
- "desc": "获取被 @ 用户的好友码",
- "eg": "加好友",
+ "name": "群排行",
+ "desc": "查看群游玩时长排行榜",
+ "eg": "群排行",
+ "need_ck": false,
+ "need_sk": false,
+ "need_admin": false
+ }
+ ]
+ },
+ "其他服务": {
+ "desc": "一些杂项服务",
+ "data": [
+ {
+ "name": "帮助",
+ "desc": "呼出本插件帮助菜单",
+ "eg": "帮助",
"need_ck": false,
"need_sk": false,
"need_admin": false
diff --git a/SteamUID/SteamLibarary/__init__.py b/SteamUID/SteamLibarary/__init__.py
index b42ff74..1f12a28 100644
--- a/SteamUID/SteamLibarary/__init__.py
+++ b/SteamUID/SteamLibarary/__init__.py
@@ -6,7 +6,7 @@
from ..utils.exceptions import SteamError, SteamValidationError
from ..utils.target import resolve_target_steamid64
-from .library_service import build_library_wall
+from .library_service import build_library_wall, build_random_pick
library_SV = SV("steam库存相关")
@@ -26,3 +26,19 @@ async def get_steamlibrary_image(bot: Bot, ev: Event):
except Exception as e:
logger.exception(f"[steam库存] 未知错误: {e!r}")
await bot.send(f"发生未知错误: {e}")
+
+@library_SV.on_command(("玩什么",))
+async def get_my_steamlibrary_image(bot: Bot, ev: Event):
+ try:
+ steamid64 = await resolve_target_steamid64(ev, ev.text.strip())
+ if not steamid64:
+ raise SteamValidationError("请先绑定 steam 账号")
+
+ await bot.send("正在从游戏库中随机挑选......")
+ img_bytes = await build_random_pick(steamid64)
+ await bot.send(MessageSegment.image(img_bytes))
+ except SteamError as e:
+ await bot.send(str(e))
+ except Exception as e:
+ logger.exception(f"[steam库存] 未知错误: {e!r}")
+ await bot.send(f"发生未知错误: {e}")
\ No newline at end of file
diff --git a/SteamUID/SteamLibarary/library_service.py b/SteamUID/SteamLibarary/library_service.py
index ebb4e31..ee84214 100644
--- a/SteamUID/SteamLibarary/library_service.py
+++ b/SteamUID/SteamLibarary/library_service.py
@@ -1,3 +1,4 @@
+import random
from io import BytesIO
from gsuid_core.data_store import get_res_path
@@ -7,6 +8,7 @@
from ..SteamConfig.interface import SteamAPI
from ..utils.api import get_steamlibrary_by_steamid64
from ..utils.exceptions import SteamConfigError, SteamValidationError
+from ..utils.PIL.draw import draw_what_to_play
from ..utils.PIL.steam_wall import build_wall
from ..utils.utils import batch_download_images
@@ -50,3 +52,44 @@ async def build_library_wall(steamid64: str) -> bytes:
buf = BytesIO()
wall.save(buf, format="JPEG", quality=pic_quality, subsampling=0)
return buf.getvalue()
+
+
+async def build_random_pick(steamid64: str) -> bytes:
+ """从用户 Steam 游戏库中随机选取 3 款游戏,生成推荐图片。"""
+ api_key = SteamConfig.get_config("SteamWebAPIKey").data
+ if not api_key:
+ raise SteamConfigError("请先配置 steam web api key")
+
+ library = await get_steamlibrary_by_steamid64(api_key, steamid64)
+ games = library.get("games")
+ if games is None:
+ raise SteamValidationError("获取 steam 游戏库列表失败")
+ if not games:
+ raise SteamValidationError("该 steam 账号暂无游戏库存")
+ pick_count = min(3, len(games))
+ picks = random.sample(games, pick_count)
+
+ for game in picks:
+ appid = str(game.get("appid", ""))
+ name = game.get("name", "未知游戏")
+ playtime = (
+ game.get("playtime_forever", 0) or
+ game.get("playtime_windows_forever", 0) +
+ game.get("playtime_mac_forever", 0) +
+ game.get("playtime_linux_forever", 0) +
+ game.get("playtime_deck_forever", 0)
+ )
+ cover_url = SteamAPI.GetGameCoverImageURL(appid, variant='library_600x900')
+ game_data.append({
+ "appid": appid,
+ "name": name,
+ "playtime": playtime,
+ "cover_url": cover_url,
+ }) })
+
+ img = await draw_what_to_play(game_data)
+ img = img.convert("RGB")
+
+ buf = BytesIO()
+ img.save(buf, format="JPEG", quality=pic_quality, subsampling=0)
+ return buf.getvalue()
diff --git a/SteamUID/SteamPoll/poll_service.py b/SteamUID/SteamPoll/poll_service.py
index 204e6be..f13866d 100644
--- a/SteamUID/SteamPoll/poll_service.py
+++ b/SteamUID/SteamPoll/poll_service.py
@@ -1,4 +1,5 @@
import json
+import time
from gsuid_core.logger import logger
from gsuid_core.segment import MessageSegment
@@ -18,6 +19,7 @@
SteamBind,
SteamArchivementInfo,
SteamPriceInfo,
+ SteamPlayRecord,
)
from ..utils.PIL.draw import draw_game_status_photo, draw_archivements_photo
from ..utils.steam_status import (
@@ -173,13 +175,32 @@ async def _dispatch_to_subs(subs, send_msg, push_column, steamid64) -> None:
async def flush_status_updates(update_list) -> None:
- """将有变化的状态数据写回数据库"""
+ """将有变化的状态数据写回状态轮询数据库"""
for steamid64, info in update_list:
await SteamIDInfo.upsert_steamuserinfo(
steamid64, json.dumps(info, ensure_ascii=False)
)
-
+async def update_game_record(push_list) -> None:
+ """写入游戏记录数据库"""
+ now = int(time.time())
+ for info, old_info in push_list:
+ steamid64 = info.get("steamid")
+ if not steamid64:
+ continue
+ old_appid = old_info.get("gameid", "")
+ new_appid = info.get("gameid", "")
+ # 结束旧游戏(覆盖"切换游戏"与"退出游戏"两种场景)
+ if old_appid:
+ ret = await SteamPlayRecord.upsert_record(steamid64, old_appid, end_ts=now)
+ if ret != 0:
+ logger.warning(
+ f"[SteamPoll] 结束游玩记录失败: steamid={steamid64} appid={old_appid}"
+ )
+ # 开始新游戏
+ if new_appid:
+ await SteamPlayRecord.upsert_record(steamid64, new_appid, now)
+
async def poll_and_push_game_status() -> None:
"""游戏状态轮询主入口:拉取状态、检测变化、推送、更新基线、落盘。"""
try:
@@ -198,6 +219,8 @@ async def poll_and_push_game_status() -> None:
await process_game_status_push(push_list, game_info_map)
await update_achievement_baselines(push_list)
await flush_status_updates(update_list)
+ await update_game_record(push_list)
+
except Exception as error:
logger.warning(f"[SteamPoll] 游戏状态轮询失败: {error!r}")
diff --git a/SteamUID/SteamRanking/__init__.py b/SteamUID/SteamRanking/__init__.py
new file mode 100644
index 0000000..46b64d5
--- /dev/null
+++ b/SteamUID/SteamRanking/__init__.py
@@ -0,0 +1,54 @@
+from gsuid_core.sv import SV
+from gsuid_core.bot import Bot
+from gsuid_core.models import Event
+from gsuid_core.logger import logger
+from gsuid_core.segment import MessageSegment
+
+from ..utils.exceptions import SteamError, SteamValidationError
+# from ..utils.target import resolve_target_steamid64
+from ..utils.utils import time_convert_s
+from .ranking_service import get_group_ranking_list
+
+ranking_sv = SV("steam排名服务")
+
+@ranking_sv.on_command(("群排行", "群排名"))
+async def group_ranking(bot: Bot, ev: Event):
+ """按用户游戏时长从高到低取5位返回"""
+ try:
+ if not ev.group_id:
+ raise SteamValidationError("请在群聊中使用此功能")
+
+ ranking_list = await get_group_ranking_list(ev.group_id)
+ top5 = ranking_list[:5]
+
+ if not top5:
+ await bot.send("本群暂无游戏时长排行数据")
+ return
+
+ text = f"本群游戏时长排行:\n"
+ for i, item in enumerate(top5, 1):
+ text += f"{i}. {item['user_id']} ({time_convert_s(item['total_duration'])})\n"
+
+ await bot.send(text)
+
+ except SteamError as e:
+ await bot.send(str(e))
+ except Exception as e:
+ logger.exception(f"[SteamRanking - 群排行] 未知错误: {e!r}")
+ await bot.send("发生未知错误,请联系管理员查看控制台")
+
+"""
+============鸽了=============
+
+@ranking_sv.on_command(("我的排行", "我的排名"))
+async def my_ranking(bot: Bot, ev: Event):
+ 获取用户在群中的排名,应为总排行 + ... + 用户所在排行位置
+
+@ranking_sv.on_command(("游戏排行", "游戏排行"))
+async def game_ranking(bot: Bot, ev: Event):
+ 获取用户在某appid在某群中的用户游玩时长排行
+ try:
+ if not ev.group_id:
+ raise SteamValidationError("请在群聊中使用此功能")
+ steamid64 = await resolve_target_steamid64(ev)
+"""
diff --git a/SteamUID/SteamRanking/ranking_service.py b/SteamUID/SteamRanking/ranking_service.py
new file mode 100644
index 0000000..1cb3402
--- /dev/null
+++ b/SteamUID/SteamRanking/ranking_service.py
@@ -0,0 +1,65 @@
+from ..utils.exceptions import SteamError
+from ..utils.database.models import SteamBind, SteamPlayRecord
+
+
+async def get_group_ranking_list(group_id: str) -> list[dict]:
+ """获取群排名列表
+
+ 流程:
+ 1. 从 SteamBind 取群内所有绑定 → 构建 steamid64 → user_id 映射
+ 2. 收集所有 steamid64,批量查询已结束的游玩记录
+ 3. 计算每条记录时长 (end_ts - start_ts),按 user_id 累加
+ 4. 按总时长降序排序
+
+ Returns:
+ list[dict]: 每项包含:
+ - user_id (str): 平台用户ID
+ - total_duration (int): 总游戏时长(秒)
+ - steamid64s (list[str]): 该用户绑定的所有SteamID
+ """
+ # 第1步:取群绑定列表,构建映射
+ binds = await SteamBind.get_binds_by_group(group_id)
+ if not binds:
+ return []
+
+ steamid_to_user: dict[str, str] = {}
+ user_steamids: dict[str, list[str]] = {}
+ all_steamids: list[str] = []
+
+ for bind in binds:
+ sid = bind.steamid64
+ uid = bind.user_id
+ steamid_to_user[sid] = uid
+ all_steamids.append(sid)
+ if uid not in user_steamids:
+ user_steamids[uid] = []
+ if sid not in user_steamids[uid]:
+ user_steamids[uid].append(sid)
+
+ # 第2步:批量查询已结束的游玩记录
+ # with_session 重试耗尽后会隐式返回 None(区别于空列表的正常业务结果)
+ records = await SteamPlayRecord.get_records_by_steamids(all_steamids)
+ if records is None:
+ raise SteamError("查询游玩记录失败,请稍后重试")
+
+ # 第3步:计算时长并按 user_id 累加
+ user_durations: dict[str, int] = {}
+ for record in records:
+ uid = steamid_to_user.get(record.steamid64)
+ if uid is None:
+ continue
+ duration = record.end_ts - record.start_ts # type: ignore
+ user_durations[uid] = user_durations.get(uid, 0) + duration
+
+ # 第4步:构建结果列表并按总时长降序排序
+ ranking_list = [
+ {
+ "user_id": uid,
+ "total_duration": duration,
+ "steamid64s": user_steamids.get(uid, []),
+ }
+ for uid, duration in user_durations.items()
+ ]
+ ranking_list.sort(key=lambda x: x["total_duration"], reverse=True)
+
+ return ranking_list
diff --git a/SteamUID/utils/PIL/draw.py b/SteamUID/utils/PIL/draw.py
index 5c5381a..530afe8 100644
--- a/SteamUID/utils/PIL/draw.py
+++ b/SteamUID/utils/PIL/draw.py
@@ -611,3 +611,194 @@ def _section_h(items: list[dict]) -> int:
cur_y += _BIND_SECTION_GAP
return canvas
+
+
+# ==================== 玩什么推荐卡片 ====================
+
+_PLAY_BG_TOP = (0x17, 0x1a, 0x21)
+_PLAY_BG_BOTTOM = (0x1b, 0x28, 0x38)
+_PLAY_CARD_BG: tuple[int, int, int, int] = (0x2a, 0x47, 0x5e, 255)
+_PLAY_CARD_BG_ALT: tuple[int, int, int, int] = (0x1e, 0x36, 0x4a, 255)
+_PLAY_WHITE: tuple[int, int, int, int] = (0xff, 0xff, 0xff, 255)
+_PLAY_GRAY: tuple[int, int, int, int] = (0x8f, 0x98, 0xa0, 255)
+_PLAY_BLUE: tuple[int, int, int, int] = (0x66, 0xc0, 0xf4, 255)
+_PLAY_PLACEHOLDER: tuple[int, int, int, int] = (0x3a, 0x4f, 0x5e, 255)
+_PLAY_DIVIDER: tuple[int, int, int, int] = (0x3d, 0x5c, 0x78, 255)
+
+_PLAY_SCALE = 2.0
+_PLAY_LOGICAL_W = 600
+
+
+def _play_A(v: float) -> int:
+ """逻辑像素 -> 实际像素"""
+ return round(v * _PLAY_SCALE)
+
+
+def _play_fmt_playtime(minutes: int) -> str:
+ """格式化游玩时长"""
+ if minutes <= 0:
+ return "未游玩"
+ if minutes < 60:
+ return f"{minutes} 分钟"
+ hours = minutes / 60
+ if hours < 100:
+ return f"{hours:.1f} 小时"
+ return f"{round(hours)} 小时"
+
+
+async def _play_load_cover(
+ cover_url: str, appid: str, target_w: int, target_h: int
+) -> Image.Image:
+ """下载游戏封面并缩放到目标尺寸;失败返回纯色占位图。"""
+ if not cover_url:
+ return Image.new("RGBA", (target_w, target_h), _PLAY_PLACEHOLDER)
+ try:
+ cache_path = cache_path_for_url(cover_url, appid)
+ img = await _load_or_download(cover_url, cache_path)
+ img = img.resize((target_w, target_h), Image.Resampling.LANCZOS)
+ except Exception:
+ return Image.new("RGBA", (target_w, target_h), _PLAY_PLACEHOLDER)
+ return img
+
+def _play_paste_rounded_top(
+ canvas: Image.Image,
+ img: Image.Image,
+ pos: tuple[int, int],
+ radius: int,
+) -> None:
+ """将 img 粘贴到 canvas,仅圆角化顶部两角,底部保持直角。"""
+ w, h = img.size
+ mask = Image.new("L", (w, h), 0)
+ md = ImageDraw.Draw(mask)
+ md.rounded_rectangle((0, 0, w - 1, h - 1), radius=radius, fill=255)
+ md.rectangle((0, radius, w, h), fill=255)
+ canvas.paste(img, pos, mask)
+
+
+async def draw_what_to_play(picks: list[dict]) -> Image.Image:
+ """
+ 绘制"今天玩什么"推荐卡片。
+
+ 参数:
+ picks: [{"appid": str, "name": str, "playtime": int, "cover_url": str}, ...]
+ 长度 1-3
+
+ 返回:
+ PIL.Image.Image —— RGBA 画布
+ """
+ A = _play_A
+
+ # —— 布局常量(逻辑像素)——
+ SIDE_PAD = 20
+ CARD_GAP = 10
+ CARD_W = 180
+ CARD_RADIUS = 10
+ COVER_PAD = 6
+ COVER_RADIUS = 6
+ TOP_PAD = 24
+ BOTTOM_PAD = 24
+ TITLE_H = 40
+ SUBTITLE_H = 20
+ TITLE_GAP = 8
+ SECTION_GAP = 20
+ NAME_TOP_PAD = 10
+ NAME_H = 22
+ NAME_PLAY_GAP = 4
+ PLAYTIME_H = 16
+ NAME_BOT_PAD = 4
+
+ # 派生尺寸
+ cover_w = CARD_W - 2 * COVER_PAD # 168
+ cover_h = round(cover_w * 1.5) # 252 (2:3)
+ name_area_h = NAME_TOP_PAD + NAME_H + NAME_PLAY_GAP + PLAYTIME_H + NAME_BOT_PAD # 56
+ card_h = COVER_PAD + cover_h + name_area_h # 314
+ canvas_h = TOP_PAD + TITLE_H + TITLE_GAP + SUBTITLE_H + SECTION_GAP + card_h + BOTTOM_PAD
+
+ W_act = A(_PLAY_LOGICAL_W)
+ H_act = A(canvas_h)
+
+ # —— 创建画布 + 渐变背景 ——
+ canvas = Image.new("RGBA", (W_act, H_act))
+ draw_vertical_gradient(canvas, W_act, H_act, _PLAY_BG_TOP, _PLAY_BG_BOTTOM)
+ draw = ImageDraw.Draw(canvas)
+
+ # —— 字体 ——
+ font_title = core_font(A(30))
+ font_subtitle = core_font(A(14))
+ font_name = core_font(A(16))
+ font_playtime = core_font(A(12))
+
+ # —— 标题 ——
+ title_text = "今天玩什么"
+ title_x = (W_act - draw.textlength(title_text, font_title)) / 2
+ title_y = text_y_for_center(A(TOP_PAD) + A(TITLE_H) / 2, font_title, title_text)
+ draw.text((title_x, title_y), title_text, font=font_title, fill=_PLAY_BLUE)
+
+ # —— 副标题 ——
+ subtitle_text = "从你的游戏库中随机推荐"
+ sub_x = (W_act - draw.textlength(subtitle_text, font_subtitle)) / 2
+ sub_y = text_y_for_center(
+ A(TOP_PAD + TITLE_H + TITLE_GAP) + A(SUBTITLE_H) / 2,
+ font_subtitle, subtitle_text,
+ )
+ draw.text((sub_x, sub_y), subtitle_text, font=font_subtitle, fill=_PLAY_GRAY)
+
+ # —— 分割线 ——
+ divider_y = A(TOP_PAD + TITLE_H + TITLE_GAP + SUBTITLE_H + SECTION_GAP // 2)
+ draw.line(
+ [(A(SIDE_PAD), divider_y), (A(_PLAY_LOGICAL_W - SIDE_PAD), divider_y)],
+ fill=_PLAY_DIVIDER, width=A(1),
+ )
+
+ # —— 并发下载封面 ——
+ cover_target_w = A(cover_w)
+ cover_target_h = A(cover_h)
+ covers = await asyncio.gather(
+ *[_play_load_cover(
+ g["cover_url"], g["appid"], cover_target_w, cover_target_h
+ ) for g in picks]
+ )
+
+ # —— 绘制卡片 ——
+ cards_top = A(TOP_PAD + TITLE_H + TITLE_GAP + SUBTITLE_H + SECTION_GAP)
+
+ for i, game in enumerate(picks):
+ cx = A(SIDE_PAD) + A(i * (CARD_W + CARD_GAP))
+ cy = cards_top
+ cw = A(CARD_W)
+ ch = A(card_h)
+
+ # 卡片背景(圆角矩形)
+ card_bg = _PLAY_CARD_BG if i % 2 == 0 else _PLAY_CARD_BG_ALT
+ draw.rounded_rectangle(
+ (cx, cy, cx + cw, cy + ch), radius=A(CARD_RADIUS), fill=card_bg,
+ )
+
+ # 封面图(顶部圆角)
+ cover = covers[i]
+ cover_x = cx + A(COVER_PAD)
+ cover_y = cy + A(COVER_PAD)
+ _play_paste_rounded_top(canvas, cover, (cover_x, cover_y), A(COVER_RADIUS))
+
+ # 游戏名
+ name_text = _truncate_to_width(
+ game["name"], font_name, cw - 2 * A(COVER_PAD),
+ )
+ name_center_x = cx + cw // 2
+ name_cy = cover_y + cover_target_h + A(NAME_TOP_PAD) + A(NAME_H) / 2
+ name_y = text_y_for_center(name_cy, font_name, name_text)
+ draw.text(
+ (_center_text_x(name_center_x, name_text, font_name), name_y),
+ name_text, font=font_name, fill=_PLAY_WHITE,
+ )
+
+ # 游玩时长
+ playtime_text = _play_fmt_playtime(game["playtime"])
+ playtime_cy = name_cy + A(NAME_H) / 2 + A(NAME_PLAY_GAP) + A(PLAYTIME_H) / 2
+ playtime_y = text_y_for_center(playtime_cy, font_playtime, playtime_text)
+ draw.text(
+ (_center_text_x(name_center_x, playtime_text, font_playtime), playtime_y),
+ playtime_text, font=font_playtime, fill=_PLAY_GRAY,
+ )
+
+ return canvas
diff --git a/SteamUID/utils/database/__init__.py b/SteamUID/utils/database/__init__.py
index 6c38e71..488c048 100644
--- a/SteamUID/utils/database/__init__.py
+++ b/SteamUID/utils/database/__init__.py
@@ -1,4 +1,4 @@
-from .models import SteamBind, SteamIDInfo, SteamArchivementInfo, SteamPriceInfo
+from .models import SteamBind, SteamIDInfo, SteamArchivementInfo, SteamPriceInfo, SteamPlayRecord
from .models_cache import SteamApiCache, SteamArchivementCache # 内部缓存表,不注册到控制台
__all__ = [
@@ -6,6 +6,7 @@
"SteamIDInfo",
"SteamArchivementInfo",
"SteamPriceInfo",
+ "SteamPlayRecord",
"SteamApiCache",
"SteamArchivementCache",
]
diff --git a/SteamUID/utils/database/admin.py b/SteamUID/utils/database/admin.py
index a462729..9987590 100644
--- a/SteamUID/utils/database/admin.py
+++ b/SteamUID/utils/database/admin.py
@@ -1,6 +1,6 @@
from gsuid_core.webconsole.mount_app import PageSchema, GsAdminModel, site
-from .models import SteamIDInfo, SteamBind, SteamArchivementInfo, SteamPriceInfo
+from .models import SteamIDInfo, SteamBind, SteamArchivementInfo, SteamPriceInfo, SteamPlayRecord
@@ -44,3 +44,13 @@ class SteamPriceInfoAdmin(GsAdminModel):
) # type: ignore
model = SteamPriceInfo
+
+@site.register_admin
+class SteamPlayRecordAdmin(GsAdminModel):
+ pk_name = "id"
+ page_schema = PageSchema(
+ label="Steam游玩记录管理",
+ icon="fa fa-database",
+ ) # type: ignore
+
+ model = SteamPlayRecord
diff --git a/SteamUID/utils/database/models.py b/SteamUID/utils/database/models.py
index 9a3179b..3468aec 100644
--- a/SteamUID/utils/database/models.py
+++ b/SteamUID/utils/database/models.py
@@ -12,6 +12,7 @@
T_SteamIDInfo = TypeVar("T_SteamIDInfo", bound="SteamIDInfo")
T_SteamArchivementInfo = TypeVar("T_SteamArchivementInfo", bound="SteamArchivementInfo")
T_SteamPriceInfo = TypeVar("T_SteamPriceInfo", bound="SteamPriceInfo")
+T_SteamPlayRecord = TypeVar("T_SteamPlayRecord", bound="SteamPlayRecord")
class SteamIDInfo(BaseIDModel, table=True):
__table_args__: Dict[str, Any] = {"extend_existing": True}
@@ -381,6 +382,18 @@ async def get_binds_by_user(
result = await session.execute(stmt)
return list(result.scalars().all())
+ @classmethod
+ @with_session
+ async def get_binds_by_group(
+ cls: Type[T_SteamBind],
+ session: AsyncSession,
+ group_id: str,
+ ) -> list["SteamBind"]:
+ """按群查所有绑定(用于排行榜:群 → steamid列表 → user_id映射)"""
+ stmt = select(cls).where(cls.group_id == group_id)
+ result = await session.execute(stmt)
+ return list(result.scalars().all())
+
@classmethod
@with_session
async def delete_bind(
@@ -581,4 +594,124 @@ async def get_main_id(
return result.scalars().first()
+class SteamPlayRecord(BaseIDModel, table=True):
+ """Steam游戏游玩记录表(用于游戏排行榜及衍生功能)"""
+ __table_args__: Dict[str, Any] = {"extend_existing": True}
+
+ steamid64: str = Field(default=None, index=True, title="SteamID64")
+ appid: str = Field(default=None, index=True, title="游戏AppID")
+ start_ts: int = Field(default=None, title="开始游戏时间(Unix时间戳)")
+ end_ts: Optional[int] = Field(default=None, title="结束游戏时间(Unix时间戳,NULL=进行中)")
+
+ @classmethod
+ @with_session
+ async def upsert_record(
+ cls: Type[T_SteamPlayRecord],
+ session: AsyncSession,
+ steamid64: str,
+ appid: str,
+ start_ts: Optional[int] = None,
+ end_ts: Optional[int] = None,
+ ) -> int:
+ """
+ 写入游玩记录(upsert)。
+ - 不传 end_ts(开始游戏):新增一条记录,start_ts 必填。
+ - 传了 end_ts(结束游戏):按 steamid64 + appid + end_ts IS NULL
+ 定位进行中的记录并写入 end_ts,start_ts 此时忽略。
+ 一个玩家同一时间在同一个 appid 只会有一条 end_ts 为 NULL 的记录,
+ 因此该定位方式不会产生歧义。
+ 返回 0 表示成功,-1 表示结束游戏时未找到进行中的记录。
+ """
+ if end_ts is None:
+ session.add(
+ cls(
+ steamid64=steamid64, # type: ignore
+ appid=appid, # type: ignore
+ start_ts=start_ts, # type: ignore
+ end_ts=None, # type: ignore
+ )
+ )
+ return 0
+
+ # 结束游戏:查找进行中的记录
+ stmt = (
+ select(cls)
+ .where(
+ cls.steamid64 == steamid64,
+ cls.appid == appid,
+ cls.end_ts.is_(None), # type: ignore
+ )
+ .order_by(cls.id.desc()) # type: ignore
+ )
+ result = await session.execute(stmt)
+ existing = result.scalars().first()
+ if existing is None:
+ return -1
+ existing.end_ts = end_ts
+ session.add(existing)
+ return 0
+
+ @classmethod
+ @with_session
+ async def delete_record(
+ cls: Type[T_SteamPlayRecord],
+ session: AsyncSession,
+ record_id: int,
+ ) -> int:
+ """
+ 按主键删除一条游玩记录。
+ 0: 成功
+ -1: 未找到匹配记录
+ """
+ stmt = delete(cls).where(cls.id == record_id) # type: ignore
+ result = await session.execute(stmt)
+ if result.rowcount and result.rowcount > 0: # type: ignore
+ return 0
+ return -1
+
+ @classmethod
+ @with_session
+ async def get_records(
+ cls: Type[T_SteamPlayRecord],
+ session: AsyncSession,
+ steamid64: Optional[str] = None,
+ appid: Optional[str] = None,
+ end_before: Optional[int] = None,
+ end_after: Optional[int] = None,
+ ) -> list["SteamPlayRecord"]:
+ """
+ 按条件查询游玩记录列表。所有参数可选,传入的条件以 AND 组合。
+ steamid64 / appid 精确匹配;end_before / end_after 对 end_ts 做范围过滤(含边界)。
+ end_ts 为 NULL 的记录不会出现在按时间范围过滤的结果中。
+ """
+ stmt = select(cls)
+ if steamid64 is not None:
+ stmt = stmt.where(cls.steamid64 == steamid64)
+ if appid is not None:
+ stmt = stmt.where(cls.appid == appid)
+ if end_before is not None:
+ stmt = stmt.where(cls.end_ts <= end_before) # type: ignore
+ if end_after is not None:
+ stmt = stmt.where(cls.end_ts >= end_after) # type: ignore
+ result = await session.execute(stmt)
+ return list(result.scalars().all())
+
+ @classmethod
+ @with_session
+ async def get_records_by_steamids(
+ cls: Type[T_SteamPlayRecord],
+ session: AsyncSession,
+ steamid64s: list[str],
+ ) -> list["SteamPlayRecord"]:
+ """按 steamid64 列表批量查询已结束的游玩记录(end_ts 为 NULL 的进行中记录不返回)"""
+ if not steamid64s:
+ return []
+ stmt = select(cls).where(
+ cls.steamid64.in_(steamid64s), # type: ignore
+ cls.end_ts.is_not(None), # type: ignore
+ )
+ result = await session.execute(stmt)
+ return list(result.scalars().all())
+
+
from . import admin # 注册到管理员
diff --git a/SteamUID/utils/utils.py b/SteamUID/utils/utils.py
index 0406f2b..219b324 100644
--- a/SteamUID/utils/utils.py
+++ b/SteamUID/utils/utils.py
@@ -76,6 +76,27 @@ def HideStr(text: str) -> str:
return text[0] + "*" * (len(text) - 3) + text[-2:]
+def time_convert_s(seconds: int) -> str:
+ """将秒数转换为人类可读的时长,如 1天2小时30分45秒"""
+ if seconds < 0:
+ seconds = 0
+ days = seconds // 86400
+ hours = (seconds % 86400) // 3600
+ minutes = (seconds % 3600) // 60
+ secs = seconds % 60
+
+ parts = []
+ if days > 0:
+ parts.append(f"{days}天")
+ if hours > 0:
+ parts.append(f"{hours}小时")
+ if minutes > 0:
+ parts.append(f"{minutes}分")
+ if secs > 0 or not parts:
+ parts.append(f"{secs}秒")
+ return "".join(parts)
+
+
def maybe_hide_steamid(text: str) -> str:
"""根据 HideSteamID 配置决定是否对 steamid / 好友码套用 HideStr"""
from ..SteamConfig import SteamConfig