From 2cf54a5e6cd6223476cc07c9509318c757302097 Mon Sep 17 00:00:00 2001
From: qsc
Date: Sun, 6 Sep 2026 19:22:56 +0800
Subject: [PATCH] fxdyz
---
CHANGELOG.md | 22 +
README.md | 144 +++--
_conf_schema.json | 2 +-
core/bilei_data.py | 201 ++++++-
core/cache.py | 916 +++++++++++++++++++++++++++++
core/jx3api_data.py | 105 +++-
core/message.py | 368 ++++++++++--
core/sqlite.py | 6 +
core/webui.py | 78 ++-
main.py | 57 +-
metadata.yaml | 2 +-
pages/server-management/app.js | 271 +++++++++
pages/server-management/index.html | 91 ++-
pages/server-management/style.css | 31 +-
templates/layouts/base.html | 3 +
templates/pages/helps.html | 94 ++-
templates/styles/base.css | 16 +
templates/styles/pages/helps.css | 12 +-
18 files changed, 2203 insertions(+), 216 deletions(-)
create mode 100644 core/cache.py
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 220d610..fd76489 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,5 +1,27 @@
## 更新记录
+### version: 3.4.6(2026-09-06):
+
+本地避雷记录新增按 AstrBot 会话隔离的数据范围。新增、查看、查询、修改和删除均使用当前消息的 `unified_msg_origin`,修改与删除同时校验会话 ID 和记录 ID,其他会话即使知道记录 ID 也无法访问或操作。
+
+插件升级时会自动迁移旧版 `bilei` 表,原有记录完整保留在不可由普通会话访问的“历史公共数据”区;新安装直接创建包含会话 ID 的表结构,并为会话内查询建立联合索引。
+
+插件 WebUI 新增“避雷迁移”页签,集中展示历史公共数据区中的旧记录。管理员可为每条记录选择已有会话或直接输入目标会话 ID;迁移采用带历史区条件的原子更新,完成后记录立即离开待迁移列表并仅归属于目标会话。
+
+功能帮助图片将“插件与推送”调整为第一个功能分区,其余分区序号依次后移,避雷功能仍保留在最后。
+
+功能帮助底部的会话区服说明移动到顶部,并补充方括号参数可省略且使用默认值的规则;所有“服务器”和“区服”参数统一移除方括号,其他可选参数标记保持不变。
+
+优化烟花、跨服名剑和武林争霸的可选参数:烟花条数为非正整数或无法解析时自动回退 50;跨服名剑改用 `22`、`33`、`55` 并映射接口值 `0`、`1`、`2`;武林争霸改用 `浩气盟`、`恶人谷` 并映射接口值 `1`、`2`。三项无效输入均使用默认值,不再返回参数范围错误。
+
+调整指令前缀容错:开启前缀检查但前缀内容为空或只有空格时,自动按未开启前缀处理并记录警告,普通指令仍可直接触发;仅在前缀内容非空时强制匹配。
+
+新增查询双层缓存:JX3API 原始 JSON 采用内存与 SQLite 两级缓存,缓存键排除 Token、Ticket 等敏感值并使用凭据摘要隔离;同参数并发请求会合并为一次上游调用,请求失败时可使用七天内的过期数据兜底。HTML 渲染图片保存到插件数据目录;普通图片指令会先按指令、完整参数、选择项、会话数据范围、资源和渲染配置查询最终图片,命中后直接发送,不再请求上游接口或重复渲染。图文结果会连同正文一起缓存,同键并发执行也只会产生一次请求和渲染。
+
+插件 WebUI 新增“缓存管理”页签,可分别设置接口数据和各图片指令的默认缓存秒数,也可逐项覆盖、恢复默认或单独清除;设置为 `0` 即关闭对应缓存。单独清除接口时会同步删除该路径的内存与 SQLite 缓存,单独清除图片时会删除该指令生成的缓存文件,均不改变已配置的缓存时间。页面同时展示缓存条数、占用空间,并支持分别清空全部接口和图片缓存。接口内存缓存条数与图片缓存总容量均可在页面调整,默认分别为 256 条和 512 MB,保存后立即按最少使用优先策略清理超额缓存;随机内容接口以及会话避雷图片采用 0 秒安全默认值,仍可显式覆盖。
+
+所有 HTML 渲染图片统一在页面底部显示“数据时间”。JX3API 数据优先显示接口缓存最初写入时间,非接口页面显示本次数据生成时间;命中最终图片缓存时保留原图片时间,便于直接判断当前图片所展示数据的新鲜程度。旧版最终图片缓存键同步升级,避免继续复用不含时间栏的历史图片。
+
### version: 3.4.5(2026-09-05):
新增 `跨服名剑 服务器 [模式]` 指令,接入 JX3API 跨服名剑榜;竞技模式支持 `0=2v2`、`1=3v3`、`2=5v5`,默认为 `1`。榜单按接口数组顺序生成排名,使用统一数据表格模板展示队伍名称、积分、胜场、败场和总场数,并同步更新功能帮助与 README。
diff --git a/README.md b/README.md
index f79380e..f0c7bd4 100644
--- a/README.md
+++ b/README.md
@@ -8,20 +8,21 @@
基于 AstrBot 的剑网三综合数据查询插件
-`astrbot_plugin_jx3` 通过 JX3API、JX3BOX 等数据源查询《剑网3》游戏数据,并根据功能将结果发送为纯文本、图片、图文消息或两轮交互消息。插件同时提供本地避雷记录和基于 JX3API WebSocket 的实时事件推送。
+`astrbot_plugin_jx3` 通过 JX3API、JX3BOX 等数据源查询《剑网3》游戏数据,并按功能输出纯文本、远程图片、HTML 渲染图片、图文消息或两轮交互结果;同时提供会话区服绑定、会话访问控制、实时事件推送、会话隔离避雷记录以及可视化缓存管理。
## 功能特点
- 中文触发词覆盖活动、名剑、排行、交易、阵营、角色、奇遇、百战、家园、社区等场景。
- 支持纯文本、远程图片、HTML/Jinja2 渲染图片和图文消息链。
-- 支持 `宏`、`资历` 以及帮会、阵营、其他排行榜的 10 秒两轮交互,超时默认选择第一项。
-- 查询图片统一使用浅色高对比主题,并可在插件配置页调整渲染清晰度、输出格式和 JPEG 质量。
-- 支持本地 SQLite 避雷记录的增删改查。
+- 支持 `宏`、`资历` 以及帮会、阵营、其他排行榜的 15 秒两轮交互,超时默认选择第一项。
+- 查询图片统一使用浅色高对比主题,并可在插件配置页调整渲染清晰度、输出格式和 JPEG 质量;所有 HTML 渲染图片都会在底部显示数据时间。
+- 本地避雷记录按 AstrBot 会话严格隔离,旧版公共记录可在 WebUI 中迁移到指定会话。
- 支持按 AstrBot 会话分别开启总开关和具体 JX3API 实时事件订阅。
- 复用 `aiohttp.ClientSession`,统一处理 GET、POST、JSON、图片和分页请求。
- JX3BOX 的 Node、Next2、CMS 请求统一封装,交易行基础物品数据支持本地快照缓存和过期兜底。
-- 内置 47 个页面片段,通过公共布局与样式在本地组装为完整 HTML,并附带通用、沙盘、门派/心法和奇遇图标资源。
+- JX3API 查询使用内存与 SQLite 两级 JSON 缓存,HTML 查询图片使用本地文件缓存;缓存时间、接口内存条数及图片缓存总容量均可在 WebUI 配置。
+- 内置 97 个中文触发指令和 51 个页面片段,通过公共布局与样式在本地组装为完整 HTML,并附带通用、沙盘、门派/心法和奇遇图标资源。
## 数据来源
@@ -29,7 +30,7 @@
| --- | --- | --- |
| [JX3API](https://www.jx3api.com/) | `core/jx3api_data.py`、`core/event_push.py` | 游戏查询、资历分布、沙盘据点数据及 WebSocket 实时事件 |
| [JX3BOX](https://www.jx3box.com/) | `core/jx3box_data.py` | 奇遇攻略、配装、宏及交易行 |
-| 本地 SQLite | `core/sqlite.py`、`core/bilei_data.py` | 心法别名、避雷记录、事件订阅和基础数据缓存 |
+| 本地 SQLite | `core/sqlite.py`、`core/bilei_data.py`、`core/cache.py` | 会话配置、别名、避雷记录、事件订阅、接口缓存及图片缓存索引 |
外部数据源的可用性、数据时效和字段结构均不由本插件控制。接口变更、网络异常、凭据权限不足或上游限流都可能导致查询失败。
@@ -71,7 +72,7 @@ pip install -r data/plugins/astrbot_plugin_jx3/requirements.txt
| `aiohttp` | 异步 HTTP 请求与连接复用 |
| `aiofiles` | 异步读取 HTML 模板 |
| `aiosqlite` | 异步访问本地 SQLite 数据库 |
-| `matplotlib` | 当前依赖清单保留的绘图依赖;v3.2.1 业务代码未直接导入 |
+| `matplotlib` | 当前依赖清单保留的绘图依赖;v3.4.6 业务代码未直接导入 |
## 插件配置
@@ -80,16 +81,16 @@ pip install -r data/plugins/astrbot_plugin_jx3/requirements.txt
| 配置项 | 类型 | 默认值 | 说明 |
| --- | --- | --- | --- |
| `prefix.enable` | `bool` | `false` | 是否启用指令前缀检查 |
-| `prefix.text` | `string` | `剑三` | 指令前缀内容 |
+| `prefix.text` | `string` | `剑三` | 指令前缀内容;开启前缀但内容为空或只有空格时按未开启处理 |
| `jx3api_token` | `string` | 空 | JX3API Token |
| `jx3api_ticket` | `string` | 空 | 部分名剑和心法接口需要的推栏 Ticket |
| `jx3api_wss` | `string` | `wss://socket.nicemoe.cn` | JX3API 事件通道地址 |
| `jx3api_wss_token` | `string` | 空 | 事件版令牌;免费事件无需填写 |
-| `image_render_quality.device_scale_factor` | `string` | `1.3` | 渲染清晰度,可选 `1.0`、`1.3`、`1.8` |
+| `image_render_quality.device_scale_factor` | `string` | `1.0` | 渲染清晰度,可选 `1.0`、`1.3`、`1.8` |
| `image_render_quality.format` | `string` | `jpeg` | 图片输出格式,可选 `jpeg`、`png` |
| `image_render_quality.jpeg_quality` | `int` | `100` | JPEG 图片质量,范围 `1-100`,PNG 下不生效 |
-会话区服绑定、区服别名和事件订阅均保存在本地 SQLite。修改 WebSocket 地址或事件版令牌后需要重新加载插件。
+会话控制、区服绑定、区服/心法别名、避雷记录、事件订阅及缓存配置均保存在本地 SQLite。修改 WebSocket 地址或事件版令牌后需要重新加载插件。
### Token 与 Ticket
@@ -112,16 +113,17 @@ JX3API Token 可从 [JX3API](https://www.jx3api.com/) 获取。推栏 Ticket 通
剑三 战绩 梦江南 角色名 33
```
-前缀与指令之间的空格可以省略,例如 `剑三日常` 也能触发。默认关闭前缀检查,可直接发送 `日常`;启用后才要求使用配置的前缀。
+前缀与指令之间的空格可以省略,例如 `剑三日常` 也能触发。默认关闭前缀检查,可直接发送 `日常`;启用且配置了非空前缀后才要求使用配置的前缀。开启前缀但内容为空或只有空格时自动按未开启处理,普通指令仍可直接触发。
参数规则:
- 指令和参数使用空白字符分隔。
-- `[参数]` 表示可选参数;未加方括号的参数必须提供。
+- 会话绑定区服后可省略指令中的区服参数;在区服位置填写 `全区` 可显式查询全区数据。
+- `[参数]` 表示该参数可以不写,省略后采用默认值;指令表中的 `服务器` 或 `区服` 不使用方括号,其可省略条件以上一条会话绑定规则为准。
- 当前分发器按空白切分消息,因此角色名、物品名、备注等单个参数不能包含空格。
- 未识别的消息会被忽略;已识别的指令会停止继续传播给其他插件。
- 缺少必填参数、数字转换失败或执行异常时,入口层统一回复 `参数错误或执行失败`。
-- 未绑定区服时按指令表提供标准参数。绑定后可以省略 `server` 参数;显式填写完整参数时仍优先使用本次输入的区服。在区服参数位置填写 `全区` 时,会忽略会话绑定并向接口传入空区服以查询全区数据。
+- 未绑定区服时必须按指令表提供服务器。绑定后可以省略 `server` 参数;显式填写完整参数时仍优先使用本次输入的区服。
- 标准区服名和 WebUI 中配置的别名都可用于查询。对于区服后的可选参数,分发器通过当前有效区服目录和别名判断首个参数是显式区服还是后续参数。
- 心法类指令会在入口层把标准心法名或 WebUI 配置的心法别名统一解析为标准名称;当前适用于试炼排行、阵眼、配装、技能、奇穴、小药和宏。门派筛选参数不参与心法别名解析。
@@ -144,6 +146,8 @@ JX3API Token 可从 [JX3API](https://www.jx3api.com/) 获取。推栏 Ticket 通
- **Token + Ticket**:当前实现会同时传入两项凭据。
- 本地功能和任务状态查询不访问对应的游戏查询接口。
+所有标记为“图片”的 HTML 渲染结果都会在页面底部显示统一的“数据时间”。JX3API 查询优先显示接口缓存最初写入时间,其他页面显示本次数据生成时间;命中最终图片缓存时,图片中的时间保持不变,因此可以直接判断当前结果使用的是哪一时刻的数据。
+
### 帮助与活动情报
| 指令 | 说明与输出 | 凭据 |
@@ -155,7 +159,7 @@ JX3API Token 可从 [JX3API](https://www.jx3api.com/) 获取。推栏 Ticket 通
| `关隘` | 查询关隘首领状态;图片 | Token |
| `赤兔`、`本周赤兔` | 查询当日或本周赤兔记录;文本 | Token |
| `阵营奉献 [阵营]` | 查询阵营奉献事件,固定最多 50 条;图片 | Token |
-| `烟花 服务器 角色 [条数]` | 查询指定服务器的烟花记录;条数默认为 50,须为正整数,填写时放在角色名称后;已绑定会话可省略服务器;图片 | Token |
+| `烟花 服务器 [角色] [条数]` | 查询指定服务器的烟花记录;角色可省略,条数默认为 50,非正整数或其他无效输入自动使用默认值;已绑定会话可省略服务器;图片 | Token |
| `刷马 服务器` | 查询刷马聊天情报;文本 | Token |
| `马场 服务器` | 查询未过期马场记录;文本 | Token |
@@ -166,11 +170,11 @@ JX3API Token 可从 [JX3API](https://www.jx3api.com/) 获取。推栏 Ticket 通
| `战绩 服务器 角色 [模式]` | 角色名剑战绩,模式可选 `22`、`33`、`55`,默认 `33`;图片 | Token + Ticket |
| `名剑排行 [模式] [数量]` | 名剑大会排行,默认 `33`、50 条;图片 | Token + Ticket |
| `名剑统计 [模式]` | 名剑门派统计,模式默认 `33`;图片 | Token + Ticket |
-| `跨服名剑 服务器 [模式]` | 跨服名剑队伍榜单,模式可选 `0`(2v2)、`1`(3v3)、`2`(5v5),默认 `1`;图片 | Token(LV.2) |
-| `武林争霸 服务器 [阵营]` | 武林争霸赛帮会榜,阵营可选 `1`(浩气)、`2`(恶人),默认 `1`;图片 | Token(LV.2) |
+| `跨服名剑 服务器 [模式]` | 跨服名剑队伍榜单,模式可选 `22`、`33`、`55`,分别映射接口模式 `0`、`1`、`2`,默认 `33`;无效输入使用默认值;图片 | Token(LV.2) |
+| `武林争霸 服务器 [阵营]` | 武林争霸赛帮会榜,阵营可选 `浩气盟`、`恶人谷`,分别映射接口阵营 `1`、`2`,默认 `浩气盟`;无效输入使用默认值;图片 | Token(LV.2) |
| `捕快荣誉 服务器` | 捕快荣誉榜,展示角色、帮会、门派、阵营和抓捕数;图片 | Token(LV.2) |
| `江湖浪客 服务器` | 江湖浪客榜,展示角色、帮会、门派、阵营、抓捕数和敌对数;图片 | Token(LV.2) |
-| `决斗挑战 服务器 [模式]` | 决斗挑战悬赏榜,模式可选 `1`(公开)、`2`(私密),默认 `1`;图片 | Token(LV.2) |
+| `决斗挑战 服务器 [模式]` | 决斗挑战悬赏榜;当前入口实际统一按公开模式查询,模式参数无法切换到私密;图片 | Token(LV.2) |
| `帮会排行 服务器` | 回复序号选择神兵宝甲或爱心帮会榜单,最多保留前 50 条;图片 | Token |
| `阵营排行 服务器` | 回复序号选择赛季、上周或本周阵营榜单,最多保留前 50 条;图片 | Token |
| `其他排行 服务器` | 回复序号选择名士、老江湖、名师等其他榜单,最多保留前 50 条;图片 | Token |
@@ -185,7 +189,7 @@ JX3API Token 可从 [JX3API](https://www.jx3api.com/) 获取。推栏 Ticket 通
| `阵营拍卖 服务器 [物品] [数量]` | 阵营拍卖记录,默认最多 50 条;图片 | Token |
| `的卢 服务器` | 的卢拍卖记录;图片 | Token |
| `金价 服务器 [数量]` | 金价行情,默认 15 条;图片 | Token |
-| `物价 外观名称 [服务器]` | 外观价格记录;图片 | Token |
+| `物价 外观名称 服务器` | 外观价格记录;图片 | Token |
| `成本 服务器 物品名称 [来源]` | 制造成本,来源默认 `0`;图片 | Token |
| `看号 万宝楼编号` | 万宝楼账号详情;文本 | Token |
| `交易行 服务器 物品` | 本地模糊匹配物品后批量查询 JX3BOX 交易行价格;图片 | 无 |
@@ -212,7 +216,7 @@ JX3API Token 可从 [JX3API](https://www.jx3api.com/) 获取。推栏 Ticket 通
| `未出 服务器 角色` | 未触发奇遇;图片 | Token |
| `汇总 服务器 [天数]` | 区服奇遇汇总,默认 7 天;图片 | Token |
| `近期 服务器 [数量]` | 区服近期奇遇,默认 20 条;图片 | Token |
-| `统计 奇遇 [服务器] [数量]` | 指定奇遇的触发统计,默认 20 条;图片 | Token |
+| `统计 奇遇 服务器 [数量]` | 指定奇遇的触发统计,默认 20 条;图片 | Token |
| `攻略 奇遇` | 从 JX3BOX 获取奇遇攻略正文并渲染;图片 | 无 |
### 百战、角色与心法
@@ -225,22 +229,22 @@ JX3API Token 可从 [JX3API](https://www.jx3api.com/) 获取。推栏 Ticket 通
| `角色 服务器 名称` | 查询角色详情与历史信息;文本 | Token |
| `阵眼 心法` | 查询阵眼效果;文本 | Token + Ticket |
| `配装 心法 [类型]` | 从 JX3BOX 获取推荐配装链接;文本。当前 `类型` 参数已接收但尚未用于请求筛选 | 无 |
-| `资历排行 [服务器] [门派]` | 资历排行榜;图片 | Token + Ticket |
+| `资历排行 服务器 [门派]` | 资历排行榜;图片 | Token + Ticket |
| `技能 心法` | 心法技能;图片 | Token + Ticket |
| `奇穴 心法` | 心法奇穴;图片 | Token + Ticket |
-| `宏 心法` | 先返回动态宏列表,10 秒内回复序号后返回宏文本和帖子内容;超时默认选择热度第一项 | 无 |
-| `资历 服务器 角色` | 返回固定资历分类菜单,10 秒内回复序号后通过 JX3API 渲染资历进度图 | Token + Ticket |
+| `宏 心法` | 先返回动态宏列表,15 秒内回复序号后返回宏文本和帖子内容;超时默认选择热度第一项 | 无 |
+| `资历 服务器 角色` | 返回固定资历分类菜单,15 秒内回复序号后通过 JX3API 渲染资历进度图 | Token + Ticket |
-`资历` 的 `1` 表示总览,`2-19` 固定对应杂闻、武学、修为、装备、技艺、阅读、任务、足迹、战斗、声望、秘境、帮会、阵营、节日、活动、风雨江湖路、家园和剑侠录,不再使用本地资历菜单和点数缓存。第二轮只需回复数字,不需要再次添加插件前缀;10 秒未选择时默认把首项 `{"1": "总览"}` 交给次轮查询并继续渲染。
+`资历` 的 `1` 表示总览,`2-19` 固定对应杂闻、武学、修为、装备、技艺、阅读、任务、足迹、战斗、声望、秘境、帮会、阵营、节日、活动、风雨江湖路、家园和剑侠录,不再使用本地资历菜单和点数缓存。第二轮只需回复数字,不需要再次添加插件前缀;15 秒未选择时默认把首项 `{"1": "总览"}` 交给次轮查询并继续渲染。
### 游戏社区与休闲
| 指令 | 说明与输出 | 凭据 |
| --- | --- | --- |
| `发言 服务器 角色 [条数] [页数]` | 角色发言记录,默认 20 条、第 1 页;图片 | Token |
-| `统战 [服务器]` | 统战频道统计;文本 | 无 |
+| `统战 服务器` | 统战频道统计;文本 | 无 |
| `小药 [心法]` | 小吃小药推荐;图片 | 无 |
-| `骗子 UID [服务器]` | 查询欺诈记录;文本 | Token |
+| `骗子 UID 服务器` | 查询欺诈记录;文本 | Token |
| `花价 服务器 [名称] [地图]` | 家园鲜花价格;图片 | 无 |
| `装饰 名称` | 家园装饰信息;图片 | 无 |
| `器物 地图名称` | 器物图谱;图片 | 无 |
@@ -260,27 +264,26 @@ JX3API Token 可从 [JX3API](https://www.jx3api.com/) 获取。推栏 Ticket 通
| 指令 | 说明与输出 | 凭据 |
| --- | --- | --- |
-| `贴吧物价 名称 [服务器] [数量]` | 贴吧物价记录,默认 5 条;文本 | Token |
-| `818 [服务器] [数量]` | 随机 818 内容,默认 10 条;文本 | Token |
+| `贴吧物价 名称 服务器 [数量]` | 贴吧物价记录,默认 5 条;文本 | Token |
+| `818 服务器 [数量]` | 随机 818 内容,默认 10 条;文本 | Token |
| `科举 题目 [条数]` | 科举题目搜索,默认 5 条;文本 | 无 |
| `区服` | 全区服状态;图片 | 无 |
| `开服 服务器` | 指定服务器开服状态;文本 | 无 |
| `技改` | 最近技改记录;文本 | 无 |
| `解密` | 当前秘境解密信息;文本 | Token |
-| `副本 服务器 角色` | 角色副本记录;图片 | Token |
-| `掉落 物品 [服务器] [数量]` | 副本掉落统计,默认 20 条;图片 | Token |
+| `掉落 物品 服务器 [数量]` | 副本掉落统计,默认 20 条;图片 | Token |
### 本地避雷
| 指令 | 说明与输出 |
| --- | --- |
-| `避雷添加 名称 备注` | 写入名称、备注、当前时间和发送者名称 |
-| `避雷查看` | 查看全部记录;图片 |
-| `避雷查询 名称` | 按名称模糊查询;图片 |
-| `避雷修改 ID 名称 备注` | 按 ID 更新记录,同时覆盖修改时间和修改人 |
-| `避雷删除 ID` | 按 ID 删除记录 |
+| `避雷添加 名称 备注` | 向当前会话写入名称、备注、当前时间和发送者名称 |
+| `避雷查看` | 查看当前会话的全部记录;图片 |
+| `避雷查询 名称` | 在当前会话内按名称模糊查询;图片 |
+| `避雷修改 ID 名称 备注` | 按当前会话和 ID 更新记录,同时覆盖修改时间和修改人 |
+| `避雷删除 ID` | 按当前会话和 ID 删除记录 |
-避雷功能没有会话内的用户角色校验。在“会话控制”放行的会话中,只要能够触发插件指令,当前实现就允许新增、修改和删除记录;如需限制到指定管理员,仍应在 AstrBot 或消息平台层配置权限。
+避雷数据按 AstrBot 的 `unified_msg_origin` 严格隔离,一个会话无法查看或操作其他会话的记录。升级前已有的避雷记录会自动保留在普通会话不可访问的“历史公共数据”区。避雷功能没有会话内的用户角色校验;在“会话控制”放行的同一会话中,只要能够触发插件指令,当前实现就允许新增、修改和删除该会话的记录。如需限制到指定管理员,仍应在 AstrBot 或消息平台层配置权限。
### 实时事件推送
@@ -297,9 +300,25 @@ JX3API Token 可从 [JX3API](https://www.jx3api.com/) 获取。推栏 Ticket 通
### WebUI 插件管理
-AstrBot 插件详情页中的“剑网三插件管理”页面提供五个页签:会话控制、事件推送、区服绑定、区服别名和心法别名。顶部通过 JX3API `POST /token/stats` 展示当前配置令牌的等级、已用次数、剩余次数和有效状态;未配置令牌或接口暂时不可用时显示未获取,不影响其他管理功能。会话控制为默认页签,页面会明确标出当前真正生效的模式,并区分尚未保存的模式选择。该功能默认使用“全部会话”,也可切换为白名单或黑名单;会话 ID 可从已有绑定和订阅记录中选择,也可直接输入,并可维护名单类型和备注。空白名单不放行任何会话,空黑名单放行全部会话,名单策略同时作用于插件指令和实时事件推送。
+AstrBot 插件详情页中的“剑网三插件管理”通过 Plugin Pages 桥接调用插件 Web API,不直接访问 Dashboard 凭据。当前共有七个页签:
-区服绑定页可使用自定义会话 ID 添加记录,绑定区服只能从标准区服下拉框选择。编辑已有记录时会话 ID 保持只读,只在绑定区服列内修改,点击解除绑定会直接删除该行。事件推送页显示所有会话的总开关及订阅编号。区服别名页展示完整标准区服目录,标准名称只读,只能行内编辑别名;心法别名页隐藏 JX3BOX 配装 ID,标准心法名称只读,同样只允许行内编辑最多 5 个别名。两个别名页均可使用随插件分发的 JSON 种子完整覆盖数据库并恢复默认配置。管理页通过 AstrBot Plugin Pages 桥接调用插件 Web API,不直接访问 Dashboard 凭据。
+| 页签 | 当前功能 |
+| --- | --- |
+| 会话控制 | 默认页签;在全部会话、白名单和黑名单之间切换,并维护会话 ID 与备注。空白名单不放行任何会话,空黑名单放行全部会话,策略同时作用于查询指令与事件推送 |
+| 事件推送 | 查看所有会话的推送总开关及已订阅事件编号 |
+| 区服绑定 | 使用自定义会话 ID 新增绑定;区服只能从标准区服下拉框选择,已有会话 ID 不可编辑 |
+| 区服别名 | 查看标准区服并行内维护别名;可使用随插件分发的 JSON 种子恢复默认 |
+| 心法别名 | 查看标准心法并维护最多 5 个别名;配装 ID 不在页面显示,可恢复默认 |
+| 缓存管理 | 分别配置接口数据和最终图片缓存时间、容量限制,查看占用并清理单项或全部缓存 |
+| 避雷迁移 | 把升级前保留在“历史公共数据”区的避雷记录迁移到指定会话 |
+
+页面顶部通过 JX3API `POST /token/stats` 展示当前 Token 的等级、已用次数、剩余次数和有效状态,成功结果在进程内保留 30 秒。普通打开或刷新 WebUI 时,区服目录、别名和绑定直接读取当前内存/SQLite 数据,不会请求区服状态接口;只有点击“刷新区服列表”时才会强制请求 `/server/status/check` 并更新当前区服目录。插件初始化时会执行一次 `server_list()` 建立用于参数消歧的有效区服目录:缓存有效时读取接口缓存,缓存不存在或已过期时才请求上游。
+
+缓存管理的接口默认时间为 300 秒,图片默认时间为 600 秒;每个 JX3API 接口和每个图片指令都可以单独覆盖,填写 `0` 表示关闭,恢复默认则重新继承全局时间。图片指令优先查询最终图片缓存,命中后不会再调用上游接口或重新渲染;图文结果会连同正文一起复用。单独清除接口会同时删除该路径所有参数组合的内存与 SQLite 缓存,单独清除图片会删除该指令生成的缓存文件,两种操作均不改变已配置时间。
+
+如果需要某条图片指令立即使用最新上游数据,应同时清除(或临时关闭)对应的接口缓存和图片缓存。只把接口缓存设为 `0` 时,已有最终图片仍可能直接命中;只把图片缓存设为 `0` 时,页面会重新渲染,但仍可能使用尚未过期的接口数据。
+
+接口内存热缓存默认最多 256 条,超出后按最近最少使用顺序淘汰;SQLite 中的接口缓存不受这项内存条数限制。图片二进制保存在 AstrBot 插件数据目录,默认总容量为 512 MB,超出后按最近最少使用顺序清理。随机名片、随机语录、吃喝选择和随机贴吧等接口默认不缓存,避雷查看和避雷查询也默认不缓存最终图片;这些项目仍可在 WebUI 中显式覆盖。
## 业务流程
@@ -323,6 +342,8 @@ flowchart LR
I --> J1["纯文本"]
I --> J2["HTML 模板渲染图片"]
I --> J3["远程图片或图文消息链"]
+ E1 <--> L["内存 / SQLite 接口缓存"]
+ J2 <--> M["本地最终图片缓存"]
```
### 1. 初始化与销毁
@@ -336,7 +357,7 @@ flowchart LR
异步初始化阶段会连接数据库并创建以下本地表:
-- `bilei`:避雷记录。
+- `bilei`:按 AstrBot 会话保存避雷记录;升级前记录迁移到不可由普通会话访问的历史公共数据区。
- `event_push_subscriptions`:按 AstrBot 会话保存事件总开关和每个事件编号的订阅开关。
- `session_server_bindings`:两列结构,保存会话 ID 与绑定区服。
- `session_control_settings`:保存当前会话控制模式,首次初始化默认为 `all`。
@@ -344,6 +365,10 @@ flowchart LR
- `server_aliases`:保存标准区服名及其 JSON 别名列表。
- `kungfu`:保存 JX3BOX 配装 ID、标准心法名及最多 5 个别名。
- `trade_item_cache`:JX3BOX 交易行基础物品数据缓存及更新时间。
+- `cache_settings`:接口与图片指令的默认时间和单项覆盖配置。
+- `cache_limits`:接口内存条数和图片缓存总容量配置。
+- `api_response_cache`:JX3API 原始 JSON、创建时间、过期时间和最近访问时间。
+- `image_render_cache`:本地渲染图片文件的索引、大小、过期时间、最近访问时间及图文消息正文。
`kungfu` 表首次创建时从 `data/kungfu.json` 幂等导入 32 条默认心法,`server_aliases` 表从 `data/server_aliases.json` 幂等导入默认区服别名;已存在的本地记录均不会被覆盖。随后启动 JX3API WebSocket 事件通道并建立指令映射。插件停用时会关闭事件通道、两个 HTTP Session 和本地 SQLite 连接。
@@ -402,11 +427,14 @@ flowchart LR
- `plain_msg()`:纯文本。
- `T2I_image_msg()`:向模板注入业务数据和本地图标,再调用 AstrBot HTML 渲染器按插件配置生成图片。
- `image_msg()`:直接发送远程图片 URL 或图片数据。
-- `plain_chain()`:发送文本与图片组成的富媒体消息链;结果包含 HTML 正文时,会将其渲染为图片组件追加到消息链。
+- `plain_chain()`:直接发送业务服务返回的富媒体消息链。
+- `plain_image_msg()`:发送正文文本,并把业务服务返回的 HTML 正文渲染为附图;正文会和最终图片一起缓存。
- `handler_plain_image_msg()`:通用两轮会话;首轮函数直接返回 `{"1": "总览", "2": "杂闻"}` 形式的数字键值对(无 `code`、`msg`、`data` 外层),处理器据此生成序号文本;选择后裁剪为只含所选项的新字典交给次轮函数,再由注入的输出器发送结果;超时固定选择第一项。
- `hong()`、`zili()` 及三类聚合排行榜入口:保持统一调用结构接入上述两轮会话处理器,并按各自需要选择图文消息链或标准图片流程输出结果。
-图片默认使用 JPEG 质量 `100`、完整页面截图和 `1.3` 倍设备像素比。可以在插件配置页的“图片渲染质量”分组切换 JPEG/PNG、调整 JPEG 质量,并选择 `1.0`、`1.3`、`1.8` 设置渲染清晰度。模板可通过 `icons.img`、`icons.sect`、`icons.serendipity` 访问通用、门派/心法和奇遇图标。
+按当前 `_conf_schema.json`,图片默认使用 JPEG 质量 `100`、完整页面截图和 `1.0` 倍设备像素比。可以在插件配置页的“图片渲染质量”分组切换 JPEG/PNG、调整 JPEG 质量,并选择 `1.0`、`1.3`、`1.8` 设置渲染清晰度。模板可通过 `icons.img`、`icons.sect`、`icons.serendipity` 访问通用、门派/心法和奇遇图标。
+
+所有经 `_render_image_file()` 生成的 HTML 图片都会注入统一的数据时间页脚。JX3API 返回对象带有接口缓存创建时间时优先使用该时间;JX3BOX、本地数据库和帮助页等没有接口缓存元数据的页面使用本次数据生成时间。最终图片缓存命中时直接发送原文件,因此不会把时间错误更新为本次发送时间。时间统一按 `Asia/Shanghai` 格式化。
AstrBot 的渲染接口接收完整 HTML 字符串,因此插件不会依赖渲染端读取本地 CSS 文件。`core/template.py` 会异步读取并缓存公共布局、设计变量、基础样式和组件样式,再按需读取页面专属样式及页面片段,组装完成后把单个完整字符串交给渲染器。共享资源只在首次请求时读取一次;标准页面没有同名 CSS 文件也可以正常组合。
@@ -420,9 +448,9 @@ AstrBot 的渲染接口接收完整 HTML 字符串,因此插件不会依赖渲
样式职责如下:
- `styles/tokens.css`:颜色、间距、圆角、字体和各页面内容宽度。
-- `styles/base.css`:固定图片画布的页面背景、外框和基础排版。
+- `styles/base.css`:固定图片画布的页面背景、外框、数据时间页脚和基础排版。
- `styles/components.css`:统一维护数据表格、列状态、排行、统计卡片、可配置列数网格、技能/奇穴卡片、奇遇卡片、器物详情、标签和空数据等跨页面组件。
-- `styles/pages/*.css`:可选,仅保留成本计算、成就、副本记录、沙盘等无法合理复用的复杂页面布局。当前 47 个页面中只有 14 个需要专属 CSS。
+- `styles/pages/*.css`:可选,仅保留成本计算、成就、副本记录、沙盘等无法合理复用的复杂页面布局。当前 51 个页面中有 14 个需要专属 CSS。
沙盘坐标集中在 `templates/pages/shapan.html`。搜索 `data-castle="据点名"` 后,同一段内第一个 `
` 的 `left/top` 控制领地图层,第二个 `
` 控制据点图标,`` 控制竖排据点名;坐标原点是 1339 × 916 的 `background.png` 左上角。阵营防线的位置在 `templates/styles/pages/shapan.css` 的 `.sand-map__frontline` 中调整。修改后重新加载插件并发送 `沙盘 梦江南` 即可使用真实数据快速验证。
@@ -445,11 +473,16 @@ AstrBot 的渲染接口接收完整 HTML 字符串,因此插件不会依赖渲
| 文件 | 生命周期 | 内容 |
| --- | --- | --- |
| AstrBot 插件数据目录下的 `local_data.db` | 运行时创建和维护 | 心法与别名、避雷记录、会话控制、会话区服绑定、区服别名、事件订阅及基础数据缓存 |
+| AstrBot 插件数据目录下的 `cache/images/` | 运行时创建和维护 | HTML 渲染后的 JPEG/PNG 图片缓存,默认最大 512 MB,可在 WebUI 调整 |
| `data/kungfu.json` | 随插件分发,只在初始化时读取 | 32 条默认心法、别名和 JX3BOX 配装 ID |
| `data/server_aliases.json` | 随插件分发,只在初始化时读取 | 默认标准区服及其别名 |
`trade_item_cache` 只保存 JX3BOX 交易行物品分组快照,当前键为 `trade_item_groups`。缓存有效期为 30 天;缓存过期后优先全量刷新,上游请求失败时继续使用可解析的旧缓存兜底。升级时会从旧 `achievement_cache` 迁移交易行缓存并删除旧表,历史资历菜单和点数缓存不会继续保留。
+`api_response_cache` 仅保存通过 JX3API 查询入口成功取得的原始 JSON;Token 和 Ticket 不写入缓存键或正文,凭据摘要只用于避免更换账号后误用旧缓存。同一缓存键的并发请求通过异步锁合并。读取顺序为“内存热缓存 → SQLite → 上游接口”;两级缓存使用同一项 TTL,不存在单独的内存保留时间。内存超过 WebUI 配置的条数时只淘汰内存副本,后续仍可从 SQLite 读取。缓存过期后优先刷新,上游失败时允许使用七天内仍可解析的旧数据兜底。
+
+图片缓存不会把二进制写入 SQLite。图片消息处理器会在请求业务数据前,使用当前指令名、完整有效参数、二轮选择项、必要的会话范围、静态资源签名和截图参数生成 SHA-256 键;命中时直接把本地路径交给消息事件发送。未命中时才请求数据并渲染,再把 AstrBot 临时渲染结果复制到 `cache/images/`;SQLite 的 `image_render_cache` 只保存文件索引、大小、时间、最近访问记录和图文消息正文。超过 WebUI 配置的容量(默认 512 MB)后按最近最少使用顺序清理。
+
### 7. 实时事件推送
`core/event_push.py` 与 JX3API WebSocket 保持长连接,每 30 秒发送协议心跳。连接中断后按 1、2、4、8 秒递增重试,最大间隔为 30 秒;插件卸载时会取消心跳和重连任务并关闭连接。当前支持官方 `SocketEventMap` 中的全部 39 种事件,包含奇遇、马驹、扶摇、烟花、的卢、玄晶、阵营、宣战、据点、攻防拍卖与免费资讯事件。
@@ -475,6 +508,7 @@ astrbot_plugin_jx3/
├── core/
│ ├── jx3api_data.py # JX3API 业务服务
│ ├── jx3box_data.py # JX3BOX 业务服务与缓存逻辑
+│ ├── cache.py # JX3API JSON 与渲染图片缓存、时间配置和清理
│ ├── message.py # 文本、图片、消息链和两轮会话构建
│ ├── request.py # aiohttp 请求封装
│ ├── event_push.py # WebSocket 事件通道与会话订阅
@@ -492,12 +526,12 @@ astrbot_plugin_jx3/
├── layouts/
│ └── base.html # 唯一的完整 HTML 文档骨架
├── pages/
- │ └── *.html # 47 个页面内容与 Jinja2 数据绑定
+ │ └── *.html # 51 个页面内容与 Jinja2 数据绑定
├── styles/
│ ├── tokens.css # 设计变量与页面宽度
│ ├── base.css # 全局背景、外框和排版
│ ├── components.css # 表格、网格、卡片、状态等公共组件
- │ └── pages/*.css # 可选的复杂页面独有布局(当前 13 个)
+ │ └── pages/*.css # 可选的复杂页面独有布局(当前 14 个)
├── img/ # 通用图片资源
├── sect/ # 门派与心法图标
└── serendipity/ # 奇遇图标
@@ -527,17 +561,21 @@ git diff --check
## 当前版本状态
-以下内容是对 v3.4.5 当前源码的静态核对结果,部署和二次开发前应注意:
+以下内容是对 v3.4.6 当前源码的静态核对结果,部署和二次开发前应注意:
1. 查询图片使用浅色高对比主题和放大的内容区域;渲染清晰度、JPEG/PNG 格式及 JPEG 质量由 `image_render_quality` 配置组控制,提高清晰度或使用 PNG 会增加图片体积与渲染耗时。
-2. `帮会排行`、`阵营排行`、`其他排行` 使用固定的“数字序号 → 榜单名称”首轮菜单,次轮查询统一截取接口返回的前 50 条;取得有效选择后会先停止超时计时器,避免渲染期间重复输出。
-3. 区服绑定 WebUI 的新增及行内编辑均只接受标准区服,聊天指令仍可使用区服别名;区服别名页展示完整标准区服目录并只允许行内编辑别名,心法别名页隐藏配装 ID,后端保存接口也只更新已有心法的别名。“恢复默认”使用页面内二次点击确认,确认后会用随包 JSON 完整覆盖对应数据库表,当前自定义别名无法从页面撤销恢复。
-4. 会话控制默认放行全部会话;切换白名单后只有白名单记录可执行插件指令及接收推送,切换黑名单后仅拦截黑名单记录。名单记录会保留在数据库中,切换模式不会删除已有配置。
-5. aiocqhttp 图片发送返回 `retcode=1200` 时只记录警告,不再追加“猪脑过载”提示;该回执代表发送结果不确定,仍建议结合平台日志确认实际送达情况。
-6. 带区服参数的指令在入口层统一进行会话绑定补齐和别名解析;新增此类指令时参数名应继续使用 `server`。
-7. 资历分布使用固定的“数字序号 → 大类名称”首轮菜单,通用会话裁剪出单项字典后交给 JX3API `/tuilan/achievement` 次轮查询;模板兼容总览与指定 `subclass` 的不同 `data.total` 层级,并直接使用接口角色字段、`pieces`、`seniority` 和 `score/totalScore` 渲染,JX3BOX 不再参与资历查询。
-8. `APIClient` 当前默认 `ssl_verify=False`,即外部 HTTPS 请求不校验证书。对传输安全有要求的部署应先评估并调整该设置。
-9. JX3API 服务初始化时会把 Token 和 Ticket 写入 debug 日志。不要公开调试日志,建议二次开发时移除敏感值输出。
+2. 所有 HTML 渲染图片底部都会显示数据时间;最终图片缓存命中后保留原时间并跳过接口请求和重新渲染。要让下一次查询同时获取最新上游数据并重新生成图片,需要一并清除或关闭对应接口缓存和图片缓存。
+3. `帮会排行`、`阵营排行`、`其他排行` 使用固定的“数字序号 → 榜单名称”首轮菜单,次轮查询统一截取接口返回的前 50 条;取得有效选择后会先停止超时计时器,避免渲染期间重复输出。
+4. 区服绑定 WebUI 的新增及行内编辑均只接受标准区服,聊天指令仍可使用区服别名;区服别名页展示完整标准区服目录并只允许行内编辑别名,心法别名页隐藏配装 ID,后端保存接口也只更新已有心法的别名。“恢复默认”使用页面内二次点击确认,确认后会用随包 JSON 完整覆盖对应数据库表,当前自定义别名无法从页面撤销恢复。
+5. 普通打开或刷新 WebUI 不会刷新远程区服目录;初始化插件时会先读取 `/server/status/check` 的接口缓存,缓存不可用才请求上游,点击“刷新区服列表”则会强制请求上游。WebUI 顶部 Token 统计最多每 30 秒请求一次 `/token/stats`。
+6. 会话控制默认放行全部会话;切换白名单后只有白名单记录可执行插件指令及接收推送,切换黑名单后仅拦截黑名单记录。名单记录会保留在数据库中,切换模式不会删除已有配置。
+7. 避雷记录按当前消息的 `unified_msg_origin` 隔离;升级前的无会话归属记录保留在普通会话不可访问的历史公共数据区。
+8. `决斗挑战` 当前处理器把模式声明为整数但使用中文字符串映射,实际输入无法切换到私密模式并会回退为公开;修正处理器类型前不应在外部说明中宣称私密模式可用。
+9. aiocqhttp 图片发送返回 `retcode=1200` 时只记录警告,不再追加“猪脑过载”提示;该回执代表发送结果不确定,仍建议结合平台日志确认实际送达情况。
+10. 带区服参数的指令在入口层统一进行会话绑定补齐和别名解析;新增此类指令时参数名应继续使用 `server`。
+11. 资历分布使用固定的“数字序号 → 大类名称”首轮菜单,通用会话裁剪出单项字典后交给 JX3API `/tuilan/achievement` 次轮查询;模板兼容总览与指定 `subclass` 的不同 `data.total` 层级,并直接使用接口角色字段、`pieces`、`seniority` 和 `score/totalScore` 渲染,JX3BOX 不再参与资历查询。
+12. `APIClient` 当前默认 `ssl_verify=False`,即外部 HTTPS 请求不校验证书。对传输安全有要求的部署应先评估并调整该设置。
+13. debug 日志会记录请求 Query、Body 和完整响应,其中可能包含 Token、Ticket 或其他敏感字段;不要公开原始调试日志。
## 注意事项
diff --git a/_conf_schema.json b/_conf_schema.json
index b8a852c..73bf902 100644
--- a/_conf_schema.json
+++ b/_conf_schema.json
@@ -13,7 +13,7 @@
"description": "前缀内容",
"type": "string",
"default": "剑三",
- "hint": "为了避免和其他插件的指令冲突可以增加指令的前缀,为空默认值为剑三。"
+ "hint": "为了避免和其他插件的指令冲突可以增加指令前缀;开启后内容为空或只有空格时按未开启前缀处理。"
}
}
},
diff --git a/core/bilei_data.py b/core/bilei_data.py
index 2b7c478..af5513e 100644
--- a/core/bilei_data.py
+++ b/core/bilei_data.py
@@ -4,17 +4,137 @@
# pyright: reportOptionalMemberAccess=false
from datetime import datetime
-from typing import Dict, Any, Optional, List, Union
+from typing import Any, Dict
from astrbot.api import logger
-from astrbot.api import AstrBotConfig
from .sqlite import AsyncSQLiteDB
from .fun_basic import load_template
+
+
class BiLeidata:
+ """按 AstrBot 会话隔离存储本地避雷记录。"""
+
+ LEGACY_SESSION_ID = "__legacy_public__"
+
def __init__(self, sqlite:AsyncSQLiteDB):
# 引用sqlite
self._sql_db = sqlite
+
+ async def initialize(self):
+ """创建避雷表,并把升级前的数据迁移到历史公共数据区。"""
+ table = await self._sql_db.fetch_one(
+ "SELECT name FROM sqlite_master WHERE type='table' AND name=?",
+ ("bilei",),
+ )
+ if not table:
+ await self._sql_db.execute(
+ """
+ CREATE TABLE bilei (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ session_id TEXT NOT NULL,
+ name TEXT,
+ text TEXT,
+ time TEXT,
+ user TEXT
+ )
+ """
+ )
+ else:
+ columns = await self._sql_db.fetch_all("PRAGMA table_info(bilei)")
+ if "session_id" not in {str(column["name"]) for column in columns}:
+ legacy_count_row = await self._sql_db.fetch_one(
+ "SELECT COUNT(*) AS count FROM bilei"
+ )
+ legacy_count = int((legacy_count_row or {}).get("count", 0))
+ await self._sql_db.execute_transaction(
+ [
+ (
+ """
+ CREATE TABLE bilei_session_migration (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ session_id TEXT NOT NULL,
+ name TEXT,
+ text TEXT,
+ time TEXT,
+ user TEXT
+ )
+ """,
+ (),
+ ),
+ (
+ """
+ INSERT INTO bilei_session_migration (
+ id, session_id, name, text, time, user
+ )
+ SELECT id, ?, name, text, time, user FROM bilei
+ """,
+ (self.LEGACY_SESSION_ID,),
+ ),
+ ("DROP TABLE bilei", ()),
+ ("ALTER TABLE bilei_session_migration RENAME TO bilei", ()),
+ ]
+ )
+ logger.info(
+ f"已将 {legacy_count} 条旧避雷记录迁移到历史公共数据区"
+ )
+
+ await self._sql_db.execute(
+ """
+ CREATE INDEX IF NOT EXISTS idx_bilei_session_id_id
+ ON bilei(session_id, id)
+ """
+ )
+
+ @classmethod
+ def _normalize_session_id(cls, session_id: Any) -> str:
+ value = str(session_id or "").strip()
+ if not value:
+ raise ValueError("会话 ID 不能为空")
+ if len(value) > 512:
+ raise ValueError("会话 ID 不能超过 512 个字符")
+ if value == cls.LEGACY_SESSION_ID:
+ raise ValueError("历史公共数据区不能作为普通会话访问")
+ return value
+
+ async def list_legacy_records(self) -> list[Dict[str, Any]]:
+ """列出等待从历史公共数据区迁出的旧版记录。"""
+ return await self._sql_db.fetch_all(
+ """
+ SELECT id, name, text, time, user
+ FROM bilei
+ WHERE session_id=?
+ ORDER BY id
+ """,
+ (self.LEGACY_SESSION_ID,),
+ )
+
+ async def migrate_legacy_record(
+ self,
+ record_id: Any,
+ target_session_id: Any,
+ ) -> None:
+ """把一条历史记录原子地分配给指定的普通会话。"""
+ if isinstance(record_id, bool):
+ raise ValueError("避雷记录 ID 无效")
+ try:
+ normalized_record_id = int(str(record_id).strip())
+ except (TypeError, ValueError):
+ raise ValueError("避雷记录 ID 无效") from None
+ if normalized_record_id <= 0:
+ raise ValueError("避雷记录 ID 无效")
+
+ session_id = self._normalize_session_id(target_session_id)
+ affected = await self._sql_db.execute_affected(
+ """
+ UPDATE bilei
+ SET session_id=?
+ WHERE id=? AND session_id=?
+ """,
+ (session_id, normalized_record_id, self.LEGACY_SESSION_ID),
+ )
+ if affected != 1:
+ raise ValueError("该历史避雷记录不存在或已完成迁移")
def _init_return_data(self) -> Dict[str, Any]:
"""初始化标准的返回数据结构"""
@@ -26,9 +146,16 @@ class BiLeidata:
# --- 业务功能函数 ---
- async def add(self,name: str, text: str ,user: str) -> Dict[str, Any]:
+ async def add(
+ self,
+ session_id: Any,
+ name: str,
+ text: str,
+ user: str,
+ ) -> Dict[str, Any]:
"""避雷添加"""
return_data = self._init_return_data()
+ session_id = self._normalize_session_id(session_id)
# 获取系统时间
now = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
@@ -38,6 +165,7 @@ class BiLeidata:
await self._sql_db.insert(
"bilei",
{
+ "session_id": session_id,
"name": name,
"text": text,
"time": now,
@@ -63,21 +191,30 @@ class BiLeidata:
return return_data
- async def all(self) -> Dict[str, Any]:
+ async def all(self, session_id: Any) -> Dict[str, Any]:
"""避雷查看"""
return_data = self._init_return_data()
+ session_id = self._normalize_session_id(session_id)
# 查询数据
try:
- data = await self._sql_db.select_all("bilei")
+ data = await self._sql_db.fetch_all(
+ """
+ SELECT id, name, text, time, user
+ FROM bilei
+ WHERE session_id=?
+ ORDER BY id
+ """,
+ (session_id,),
+ )
except FileNotFoundError as e:
logger.error(f"查看避雷失败: {e}")
return_data["msg"] = "查看避雷失败"
return return_data
if not data:
- return_data["msg"] = "未找到避雷数据"
+ return_data["msg"] = "当前会话暂无避雷数据"
return return_data
@@ -97,18 +234,23 @@ class BiLeidata:
return return_data
- async def select(self, name:str) -> Dict[str, Any]:
+ async def select(self, session_id: Any, name: str) -> Dict[str, Any]:
"""避雷查询 名称"""
return_data = self._init_return_data()
+ session_id = self._normalize_session_id(session_id)
# 模糊拼接
like_name = f"%{name}%"
# 查询数据
try:
- data = await self._sql_db.select_all(
- "bilei",
- "name LIKE ?",
- (like_name,)
+ data = await self._sql_db.fetch_all(
+ """
+ SELECT id, name, text, time, user
+ FROM bilei
+ WHERE session_id=? AND name LIKE ?
+ ORDER BY id
+ """,
+ (session_id, like_name),
)
except FileNotFoundError as e:
logger.error(f"查询避雷失败: {e}")
@@ -116,7 +258,7 @@ class BiLeidata:
return return_data
if not data:
- return_data["msg"] = "未查询到避雷数据"
+ return_data["msg"] = "当前会话未查询到避雷数据"
return return_data
@@ -136,18 +278,26 @@ class BiLeidata:
return return_data
- async def update(self, id:int, name: str, text: str ,user: str) -> Dict[str, Any]:
+ async def update(
+ self,
+ session_id: Any,
+ id: int,
+ name: str,
+ text: str,
+ user: str,
+ ) -> Dict[str, Any]:
"""避雷修改 ID 名称 备注"""
return_data = self._init_return_data()
+ session_id = self._normalize_session_id(session_id)
data = await self._sql_db.select_one(
"bilei",
- "id=?",
- (id,)
+ "session_id=? AND id=?",
+ (session_id, id),
)
if not data:
- return_data["msg"] = "没有当前ID"
+ return_data["msg"] = "当前会话中不存在该避雷记录"
return return_data
# 获取系统时间
@@ -163,8 +313,8 @@ class BiLeidata:
"time": now,
"user": user,
},
- "id=?",
- (id,)
+ "session_id=? AND id=?",
+ (session_id, id),
)
except FileNotFoundError as e:
@@ -186,26 +336,27 @@ class BiLeidata:
return return_data
- async def delete(self, id:int) -> Dict[str, Any]:
+ async def delete(self, session_id: Any, id: int) -> Dict[str, Any]:
"""避雷删除 ID"""
return_data = self._init_return_data()
+ session_id = self._normalize_session_id(session_id)
data = await self._sql_db.select_one(
"bilei",
- "id=?",
- (id,)
+ "session_id=? AND id=?",
+ (session_id, id),
)
if not data:
- return_data["msg"] = "没有当前ID"
+ return_data["msg"] = "当前会话中不存在该避雷记录"
return return_data
# 删除
try:
await self._sql_db.delete(
"bilei",
- "id=?",
- (id,)
+ "session_id=? AND id=?",
+ (session_id, id),
)
except FileNotFoundError as e:
@@ -217,4 +368,4 @@ class BiLeidata:
return_data["code"] = 200
- return return_data
\ No newline at end of file
+ return return_data
diff --git a/core/cache.py b/core/cache.py
new file mode 100644
index 0000000..a3c326c
--- /dev/null
+++ b/core/cache.py
@@ -0,0 +1,916 @@
+from __future__ import annotations
+
+import asyncio
+import hashlib
+import json
+import os
+import shutil
+import time
+import weakref
+from collections import OrderedDict
+from collections.abc import Awaitable, Callable, Iterable
+from contextvars import ContextVar, Token
+from pathlib import Path
+from typing import Any
+
+from astrbot.api import logger
+
+from .sqlite import AsyncSQLiteDB
+
+API_ENDPOINTS: tuple[str, ...] = (
+ "/active/calendar",
+ "/active/celebs",
+ "/arena/awesome",
+ "/arena/recent",
+ "/arena/schools",
+ "/auction/records",
+ "/battle/records",
+ "/card/cached",
+ "/card/random",
+ "/card/records",
+ "/castle/status",
+ "/chat/records",
+ "/chitu/records",
+ "/chitu/week/records",
+ "/duowan/statistics",
+ "/event/collect",
+ "/event/missing",
+ "/event/recent",
+ "/event/records",
+ "/event/statistics",
+ "/exam/search",
+ "/fenxian/records",
+ "/firework/records",
+ "/food/list",
+ "/fraud/detail",
+ "/home/flower",
+ "/home/furniture",
+ "/home/travel",
+ "/mech/decrypt",
+ "/mentor/search",
+ "/monster/records",
+ "/monster/weekly",
+ "/news/announce",
+ "/news/records",
+ "/raid/records",
+ "/ranch/chat",
+ "/ranch/records",
+ "/rank/arena",
+ "/rank/championship",
+ "/rank/constable",
+ "/rank/outlaw",
+ "/rank/statistics",
+ "/rank/trials",
+ "/rank/wanted",
+ "/recruit/search",
+ "/reward/statistics",
+ "/role/achievement",
+ "/role/detail",
+ "/sand/records",
+ "/saohua/answer",
+ "/saohua/content",
+ "/saohua/context",
+ "/saohua/drink",
+ "/saohua/eat",
+ "/saohua/random",
+ "/saohua/zhanan",
+ "/school/matrix",
+ "/school/seniority",
+ "/school/skills",
+ "/school/talent",
+ "/server/status/check",
+ "/skill/rework",
+ "/steed/records",
+ "/tieba/item/records",
+ "/tieba/random",
+ "/trade/demon",
+ "/trade/manufacture",
+ "/trade/records",
+ "/trade/wanbaolou",
+ "/tuilan/achievement",
+ "/wicked/records",
+)
+
+
+class CacheService:
+ """持久化接口 JSON 与 HTML 渲染图片,并提供 WebUI 配置。"""
+
+ DEFAULT_API_TTL = 300
+ DEFAULT_IMAGE_TTL = 600
+ MAX_TTL_SECONDS = 30 * 24 * 60 * 60
+ DEFAULT_MAX_MEMORY_ENTRIES = 256
+ DEFAULT_MAX_IMAGE_BYTES = 512 * 1024 * 1024
+ MAX_MEMORY_ENTRIES_LIMIT = 100_000
+ MAX_IMAGE_MB_LIMIT = 10_240
+ STALE_RETENTION_SECONDS = 7 * 24 * 60 * 60
+ _SENSITIVE_KEYS = frozenset(
+ {"token", "ticket", "authorization", "access_token", "jx3api_token"}
+ )
+ _NO_CACHE_API_DEFAULTS = frozenset(
+ {
+ "/card/random",
+ "/saohua/answer",
+ "/saohua/content",
+ "/saohua/context",
+ "/saohua/drink",
+ "/saohua/eat",
+ "/saohua/random",
+ "/saohua/zhanan",
+ "/tieba/random",
+ }
+ )
+
+ def __init__(
+ self,
+ sqlite: AsyncSQLiteDB,
+ image_dir: Path,
+ asset_roots: Iterable[Path] = (),
+ ):
+ self._sqlite = sqlite
+ self.image_dir = Path(image_dir)
+ self._settings: dict[tuple[str, str], int] = {}
+ self.max_memory_entries = self.DEFAULT_MAX_MEMORY_ENTRIES
+ self.max_image_bytes = self.DEFAULT_MAX_IMAGE_BYTES
+ self._memory: OrderedDict[str, tuple[int, int, str]] = OrderedDict()
+ self._api_locks: weakref.WeakValueDictionary[str, asyncio.Lock] = (
+ weakref.WeakValueDictionary()
+ )
+ self._image_locks: weakref.WeakValueDictionary[str, asyncio.Lock] = (
+ weakref.WeakValueDictionary()
+ )
+ self._command_context: ContextVar[tuple[str, str]] = ContextVar(
+ "jx3_cache_command_context",
+ default=("", ""),
+ )
+ self._image_names: set[str] = set()
+ self._asset_signature = self._build_asset_signature(asset_roots)
+
+ @staticmethod
+ def _build_asset_signature(roots: Iterable[Path]) -> str:
+ parts: list[str] = []
+ for root in roots:
+ path = Path(root)
+ if not path.exists():
+ continue
+ for item in sorted(
+ candidate for candidate in path.rglob("*") if candidate.is_file()
+ ):
+ try:
+ stat = item.stat()
+ except OSError:
+ continue
+ parts.append(
+ f"{item.relative_to(path)}:{stat.st_size}:{stat.st_mtime_ns}"
+ )
+ return hashlib.sha256("|".join(parts).encode("utf-8")).hexdigest()
+
+ async def initialize(self):
+ self.image_dir.mkdir(parents=True, exist_ok=True)
+ await self._sqlite.execute(
+ """
+ CREATE TABLE IF NOT EXISTS cache_settings(
+ cache_type TEXT NOT NULL,
+ cache_name TEXT NOT NULL,
+ ttl_seconds INTEGER NOT NULL,
+ PRIMARY KEY(cache_type, cache_name)
+ )
+ """
+ )
+ await self._sqlite.execute(
+ """
+ CREATE TABLE IF NOT EXISTS cache_limits(
+ limit_name TEXT PRIMARY KEY,
+ limit_value INTEGER NOT NULL
+ )
+ """
+ )
+ await self._sqlite.execute(
+ """
+ CREATE TABLE IF NOT EXISTS api_response_cache(
+ cache_key TEXT PRIMARY KEY,
+ endpoint TEXT NOT NULL,
+ payload TEXT NOT NULL,
+ created_at INTEGER NOT NULL,
+ expires_at INTEGER NOT NULL,
+ last_accessed_at INTEGER NOT NULL
+ )
+ """
+ )
+ await self._sqlite.execute(
+ """
+ CREATE TABLE IF NOT EXISTS image_render_cache(
+ cache_key TEXT PRIMARY KEY,
+ cache_name TEXT NOT NULL,
+ file_name TEXT NOT NULL,
+ size_bytes INTEGER NOT NULL,
+ created_at INTEGER NOT NULL,
+ expires_at INTEGER NOT NULL,
+ last_accessed_at INTEGER NOT NULL,
+ message_text TEXT NOT NULL DEFAULT ''
+ )
+ """
+ )
+ image_columns = await self._sqlite.fetch_all(
+ "PRAGMA table_info(image_render_cache)"
+ )
+ if "message_text" not in {str(row.get("name")) for row in image_columns}:
+ await self._sqlite.execute(
+ """
+ ALTER TABLE image_render_cache
+ ADD COLUMN message_text TEXT NOT NULL DEFAULT ''
+ """
+ )
+ await self._sqlite.execute(
+ "CREATE INDEX IF NOT EXISTS idx_api_cache_endpoint ON api_response_cache(endpoint)"
+ )
+ await self._sqlite.execute(
+ "CREATE INDEX IF NOT EXISTS idx_image_cache_name ON image_render_cache(cache_name)"
+ )
+ await self._load_settings()
+ await self._load_limits()
+ await self.cleanup_expired()
+ self._enforce_memory_limit()
+ await self._enforce_image_limit()
+
+ async def _load_settings(self):
+ rows = await self._sqlite.select_all("cache_settings")
+ self._settings = {
+ (str(row["cache_type"]), str(row["cache_name"])): int(row["ttl_seconds"])
+ for row in rows
+ }
+
+ async def _load_limits(self):
+ rows = await self._sqlite.select_all("cache_limits")
+ limits = {str(row["limit_name"]): int(row["limit_value"]) for row in rows}
+ self.max_memory_entries = self._validated_memory_limit(
+ limits.get("api_memory_entries", self.DEFAULT_MAX_MEMORY_ENTRIES)
+ )
+ image_limit_mb = self._validated_image_limit_mb(
+ limits.get("image_max_mb", self.DEFAULT_MAX_IMAGE_BYTES // 1024 // 1024)
+ )
+ self.max_image_bytes = image_limit_mb * 1024 * 1024
+
+ @classmethod
+ def _validated_memory_limit(cls, value: Any) -> int:
+ if isinstance(value, bool):
+ raise ValueError("接口内存缓存条数必须是整数")
+ try:
+ limit = int(value)
+ except (TypeError, ValueError) as exc:
+ raise ValueError("接口内存缓存条数必须是整数") from exc
+ if limit < 1 or limit > cls.MAX_MEMORY_ENTRIES_LIMIT:
+ raise ValueError("接口内存缓存条数必须在 1 到 100000 之间")
+ return limit
+
+ @classmethod
+ def _validated_image_limit_mb(cls, value: Any) -> int:
+ if isinstance(value, bool):
+ raise ValueError("图片缓存容量必须是整数 MB")
+ try:
+ limit = int(value)
+ except (TypeError, ValueError) as exc:
+ raise ValueError("图片缓存容量必须是整数 MB") from exc
+ if limit < 1 or limit > cls.MAX_IMAGE_MB_LIMIT:
+ raise ValueError("图片缓存容量必须在 1 到 10240 MB 之间")
+ return limit
+
+ async def set_limits(self, api_memory_entries: Any, image_max_mb: Any):
+ memory_limit = self._validated_memory_limit(api_memory_entries)
+ image_limit_mb = self._validated_image_limit_mb(image_max_mb)
+ for limit_name, limit_value in (
+ ("api_memory_entries", memory_limit),
+ ("image_max_mb", image_limit_mb),
+ ):
+ await self._sqlite.execute(
+ """
+ INSERT INTO cache_limits(limit_name, limit_value)
+ VALUES(?, ?)
+ ON CONFLICT(limit_name) DO UPDATE SET
+ limit_value=excluded.limit_value
+ """,
+ (limit_name, limit_value),
+ )
+ self.max_memory_entries = memory_limit
+ self.max_image_bytes = image_limit_mb * 1024 * 1024
+ self._enforce_memory_limit()
+ await self._enforce_image_limit()
+
+ def register_image_names(self, names: Iterable[str]):
+ self._image_names.update(
+ str(name).strip() for name in names if str(name).strip()
+ )
+
+ def enter_command(self, command_name: str, args: Iterable[Any] = ()) -> Token:
+ argument_signature = hashlib.sha256(
+ self._json(list(args)).encode("utf-8")
+ ).hexdigest()
+ return self._command_context.set(
+ (str(command_name or "").strip(), argument_signature)
+ )
+
+ def leave_command(self, token: Token):
+ self._command_context.reset(token)
+
+ def current_command(self) -> str:
+ return self._command_context.get()[0]
+
+ def current_command_signature(self) -> str:
+ return self._command_context.get()[1]
+
+ def _base_ttl(self, cache_type: str, cache_name: str) -> int:
+ if cache_type == "api" and cache_name in self._NO_CACHE_API_DEFAULTS:
+ return 0
+ # 会话避雷图片默认不缓存,避免修改记录后仍展示旧图;仍可在 WebUI 单独开启。
+ if cache_type == "image" and cache_name in {"避雷查看", "避雷查询"}:
+ return 0
+ return self.DEFAULT_API_TTL if cache_type == "api" else self.DEFAULT_IMAGE_TTL
+
+ def get_ttl(self, cache_type: str, cache_name: str) -> int:
+ specific = self._settings.get((cache_type, cache_name))
+ if specific is not None:
+ return specific
+ if cache_type == "api" and cache_name in self._NO_CACHE_API_DEFAULTS:
+ return 0
+ if cache_type == "image" and cache_name in {"避雷查看", "避雷查询"}:
+ return 0
+ default = self._settings.get((cache_type, "*"))
+ if default is not None:
+ return default
+ return self._base_ttl(cache_type, cache_name)
+
+ @classmethod
+ def _validate_ttl(cls, value: Any) -> int:
+ if isinstance(value, bool):
+ raise ValueError("缓存时间必须是整数秒")
+ try:
+ ttl = int(value)
+ except (TypeError, ValueError) as exc:
+ raise ValueError("缓存时间必须是整数秒") from exc
+ if ttl < 0 or ttl > cls.MAX_TTL_SECONDS:
+ raise ValueError("缓存时间必须在 0 到 2592000 秒之间")
+ return ttl
+
+ async def set_ttl(
+ self,
+ cache_type: str,
+ cache_name: str,
+ ttl_seconds: Any = None,
+ inherit: bool = False,
+ ):
+ if cache_type not in {"api", "image"}:
+ raise ValueError("缓存类型仅支持 api 或 image")
+ cache_name = str(cache_name or "").strip()
+ if not cache_name:
+ raise ValueError("缓存项目不能为空")
+ if cache_name == "*" and inherit:
+ raise ValueError("默认缓存时间不能继承")
+
+ if inherit:
+ await self._sqlite.delete(
+ "cache_settings",
+ "cache_type=? AND cache_name=?",
+ (cache_type, cache_name),
+ )
+ self._settings.pop((cache_type, cache_name), None)
+ return
+
+ ttl = self._validate_ttl(ttl_seconds)
+ await self._sqlite.execute(
+ """
+ INSERT INTO cache_settings(cache_type, cache_name, ttl_seconds)
+ VALUES(?, ?, ?)
+ ON CONFLICT(cache_type, cache_name) DO UPDATE SET
+ ttl_seconds=excluded.ttl_seconds
+ """,
+ (cache_type, cache_name, ttl),
+ )
+ self._settings[(cache_type, cache_name)] = ttl
+
+ @classmethod
+ def _normalized(cls, value: Any, strip_sensitive: bool = False) -> Any:
+ if isinstance(value, dict):
+ return {
+ str(key): cls._normalized(item, strip_sensitive)
+ for key, item in sorted(value.items(), key=lambda pair: str(pair[0]))
+ if not strip_sensitive or str(key).lower() not in cls._SENSITIVE_KEYS
+ }
+ if isinstance(value, (list, tuple)):
+ return [cls._normalized(item, strip_sensitive) for item in value]
+ if isinstance(value, (str, int, float, bool)) or value is None:
+ return value
+ return str(value)
+
+ @classmethod
+ def _json(cls, value: Any, strip_sensitive: bool = False) -> str:
+ return json.dumps(
+ cls._normalized(value, strip_sensitive),
+ ensure_ascii=False,
+ sort_keys=True,
+ separators=(",", ":"),
+ )
+
+ @classmethod
+ def build_api_key(cls, endpoint: str, params: dict[str, Any]) -> str:
+ source = f"api:v1|{endpoint}|{cls._json(params, strip_sensitive=True)}"
+ return hashlib.sha256(source.encode("utf-8")).hexdigest()
+
+ async def _read_api_payload(
+ self,
+ cache_key: str,
+ endpoint: str,
+ allow_expired: bool = False,
+ ) -> tuple[Any | None, int | None, int | None]:
+ now = int(time.time())
+ ttl = self.get_ttl("api", endpoint)
+ memory = self._memory.get(cache_key)
+ if memory is not None:
+ created_at, expires_at, payload = memory
+ effective_expiry = min(expires_at, created_at + ttl)
+ if allow_expired and effective_expiry <= now - self.STALE_RETENTION_SECONDS:
+ self._memory.pop(cache_key, None)
+ elif allow_expired or effective_expiry > now:
+ self._memory.move_to_end(cache_key)
+ try:
+ return json.loads(payload), effective_expiry, created_at
+ except json.JSONDecodeError:
+ self._memory.pop(cache_key, None)
+
+ row = await self._sqlite.fetch_one(
+ """
+ SELECT payload, created_at, expires_at
+ FROM api_response_cache
+ WHERE cache_key=? AND endpoint=?
+ """,
+ (cache_key, endpoint),
+ )
+ if not row:
+ return None, None, None
+ expires_at = int(row["expires_at"])
+ created_at = int(row["created_at"])
+ effective_expiry = min(expires_at, created_at + ttl)
+ if not allow_expired and effective_expiry <= now:
+ return None, effective_expiry, created_at
+ if allow_expired and effective_expiry <= now - self.STALE_RETENTION_SECONDS:
+ await self._sqlite.delete("api_response_cache", "cache_key=?", (cache_key,))
+ return None, effective_expiry, created_at
+ try:
+ data = json.loads(str(row["payload"]))
+ except json.JSONDecodeError:
+ await self._sqlite.delete("api_response_cache", "cache_key=?", (cache_key,))
+ return None, None, None
+
+ self._remember(cache_key, created_at, expires_at, str(row["payload"]))
+ await self._sqlite.execute(
+ "UPDATE api_response_cache SET last_accessed_at=? WHERE cache_key=?",
+ (now, cache_key),
+ )
+ return data, effective_expiry, created_at
+
+ def _remember(self, cache_key: str, created_at: int, expires_at: int, payload: str):
+ self._memory[cache_key] = (created_at, expires_at, payload)
+ self._memory.move_to_end(cache_key)
+ self._enforce_memory_limit()
+
+ def _enforce_memory_limit(self):
+ while len(self._memory) > self.max_memory_entries:
+ self._memory.popitem(last=False)
+
+ async def _save_api_payload(
+ self,
+ cache_key: str,
+ endpoint: str,
+ data: Any,
+ ttl_seconds: int,
+ ) -> int:
+ payload = self._json(data)
+ now = int(time.time())
+ expires_at = now + ttl_seconds
+ await self._sqlite.execute(
+ """
+ INSERT INTO api_response_cache(
+ cache_key, endpoint, payload, created_at, expires_at, last_accessed_at
+ ) VALUES(?, ?, ?, ?, ?, ?)
+ ON CONFLICT(cache_key) DO UPDATE SET
+ endpoint=excluded.endpoint,
+ payload=excluded.payload,
+ created_at=excluded.created_at,
+ expires_at=excluded.expires_at,
+ last_accessed_at=excluded.last_accessed_at
+ """,
+ (cache_key, endpoint, payload, now, expires_at, now),
+ )
+ self._remember(cache_key, now, expires_at, payload)
+ return now
+
+ async def request_api(
+ self,
+ endpoint: str,
+ params: dict[str, Any],
+ requester: Callable[[], Awaitable[Any]],
+ is_cacheable: Callable[[Any], bool],
+ force_refresh: bool = False,
+ allow_stale: bool = True,
+ ) -> tuple[Any, dict[str, Any]]:
+ ttl = self.get_ttl("api", endpoint)
+ cache_key = self.build_api_key(endpoint, params)
+ metadata = {
+ "endpoint": endpoint,
+ "cache_key": cache_key,
+ "hit": False,
+ "stale": False,
+ "ttl_seconds": ttl,
+ "data_hash": "",
+ "created_at": None,
+ }
+ if ttl <= 0:
+ data = await requester()
+ metadata["created_at"] = int(time.time())
+ if is_cacheable(data):
+ metadata["data_hash"] = hashlib.sha256(
+ self._json(data).encode("utf-8")
+ ).hexdigest()
+ return data, metadata
+
+ if not force_refresh:
+ cached, _, created_at = await self._read_api_payload(cache_key, endpoint)
+ if cached is not None:
+ metadata["hit"] = True
+ metadata["created_at"] = created_at
+ metadata["data_hash"] = hashlib.sha256(
+ self._json(cached).encode("utf-8")
+ ).hexdigest()
+ return cached, metadata
+
+ lock = self._api_locks.setdefault(cache_key, asyncio.Lock())
+ async with lock:
+ if not force_refresh:
+ cached, _, created_at = await self._read_api_payload(cache_key, endpoint)
+ if cached is not None:
+ metadata["hit"] = True
+ metadata["created_at"] = created_at
+ metadata["data_hash"] = hashlib.sha256(
+ self._json(cached).encode("utf-8")
+ ).hexdigest()
+ return cached, metadata
+
+ stale, _, stale_created_at = await self._read_api_payload(
+ cache_key,
+ endpoint,
+ allow_expired=True,
+ )
+ data = await requester()
+ if is_cacheable(data):
+ metadata["data_hash"] = hashlib.sha256(
+ self._json(data).encode("utf-8")
+ ).hexdigest()
+ try:
+ metadata["created_at"] = await self._save_api_payload(
+ cache_key,
+ endpoint,
+ data,
+ ttl,
+ )
+ except Exception as exc:
+ metadata["created_at"] = int(time.time())
+ logger.warning(f"写入接口缓存失败 endpoint={endpoint}: {exc}")
+ return data, metadata
+ if stale is not None and allow_stale:
+ metadata["hit"] = True
+ metadata["stale"] = True
+ metadata["created_at"] = stale_created_at
+ metadata["data_hash"] = hashlib.sha256(
+ self._json(stale).encode("utf-8")
+ ).hexdigest()
+ logger.warning(f"JX3API 请求失败,使用过期缓存:{endpoint}")
+ return stale, metadata
+ return data, metadata
+
+ def build_image_key(
+ self,
+ cache_name: str,
+ template: str,
+ data: dict[str, Any],
+ render_options: dict[str, Any],
+ source_signature: str = "",
+ variant_signature: str = "",
+ ) -> str:
+ source = "|".join(
+ (
+ "image:v2",
+ cache_name,
+ hashlib.sha256(template.encode("utf-8")).hexdigest(),
+ source_signature
+ or hashlib.sha256(self._json(data).encode("utf-8")).hexdigest(),
+ variant_signature,
+ self._json(render_options),
+ self._asset_signature,
+ )
+ )
+ return hashlib.sha256(source.encode("utf-8")).hexdigest()
+
+ @classmethod
+ def value_signature(cls, value: Any) -> str:
+ return hashlib.sha256(cls._json(value).encode("utf-8")).hexdigest()
+
+ def build_image_request_key(
+ self,
+ cache_name: str,
+ render_options: dict[str, Any],
+ variant_signature: str,
+ scope_signature: str = "",
+ ) -> str:
+ """生成可在请求接口前计算的最终图片缓存键。"""
+ source = "|".join(
+ (
+ "image-request:v2",
+ cache_name,
+ variant_signature,
+ scope_signature,
+ self._json(render_options),
+ self._asset_signature,
+ )
+ )
+ return hashlib.sha256(source.encode("utf-8")).hexdigest()
+
+ def image_lock(self, cache_key: str) -> asyncio.Lock:
+ return self._image_locks.setdefault(cache_key, asyncio.Lock())
+
+ async def get_image_entry(
+ self,
+ cache_key: str,
+ cache_name: str,
+ ) -> tuple[Path, str] | None:
+ try:
+ ttl = self.get_ttl("image", cache_name)
+ if ttl <= 0:
+ return None
+ now = int(time.time())
+ row = await self._sqlite.fetch_one(
+ """
+ SELECT file_name, created_at, expires_at, message_text
+ FROM image_render_cache
+ WHERE cache_key=? AND cache_name=?
+ """,
+ (cache_key, cache_name),
+ )
+ if not row:
+ return None
+ effective_expiry = min(int(row["expires_at"]), int(row["created_at"]) + ttl)
+ path = self.image_dir / str(row["file_name"])
+ if effective_expiry <= now or not path.is_file():
+ await self._delete_image_record(cache_key, path)
+ return None
+ await self._sqlite.execute(
+ "UPDATE image_render_cache SET last_accessed_at=? WHERE cache_key=?",
+ (now, cache_key),
+ )
+ return path, str(row.get("message_text") or "")
+ except Exception as exc:
+ logger.warning(f"读取图片缓存失败 cache={cache_name}: {exc}")
+ return None
+
+ async def get_image(self, cache_key: str, cache_name: str) -> Path | None:
+ entry = await self.get_image_entry(cache_key, cache_name)
+ return entry[0] if entry else None
+
+ async def save_image(
+ self,
+ cache_key: str,
+ cache_name: str,
+ source_path: str,
+ image_format: str,
+ message_text: str = "",
+ ) -> Path | None:
+ ttl = self.get_ttl("image", cache_name)
+ source = Path(source_path)
+ if ttl <= 0 or not source.is_file():
+ return None
+ extension = "jpg" if image_format == "jpeg" else "png"
+ file_name = f"{cache_key}.{extension}"
+ target = self.image_dir / file_name
+ temporary = self.image_dir / f".{file_name}.tmp"
+ try:
+ await asyncio.to_thread(shutil.copy2, source, temporary)
+ await asyncio.to_thread(os.replace, temporary, target)
+ size_bytes = target.stat().st_size
+ now = int(time.time())
+ await self._sqlite.execute(
+ """
+ INSERT INTO image_render_cache(
+ cache_key, cache_name, file_name, size_bytes,
+ created_at, expires_at, last_accessed_at, message_text
+ ) VALUES(?, ?, ?, ?, ?, ?, ?, ?)
+ ON CONFLICT(cache_key) DO UPDATE SET
+ cache_name=excluded.cache_name,
+ file_name=excluded.file_name,
+ size_bytes=excluded.size_bytes,
+ created_at=excluded.created_at,
+ expires_at=excluded.expires_at,
+ last_accessed_at=excluded.last_accessed_at,
+ message_text=excluded.message_text
+ """,
+ (
+ cache_key,
+ cache_name,
+ file_name,
+ size_bytes,
+ now,
+ now + ttl,
+ now,
+ str(message_text or ""),
+ ),
+ )
+ await self._enforce_image_limit()
+ return target
+ except Exception as exc:
+ logger.warning(f"保存图片缓存失败 cache={cache_name}: {exc}")
+ for path in (temporary, target):
+ try:
+ if path.exists():
+ path.unlink()
+ except OSError:
+ pass
+ return None
+
+ async def _delete_image_record(self, cache_key: str, path: Path):
+ await self._sqlite.delete("image_render_cache", "cache_key=?", (cache_key,))
+ try:
+ if path.is_file() and path.parent.resolve() == self.image_dir.resolve():
+ path.unlink()
+ except OSError:
+ pass
+
+ async def _enforce_image_limit(self):
+ row = await self._sqlite.fetch_one(
+ "SELECT COALESCE(SUM(size_bytes), 0) AS total FROM image_render_cache"
+ )
+ total = int((row or {}).get("total") or 0)
+ if total <= self.max_image_bytes:
+ return
+ rows = await self._sqlite.fetch_all(
+ """
+ SELECT cache_key, file_name, size_bytes
+ FROM image_render_cache
+ ORDER BY last_accessed_at ASC
+ """
+ )
+ for item in rows:
+ if total <= self.max_image_bytes:
+ break
+ await self._delete_image_record(
+ str(item["cache_key"]),
+ self.image_dir / str(item["file_name"]),
+ )
+ total -= int(item["size_bytes"])
+
+ async def cleanup_expired(self):
+ now = int(time.time())
+ self._memory = OrderedDict(
+ (key, value) for key, value in self._memory.items() if value[1] > now
+ )
+ await self._sqlite.delete(
+ "api_response_cache",
+ "expires_at<=?",
+ (now - self.STALE_RETENTION_SECONDS,),
+ )
+ rows = await self._sqlite.fetch_all(
+ "SELECT cache_key, file_name FROM image_render_cache WHERE expires_at<=?",
+ (now,),
+ )
+ for row in rows:
+ await self._delete_image_record(
+ str(row["cache_key"]),
+ self.image_dir / str(row["file_name"]),
+ )
+
+ async def clear(self, cache_type: str) -> dict[str, int]:
+ if cache_type not in {"api", "image", "all"}:
+ raise ValueError("清理类型仅支持 api、image 或 all")
+ removed = {"api": 0, "image": 0}
+ if cache_type in {"api", "all"}:
+ row = await self._sqlite.fetch_one(
+ "SELECT COUNT(*) AS count FROM api_response_cache"
+ )
+ removed["api"] = int((row or {}).get("count") or 0)
+ await self._sqlite.execute("DELETE FROM api_response_cache")
+ self._memory.clear()
+ if cache_type in {"image", "all"}:
+ rows = await self._sqlite.fetch_all(
+ "SELECT cache_key, file_name FROM image_render_cache"
+ )
+ removed["image"] = len(rows)
+ for row in rows:
+ await self._delete_image_record(
+ str(row["cache_key"]),
+ self.image_dir / str(row["file_name"]),
+ )
+ return removed
+
+ async def clear_item(self, cache_type: str, cache_name: str) -> int:
+ if cache_type not in {"api", "image"}:
+ raise ValueError("缓存类型仅支持 api 或 image")
+ cache_name = str(cache_name or "").strip()
+ if not cache_name:
+ raise ValueError("缓存项目不能为空")
+
+ if cache_type == "api":
+ rows = await self._sqlite.fetch_all(
+ "SELECT cache_key FROM api_response_cache WHERE endpoint=?",
+ (cache_name,),
+ )
+ await self._sqlite.delete(
+ "api_response_cache",
+ "endpoint=?",
+ (cache_name,),
+ )
+ for row in rows:
+ self._memory.pop(str(row["cache_key"]), None)
+ return len(rows)
+
+ rows = await self._sqlite.fetch_all(
+ """
+ SELECT cache_key, file_name
+ FROM image_render_cache
+ WHERE cache_name=?
+ """,
+ (cache_name,),
+ )
+ for row in rows:
+ await self._delete_image_record(
+ str(row["cache_key"]),
+ self.image_dir / str(row["file_name"]),
+ )
+ return len(rows)
+
+ def _setting_item(self, cache_type: str, cache_name: str) -> dict[str, Any]:
+ return {
+ "name": cache_name,
+ "ttl_seconds": self.get_ttl(cache_type, cache_name),
+ "overridden": (cache_type, cache_name) in self._settings,
+ "safe_default": (
+ (
+ (cache_type == "api" and cache_name in self._NO_CACHE_API_DEFAULTS)
+ or (
+ cache_type == "image" and cache_name in {"避雷查看", "避雷查询"}
+ )
+ )
+ and (cache_type, cache_name) not in self._settings
+ ),
+ }
+
+ async def dashboard(self) -> dict[str, Any]:
+ await self.cleanup_expired()
+ api_row = await self._sqlite.fetch_one(
+ """
+ SELECT COUNT(*) AS count,
+ COALESCE(SUM(LENGTH(CAST(payload AS BLOB))), 0) AS size_bytes
+ FROM api_response_cache
+ """
+ )
+ image_row = await self._sqlite.fetch_one(
+ """
+ SELECT COUNT(*) AS count,
+ COALESCE(SUM(size_bytes), 0) AS size_bytes
+ FROM image_render_cache
+ """
+ )
+ known_api_names = set(API_ENDPOINTS)
+ known_api_names.update(
+ name
+ for cache_type, name in self._settings
+ if cache_type == "api" and name != "*"
+ )
+ known_image_names = set(self._image_names)
+ known_image_names.update(
+ name
+ for cache_type, name in self._settings
+ if cache_type == "image" and name != "*"
+ )
+ return {
+ "defaults": {
+ "api": self.get_ttl("api", "*"),
+ "image": self.get_ttl("image", "*"),
+ },
+ "limits": {
+ "api_memory_entries": self.max_memory_entries,
+ "image_max_mb": self.max_image_bytes // 1024 // 1024,
+ },
+ "api": [
+ self._setting_item("api", name) for name in sorted(known_api_names)
+ ],
+ "images": [
+ self._setting_item("image", name)
+ for name in sorted(
+ known_image_names, key=lambda value: value.encode("utf-8")
+ )
+ ],
+ "stats": {
+ "api_count": int((api_row or {}).get("count") or 0),
+ "api_size_bytes": int((api_row or {}).get("size_bytes") or 0),
+ "api_memory_count": len(self._memory),
+ "api_memory_limit": self.max_memory_entries,
+ "image_count": int((image_row or {}).get("count") or 0),
+ "image_size_bytes": int((image_row or {}).get("size_bytes") or 0),
+ "image_limit_bytes": self.max_image_bytes,
+ },
+ }
diff --git a/core/jx3api_data.py b/core/jx3api_data.py
index 9152eec..59a16a7 100644
--- a/core/jx3api_data.py
+++ b/core/jx3api_data.py
@@ -1,8 +1,11 @@
import json
import html
import re
+import hashlib
+import asyncio
+import time
from datetime import datetime
-from typing import Dict, Any, Optional, List, Union
+from typing import TYPE_CHECKING, Dict, Any, Optional, List, Union
from inspect import isawaitable
from typing import Any, Awaitable, Callable, Dict, Optional
@@ -14,6 +17,9 @@ from .request import APIClient, APIErrorResponse
from .sqlite import AsyncSQLiteDB
from .fun_basic import load_template,gold_to_parts,week_to_num,compare_date_str,format_time,format_remaining
+if TYPE_CHECKING:
+ from .cache import CacheService
+
ROLE_RANK_NAMES = {
"名士五十强",
@@ -74,25 +80,33 @@ RANK_NAMES = frozenset().union(
class JX3APIService:
- def __init__(self, config: AstrBotConfig, sqlite: AsyncSQLiteDB):
+ def __init__(
+ self,
+ config: AstrBotConfig,
+ sqlite: AsyncSQLiteDB,
+ cache: Optional["CacheService"] = None,
+ ):
# 实例化 API Client
self._api: APIClient = APIClient()
# 引用插件配置文件
self._config = config
# 引用sqlite
self._sql_db = sqlite
+ self._cache = cache
+ self._token_stats_cache: tuple[float, Dict[str, Any]] | None = None
+ self._token_stats_lock = asyncio.Lock()
# 获取配置中的 Token
self.token = self._config.get("jx3api_token", "")
if self.token == "":
logger.warning("获取配置token失败,请正确填写token,否则部分功能无法正常使用")
else:
- logger.debug(f"获取配置token成功。{self.token}")
+ logger.debug(f"获取配置token成功。")
# 获取配置中的 ticket
self.ticket = self._config.get("jx3api_ticket", "")
if self.ticket == "":
logger.warning("获取配置ticket失败,请正确填写ticket,否则部分功能无法正常使用")
else:
- logger.debug(f"获取配置ticket成功。{self.ticket}")
+ logger.debug(f"获取配置ticket成功。")
async def close(self):
@@ -100,11 +114,13 @@ class JX3APIService:
if self._api:
await self._api.close()
- async def server_list(self) -> list[str]:
+ async def server_list(self, force_refresh: bool = False) -> list[str]:
"""获取当前有效区服名称,供会话绑定和参数消歧使用。"""
- data = await self._base_request(
+ data, _ = await self._cached_request(
"/server/status/check",
{"server": "", "type": "其他"},
+ force_refresh=force_refresh,
+ allow_stale=not force_refresh,
)
if not isinstance(data, list):
return []
@@ -117,18 +133,38 @@ class JX3APIService:
)
async def token_stats(self) -> Optional[Dict[str, Any]]:
+ """读取令牌统计;短时内存复用,避免 WebUI 保存配置时重复请求。"""
+ if not str(self.token or "").strip():
+ return None
+ now = time.monotonic()
+ if self._token_stats_cache and self._token_stats_cache[0] > now:
+ return dict(self._token_stats_cache[1])
+
+ async with self._token_stats_lock:
+ now = time.monotonic()
+ if self._token_stats_cache and self._token_stats_cache[0] > now:
+ return dict(self._token_stats_cache[1])
+ result = await self._fetch_token_stats()
+ if result is not None:
+ self._token_stats_cache = (now + 30, dict(result))
+ return result
+
+ async def _fetch_token_stats(self) -> Optional[Dict[str, Any]]:
"""查询当前配置 JX3API Token 的等级、用量及有效状态。"""
if not str(self.token or "").strip():
return None
- try:
- data = await self._api.post(
+ async def requester():
+ return await self._api.post(
"https://www.jx3api.com/token/stats",
data={"token": self.token},
out_key="data",
success_codes=(200, "200"),
return_error=True,
)
+
+ try:
+ data = await requester()
except Exception as exc:
logger.warning(f"查询 JX3API Token 统计失败: {exc}")
return None
@@ -204,6 +240,56 @@ class JX3APIService:
logger.error(f"基础请求调用出错 ({api_path}): {e}")
return None
+ @staticmethod
+ def _is_cacheable_response(data: Any) -> bool:
+ return data is not None and not isinstance(data, APIErrorResponse)
+
+ async def _cached_request(
+ self,
+ api_path: str,
+ params: Optional[Dict[str, Any]] = None,
+ out: Optional[str] = "data",
+ force_refresh: bool = False,
+ allow_stale: bool = True,
+ ) -> tuple[Any, dict[str, Any]]:
+ request_params = params or {}
+ if not self._cache:
+ return await self._base_request(api_path, request_params, out), {
+ "endpoint": api_path,
+ "hit": False,
+ "stale": False,
+ "ttl_seconds": 0,
+ }
+
+ cache_params = dict(request_params)
+ credential_values = [
+ str(value)
+ for key, value in request_params.items()
+ if str(key).lower() in {"token", "ticket"} and value
+ ]
+ if credential_values:
+ cache_params["__credential_scope"] = hashlib.sha256(
+ "|".join(credential_values).encode("utf-8")
+ ).hexdigest()
+
+ try:
+ return await self._cache.request_api(
+ api_path,
+ cache_params,
+ lambda: self._base_request(api_path, request_params, out),
+ self._is_cacheable_response,
+ force_refresh=force_refresh,
+ allow_stale=allow_stale,
+ )
+ except Exception as exc:
+ logger.warning(f"接口缓存不可用,直接请求 JX3API endpoint={api_path}: {exc}")
+ return await self._base_request(api_path, request_params, out), {
+ "endpoint": api_path,
+ "hit": False,
+ "stale": False,
+ "ttl_seconds": 0,
+ }
+
async def _request_api(
self,
@@ -217,7 +303,8 @@ class JX3APIService:
"""通用接口请求与模板处理。"""
return_data = self._init_return_data()
- data = await self._base_request(path, params)
+ data, cache_metadata = await self._cached_request(path, params)
+ return_data["_cache"] = cache_metadata
if isinstance(data, APIErrorResponse):
return_data["msg"] = data.message or "获取接口信息失败"
return return_data
diff --git a/core/message.py b/core/message.py
index 5cce9ef..18e9e57 100644
--- a/core/message.py
+++ b/core/message.py
@@ -1,24 +1,48 @@
+from datetime import datetime
from typing import Any
+from zoneinfo import ZoneInfo
from aiocqhttp.exceptions import ActionFailed
-from astrbot.core import html_renderer
+
from astrbot.api import logger
from astrbot.api.event import AstrMessageEvent, MessageChain
+from astrbot.core import html_renderer
from astrbot.core.utils.session_waiter import (
SessionController,
session_waiter,
)
+from .bilei_data import BiLeidata
+from .cache import CacheService
+from .event_push import EventPushService
from .jx3api_data import JX3APIService
from .jx3box_data import JX3BOXService
-from .event_push import EventPushService
-from .bilei_data import BiLeidata
class MessageBuilder:
"""回复消息构建"""
+ IMAGE_RENDER_HANDLERS = frozenset(
+ {
+ "helps", "richangyuche", "qiongyewei", "pifenghui", "yunchongshe",
+ "chutianshe", "guanaishouling", "zhenyingevent", "yanhuachaxun",
+ "zhanji", "mingjianpaihang", "mingjiantongji", "kuafumingjian",
+ "wulinzhengba", "bukairongyu", "jianghulangke", "juedoutiaozhan",
+ "banghuipaihang", "zhenyingpaihang", "qitapaihang", "shilianpaixing",
+ "zhengyingpaimai", "dilujilu", "jinjia", "wujia", "chengbeng",
+ "bangzhanjilu", "shapan", "zhueevent", "qiyuhuizong", "weizuoqiyu",
+ "jinqiqiyu", "juesheqiyu", "qiyutongji", "qiyugonglue", "jingnai",
+ "baizhan", "chengjiu", "zilipaixing", "jineng", "qixue", "liaotian",
+ "xiaoyao", "huajia", "zhuangshi", "qiwu", "baishi", "shoutu",
+ "tuanduizhaomu", "tuanzhang", "tuanpai", "zhuangtai", "fubeng",
+ "diaoluo", "hong", "zili", "jiaoyihang", "bilei_all", "bilei_select",
+ }
+ )
+
_RENDER_FORMATS = {"jpeg", "png"}
+ _DATA_TIME_MARKER = "data-jx3-data-time"
+ _DATA_TIME_ZONE = ZoneInfo("Asia/Shanghai")
+ _SESSION_SCOPED_IMAGE_NAMES = frozenset({"避雷查看", "避雷查询"})
_DEVICE_SCALE_FACTOR_LEVELS = {
1.0: "normal",
1.3: "high",
@@ -31,6 +55,7 @@ class MessageBuilder:
event_push: EventPushService,
icons: dict[str, dict[str, str]],
render_config: dict[str, Any] | None = None,
+ cache: CacheService | None = None,
):
self.jx3api = jx3api
self.jx3box = jx3box
@@ -38,6 +63,7 @@ class MessageBuilder:
self.event_push = event_push
self.icons = icons
self.render_config = render_config if isinstance(render_config, dict) else {}
+ self.cache = cache
def _build_render_options(
@@ -94,7 +120,151 @@ class MessageBuilder:
return_url=return_url,
options=self._build_render_options(options),
)
-
+
+ @staticmethod
+ def _format_data_time(result: dict[str, Any] | None = None) -> str:
+ """优先使用接口缓存创建时间,否则使用本次数据生成时间。"""
+ cache_metadata = (result or {}).get("_cache") or {}
+ timestamp = cache_metadata.get("created_at")
+ try:
+ numeric_timestamp = float(timestamp)
+ if numeric_timestamp > 10_000_000_000:
+ numeric_timestamp /= 1000
+ if numeric_timestamp > 0:
+ return datetime.fromtimestamp(
+ numeric_timestamp,
+ tz=MessageBuilder._DATA_TIME_ZONE,
+ ).strftime("%Y-%m-%d %H:%M:%S")
+ except (TypeError, ValueError, OSError, OverflowError):
+ pass
+ return datetime.now(MessageBuilder._DATA_TIME_ZONE).strftime(
+ "%Y-%m-%d %H:%M:%S"
+ )
+
+ @classmethod
+ def _ensure_data_time_footer(cls, template: str) -> str:
+ """为不使用公共布局的动态 HTML 补上统一的数据时间区域。"""
+ if cls._DATA_TIME_MARKER in template:
+ return template
+ footer = """
+
+
+"""
+ body_end = template.lower().rfind("