fxdyz
This commit is contained in:
@@ -1,5 +1,15 @@
|
|||||||
## 更新记录
|
## 更新记录
|
||||||
|
|
||||||
|
### version: 3.4.7(2026-09-07):
|
||||||
|
|
||||||
|
移除 JX3API 请求失败时使用七天内过期缓存的兜底逻辑。接口缓存过期后重新请求上游,权限拒绝、业务错误及网络失败均不再返回旧数据;强制刷新失败时也会清除该请求原有的接口缓存。
|
||||||
|
|
||||||
|
接口内存缓存改为按 JSON 的 UTF-8 总字节数限制,默认 16 MB,并在 WebUI 同时展示当前容量和缓存条数;SQLite 接口缓存继续按条数限制,默认 256 条。旧版 `api_memory_entries` 配置自动作为 SQLite 条数上限继承。新写入、修改上限和启动插件时均检查 SQLite 条数,按最近最少使用顺序淘汰超额记录,内存命中也会更新 SQLite 的访问时间。新增每 60 秒运行的自动过期清理任务,接口缓存按当前有效 TTL 清理,图片缓存按过期时间清理;插件停用或初始化失败时回收任务。
|
||||||
|
|
||||||
|
插件 WebUI 的“事件推送”页补齐会话推送配置管理:支持选择已有会话或输入新会话 ID 新增配置、删除已有会话的推送配置,以及编辑推送总开关和具体事件订阅。事件选项复用推送服务目录,分为免费事件和令牌事件,支持全选、清空选择及已选数量提示;关闭总开关保留事件选择,保存后立即用于后续事件分发。
|
||||||
|
|
||||||
|
新增推送配置保存和删除接口,严格校验会话 ID、布尔开关及事件编号,通过单条 SQL 原子写入完整订阅选择。重复新增不会覆盖已有配置,编辑已删除的会话不会重新创建记录;删除仅清除推送配置,保留区服绑定及会话访问名单。页面保存失败时保留输入,便于修正或重试。
|
||||||
|
|
||||||
### version: 3.4.6(2026-09-06):
|
### version: 3.4.6(2026-09-06):
|
||||||
|
|
||||||
本地避雷记录新增按 AstrBot 会话隔离的数据范围。新增、查看、查询、修改和删除均使用当前消息的 `unified_msg_origin`,修改与删除同时校验会话 ID 和记录 ID,其他会话即使知道记录 ID 也无法访问或操作。
|
本地避雷记录新增按 AstrBot 会话隔离的数据范围。新增、查看、查询、修改和删除均使用当前消息的 `unified_msg_origin`,修改与删除同时校验会话 ID 和记录 ID,其他会话即使知道记录 ID 也无法访问或操作。
|
||||||
|
|||||||
@@ -21,7 +21,7 @@
|
|||||||
- 支持按 AstrBot 会话分别开启总开关和具体 JX3API 实时事件订阅。
|
- 支持按 AstrBot 会话分别开启总开关和具体 JX3API 实时事件订阅。
|
||||||
- 复用 `aiohttp.ClientSession`,统一处理 GET、POST、JSON、图片和分页请求。
|
- 复用 `aiohttp.ClientSession`,统一处理 GET、POST、JSON、图片和分页请求。
|
||||||
- JX3BOX 的 Node、Next2、CMS 请求统一封装,交易行基础物品数据支持本地快照缓存和过期兜底。
|
- JX3BOX 的 Node、Next2、CMS 请求统一封装,交易行基础物品数据支持本地快照缓存和过期兜底。
|
||||||
- JX3API 查询使用内存与 SQLite 两级 JSON 缓存,HTML 查询图片使用本地文件缓存;缓存时间、接口内存条数及图片缓存总容量均可在 WebUI 配置。
|
- JX3API 查询使用内存与 SQLite 两级 JSON 缓存,HTML 查询图片使用本地文件缓存;缓存时间、接口内存容量、SQLite 接口缓存条数及图片缓存总容量均可在 WebUI 配置。
|
||||||
- 内置 97 个中文触发指令和 51 个页面片段,通过公共布局与样式在本地组装为完整 HTML,并附带通用、沙盘、门派/心法和奇遇图标资源。
|
- 内置 97 个中文触发指令和 51 个页面片段,通过公共布局与样式在本地组装为完整 HTML,并附带通用、沙盘、门派/心法和奇遇图标资源。
|
||||||
|
|
||||||
## 数据来源
|
## 数据来源
|
||||||
@@ -72,7 +72,7 @@ pip install -r data/plugins/astrbot_plugin_jx3/requirements.txt
|
|||||||
| `aiohttp` | 异步 HTTP 请求与连接复用 |
|
| `aiohttp` | 异步 HTTP 请求与连接复用 |
|
||||||
| `aiofiles` | 异步读取 HTML 模板 |
|
| `aiofiles` | 异步读取 HTML 模板 |
|
||||||
| `aiosqlite` | 异步访问本地 SQLite 数据库 |
|
| `aiosqlite` | 异步访问本地 SQLite 数据库 |
|
||||||
| `matplotlib` | 当前依赖清单保留的绘图依赖;v3.4.6 业务代码未直接导入 |
|
| `matplotlib` | 当前依赖清单保留的绘图依赖;v3.4.7 业务代码未直接导入 |
|
||||||
|
|
||||||
## 插件配置
|
## 插件配置
|
||||||
|
|
||||||
@@ -305,20 +305,22 @@ AstrBot 插件详情页中的“剑网三插件管理”通过 Plugin Pages 桥
|
|||||||
| 页签 | 当前功能 |
|
| 页签 | 当前功能 |
|
||||||
| --- | --- |
|
| --- | --- |
|
||||||
| 会话控制 | 默认页签;在全部会话、白名单和黑名单之间切换,并维护会话 ID 与备注。空白名单不放行任何会话,空黑名单放行全部会话,策略同时作用于查询指令与事件推送 |
|
| 会话控制 | 默认页签;在全部会话、白名单和黑名单之间切换,并维护会话 ID 与备注。空白名单不放行任何会话,空黑名单放行全部会话,策略同时作用于查询指令与事件推送 |
|
||||||
| 事件推送 | 查看所有会话的推送总开关及已订阅事件编号 |
|
| 事件推送 | 新增、编辑和删除会话推送配置;设置总开关并勾选具体事件,支持全选和清空选择,关闭总开关保留订阅选择 |
|
||||||
| 区服绑定 | 使用自定义会话 ID 新增绑定;区服只能从标准区服下拉框选择,已有会话 ID 不可编辑 |
|
| 区服绑定 | 使用自定义会话 ID 新增绑定;区服只能从标准区服下拉框选择,已有会话 ID 不可编辑 |
|
||||||
| 区服别名 | 查看标准区服并行内维护别名;可使用随插件分发的 JSON 种子恢复默认 |
|
| 区服别名 | 查看标准区服并行内维护别名;可使用随插件分发的 JSON 种子恢复默认 |
|
||||||
| 心法别名 | 查看标准心法并维护最多 5 个别名;配装 ID 不在页面显示,可恢复默认 |
|
| 心法别名 | 查看标准心法并维护最多 5 个别名;配装 ID 不在页面显示,可恢复默认 |
|
||||||
| 缓存管理 | 分别配置接口数据和最终图片缓存时间、容量限制,查看占用并清理单项或全部缓存 |
|
| 缓存管理 | 分别配置接口数据和最终图片缓存时间、容量限制,查看占用并清理单项或全部缓存 |
|
||||||
| 避雷迁移 | 把升级前保留在“历史公共数据”区的避雷记录迁移到指定会话 |
|
| 避雷迁移 | 把升级前保留在“历史公共数据”区的避雷记录迁移到指定会话 |
|
||||||
|
|
||||||
|
事件推送配置保存后立即用于后续事件分发,仍受会话访问模式和绑定区服限制。新增时可以选择已有会话或输入完整的 AstrBot 会话 ID,重复新增会提示编辑已有配置;编辑时会话 ID 只读。空事件选择表示不接收任何事件;删除仅移除该会话的推送配置,保留区服绑定、访问名单和其他会话数据。免费事件与令牌事件分组展示,令牌事件仍需配置 `jx3api_wss_token`。
|
||||||
|
|
||||||
页面顶部通过 JX3API `POST /token/stats` 展示当前 Token 的等级、已用次数、剩余次数和有效状态,成功结果在进程内保留 30 秒。普通打开或刷新 WebUI 时,区服目录、别名和绑定直接读取当前内存/SQLite 数据,不会请求区服状态接口;只有点击“刷新区服列表”时才会强制请求 `/server/status/check` 并更新当前区服目录。插件初始化时会执行一次 `server_list()` 建立用于参数消歧的有效区服目录:缓存有效时读取接口缓存,缓存不存在或已过期时才请求上游。
|
页面顶部通过 JX3API `POST /token/stats` 展示当前 Token 的等级、已用次数、剩余次数和有效状态,成功结果在进程内保留 30 秒。普通打开或刷新 WebUI 时,区服目录、别名和绑定直接读取当前内存/SQLite 数据,不会请求区服状态接口;只有点击“刷新区服列表”时才会强制请求 `/server/status/check` 并更新当前区服目录。插件初始化时会执行一次 `server_list()` 建立用于参数消歧的有效区服目录:缓存有效时读取接口缓存,缓存不存在或已过期时才请求上游。
|
||||||
|
|
||||||
缓存管理的接口默认时间为 300 秒,图片默认时间为 600 秒;每个 JX3API 接口和每个图片指令都可以单独覆盖,填写 `0` 表示关闭,恢复默认则重新继承全局时间。图片指令优先查询最终图片缓存,命中后不会再调用上游接口或重新渲染;图文结果会连同正文一起复用。单独清除接口会同时删除该路径所有参数组合的内存与 SQLite 缓存,单独清除图片会删除该指令生成的缓存文件,两种操作均不改变已配置时间。
|
缓存管理的接口默认时间为 300 秒,图片默认时间为 600 秒;每个 JX3API 接口和每个图片指令都可以单独覆盖,填写 `0` 表示关闭,恢复默认则重新继承全局时间。图片指令优先查询最终图片缓存,命中后不会再调用上游接口或重新渲染;图文结果会连同正文一起复用。单独清除接口会同时删除该路径所有参数组合的内存与 SQLite 缓存,单独清除图片会删除该指令生成的缓存文件,两种操作均不改变已配置时间。
|
||||||
|
|
||||||
如果需要某条图片指令立即使用最新上游数据,应同时清除(或临时关闭)对应的接口缓存和图片缓存。只把接口缓存设为 `0` 时,已有最终图片仍可能直接命中;只把图片缓存设为 `0` 时,页面会重新渲染,但仍可能使用尚未过期的接口数据。
|
如果需要某条图片指令立即使用最新上游数据,应同时清除(或临时关闭)对应的接口缓存和图片缓存。只把接口缓存设为 `0` 时,已有最终图片仍可能直接命中;只把图片缓存设为 `0` 时,页面会重新渲染,但仍可能使用尚未过期的接口数据。
|
||||||
|
|
||||||
接口内存热缓存默认最多 256 条,超出后按最近最少使用顺序淘汰;SQLite 中的接口缓存不受这项内存条数限制。图片二进制保存在 AstrBot 插件数据目录,默认总容量为 512 MB,超出后按最近最少使用顺序清理。随机名片、随机语录、吃喝选择和随机贴吧等接口默认不缓存,避雷查看和避雷查询也默认不缓存最终图片;这些项目仍可在 WebUI 中显式覆盖。
|
接口内存缓存默认最多占用 16 MB,按 JSON 的 UTF-8 字节数统计并按最近最少使用顺序淘汰,WebUI 同时展示当前占用和缓存条数。SQLite 接口缓存独立按条数限制,默认最多 256 条;旧版接口条数配置会自动继承为 SQLite 上限。内存命中也会更新 SQLite 的访问时间,修改上限和插件启动时立即清理超额记录。SQLite 条数限制不是磁盘字节容量;删除记录后释放的页可供后续写入复用,数据库文件不一定立即缩小。图片二进制保存在 AstrBot 插件数据目录,默认总容量为 512 MB,超出后按最近最少使用顺序清理。每 60 秒自动清理过期接口和图片缓存,无需打开 WebUI;接口请求失败时不再返回过期数据。随机名片、随机语录、吃喝选择和随机贴吧等接口默认不缓存,避雷查看和避雷查询也默认不缓存最终图片;这些项目仍可在 WebUI 中显式覆盖。
|
||||||
|
|
||||||
## 业务流程
|
## 业务流程
|
||||||
|
|
||||||
@@ -366,7 +368,7 @@ flowchart LR
|
|||||||
- `kungfu`:保存 JX3BOX 配装 ID、标准心法名及最多 5 个别名。
|
- `kungfu`:保存 JX3BOX 配装 ID、标准心法名及最多 5 个别名。
|
||||||
- `trade_item_cache`:JX3BOX 交易行基础物品数据缓存及更新时间。
|
- `trade_item_cache`:JX3BOX 交易行基础物品数据缓存及更新时间。
|
||||||
- `cache_settings`:接口与图片指令的默认时间和单项覆盖配置。
|
- `cache_settings`:接口与图片指令的默认时间和单项覆盖配置。
|
||||||
- `cache_limits`:接口内存条数和图片缓存总容量配置。
|
- `cache_limits`:接口内存缓存总容量、SQLite 接口缓存最大条数及图片缓存总容量配置。
|
||||||
- `api_response_cache`:JX3API 原始 JSON、创建时间、过期时间和最近访问时间。
|
- `api_response_cache`:JX3API 原始 JSON、创建时间、过期时间和最近访问时间。
|
||||||
- `image_render_cache`:本地渲染图片文件的索引、大小、过期时间、最近访问时间及图文消息正文。
|
- `image_render_cache`:本地渲染图片文件的索引、大小、过期时间、最近访问时间及图文消息正文。
|
||||||
|
|
||||||
@@ -479,7 +481,7 @@ AstrBot 的渲染接口接收完整 HTML 字符串,因此插件不会依赖渲
|
|||||||
|
|
||||||
`trade_item_cache` 只保存 JX3BOX 交易行物品分组快照,当前键为 `trade_item_groups`。缓存有效期为 30 天;缓存过期后优先全量刷新,上游请求失败时继续使用可解析的旧缓存兜底。升级时会从旧 `achievement_cache` 迁移交易行缓存并删除旧表,历史资历菜单和点数缓存不会继续保留。
|
`trade_item_cache` 只保存 JX3BOX 交易行物品分组快照,当前键为 `trade_item_groups`。缓存有效期为 30 天;缓存过期后优先全量刷新,上游请求失败时继续使用可解析的旧缓存兜底。升级时会从旧 `achievement_cache` 迁移交易行缓存并删除旧表,历史资历菜单和点数缓存不会继续保留。
|
||||||
|
|
||||||
`api_response_cache` 仅保存通过 JX3API 查询入口成功取得的原始 JSON;Token 和 Ticket 不写入缓存键或正文,凭据摘要只用于避免更换账号后误用旧缓存。同一缓存键的并发请求通过异步锁合并。读取顺序为“内存热缓存 → SQLite → 上游接口”;两级缓存使用同一项 TTL,不存在单独的内存保留时间。内存超过 WebUI 配置的条数时只淘汰内存副本,后续仍可从 SQLite 读取。缓存过期后优先刷新,上游失败时允许使用七天内仍可解析的旧数据兜底。
|
`api_response_cache` 仅保存通过 JX3API 查询入口成功取得的原始 JSON;Token 和 Ticket 不写入缓存键或正文,凭据摘要只用于避免更换账号后误用旧缓存。同一缓存键的并发请求通过异步锁合并。读取顺序为“内存热缓存 → SQLite → 上游接口”;两级缓存使用同一项 TTL,内存按总字节数限制,SQLite 按记录条数限制。缓存过期后重新请求上游,权限拒绝、业务错误和网络失败均返回失败结果,不再使用旧数据兜底;强制刷新失败也会清除该请求原有的接口缓存。每 60 秒根据当前有效 TTL 自动清理过期接口记录,启动时同步淘汰历史超额记录;插件停用和初始化失败时回收清理任务。
|
||||||
|
|
||||||
图片缓存不会把二进制写入 SQLite。图片消息处理器会在请求业务数据前,使用当前指令名、完整有效参数、二轮选择项、必要的会话范围、静态资源签名和截图参数生成 SHA-256 键;命中时直接把本地路径交给消息事件发送。未命中时才请求数据并渲染,再把 AstrBot 临时渲染结果复制到 `cache/images/`;SQLite 的 `image_render_cache` 只保存文件索引、大小、时间、最近访问记录和图文消息正文。超过 WebUI 配置的容量(默认 512 MB)后按最近最少使用顺序清理。
|
图片缓存不会把二进制写入 SQLite。图片消息处理器会在请求业务数据前,使用当前指令名、完整有效参数、二轮选择项、必要的会话范围、静态资源签名和截图参数生成 SHA-256 键;命中时直接把本地路径交给消息事件发送。未命中时才请求数据并渲染,再把 AstrBot 临时渲染结果复制到 `cache/images/`;SQLite 的 `image_render_cache` 只保存文件索引、大小、时间、最近访问记录和图文消息正文。超过 WebUI 配置的容量(默认 512 MB)后按最近最少使用顺序清理。
|
||||||
|
|
||||||
@@ -561,7 +563,7 @@ git diff --check
|
|||||||
|
|
||||||
## 当前版本状态
|
## 当前版本状态
|
||||||
|
|
||||||
以下内容是对 v3.4.6 当前源码的静态核对结果,部署和二次开发前应注意:
|
以下内容是对 v3.4.7 当前源码的静态核对结果,部署和二次开发前应注意:
|
||||||
|
|
||||||
1. 查询图片使用浅色高对比主题和放大的内容区域;渲染清晰度、JPEG/PNG 格式及 JPEG 质量由 `image_render_quality` 配置组控制,提高清晰度或使用 PNG 会增加图片体积与渲染耗时。
|
1. 查询图片使用浅色高对比主题和放大的内容区域;渲染清晰度、JPEG/PNG 格式及 JPEG 质量由 `image_render_quality` 配置组控制,提高清晰度或使用 PNG 会增加图片体积与渲染耗时。
|
||||||
2. 所有 HTML 渲染图片底部都会显示数据时间;最终图片缓存命中后保留原时间并跳过接口请求和重新渲染。要让下一次查询同时获取最新上游数据并重新生成图片,需要一并清除或关闭对应接口缓存和图片缓存。
|
2. 所有 HTML 渲染图片底部都会显示数据时间;最终图片缓存命中后保留原时间并跳过接口请求和重新渲染。要让下一次查询同时获取最新上游数据并重新生成图片,需要一并清除或关闭对应接口缓存和图片缓存。
|
||||||
|
|||||||
+250
-120
@@ -1,6 +1,7 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
|
import contextlib
|
||||||
import hashlib
|
import hashlib
|
||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
@@ -98,11 +99,13 @@ class CacheService:
|
|||||||
DEFAULT_API_TTL = 300
|
DEFAULT_API_TTL = 300
|
||||||
DEFAULT_IMAGE_TTL = 600
|
DEFAULT_IMAGE_TTL = 600
|
||||||
MAX_TTL_SECONDS = 30 * 24 * 60 * 60
|
MAX_TTL_SECONDS = 30 * 24 * 60 * 60
|
||||||
DEFAULT_MAX_MEMORY_ENTRIES = 256
|
DEFAULT_MAX_MEMORY_BYTES = 16 * 1024 * 1024
|
||||||
|
DEFAULT_MAX_API_ENTRIES = 256
|
||||||
DEFAULT_MAX_IMAGE_BYTES = 512 * 1024 * 1024
|
DEFAULT_MAX_IMAGE_BYTES = 512 * 1024 * 1024
|
||||||
MAX_MEMORY_ENTRIES_LIMIT = 100_000
|
MAX_MEMORY_MB_LIMIT = 1024
|
||||||
|
MAX_API_ENTRIES_LIMIT = 100_000
|
||||||
MAX_IMAGE_MB_LIMIT = 10_240
|
MAX_IMAGE_MB_LIMIT = 10_240
|
||||||
STALE_RETENTION_SECONDS = 7 * 24 * 60 * 60
|
CLEANUP_INTERVAL_SECONDS = 60
|
||||||
_SENSITIVE_KEYS = frozenset(
|
_SENSITIVE_KEYS = frozenset(
|
||||||
{"token", "ticket", "authorization", "access_token", "jx3api_token"}
|
{"token", "ticket", "authorization", "access_token", "jx3api_token"}
|
||||||
)
|
)
|
||||||
@@ -129,9 +132,13 @@ class CacheService:
|
|||||||
self._sqlite = sqlite
|
self._sqlite = sqlite
|
||||||
self.image_dir = Path(image_dir)
|
self.image_dir = Path(image_dir)
|
||||||
self._settings: dict[tuple[str, str], int] = {}
|
self._settings: dict[tuple[str, str], int] = {}
|
||||||
self.max_memory_entries = self.DEFAULT_MAX_MEMORY_ENTRIES
|
self.max_memory_bytes = self.DEFAULT_MAX_MEMORY_BYTES
|
||||||
|
self.max_api_entries = self.DEFAULT_MAX_API_ENTRIES
|
||||||
self.max_image_bytes = self.DEFAULT_MAX_IMAGE_BYTES
|
self.max_image_bytes = self.DEFAULT_MAX_IMAGE_BYTES
|
||||||
self._memory: OrderedDict[str, tuple[int, int, str]] = OrderedDict()
|
self._memory: OrderedDict[str, tuple[int, int, bytes, int]] = OrderedDict()
|
||||||
|
self._memory_size_bytes = 0
|
||||||
|
self._api_storage_lock = asyncio.Lock()
|
||||||
|
self._cleanup_task: asyncio.Task | None = None
|
||||||
self._api_locks: weakref.WeakValueDictionary[str, asyncio.Lock] = (
|
self._api_locks: weakref.WeakValueDictionary[str, asyncio.Lock] = (
|
||||||
weakref.WeakValueDictionary()
|
weakref.WeakValueDictionary()
|
||||||
)
|
)
|
||||||
@@ -223,14 +230,37 @@ class CacheService:
|
|||||||
await self._sqlite.execute(
|
await self._sqlite.execute(
|
||||||
"CREATE INDEX IF NOT EXISTS idx_api_cache_endpoint ON api_response_cache(endpoint)"
|
"CREATE INDEX IF NOT EXISTS idx_api_cache_endpoint ON api_response_cache(endpoint)"
|
||||||
)
|
)
|
||||||
|
await self._sqlite.execute(
|
||||||
|
"CREATE INDEX IF NOT EXISTS idx_api_cache_lru ON api_response_cache(last_accessed_at)"
|
||||||
|
)
|
||||||
await self._sqlite.execute(
|
await self._sqlite.execute(
|
||||||
"CREATE INDEX IF NOT EXISTS idx_image_cache_name ON image_render_cache(cache_name)"
|
"CREATE INDEX IF NOT EXISTS idx_image_cache_name ON image_render_cache(cache_name)"
|
||||||
)
|
)
|
||||||
await self._load_settings()
|
await self._load_settings()
|
||||||
await self._load_limits()
|
await self._load_limits()
|
||||||
|
async with self._api_storage_lock:
|
||||||
|
await self._enforce_api_limit()
|
||||||
await self.cleanup_expired()
|
await self.cleanup_expired()
|
||||||
self._enforce_memory_limit()
|
|
||||||
await self._enforce_image_limit()
|
await self._enforce_image_limit()
|
||||||
|
if self._cleanup_task is None or self._cleanup_task.done():
|
||||||
|
self._cleanup_task = asyncio.create_task(
|
||||||
|
self._cleanup_loop(), name="jx3-cache-cleanup"
|
||||||
|
)
|
||||||
|
|
||||||
|
async def stop(self):
|
||||||
|
if self._cleanup_task is not None:
|
||||||
|
self._cleanup_task.cancel()
|
||||||
|
with contextlib.suppress(asyncio.CancelledError):
|
||||||
|
await self._cleanup_task
|
||||||
|
self._cleanup_task = None
|
||||||
|
|
||||||
|
async def _cleanup_loop(self):
|
||||||
|
while True:
|
||||||
|
await asyncio.sleep(self.CLEANUP_INTERVAL_SECONDS)
|
||||||
|
try:
|
||||||
|
await self.cleanup_expired()
|
||||||
|
except Exception as exc:
|
||||||
|
logger.warning(f"自动清理查询缓存失败:{exc}")
|
||||||
|
|
||||||
async def _load_settings(self):
|
async def _load_settings(self):
|
||||||
rows = await self._sqlite.select_all("cache_settings")
|
rows = await self._sqlite.select_all("cache_settings")
|
||||||
@@ -242,8 +272,18 @@ class CacheService:
|
|||||||
async def _load_limits(self):
|
async def _load_limits(self):
|
||||||
rows = await self._sqlite.select_all("cache_limits")
|
rows = await self._sqlite.select_all("cache_limits")
|
||||||
limits = {str(row["limit_name"]): int(row["limit_value"]) for row in rows}
|
limits = {str(row["limit_name"]): int(row["limit_value"]) for row in rows}
|
||||||
self.max_memory_entries = self._validated_memory_limit(
|
memory_limit_mb = self._validated_memory_limit_mb(
|
||||||
limits.get("api_memory_entries", self.DEFAULT_MAX_MEMORY_ENTRIES)
|
limits.get(
|
||||||
|
"api_memory_max_mb",
|
||||||
|
self.DEFAULT_MAX_MEMORY_BYTES // 1024 // 1024,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
self.max_memory_bytes = memory_limit_mb * 1024 * 1024
|
||||||
|
self.max_api_entries = self._validated_api_entry_limit(
|
||||||
|
limits.get(
|
||||||
|
"api_max_entries",
|
||||||
|
limits.get("api_memory_entries", self.DEFAULT_MAX_API_ENTRIES),
|
||||||
|
)
|
||||||
)
|
)
|
||||||
image_limit_mb = self._validated_image_limit_mb(
|
image_limit_mb = self._validated_image_limit_mb(
|
||||||
limits.get("image_max_mb", self.DEFAULT_MAX_IMAGE_BYTES // 1024 // 1024)
|
limits.get("image_max_mb", self.DEFAULT_MAX_IMAGE_BYTES // 1024 // 1024)
|
||||||
@@ -251,15 +291,27 @@ class CacheService:
|
|||||||
self.max_image_bytes = image_limit_mb * 1024 * 1024
|
self.max_image_bytes = image_limit_mb * 1024 * 1024
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def _validated_memory_limit(cls, value: Any) -> int:
|
def _validated_memory_limit_mb(cls, value: Any) -> int:
|
||||||
if isinstance(value, bool):
|
if isinstance(value, bool):
|
||||||
raise ValueError("接口内存缓存条数必须是整数")
|
raise ValueError("接口内存缓存容量必须是整数 MB")
|
||||||
try:
|
try:
|
||||||
limit = int(value)
|
limit = int(value)
|
||||||
except (TypeError, ValueError) as exc:
|
except (TypeError, ValueError) as exc:
|
||||||
raise ValueError("接口内存缓存条数必须是整数") from exc
|
raise ValueError("接口内存缓存容量必须是整数 MB") from exc
|
||||||
if limit < 1 or limit > cls.MAX_MEMORY_ENTRIES_LIMIT:
|
if limit < 1 or limit > cls.MAX_MEMORY_MB_LIMIT:
|
||||||
raise ValueError("接口内存缓存条数必须在 1 到 100000 之间")
|
raise ValueError("接口内存缓存容量必须在 1 到 1024 MB 之间")
|
||||||
|
return limit
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def _validated_api_entry_limit(cls, value: Any) -> int:
|
||||||
|
if isinstance(value, bool):
|
||||||
|
raise ValueError("SQLite 接口缓存条数必须是整数")
|
||||||
|
try:
|
||||||
|
limit = int(value)
|
||||||
|
except (TypeError, ValueError) as exc:
|
||||||
|
raise ValueError("SQLite 接口缓存条数必须是整数") from exc
|
||||||
|
if limit < 1 or limit > cls.MAX_API_ENTRIES_LIMIT:
|
||||||
|
raise ValueError("SQLite 接口缓存条数必须在 1 到 100000 之间")
|
||||||
return limit
|
return limit
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
@@ -274,11 +326,18 @@ class CacheService:
|
|||||||
raise ValueError("图片缓存容量必须在 1 到 10240 MB 之间")
|
raise ValueError("图片缓存容量必须在 1 到 10240 MB 之间")
|
||||||
return limit
|
return limit
|
||||||
|
|
||||||
async def set_limits(self, api_memory_entries: Any, image_max_mb: Any):
|
async def set_limits(
|
||||||
memory_limit = self._validated_memory_limit(api_memory_entries)
|
self,
|
||||||
|
api_memory_max_mb: Any,
|
||||||
|
api_max_entries: Any,
|
||||||
|
image_max_mb: Any,
|
||||||
|
):
|
||||||
|
memory_limit_mb = self._validated_memory_limit_mb(api_memory_max_mb)
|
||||||
|
api_entry_limit = self._validated_api_entry_limit(api_max_entries)
|
||||||
image_limit_mb = self._validated_image_limit_mb(image_max_mb)
|
image_limit_mb = self._validated_image_limit_mb(image_max_mb)
|
||||||
for limit_name, limit_value in (
|
for limit_name, limit_value in (
|
||||||
("api_memory_entries", memory_limit),
|
("api_memory_max_mb", memory_limit_mb),
|
||||||
|
("api_max_entries", api_entry_limit),
|
||||||
("image_max_mb", image_limit_mb),
|
("image_max_mb", image_limit_mb),
|
||||||
):
|
):
|
||||||
await self._sqlite.execute(
|
await self._sqlite.execute(
|
||||||
@@ -290,9 +349,11 @@ class CacheService:
|
|||||||
""",
|
""",
|
||||||
(limit_name, limit_value),
|
(limit_name, limit_value),
|
||||||
)
|
)
|
||||||
self.max_memory_entries = memory_limit
|
|
||||||
self.max_image_bytes = image_limit_mb * 1024 * 1024
|
self.max_image_bytes = image_limit_mb * 1024 * 1024
|
||||||
self._enforce_memory_limit()
|
async with self._api_storage_lock:
|
||||||
|
self.max_memory_bytes = memory_limit_mb * 1024 * 1024
|
||||||
|
self.max_api_entries = api_entry_limit
|
||||||
|
await self._enforce_api_limit()
|
||||||
await self._enforce_image_limit()
|
await self._enforce_image_limit()
|
||||||
|
|
||||||
def register_image_names(self, names: Iterable[str]):
|
def register_image_names(self, names: Iterable[str]):
|
||||||
@@ -418,62 +479,108 @@ class CacheService:
|
|||||||
self,
|
self,
|
||||||
cache_key: str,
|
cache_key: str,
|
||||||
endpoint: str,
|
endpoint: str,
|
||||||
allow_expired: bool = False,
|
|
||||||
) -> tuple[Any | None, int | None, int | None]:
|
) -> tuple[Any | None, int | None, int | None]:
|
||||||
now = int(time.time())
|
async with self._api_storage_lock:
|
||||||
ttl = self.get_ttl("api", endpoint)
|
now = time.time()
|
||||||
memory = self._memory.get(cache_key)
|
ttl = self.get_ttl("api", endpoint)
|
||||||
if memory is not None:
|
memory = self._memory.get(cache_key)
|
||||||
created_at, expires_at, payload = memory
|
if memory is not None:
|
||||||
|
created_at, expires_at, payload, _ = memory
|
||||||
|
else:
|
||||||
|
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
|
||||||
|
created_at = int(row["created_at"])
|
||||||
|
expires_at = int(row["expires_at"])
|
||||||
|
payload = str(row["payload"])
|
||||||
effective_expiry = min(expires_at, created_at + ttl)
|
effective_expiry = min(expires_at, created_at + ttl)
|
||||||
if allow_expired and effective_expiry <= now - self.STALE_RETENTION_SECONDS:
|
try:
|
||||||
self._memory.pop(cache_key, None)
|
data = (
|
||||||
elif allow_expired or effective_expiry > now:
|
json.loads(payload) if ttl > 0 and effective_expiry > now else None
|
||||||
self._memory.move_to_end(cache_key)
|
)
|
||||||
try:
|
except json.JSONDecodeError:
|
||||||
return json.loads(payload), effective_expiry, created_at
|
data = None
|
||||||
except json.JSONDecodeError:
|
if data is None:
|
||||||
self._memory.pop(cache_key, None)
|
self._forget_memory(cache_key)
|
||||||
|
await self._sqlite.delete(
|
||||||
|
"api_response_cache", "cache_key=?", (cache_key,)
|
||||||
|
)
|
||||||
|
return None, effective_expiry, created_at
|
||||||
|
|
||||||
row = await self._sqlite.fetch_one(
|
self._remember(cache_key, created_at, expires_at, payload)
|
||||||
"""
|
await self._sqlite.execute(
|
||||||
SELECT payload, created_at, expires_at
|
"UPDATE api_response_cache SET last_accessed_at=? WHERE cache_key=?",
|
||||||
FROM api_response_cache
|
(now, cache_key),
|
||||||
WHERE cache_key=? AND endpoint=?
|
)
|
||||||
""",
|
return data, effective_expiry, created_at
|
||||||
(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"]))
|
def _remember(
|
||||||
await self._sqlite.execute(
|
self,
|
||||||
"UPDATE api_response_cache SET last_accessed_at=? WHERE cache_key=?",
|
cache_key: str,
|
||||||
(now, cache_key),
|
created_at: int,
|
||||||
)
|
expires_at: int,
|
||||||
return data, effective_expiry, created_at
|
payload: str | bytes,
|
||||||
|
):
|
||||||
def _remember(self, cache_key: str, created_at: int, expires_at: int, payload: str):
|
self._forget_memory(cache_key)
|
||||||
self._memory[cache_key] = (created_at, expires_at, payload)
|
encoded = payload if isinstance(payload, bytes) else payload.encode("utf-8")
|
||||||
|
size_bytes = len(encoded)
|
||||||
|
if size_bytes > self.max_memory_bytes:
|
||||||
|
return
|
||||||
|
self._memory[cache_key] = (created_at, expires_at, encoded, size_bytes)
|
||||||
|
self._memory_size_bytes += size_bytes
|
||||||
self._memory.move_to_end(cache_key)
|
self._memory.move_to_end(cache_key)
|
||||||
self._enforce_memory_limit()
|
self._enforce_memory_limit()
|
||||||
|
|
||||||
|
def _forget_memory(self, cache_key: str):
|
||||||
|
removed = self._memory.pop(cache_key, None)
|
||||||
|
if removed is not None:
|
||||||
|
self._memory_size_bytes -= removed[3]
|
||||||
|
|
||||||
|
def _clear_memory(self):
|
||||||
|
self._memory.clear()
|
||||||
|
self._memory_size_bytes = 0
|
||||||
|
|
||||||
|
def _retain_memory_keys(self, retained: set[str]):
|
||||||
|
self._memory = OrderedDict(
|
||||||
|
(key, value) for key, value in self._memory.items() if key in retained
|
||||||
|
)
|
||||||
|
self._memory_size_bytes = sum(value[3] for value in self._memory.values())
|
||||||
|
|
||||||
def _enforce_memory_limit(self):
|
def _enforce_memory_limit(self):
|
||||||
while len(self._memory) > self.max_memory_entries:
|
while self._memory_size_bytes > self.max_memory_bytes and self._memory:
|
||||||
self._memory.popitem(last=False)
|
_, removed = self._memory.popitem(last=False)
|
||||||
|
self._memory_size_bytes -= removed[3]
|
||||||
|
|
||||||
|
async def _enforce_api_limit(self):
|
||||||
|
"""Enforce memory bytes and SQLite entries while holding the storage lock."""
|
||||||
|
self._enforce_memory_limit()
|
||||||
|
row = await self._sqlite.fetch_one(
|
||||||
|
"SELECT COUNT(*) AS count FROM api_response_cache"
|
||||||
|
)
|
||||||
|
if int((row or {}).get("count") or 0) <= self.max_api_entries:
|
||||||
|
return
|
||||||
|
await self._sqlite.execute(
|
||||||
|
"""
|
||||||
|
DELETE FROM api_response_cache WHERE cache_key IN (
|
||||||
|
SELECT cache_key FROM api_response_cache
|
||||||
|
ORDER BY last_accessed_at DESC, rowid DESC
|
||||||
|
LIMIT -1 OFFSET ?
|
||||||
|
)
|
||||||
|
""",
|
||||||
|
(self.max_api_entries,),
|
||||||
|
)
|
||||||
|
remaining = await self._sqlite.fetch_all(
|
||||||
|
"SELECT cache_key FROM api_response_cache"
|
||||||
|
)
|
||||||
|
retained = {row["cache_key"] for row in remaining}
|
||||||
|
self._retain_memory_keys(retained)
|
||||||
|
|
||||||
async def _save_api_payload(
|
async def _save_api_payload(
|
||||||
self,
|
self,
|
||||||
@@ -485,8 +592,9 @@ class CacheService:
|
|||||||
payload = self._json(data)
|
payload = self._json(data)
|
||||||
now = int(time.time())
|
now = int(time.time())
|
||||||
expires_at = now + ttl_seconds
|
expires_at = now + ttl_seconds
|
||||||
await self._sqlite.execute(
|
async with self._api_storage_lock:
|
||||||
"""
|
await self._sqlite.execute(
|
||||||
|
"""
|
||||||
INSERT INTO api_response_cache(
|
INSERT INTO api_response_cache(
|
||||||
cache_key, endpoint, payload, created_at, expires_at, last_accessed_at
|
cache_key, endpoint, payload, created_at, expires_at, last_accessed_at
|
||||||
) VALUES(?, ?, ?, ?, ?, ?)
|
) VALUES(?, ?, ?, ?, ?, ?)
|
||||||
@@ -497,9 +605,10 @@ class CacheService:
|
|||||||
expires_at=excluded.expires_at,
|
expires_at=excluded.expires_at,
|
||||||
last_accessed_at=excluded.last_accessed_at
|
last_accessed_at=excluded.last_accessed_at
|
||||||
""",
|
""",
|
||||||
(cache_key, endpoint, payload, now, expires_at, now),
|
(cache_key, endpoint, payload, now, expires_at, time.time()),
|
||||||
)
|
)
|
||||||
self._remember(cache_key, now, expires_at, payload)
|
self._remember(cache_key, now, expires_at, payload)
|
||||||
|
await self._enforce_api_limit()
|
||||||
return now
|
return now
|
||||||
|
|
||||||
async def request_api(
|
async def request_api(
|
||||||
@@ -509,7 +618,6 @@ class CacheService:
|
|||||||
requester: Callable[[], Awaitable[Any]],
|
requester: Callable[[], Awaitable[Any]],
|
||||||
is_cacheable: Callable[[Any], bool],
|
is_cacheable: Callable[[Any], bool],
|
||||||
force_refresh: bool = False,
|
force_refresh: bool = False,
|
||||||
allow_stale: bool = True,
|
|
||||||
) -> tuple[Any, dict[str, Any]]:
|
) -> tuple[Any, dict[str, Any]]:
|
||||||
ttl = self.get_ttl("api", endpoint)
|
ttl = self.get_ttl("api", endpoint)
|
||||||
cache_key = self.build_api_key(endpoint, params)
|
cache_key = self.build_api_key(endpoint, params)
|
||||||
@@ -544,7 +652,9 @@ class CacheService:
|
|||||||
lock = self._api_locks.setdefault(cache_key, asyncio.Lock())
|
lock = self._api_locks.setdefault(cache_key, asyncio.Lock())
|
||||||
async with lock:
|
async with lock:
|
||||||
if not force_refresh:
|
if not force_refresh:
|
||||||
cached, _, created_at = await self._read_api_payload(cache_key, endpoint)
|
cached, _, created_at = await self._read_api_payload(
|
||||||
|
cache_key, endpoint
|
||||||
|
)
|
||||||
if cached is not None:
|
if cached is not None:
|
||||||
metadata["hit"] = True
|
metadata["hit"] = True
|
||||||
metadata["created_at"] = created_at
|
metadata["created_at"] = created_at
|
||||||
@@ -553,12 +663,16 @@ class CacheService:
|
|||||||
).hexdigest()
|
).hexdigest()
|
||||||
return cached, metadata
|
return cached, metadata
|
||||||
|
|
||||||
stale, _, stale_created_at = await self._read_api_payload(
|
data = None
|
||||||
cache_key,
|
try:
|
||||||
endpoint,
|
data = await requester()
|
||||||
allow_expired=True,
|
finally:
|
||||||
)
|
if not is_cacheable(data):
|
||||||
data = await requester()
|
async with self._api_storage_lock:
|
||||||
|
self._forget_memory(cache_key)
|
||||||
|
await self._sqlite.delete(
|
||||||
|
"api_response_cache", "cache_key=?", (cache_key,)
|
||||||
|
)
|
||||||
if is_cacheable(data):
|
if is_cacheable(data):
|
||||||
metadata["data_hash"] = hashlib.sha256(
|
metadata["data_hash"] = hashlib.sha256(
|
||||||
self._json(data).encode("utf-8")
|
self._json(data).encode("utf-8")
|
||||||
@@ -574,15 +688,6 @@ class CacheService:
|
|||||||
metadata["created_at"] = int(time.time())
|
metadata["created_at"] = int(time.time())
|
||||||
logger.warning(f"写入接口缓存失败 endpoint={endpoint}: {exc}")
|
logger.warning(f"写入接口缓存失败 endpoint={endpoint}: {exc}")
|
||||||
return data, metadata
|
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
|
return data, metadata
|
||||||
|
|
||||||
def build_image_key(
|
def build_image_key(
|
||||||
@@ -765,35 +870,56 @@ class CacheService:
|
|||||||
|
|
||||||
async def cleanup_expired(self):
|
async def cleanup_expired(self):
|
||||||
now = int(time.time())
|
now = int(time.time())
|
||||||
self._memory = OrderedDict(
|
async with self._api_storage_lock:
|
||||||
(key, value) for key, value in self._memory.items() if value[1] > now
|
uncached_defaults = sorted(self._NO_CACHE_API_DEFAULTS)
|
||||||
)
|
placeholders = ",".join("?" for _ in uncached_defaults)
|
||||||
await self._sqlite.delete(
|
await self._sqlite.execute(
|
||||||
"api_response_cache",
|
f"""
|
||||||
"expires_at<=?",
|
DELETE FROM api_response_cache
|
||||||
(now - self.STALE_RETENTION_SECONDS,),
|
WHERE expires_at<=? OR created_at + COALESCE(
|
||||||
)
|
(SELECT ttl_seconds FROM cache_settings
|
||||||
|
WHERE cache_type='api' AND cache_name=api_response_cache.endpoint),
|
||||||
|
CASE WHEN endpoint IN ({placeholders}) THEN 0 ELSE ? END
|
||||||
|
)<=?
|
||||||
|
""",
|
||||||
|
(now, *uncached_defaults, self.get_ttl("api", "*"), now),
|
||||||
|
)
|
||||||
|
remaining = await self._sqlite.fetch_all(
|
||||||
|
"SELECT cache_key FROM api_response_cache"
|
||||||
|
)
|
||||||
|
retained = {row["cache_key"] for row in remaining}
|
||||||
|
self._retain_memory_keys(retained)
|
||||||
rows = await self._sqlite.fetch_all(
|
rows = await self._sqlite.fetch_all(
|
||||||
"SELECT cache_key, file_name FROM image_render_cache WHERE expires_at<=?",
|
"SELECT cache_key, file_name FROM image_render_cache WHERE expires_at<=?",
|
||||||
(now,),
|
(now,),
|
||||||
)
|
)
|
||||||
for row in rows:
|
for row in rows:
|
||||||
await self._delete_image_record(
|
cache_key = str(row["cache_key"])
|
||||||
str(row["cache_key"]),
|
async with self.image_lock(cache_key):
|
||||||
self.image_dir / str(row["file_name"]),
|
expired = await self._sqlite.fetch_one(
|
||||||
)
|
"""
|
||||||
|
SELECT file_name FROM image_render_cache
|
||||||
|
WHERE cache_key=? AND expires_at<=?
|
||||||
|
""",
|
||||||
|
(cache_key, now),
|
||||||
|
)
|
||||||
|
if expired:
|
||||||
|
await self._delete_image_record(
|
||||||
|
cache_key, self.image_dir / str(expired["file_name"])
|
||||||
|
)
|
||||||
|
|
||||||
async def clear(self, cache_type: str) -> dict[str, int]:
|
async def clear(self, cache_type: str) -> dict[str, int]:
|
||||||
if cache_type not in {"api", "image", "all"}:
|
if cache_type not in {"api", "image", "all"}:
|
||||||
raise ValueError("清理类型仅支持 api、image 或 all")
|
raise ValueError("清理类型仅支持 api、image 或 all")
|
||||||
removed = {"api": 0, "image": 0}
|
removed = {"api": 0, "image": 0}
|
||||||
if cache_type in {"api", "all"}:
|
if cache_type in {"api", "all"}:
|
||||||
row = await self._sqlite.fetch_one(
|
async with self._api_storage_lock:
|
||||||
"SELECT COUNT(*) AS count FROM api_response_cache"
|
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")
|
removed["api"] = int((row or {}).get("count") or 0)
|
||||||
self._memory.clear()
|
await self._sqlite.execute("DELETE FROM api_response_cache")
|
||||||
|
self._clear_memory()
|
||||||
if cache_type in {"image", "all"}:
|
if cache_type in {"image", "all"}:
|
||||||
rows = await self._sqlite.fetch_all(
|
rows = await self._sqlite.fetch_all(
|
||||||
"SELECT cache_key, file_name FROM image_render_cache"
|
"SELECT cache_key, file_name FROM image_render_cache"
|
||||||
@@ -814,18 +940,19 @@ class CacheService:
|
|||||||
raise ValueError("缓存项目不能为空")
|
raise ValueError("缓存项目不能为空")
|
||||||
|
|
||||||
if cache_type == "api":
|
if cache_type == "api":
|
||||||
rows = await self._sqlite.fetch_all(
|
async with self._api_storage_lock:
|
||||||
"SELECT cache_key FROM api_response_cache WHERE endpoint=?",
|
rows = await self._sqlite.fetch_all(
|
||||||
(cache_name,),
|
"SELECT cache_key FROM api_response_cache WHERE endpoint=?",
|
||||||
)
|
(cache_name,),
|
||||||
await self._sqlite.delete(
|
)
|
||||||
"api_response_cache",
|
await self._sqlite.delete(
|
||||||
"endpoint=?",
|
"api_response_cache",
|
||||||
(cache_name,),
|
"endpoint=?",
|
||||||
)
|
(cache_name,),
|
||||||
for row in rows:
|
)
|
||||||
self._memory.pop(str(row["cache_key"]), None)
|
for row in rows:
|
||||||
return len(rows)
|
self._forget_memory(str(row["cache_key"]))
|
||||||
|
return len(rows)
|
||||||
|
|
||||||
rows = await self._sqlite.fetch_all(
|
rows = await self._sqlite.fetch_all(
|
||||||
"""
|
"""
|
||||||
@@ -892,7 +1019,8 @@ class CacheService:
|
|||||||
"image": self.get_ttl("image", "*"),
|
"image": self.get_ttl("image", "*"),
|
||||||
},
|
},
|
||||||
"limits": {
|
"limits": {
|
||||||
"api_memory_entries": self.max_memory_entries,
|
"api_memory_max_mb": self.max_memory_bytes // 1024 // 1024,
|
||||||
|
"api_max_entries": self.max_api_entries,
|
||||||
"image_max_mb": self.max_image_bytes // 1024 // 1024,
|
"image_max_mb": self.max_image_bytes // 1024 // 1024,
|
||||||
},
|
},
|
||||||
"api": [
|
"api": [
|
||||||
@@ -908,7 +1036,9 @@ class CacheService:
|
|||||||
"api_count": int((api_row or {}).get("count") or 0),
|
"api_count": int((api_row or {}).get("count") or 0),
|
||||||
"api_size_bytes": int((api_row or {}).get("size_bytes") or 0),
|
"api_size_bytes": int((api_row or {}).get("size_bytes") or 0),
|
||||||
"api_memory_count": len(self._memory),
|
"api_memory_count": len(self._memory),
|
||||||
"api_memory_limit": self.max_memory_entries,
|
"api_memory_size_bytes": self._memory_size_bytes,
|
||||||
|
"api_memory_limit_bytes": self.max_memory_bytes,
|
||||||
|
"api_entry_limit": self.max_api_entries,
|
||||||
"image_count": int((image_row or {}).get("count") or 0),
|
"image_count": int((image_row or {}).get("count") or 0),
|
||||||
"image_size_bytes": int((image_row or {}).get("size_bytes") or 0),
|
"image_size_bytes": int((image_row or {}).get("size_bytes") or 0),
|
||||||
"image_limit_bytes": self.max_image_bytes,
|
"image_limit_bytes": self.max_image_bytes,
|
||||||
|
|||||||
+137
-45
@@ -3,7 +3,7 @@ import asyncio
|
|||||||
import contextlib
|
import contextlib
|
||||||
import json
|
import json
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from typing import Any, Optional
|
from typing import Any
|
||||||
from urllib.parse import parse_qsl, urlencode, urlsplit, urlunsplit
|
from urllib.parse import parse_qsl, urlencode, urlsplit, urlunsplit
|
||||||
|
|
||||||
import aiohttp
|
import aiohttp
|
||||||
@@ -13,10 +13,9 @@ from astrbot.api import AstrBotConfig, logger
|
|||||||
from astrbot.api.event import MessageChain
|
from astrbot.api.event import MessageChain
|
||||||
from astrbot.api.star import Context
|
from astrbot.api.star import Context
|
||||||
|
|
||||||
from .sqlite import AsyncSQLiteDB
|
|
||||||
from .server_binding import ServerBindingService
|
from .server_binding import ServerBindingService
|
||||||
from .session_control import SessionControlService
|
from .session_control import SessionControlService
|
||||||
|
from .sqlite import AsyncSQLiteDB
|
||||||
|
|
||||||
DEFAULT_WSS_URL = "wss://socket.nicemoe.cn"
|
DEFAULT_WSS_URL = "wss://socket.nicemoe.cn"
|
||||||
FREE_EVENT_ACTIONS = frozenset({2001, 2002, 2003, 2004, 2005, 2006})
|
FREE_EVENT_ACTIONS = frozenset({2001, 2002, 2003, 2004, 2005, 2006})
|
||||||
@@ -68,17 +67,20 @@ SERVER_FIELDS = (
|
|||||||
("服务器", "server", "text"),
|
("服务器", "server", "text"),
|
||||||
)
|
)
|
||||||
EVENT_FIELDS = {
|
EVENT_FIELDS = {
|
||||||
1001: SERVER_FIELDS + (
|
1001: SERVER_FIELDS
|
||||||
|
+ (
|
||||||
("角色", "name", "text"),
|
("角色", "name", "text"),
|
||||||
("奇遇", "event", "text"),
|
("奇遇", "event", "text"),
|
||||||
("等级", "level", "text"),
|
("等级", "level", "text"),
|
||||||
("时间", "time", "time"),
|
("时间", "time", "time"),
|
||||||
),
|
),
|
||||||
1002: SERVER_FIELDS + (
|
1002: SERVER_FIELDS
|
||||||
|
+ (
|
||||||
("地图", "map_name", "text"),
|
("地图", "map_name", "text"),
|
||||||
("刷新时间", "time", "time"),
|
("刷新时间", "time", "time"),
|
||||||
),
|
),
|
||||||
1003: SERVER_FIELDS + (
|
1003: SERVER_FIELDS
|
||||||
|
+ (
|
||||||
("名称", "name", "text"),
|
("名称", "name", "text"),
|
||||||
("地图", "map_name", "text"),
|
("地图", "map_name", "text"),
|
||||||
("马驹", "horse", "text"),
|
("马驹", "horse", "text"),
|
||||||
@@ -87,79 +89,92 @@ EVENT_FIELDS = {
|
|||||||
),
|
),
|
||||||
1004: SERVER_FIELDS + (("预告时间", "time", "time"),),
|
1004: SERVER_FIELDS + (("预告时间", "time", "time"),),
|
||||||
1005: SERVER_FIELDS + (("开启时间", "time", "time"),),
|
1005: SERVER_FIELDS + (("开启时间", "time", "time"),),
|
||||||
1006: SERVER_FIELDS + (
|
1006: SERVER_FIELDS
|
||||||
|
+ (
|
||||||
("点名角色", "name", "list"),
|
("点名角色", "name", "list"),
|
||||||
("时间", "time", "time"),
|
("时间", "time", "time"),
|
||||||
),
|
),
|
||||||
1007: SERVER_FIELDS + (
|
1007: SERVER_FIELDS
|
||||||
|
+ (
|
||||||
("燃放者", "sender", "text"),
|
("燃放者", "sender", "text"),
|
||||||
("接收者", "receiver", "text"),
|
("接收者", "receiver", "text"),
|
||||||
("烟花", "firework", "text"),
|
("烟花", "firework", "text"),
|
||||||
("地图", "map_name", "text"),
|
("地图", "map_name", "text"),
|
||||||
("时间", "time", "time"),
|
("时间", "time", "time"),
|
||||||
),
|
),
|
||||||
1008: SERVER_FIELDS + (
|
1008: SERVER_FIELDS
|
||||||
|
+ (
|
||||||
("马驹", "name", "text"),
|
("马驹", "name", "text"),
|
||||||
("地图", "map_name", "text"),
|
("地图", "map_name", "text"),
|
||||||
("预告时间", "time", "time"),
|
("预告时间", "time", "time"),
|
||||||
),
|
),
|
||||||
1009: SERVER_FIELDS + (
|
1009: SERVER_FIELDS
|
||||||
|
+ (
|
||||||
("马驹", "name", "text"),
|
("马驹", "name", "text"),
|
||||||
("地图", "map_name", "text"),
|
("地图", "map_name", "text"),
|
||||||
("刷新时间", "refresh_time", "time"),
|
("刷新时间", "refresh_time", "time"),
|
||||||
),
|
),
|
||||||
1010: SERVER_FIELDS + (
|
1010: SERVER_FIELDS
|
||||||
|
+ (
|
||||||
("马驹", "name", "text"),
|
("马驹", "name", "text"),
|
||||||
("地图", "map_name", "text"),
|
("地图", "map_name", "text"),
|
||||||
("捕获角色", "capture_role_name", "text"),
|
("捕获角色", "capture_role_name", "text"),
|
||||||
("角色阵营", "capture_camp_name", "text"),
|
("角色阵营", "capture_camp_name", "text"),
|
||||||
("捕获时间", "capture_time", "time"),
|
("捕获时间", "capture_time", "time"),
|
||||||
),
|
),
|
||||||
1011: SERVER_FIELDS + (
|
1011: SERVER_FIELDS
|
||||||
|
+ (
|
||||||
("马驹", "name", "text"),
|
("马驹", "name", "text"),
|
||||||
("竞拍角色", "auction_role_name", "text"),
|
("竞拍角色", "auction_role_name", "text"),
|
||||||
("角色阵营", "auction_camp_name", "text"),
|
("角色阵营", "auction_camp_name", "text"),
|
||||||
("成交金额", "auction_amount", "text"),
|
("成交金额", "auction_amount", "text"),
|
||||||
("拍卖时间", "auction_time", "time"),
|
("拍卖时间", "auction_time", "time"),
|
||||||
),
|
),
|
||||||
1012: SERVER_FIELDS + (
|
1012: SERVER_FIELDS
|
||||||
|
+ (
|
||||||
("角色", "role_name", "text"),
|
("角色", "role_name", "text"),
|
||||||
("副本", "map_name", "text"),
|
("副本", "map_name", "text"),
|
||||||
("物品", "item_name", "text"),
|
("物品", "item_name", "text"),
|
||||||
("时间", "time", "time"),
|
("时间", "time", "time"),
|
||||||
),
|
),
|
||||||
1013: SERVER_FIELDS + (
|
1013: SERVER_FIELDS
|
||||||
|
+ (
|
||||||
("竞拍角色", "role_name", "text"),
|
("竞拍角色", "role_name", "text"),
|
||||||
("阵营", "camp_name", "text"),
|
("阵营", "camp_name", "text"),
|
||||||
("物品", "item_name", "text"),
|
("物品", "item_name", "text"),
|
||||||
("成交金额", "item_amount", "text"),
|
("成交金额", "item_amount", "text"),
|
||||||
("时间", "time", "time"),
|
("时间", "time", "time"),
|
||||||
),
|
),
|
||||||
1014: SERVER_FIELDS + (
|
1014: SERVER_FIELDS
|
||||||
|
+ (
|
||||||
("地图", "map_name", "text"),
|
("地图", "map_name", "text"),
|
||||||
("时间", "time", "time"),
|
("时间", "time", "time"),
|
||||||
),
|
),
|
||||||
1015: SERVER_FIELDS + (
|
1015: SERVER_FIELDS
|
||||||
|
+ (
|
||||||
("角色所在服", "role_server", "text"),
|
("角色所在服", "role_server", "text"),
|
||||||
("点名角色", "role_name", "text"),
|
("点名角色", "role_name", "text"),
|
||||||
("时间", "time", "time"),
|
("时间", "time", "time"),
|
||||||
),
|
),
|
||||||
1016: SERVER_FIELDS + (("预告时间", "time", "time"),),
|
1016: SERVER_FIELDS + (("预告时间", "time", "time"),),
|
||||||
1017: SERVER_FIELDS + (
|
1017: SERVER_FIELDS
|
||||||
|
+ (
|
||||||
("阵营", "camp_name", "text"),
|
("阵营", "camp_name", "text"),
|
||||||
("帮会", "tong_name", "text"),
|
("帮会", "tong_name", "text"),
|
||||||
("角色", "role_name", "text"),
|
("角色", "role_name", "text"),
|
||||||
("据点", "castle_name", "text"),
|
("据点", "castle_name", "text"),
|
||||||
("时间", "time", "time"),
|
("时间", "time", "time"),
|
||||||
),
|
),
|
||||||
1101: SERVER_FIELDS + (
|
1101: SERVER_FIELDS
|
||||||
|
+ (
|
||||||
("战场类型", "battlefield_type", "text"),
|
("战场类型", "battlefield_type", "text"),
|
||||||
("宣战帮会", "declaring_tong_name", "text"),
|
("宣战帮会", "declaring_tong_name", "text"),
|
||||||
("应战帮会", "accepting_tong_name", "text"),
|
("应战帮会", "accepting_tong_name", "text"),
|
||||||
("领地帮会", "battlefield_tong_name", "text"),
|
("领地帮会", "battlefield_tong_name", "text"),
|
||||||
("开始时间", "start_time", "time"),
|
("开始时间", "start_time", "time"),
|
||||||
),
|
),
|
||||||
1102: SERVER_FIELDS + (
|
1102: SERVER_FIELDS
|
||||||
|
+ (
|
||||||
("战场类型", "battlefield_type", "text"),
|
("战场类型", "battlefield_type", "text"),
|
||||||
("宣战帮会", "declaring_tong_name", "text"),
|
("宣战帮会", "declaring_tong_name", "text"),
|
||||||
("应战帮会", "accepting_tong_name", "text"),
|
("应战帮会", "accepting_tong_name", "text"),
|
||||||
@@ -168,87 +183,102 @@ EVENT_FIELDS = {
|
|||||||
("获胜积分", "victory_score", "text"),
|
("获胜积分", "victory_score", "text"),
|
||||||
("结束时间", "end_time", "time"),
|
("结束时间", "end_time", "time"),
|
||||||
),
|
),
|
||||||
1108: SERVER_FIELDS + (
|
1108: SERVER_FIELDS
|
||||||
|
+ (
|
||||||
("战场类型", "battlefield_type", "text"),
|
("战场类型", "battlefield_type", "text"),
|
||||||
("宣战帮会", "declaring_tong_name", "text"),
|
("宣战帮会", "declaring_tong_name", "text"),
|
||||||
("应战帮会", "accepting_tong_name", "text"),
|
("应战帮会", "accepting_tong_name", "text"),
|
||||||
("持续时长(小时)", "duration_hours", "text"),
|
("持续时长(小时)", "duration_hours", "text"),
|
||||||
("开始时间", "start_time", "time"),
|
("开始时间", "start_time", "time"),
|
||||||
),
|
),
|
||||||
1109: SERVER_FIELDS + (
|
1109: SERVER_FIELDS
|
||||||
|
+ (
|
||||||
("战场类型", "battlefield_type", "text"),
|
("战场类型", "battlefield_type", "text"),
|
||||||
("宣战帮会", "declaring_tong_name", "text"),
|
("宣战帮会", "declaring_tong_name", "text"),
|
||||||
("应战帮会", "accepting_tong_name", "text"),
|
("应战帮会", "accepting_tong_name", "text"),
|
||||||
("结束时间", "end_time", "time"),
|
("结束时间", "end_time", "time"),
|
||||||
),
|
),
|
||||||
1111: SERVER_FIELDS + (
|
1111: SERVER_FIELDS
|
||||||
|
+ (
|
||||||
("据点", "castle_name", "text"),
|
("据点", "castle_name", "text"),
|
||||||
("阵营", "camp_name", "text"),
|
("阵营", "camp_name", "text"),
|
||||||
("时间", "time", "time"),
|
("时间", "time", "time"),
|
||||||
),
|
),
|
||||||
1112: SERVER_FIELDS + (
|
1112: SERVER_FIELDS
|
||||||
|
+ (
|
||||||
("据点", "castle_name", "text"),
|
("据点", "castle_name", "text"),
|
||||||
("时间", "time", "time"),
|
("时间", "time", "time"),
|
||||||
),
|
),
|
||||||
1113: SERVER_FIELDS + (
|
1113: SERVER_FIELDS
|
||||||
|
+ (
|
||||||
("阵营", "camp_name", "text"),
|
("阵营", "camp_name", "text"),
|
||||||
("地图", "map_name", "text"),
|
("地图", "map_name", "text"),
|
||||||
("据点", "castle_name", "text"),
|
("据点", "castle_name", "text"),
|
||||||
("时间", "time", "time"),
|
("时间", "time", "time"),
|
||||||
),
|
),
|
||||||
1114: SERVER_FIELDS + (
|
1114: SERVER_FIELDS
|
||||||
|
+ (
|
||||||
("阵营", "camp_name", "text"),
|
("阵营", "camp_name", "text"),
|
||||||
("帮会", "tong_name", "text"),
|
("帮会", "tong_name", "text"),
|
||||||
("据点", "castle_name", "text"),
|
("据点", "castle_name", "text"),
|
||||||
("时间", "time", "time"),
|
("时间", "time", "time"),
|
||||||
),
|
),
|
||||||
1115: SERVER_FIELDS + (
|
1115: SERVER_FIELDS
|
||||||
|
+ (
|
||||||
("阵营", "camp_name", "text"),
|
("阵营", "camp_name", "text"),
|
||||||
("据点", "castle_name", "text"),
|
("据点", "castle_name", "text"),
|
||||||
("时间", "time", "time"),
|
("时间", "time", "time"),
|
||||||
),
|
),
|
||||||
1116: SERVER_FIELDS + (
|
1116: SERVER_FIELDS
|
||||||
|
+ (
|
||||||
("阵营", "camp_name", "text"),
|
("阵营", "camp_name", "text"),
|
||||||
("贡献帮会", "tong_name", "list"),
|
("贡献帮会", "tong_name", "list"),
|
||||||
("时间", "time", "time"),
|
("时间", "time", "time"),
|
||||||
),
|
),
|
||||||
1117: SERVER_FIELDS + (
|
1117: SERVER_FIELDS
|
||||||
|
+ (
|
||||||
("阵营", "camp_name", "text"),
|
("阵营", "camp_name", "text"),
|
||||||
("贡献帮会", "tong_name", "list"),
|
("贡献帮会", "tong_name", "list"),
|
||||||
("时间", "time", "time"),
|
("时间", "time", "time"),
|
||||||
),
|
),
|
||||||
1118: SERVER_FIELDS + (
|
1118: SERVER_FIELDS
|
||||||
|
+ (
|
||||||
("阵营", "camp_name", "text"),
|
("阵营", "camp_name", "text"),
|
||||||
("贡献帮会", "tong_name", "list"),
|
("贡献帮会", "tong_name", "list"),
|
||||||
("时间", "time", "time"),
|
("时间", "time", "time"),
|
||||||
),
|
),
|
||||||
1119: SERVER_FIELDS + (
|
1119: SERVER_FIELDS
|
||||||
|
+ (
|
||||||
("阵营", "camp_name", "text"),
|
("阵营", "camp_name", "text"),
|
||||||
("竞拍角色", "role_name", "text"),
|
("竞拍角色", "role_name", "text"),
|
||||||
("物品", "item_name", "text"),
|
("物品", "item_name", "text"),
|
||||||
("成交金额", "item_amount", "text"),
|
("成交金额", "item_amount", "text"),
|
||||||
("时间", "time", "time"),
|
("时间", "time", "time"),
|
||||||
),
|
),
|
||||||
1120: SERVER_FIELDS + (
|
1120: SERVER_FIELDS
|
||||||
|
+ (
|
||||||
("阵营", "camp_name", "text"),
|
("阵营", "camp_name", "text"),
|
||||||
("分红帮会", "tong_name", "list"),
|
("分红帮会", "tong_name", "list"),
|
||||||
("分红金额", "split_amount", "text"),
|
("分红金额", "split_amount", "text"),
|
||||||
("时间", "time", "time"),
|
("时间", "time", "time"),
|
||||||
),
|
),
|
||||||
1121: SERVER_FIELDS + (
|
1121: SERVER_FIELDS
|
||||||
|
+ (
|
||||||
("阵营", "camp_name", "text"),
|
("阵营", "camp_name", "text"),
|
||||||
("分红帮会", "tong_name", "list"),
|
("分红帮会", "tong_name", "list"),
|
||||||
("分红金额", "split_amount", "text"),
|
("分红金额", "split_amount", "text"),
|
||||||
("时间", "time", "time"),
|
("时间", "time", "time"),
|
||||||
),
|
),
|
||||||
1122: SERVER_FIELDS + (
|
1122: SERVER_FIELDS
|
||||||
|
+ (
|
||||||
("阵营", "camp_name", "text"),
|
("阵营", "camp_name", "text"),
|
||||||
("指挥帮会", "chief_tong_name", "text"),
|
("指挥帮会", "chief_tong_name", "text"),
|
||||||
("分红帮会", "tong_name", "list"),
|
("分红帮会", "tong_name", "list"),
|
||||||
("分红金额", "split_amount", "text"),
|
("分红金额", "split_amount", "text"),
|
||||||
("时间", "time", "time"),
|
("时间", "time", "time"),
|
||||||
),
|
),
|
||||||
2001: SERVER_FIELDS + (
|
2001: SERVER_FIELDS
|
||||||
|
+ (
|
||||||
("状态", "status", "status"),
|
("状态", "status", "status"),
|
||||||
("时间", "time", "time"),
|
("时间", "time", "time"),
|
||||||
),
|
),
|
||||||
@@ -304,9 +334,9 @@ class EventPushService:
|
|||||||
self.session_control = session_control
|
self.session_control = session_control
|
||||||
self.url = str(config.get("jx3api_wss", "") or DEFAULT_WSS_URL).strip()
|
self.url = str(config.get("jx3api_wss", "") or DEFAULT_WSS_URL).strip()
|
||||||
self.token = str(config.get("jx3api_wss_token", "") or "").strip()
|
self.token = str(config.get("jx3api_wss_token", "") or "").strip()
|
||||||
self._runner: Optional[asyncio.Task] = None
|
self._runner: asyncio.Task | None = None
|
||||||
self._session: Optional[ClientSession] = None
|
self._session: ClientSession | None = None
|
||||||
self._websocket: Optional[aiohttp.ClientWebSocketResponse] = None
|
self._websocket: aiohttp.ClientWebSocketResponse | None = None
|
||||||
self._stopping = asyncio.Event()
|
self._stopping = asyncio.Event()
|
||||||
|
|
||||||
async def initialize(self):
|
async def initialize(self):
|
||||||
@@ -321,8 +351,7 @@ class EventPushService:
|
|||||||
|
|
||||||
async def _init_subscription_table(self):
|
async def _init_subscription_table(self):
|
||||||
columns = ",\n".join(
|
columns = ",\n".join(
|
||||||
f"action_{action} INTEGER NOT NULL DEFAULT 0"
|
f"action_{action} INTEGER NOT NULL DEFAULT 0" for action in EVENT_ACTIONS
|
||||||
for action in EVENT_ACTIONS
|
|
||||||
)
|
)
|
||||||
await self.sql.execute(
|
await self.sql.execute(
|
||||||
f"""
|
f"""
|
||||||
@@ -418,7 +447,7 @@ class EventPushService:
|
|||||||
if self._stopping.is_set():
|
if self._stopping.is_set():
|
||||||
break
|
break
|
||||||
|
|
||||||
delay = min(2 ** retry_count, 30)
|
delay = min(2**retry_count, 30)
|
||||||
retry_count += 1
|
retry_count += 1
|
||||||
logger.info(f"JX3API 事件通道将在 {delay} 秒后重连")
|
logger.info(f"JX3API 事件通道将在 {delay} 秒后重连")
|
||||||
try:
|
try:
|
||||||
@@ -543,6 +572,72 @@ class EventPushService:
|
|||||||
for row in rows
|
for row in rows
|
||||||
]
|
]
|
||||||
|
|
||||||
|
async def save_subscription(
|
||||||
|
self, session_id: Any, enabled: Any, actions: Any, mode: Any
|
||||||
|
):
|
||||||
|
"""Create or replace a session's complete subscription selection.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
session_id: AstrBot unified message origin.
|
||||||
|
enabled: Boolean master switch.
|
||||||
|
actions: List of supported integer event IDs; empty clears selections.
|
||||||
|
mode: Either create or update, checked atomically against the database.
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
ValueError: Invalid input, duplicate creation, or missing update target.
|
||||||
|
"""
|
||||||
|
if not isinstance(session_id, str) or not session_id.strip():
|
||||||
|
raise ValueError("会话 ID 不能为空,且必须是字符串")
|
||||||
|
session_id = session_id.strip()
|
||||||
|
if len(session_id) > 512:
|
||||||
|
raise ValueError("会话 ID 不能超过 512 个字符")
|
||||||
|
if not isinstance(enabled, bool):
|
||||||
|
raise ValueError("事件推送总开关必须是布尔值")
|
||||||
|
if not isinstance(actions, list):
|
||||||
|
raise ValueError("已订阅事件必须是事件编号数组")
|
||||||
|
if any(
|
||||||
|
type(action) is not int or action not in EVENT_NAMES for action in actions
|
||||||
|
):
|
||||||
|
raise ValueError("已订阅事件包含不支持的事件编号")
|
||||||
|
if mode not in ("create", "update"):
|
||||||
|
raise ValueError("保存模式必须是 create 或 update")
|
||||||
|
|
||||||
|
selected = set(actions)
|
||||||
|
data: dict[str, Any] = {"enabled": int(enabled)}
|
||||||
|
data.update(
|
||||||
|
{
|
||||||
|
self._action_column(action): int(action in selected)
|
||||||
|
for action in EVENT_ACTIONS
|
||||||
|
}
|
||||||
|
)
|
||||||
|
data["updated_at"] = self._now_text()
|
||||||
|
if mode == "create":
|
||||||
|
columns = ", ".join(data)
|
||||||
|
placeholders = ", ".join("?" for _ in data)
|
||||||
|
affected = await self.sql.execute_affected(
|
||||||
|
f"INSERT INTO event_push_subscriptions (session_id, {columns}) "
|
||||||
|
f"VALUES (?, {placeholders}) ON CONFLICT(session_id) DO NOTHING",
|
||||||
|
(session_id, *data.values()),
|
||||||
|
)
|
||||||
|
if not affected:
|
||||||
|
raise ValueError("该会话已有推送配置,请在列表中点击编辑")
|
||||||
|
else:
|
||||||
|
assignments = ", ".join(f"{column}=?" for column in data)
|
||||||
|
affected = await self.sql.execute_affected(
|
||||||
|
f"UPDATE event_push_subscriptions SET {assignments} WHERE session_id=?",
|
||||||
|
(*data.values(), session_id),
|
||||||
|
)
|
||||||
|
if not affected:
|
||||||
|
raise ValueError("该会话推送配置已不存在,请刷新页面后重新添加")
|
||||||
|
|
||||||
|
async def delete_subscription(self, session_id: Any):
|
||||||
|
if not isinstance(session_id, str) or not session_id.strip():
|
||||||
|
raise ValueError("会话 ID 不能为空,且必须是字符串")
|
||||||
|
session_id = session_id.strip()
|
||||||
|
if len(session_id) > 512:
|
||||||
|
raise ValueError("会话 ID 不能超过 512 个字符")
|
||||||
|
await self.sql.delete("event_push_subscriptions", "session_id=?", (session_id,))
|
||||||
|
|
||||||
async def configure(
|
async def configure(
|
||||||
self,
|
self,
|
||||||
session_id: str,
|
session_id: str,
|
||||||
@@ -624,9 +719,7 @@ class EventPushService:
|
|||||||
switch = "开启" if row.get("enabled") == 1 else "关闭"
|
switch = "开启" if row.get("enabled") == 1 else "关闭"
|
||||||
selected = "、".join(subscriptions) if subscriptions else "无"
|
selected = "、".join(subscriptions) if subscriptions else "无"
|
||||||
return (
|
return (
|
||||||
f"事件推送总开关:{switch}\n"
|
f"事件推送总开关:{switch}\n已订阅事件:{selected}\n\n{self._usage_text()}"
|
||||||
f"已订阅事件:{selected}\n\n"
|
|
||||||
f"{self._usage_text()}"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
@@ -688,8 +781,7 @@ class EventPushService:
|
|||||||
@staticmethod
|
@staticmethod
|
||||||
def _event_list_text() -> str:
|
def _event_list_text() -> str:
|
||||||
free = "\n".join(
|
free = "\n".join(
|
||||||
f"{action}:{EVENT_NAMES[action]}"
|
f"{action}:{EVENT_NAMES[action]}" for action in sorted(FREE_EVENT_ACTIONS)
|
||||||
for action in sorted(FREE_EVENT_ACTIONS)
|
|
||||||
)
|
)
|
||||||
paid = "\n".join(
|
paid = "\n".join(
|
||||||
f"{action}:{EVENT_NAMES[action]}"
|
f"{action}:{EVENT_NAMES[action]}"
|
||||||
|
|||||||
@@ -120,7 +120,6 @@ class JX3APIService:
|
|||||||
"/server/status/check",
|
"/server/status/check",
|
||||||
{"server": "", "type": "其他"},
|
{"server": "", "type": "其他"},
|
||||||
force_refresh=force_refresh,
|
force_refresh=force_refresh,
|
||||||
allow_stale=not force_refresh,
|
|
||||||
)
|
)
|
||||||
if not isinstance(data, list):
|
if not isinstance(data, list):
|
||||||
return []
|
return []
|
||||||
@@ -250,7 +249,6 @@ class JX3APIService:
|
|||||||
params: Optional[Dict[str, Any]] = None,
|
params: Optional[Dict[str, Any]] = None,
|
||||||
out: Optional[str] = "data",
|
out: Optional[str] = "data",
|
||||||
force_refresh: bool = False,
|
force_refresh: bool = False,
|
||||||
allow_stale: bool = True,
|
|
||||||
) -> tuple[Any, dict[str, Any]]:
|
) -> tuple[Any, dict[str, Any]]:
|
||||||
request_params = params or {}
|
request_params = params or {}
|
||||||
if not self._cache:
|
if not self._cache:
|
||||||
@@ -279,7 +277,6 @@ class JX3APIService:
|
|||||||
lambda: self._base_request(api_path, request_params, out),
|
lambda: self._base_request(api_path, request_params, out),
|
||||||
self._is_cacheable_response,
|
self._is_cacheable_response,
|
||||||
force_refresh=force_refresh,
|
force_refresh=force_refresh,
|
||||||
allow_stale=allow_stale,
|
|
||||||
)
|
)
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
logger.warning(f"接口缓存不可用,直接请求 JX3API endpoint={api_path}: {exc}")
|
logger.warning(f"接口缓存不可用,直接请求 JX3API endpoint={api_path}: {exc}")
|
||||||
|
|||||||
+39
-8
@@ -7,7 +7,7 @@ from typing import TYPE_CHECKING, Any
|
|||||||
from astrbot.api.star import Context
|
from astrbot.api.star import Context
|
||||||
from astrbot.api.web import error_response, json_response, request
|
from astrbot.api.web import error_response, json_response, request
|
||||||
|
|
||||||
from .event_push import EVENT_NAMES
|
from .event_push import EVENT_NAMES, FREE_EVENT_ACTIONS
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
from .bilei_data import BiLeidata
|
from .bilei_data import BiLeidata
|
||||||
@@ -43,6 +43,18 @@ class WebUIService:
|
|||||||
def register(self, context: Context, plugin_name: str):
|
def register(self, context: Context, plugin_name: str):
|
||||||
routes = (
|
routes = (
|
||||||
("dashboard", self.dashboard, ["GET"], "读取会话管理数据"),
|
("dashboard", self.dashboard, ["GET"], "读取会话管理数据"),
|
||||||
|
(
|
||||||
|
"subscriptions/save",
|
||||||
|
self.save_subscription,
|
||||||
|
["POST"],
|
||||||
|
"保存会话事件推送配置",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"subscriptions/delete",
|
||||||
|
self.delete_subscription,
|
||||||
|
["POST"],
|
||||||
|
"删除会话事件推送配置",
|
||||||
|
),
|
||||||
("bindings/save", self.save_binding, ["POST"], "保存会话区服绑定"),
|
("bindings/save", self.save_binding, ["POST"], "保存会话区服绑定"),
|
||||||
("bindings/delete", self.delete_binding, ["POST"], "删除会话区服绑定"),
|
("bindings/delete", self.delete_binding, ["POST"], "删除会话区服绑定"),
|
||||||
("aliases/save", self.save_aliases, ["POST"], "保存区服别名"),
|
("aliases/save", self.save_aliases, ["POST"], "保存区服别名"),
|
||||||
@@ -130,9 +142,8 @@ class WebUIService:
|
|||||||
"aliases": aliases,
|
"aliases": aliases,
|
||||||
"kungfu": kungfu,
|
"kungfu": kungfu,
|
||||||
"servers": self.server_binding.standard_servers(),
|
"servers": self.server_binding.standard_servers(),
|
||||||
"events": {
|
"events": {str(action): name for action, name in EVENT_NAMES.items()},
|
||||||
str(action): name for action, name in EVENT_NAMES.items()
|
"free_event_actions": sorted(FREE_EVENT_ACTIONS),
|
||||||
},
|
|
||||||
"session_control": session_control,
|
"session_control": session_control,
|
||||||
"legacy_bilei": legacy_bilei,
|
"legacy_bilei": legacy_bilei,
|
||||||
"token_stats": token_stats,
|
"token_stats": token_stats,
|
||||||
@@ -140,12 +151,31 @@ class WebUIService:
|
|||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
|
async def save_subscription(self):
|
||||||
|
try:
|
||||||
|
payload = await self._json_payload()
|
||||||
|
await self.event_push.save_subscription(
|
||||||
|
payload.get("session_id"),
|
||||||
|
payload.get("enabled"),
|
||||||
|
payload.get("actions"),
|
||||||
|
payload.get("mode"),
|
||||||
|
)
|
||||||
|
except ValueError as exc:
|
||||||
|
return error_response(str(exc), status_code=400)
|
||||||
|
return json_response({"saved": True})
|
||||||
|
|
||||||
|
async def delete_subscription(self):
|
||||||
|
try:
|
||||||
|
payload = await self._json_payload()
|
||||||
|
await self.event_push.delete_subscription(payload.get("session_id"))
|
||||||
|
except ValueError as exc:
|
||||||
|
return error_response(str(exc), status_code=400)
|
||||||
|
return json_response({"deleted": True})
|
||||||
|
|
||||||
async def save_binding(self):
|
async def save_binding(self):
|
||||||
try:
|
try:
|
||||||
payload = await self._json_payload()
|
payload = await self._json_payload()
|
||||||
server = self.server_binding.resolve_standard_server(
|
server = self.server_binding.resolve_standard_server(payload.get("server"))
|
||||||
payload.get("server")
|
|
||||||
)
|
|
||||||
if not server:
|
if not server:
|
||||||
raise ValueError("绑定区服必须选择标准区服")
|
raise ValueError("绑定区服必须选择标准区服")
|
||||||
await self.server_binding.set_binding(
|
await self.server_binding.set_binding(
|
||||||
@@ -277,7 +307,8 @@ class WebUIService:
|
|||||||
try:
|
try:
|
||||||
payload = await self._json_payload()
|
payload = await self._json_payload()
|
||||||
await self.cache.set_limits(
|
await self.cache.set_limits(
|
||||||
payload.get("api_memory_entries"),
|
payload.get("api_memory_max_mb"),
|
||||||
|
payload.get("api_max_entries"),
|
||||||
payload.get("image_max_mb"),
|
payload.get("image_max_mb"),
|
||||||
)
|
)
|
||||||
except ValueError as exc:
|
except ValueError as exc:
|
||||||
|
|||||||
@@ -26,7 +26,7 @@ PLUGIN_NAME = "astrbot_plugin_jx3"
|
|||||||
@register("astrbot_plugin_jx3",
|
@register("astrbot_plugin_jx3",
|
||||||
"fxdyz",
|
"fxdyz",
|
||||||
"聚合剑网三游戏数据,提供查询、图片渲染、本地避雷和实时事件推送。",
|
"聚合剑网三游戏数据,提供查询、图片渲染、本地避雷和实时事件推送。",
|
||||||
"3.4.6",
|
"3.4.7",
|
||||||
"https://github.com/qsc20001102/astrbot_plugin_jx3"
|
"https://github.com/qsc20001102/astrbot_plugin_jx3"
|
||||||
)
|
)
|
||||||
class Jx3ApiPlugin(Star):
|
class Jx3ApiPlugin(Star):
|
||||||
@@ -84,6 +84,7 @@ class Jx3ApiPlugin(Star):
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
if self.event_push is not None:
|
if self.event_push is not None:
|
||||||
await self.event_push.stop()
|
await self.event_push.stop()
|
||||||
|
await self.cache.stop()
|
||||||
logger.exception("功能模块初始化失败")
|
logger.exception("功能模块初始化失败")
|
||||||
raise
|
raise
|
||||||
|
|
||||||
@@ -96,6 +97,9 @@ class Jx3ApiPlugin(Star):
|
|||||||
async def terminate(self):
|
async def terminate(self):
|
||||||
"""可选择实现异步的插件销毁方法,当插件被卸载/停用时会调用。"""
|
"""可选择实现异步的插件销毁方法,当插件被卸载/停用时会调用。"""
|
||||||
|
|
||||||
|
if self.cache:
|
||||||
|
await self.cache.stop()
|
||||||
|
|
||||||
if self.event_push:
|
if self.event_push:
|
||||||
await self.event_push.stop()
|
await self.event_push.stop()
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -1,7 +1,7 @@
|
|||||||
name: astrbot_plugin_jx3
|
name: astrbot_plugin_jx3
|
||||||
display_name: 剑网三游戏数据查询工具
|
display_name: 剑网三游戏数据查询工具
|
||||||
desc: 聚合剑网三游戏数据,提供查询、图片渲染、本地避雷和实时事件推送。
|
desc: 聚合剑网三游戏数据,提供查询、图片渲染、本地避雷和实时事件推送。
|
||||||
version: 3.4.6
|
version: 3.4.7
|
||||||
author: 飞翔大野猪
|
author: 飞翔大野猪
|
||||||
repo: https://github.com/qsc20001102/astrbot_plugin_jx3
|
repo: https://github.com/qsc20001102/astrbot_plugin_jx3
|
||||||
astrbot_version: ">=4.24.1"
|
astrbot_version: ">=4.24.1"
|
||||||
|
|||||||
@@ -6,18 +6,20 @@ const state = {
|
|||||||
kungfu: [],
|
kungfu: [],
|
||||||
servers: [],
|
servers: [],
|
||||||
events: {},
|
events: {},
|
||||||
|
free_event_actions: [],
|
||||||
session_control: { mode: "all", entries: [] },
|
session_control: { mode: "all", entries: [] },
|
||||||
legacy_bilei: [],
|
legacy_bilei: [],
|
||||||
token_stats: null,
|
token_stats: null,
|
||||||
cache: {
|
cache: {
|
||||||
defaults: { api: 300, image: 300 },
|
defaults: { api: 300, image: 300 },
|
||||||
limits: { api_memory_entries: 256, image_max_mb: 512 },
|
limits: { api_memory_max_mb: 16, api_max_entries: 1024, image_max_mb: 512 },
|
||||||
api: [],
|
api: [],
|
||||||
images: [],
|
images: [],
|
||||||
stats: {},
|
stats: {},
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
const editing = { bindingSession: null, controlSession: null, aliasServer: null, kungfuPzid: null };
|
const editing = { bindingSession: null, controlSession: null, aliasServer: null, kungfuPzid: null, subscriptionSession: null };
|
||||||
|
let subscriptionSaving = false;
|
||||||
const restoreConfirmationTimers = new WeakMap();
|
const restoreConfirmationTimers = new WeakMap();
|
||||||
let toastTimer;
|
let toastTimer;
|
||||||
|
|
||||||
@@ -478,11 +480,63 @@ function renderLegacyBilei() {
|
|||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function openSubscriptionEditor(item = null) {
|
||||||
|
if (subscriptionSaving) return;
|
||||||
|
editing.subscriptionSession = item?.session_id ?? null;
|
||||||
|
const form = byId("subscription-form");
|
||||||
|
form.reset();
|
||||||
|
byId("subscription-editor-title").textContent = item ? "编辑推送配置" : "添加推送会话";
|
||||||
|
const sessionInput = byId("subscription-session");
|
||||||
|
sessionInput.value = item?.session_id || "";
|
||||||
|
sessionInput.readOnly = Boolean(item);
|
||||||
|
byId("subscription-enabled").checked = item?.enabled ?? false;
|
||||||
|
|
||||||
|
const selected = new Set(item?.actions || []);
|
||||||
|
const freeActions = new Set(state.free_event_actions);
|
||||||
|
const groups = [
|
||||||
|
{ title: "免费事件", free: true },
|
||||||
|
{ title: "令牌事件", free: false },
|
||||||
|
];
|
||||||
|
byId("subscription-events").replaceChildren(...groups.map((group) => {
|
||||||
|
const fieldset = document.createElement("fieldset");
|
||||||
|
fieldset.className = "subscription-event-group";
|
||||||
|
const legend = document.createElement("legend");
|
||||||
|
legend.textContent = group.title;
|
||||||
|
const grid = document.createElement("div");
|
||||||
|
grid.className = "subscription-event-grid";
|
||||||
|
Object.entries(state.events).forEach(([action, name]) => {
|
||||||
|
if (freeActions.has(Number(action)) !== group.free) return;
|
||||||
|
const label = document.createElement("label");
|
||||||
|
label.className = "subscription-choice";
|
||||||
|
const input = document.createElement("input");
|
||||||
|
input.type = "checkbox";
|
||||||
|
input.name = "subscription_action";
|
||||||
|
input.value = action;
|
||||||
|
input.checked = selected.has(Number(action));
|
||||||
|
const text = document.createElement("span");
|
||||||
|
text.textContent = `${action} ${name}`;
|
||||||
|
label.append(input, text);
|
||||||
|
grid.append(label);
|
||||||
|
});
|
||||||
|
fieldset.append(legend, grid);
|
||||||
|
return fieldset;
|
||||||
|
}));
|
||||||
|
updateSubscriptionSelectionCount();
|
||||||
|
form.hidden = false;
|
||||||
|
form.scrollIntoView({ block: "nearest" });
|
||||||
|
(item ? byId("subscription-enabled") : sessionInput).focus({ preventScroll: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateSubscriptionSelectionCount() {
|
||||||
|
const count = byId("subscription-events").querySelectorAll("input:checked").length;
|
||||||
|
byId("subscription-selection-count").textContent = `已选择 ${count} 项`;
|
||||||
|
}
|
||||||
|
|
||||||
function renderSubscriptions() {
|
function renderSubscriptions() {
|
||||||
const body = byId("subscriptions-body");
|
const body = byId("subscriptions-body");
|
||||||
const bindings = bindingMap();
|
const bindings = bindingMap();
|
||||||
if (!state.subscriptions.length) {
|
if (!state.subscriptions.length) {
|
||||||
body.replaceChildren(emptyRow(4, "暂无事件订阅会话"));
|
body.replaceChildren(emptyRow(5, "暂无事件订阅会话,点击“添加推送会话”开始配置"));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
body.replaceChildren(...state.subscriptions.map((item) => {
|
body.replaceChildren(...state.subscriptions.map((item) => {
|
||||||
@@ -491,10 +545,14 @@ function renderSubscriptions() {
|
|||||||
const server = document.createElement("td");
|
const server = document.createElement("td");
|
||||||
const enabled = document.createElement("td");
|
const enabled = document.createElement("td");
|
||||||
const actions = document.createElement("td");
|
const actions = document.createElement("td");
|
||||||
|
const controls = document.createElement("td");
|
||||||
session.dataset.label = "会话 ID";
|
session.dataset.label = "会话 ID";
|
||||||
server.dataset.label = "绑定区服";
|
server.dataset.label = "绑定区服";
|
||||||
enabled.dataset.label = "总开关";
|
enabled.dataset.label = "总开关";
|
||||||
actions.dataset.label = "已订阅事件";
|
actions.dataset.label = "已订阅事件";
|
||||||
|
controls.dataset.label = "操作";
|
||||||
|
controls.className = "actions";
|
||||||
|
session.className = "subscription-session-cell";
|
||||||
session.textContent = item.session_id;
|
session.textContent = item.session_id;
|
||||||
server.textContent = bindings.get(item.session_id) || "未绑定(全部区服)";
|
server.textContent = bindings.get(item.session_id) || "未绑定(全部区服)";
|
||||||
const stateLabel = document.createElement("span");
|
const stateLabel = document.createElement("span");
|
||||||
@@ -514,7 +572,34 @@ function renderSubscriptions() {
|
|||||||
tags.textContent = "无";
|
tags.textContent = "无";
|
||||||
}
|
}
|
||||||
actions.append(tags);
|
actions.append(tags);
|
||||||
row.append(session, server, enabled, actions);
|
controls.append(
|
||||||
|
button("编辑", "", () => openSubscriptionEditor(item)),
|
||||||
|
button("删除", "link-button--danger", async (event) => {
|
||||||
|
if (subscriptionSaving) return;
|
||||||
|
const control = event.currentTarget;
|
||||||
|
control.disabled = true;
|
||||||
|
subscriptionSaving = true;
|
||||||
|
byId("subscription-fields").disabled = true;
|
||||||
|
byId("add-subscription").disabled = true;
|
||||||
|
try {
|
||||||
|
const deleted = await mutate(
|
||||||
|
"subscriptions/delete",
|
||||||
|
{ session_id: item.session_id },
|
||||||
|
"会话推送配置已删除",
|
||||||
|
);
|
||||||
|
if (deleted && editing.subscriptionSession === item.session_id) {
|
||||||
|
byId("subscription-form").hidden = true;
|
||||||
|
editing.subscriptionSession = null;
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
subscriptionSaving = false;
|
||||||
|
control.disabled = false;
|
||||||
|
byId("subscription-fields").disabled = false;
|
||||||
|
byId("add-subscription").disabled = false;
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
row.append(session, server, enabled, actions, controls);
|
||||||
return row;
|
return row;
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
@@ -739,17 +824,20 @@ function renderCache() {
|
|||||||
const stats = cache.stats || {};
|
const stats = cache.stats || {};
|
||||||
const apiDefault = cache.defaults?.api ?? 300;
|
const apiDefault = cache.defaults?.api ?? 300;
|
||||||
const imageDefault = cache.defaults?.image ?? 600;
|
const imageDefault = cache.defaults?.image ?? 600;
|
||||||
const memoryLimit = cache.limits?.api_memory_entries ?? stats.api_memory_limit ?? 256;
|
const memoryLimitMb = cache.limits?.api_memory_max_mb ?? 16;
|
||||||
|
const apiEntryLimit = cache.limits?.api_max_entries ?? stats.api_entry_limit ?? 256;
|
||||||
const imageLimitMb = cache.limits?.image_max_mb ?? 512;
|
const imageLimitMb = cache.limits?.image_max_mb ?? 512;
|
||||||
byId("api-cache-count").textContent = `${stats.api_count || 0} 条`;
|
byId("api-cache-count").textContent = `${stats.api_count || 0} / ${apiEntryLimit} 条`;
|
||||||
byId("api-cache-size").textContent = formatBytes(stats.api_size_bytes);
|
byId("api-cache-size").textContent = formatBytes(stats.api_size_bytes);
|
||||||
byId("image-cache-count").textContent = `${stats.image_count || 0} 张`;
|
byId("image-cache-count").textContent = `${stats.image_count || 0} 张`;
|
||||||
byId("image-cache-size").textContent = `${formatBytes(stats.image_size_bytes)} / ${formatBytes(stats.image_limit_bytes)}`;
|
byId("image-cache-size").textContent = `${formatBytes(stats.image_size_bytes)} / ${formatBytes(stats.image_limit_bytes)}`;
|
||||||
byId("api-default-ttl").value = String(apiDefault);
|
byId("api-default-ttl").value = String(apiDefault);
|
||||||
byId("image-default-ttl").value = String(imageDefault);
|
byId("image-default-ttl").value = String(imageDefault);
|
||||||
byId("api-memory-limit").value = String(memoryLimit);
|
byId("api-memory-size-limit").value = String(memoryLimitMb);
|
||||||
|
byId("api-entry-limit").value = String(apiEntryLimit);
|
||||||
byId("image-size-limit").value = String(imageLimitMb);
|
byId("image-size-limit").value = String(imageLimitMb);
|
||||||
byId("api-memory-summary").textContent = `${stats.api_memory_count || 0} / ${memoryLimit} 条`;
|
byId("api-memory-summary").textContent = `${formatBytes(stats.api_memory_size_bytes)} / ${formatBytes(stats.api_memory_limit_bytes)}`;
|
||||||
|
byId("api-memory-count").textContent = `${stats.api_memory_count || 0} 条,最久未使用优先淘汰`;
|
||||||
byId("cache-default-summary").textContent = `${apiDefault} / ${imageDefault} 秒`;
|
byId("cache-default-summary").textContent = `${apiDefault} / ${imageDefault} 秒`;
|
||||||
renderCacheTable("api");
|
renderCacheTable("api");
|
||||||
renderCacheTable("image");
|
renderCacheTable("image");
|
||||||
@@ -845,6 +933,52 @@ document.querySelectorAll(".tab").forEach((tab) => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
byId("add-subscription").addEventListener("click", () => openSubscriptionEditor());
|
||||||
|
byId("cancel-subscription").addEventListener("click", () => {
|
||||||
|
byId("subscription-form").hidden = true;
|
||||||
|
editing.subscriptionSession = null;
|
||||||
|
});
|
||||||
|
byId("subscription-events").addEventListener("change", updateSubscriptionSelectionCount);
|
||||||
|
byId("subscription-select-all").addEventListener("click", () => {
|
||||||
|
byId("subscription-events").querySelectorAll("input").forEach((input) => { input.checked = true; });
|
||||||
|
updateSubscriptionSelectionCount();
|
||||||
|
});
|
||||||
|
byId("subscription-clear-all").addEventListener("click", () => {
|
||||||
|
byId("subscription-events").querySelectorAll("input").forEach((input) => { input.checked = false; });
|
||||||
|
updateSubscriptionSelectionCount();
|
||||||
|
});
|
||||||
|
byId("subscription-form").addEventListener("submit", async (event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
if (subscriptionSaving) return;
|
||||||
|
const form = event.currentTarget;
|
||||||
|
const sessionId = (editing.subscriptionSession ?? byId("subscription-session").value).trim();
|
||||||
|
if (!sessionId) {
|
||||||
|
showToast("会话 ID 不能为空", true);
|
||||||
|
byId("subscription-session").focus();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const payload = {
|
||||||
|
session_id: sessionId,
|
||||||
|
enabled: byId("subscription-enabled").checked,
|
||||||
|
actions: [...byId("subscription-events").querySelectorAll("input:checked")].map((input) => Number(input.value)),
|
||||||
|
mode: editing.subscriptionSession === null ? "create" : "update",
|
||||||
|
};
|
||||||
|
subscriptionSaving = true;
|
||||||
|
byId("subscription-fields").disabled = true;
|
||||||
|
byId("add-subscription").disabled = true;
|
||||||
|
try {
|
||||||
|
const saved = await mutate("subscriptions/save", payload, "会话推送配置已保存");
|
||||||
|
if (saved) {
|
||||||
|
form.hidden = true;
|
||||||
|
editing.subscriptionSession = null;
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
subscriptionSaving = false;
|
||||||
|
byId("subscription-fields").disabled = false;
|
||||||
|
byId("add-subscription").disabled = false;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
byId("binding-form").addEventListener("submit", async (event) => {
|
byId("binding-form").addEventListener("submit", async (event) => {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
const saved = await mutate("bindings/save", {
|
const saved = await mutate("bindings/save", {
|
||||||
@@ -929,7 +1063,8 @@ byId("cache-limit-form").addEventListener("submit", async (event) => {
|
|||||||
submit.disabled = true;
|
submit.disabled = true;
|
||||||
try {
|
try {
|
||||||
await bridge.apiPost("cache/limits/save", {
|
await bridge.apiPost("cache/limits/save", {
|
||||||
api_memory_entries: Number(byId("api-memory-limit").value),
|
api_memory_max_mb: Number(byId("api-memory-size-limit").value),
|
||||||
|
api_max_entries: Number(byId("api-entry-limit").value),
|
||||||
image_max_mb: Number(byId("image-size-limit").value),
|
image_max_mb: Number(byId("image-size-limit").value),
|
||||||
});
|
});
|
||||||
await loadData();
|
await loadData();
|
||||||
|
|||||||
@@ -106,7 +106,7 @@
|
|||||||
<div class="section-intro section-intro--actions">
|
<div class="section-intro section-intro--actions">
|
||||||
<div>
|
<div>
|
||||||
<h2>查询与图片缓存</h2>
|
<h2>查询与图片缓存</h2>
|
||||||
<p>接口数据以 JSON 保存到 SQLite,渲染图片保存到插件数据目录。图片命中时会直接发送,不再请求其上游接口;缓存时间使用秒,填写 0 可关闭对应缓存。</p>
|
<p>接口数据以 JSON 保存到 SQLite,渲染图片保存到插件数据目录。图片命中时会直接发送,不再请求其上游接口;缓存时间使用秒,填写 0 可关闭对应缓存。每 60 秒自动清理过期缓存,接口请求失败时不使用过期数据。</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="cache-clear-actions">
|
<div class="cache-clear-actions">
|
||||||
<button class="button button--secondary" id="clear-api-cache" type="button">清空接口缓存</button>
|
<button class="button button--secondary" id="clear-api-cache" type="button">清空接口缓存</button>
|
||||||
@@ -115,9 +115,9 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="cache-summary" aria-label="缓存统计">
|
<div class="cache-summary" aria-label="缓存统计">
|
||||||
<div class="cache-stat"><span>接口缓存(SQLite)</span><strong id="api-cache-count">0 条</strong><small id="api-cache-size">0 B</small></div>
|
<div class="cache-stat"><span>接口缓存(SQLite)</span><strong id="api-cache-count">0 / 256 条</strong><small id="api-cache-size">0 B</small></div>
|
||||||
<div class="cache-stat"><span>图片缓存</span><strong id="image-cache-count">0 张</strong><small id="image-cache-size">0 B</small></div>
|
<div class="cache-stat"><span>图片缓存</span><strong id="image-cache-count">0 张</strong><small id="image-cache-size">0 B</small></div>
|
||||||
<div class="cache-stat"><span>接口内存缓存</span><strong id="api-memory-summary">0 / 256 条</strong><small>最久未使用优先淘汰</small></div>
|
<div class="cache-stat"><span>接口内存缓存</span><strong id="api-memory-summary">0 B / 16 MB</strong><small id="api-memory-count">0 条,最久未使用优先淘汰</small></div>
|
||||||
<div class="cache-stat"><span>默认缓存时间</span><strong id="cache-default-summary">300 / 600 秒</strong><small>接口 / 图片</small></div>
|
<div class="cache-stat"><span>默认缓存时间</span><strong id="cache-default-summary">300 / 600 秒</strong><small>接口 / 图片</small></div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -135,8 +135,12 @@
|
|||||||
|
|
||||||
<form class="cache-defaults cache-limits" id="cache-limit-form">
|
<form class="cache-defaults cache-limits" id="cache-limit-form">
|
||||||
<label>
|
<label>
|
||||||
<span>接口内存缓存最大条数</span>
|
<span>接口内存缓存最大容量(MB)</span>
|
||||||
<input id="api-memory-limit" type="number" min="1" max="100000" step="1" required />
|
<input id="api-memory-size-limit" type="number" min="1" max="1024" step="1" required />
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
<span>SQLite 接口缓存最大条数</span>
|
||||||
|
<input id="api-entry-limit" type="number" min="1" max="100000" step="1" required />
|
||||||
</label>
|
</label>
|
||||||
<label>
|
<label>
|
||||||
<span>图片缓存最大容量(MB)</span>
|
<span>图片缓存最大容量(MB)</span>
|
||||||
@@ -212,12 +216,41 @@
|
|||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section class="panel" id="subscriptions-panel" role="tabpanel" hidden>
|
<section class="panel" id="subscriptions-panel" role="tabpanel" hidden>
|
||||||
<div class="section-intro">
|
<div class="section-intro section-intro--actions">
|
||||||
<div><h2>所有会话事件状态</h2><p>总开关和已订阅事件均来自本地订阅表。</p></div>
|
<div><h2>会话事件推送</h2><p>为会话配置推送总开关和订阅事件,保存后立即生效。推送仍受会话访问模式及绑定区服限制;删除仅移除该会话的推送配置。</p></div>
|
||||||
|
<button class="button button--primary" id="add-subscription" type="button">添加推送会话</button>
|
||||||
</div>
|
</div>
|
||||||
|
<form class="subscription-editor" id="subscription-form" hidden>
|
||||||
|
<fieldset id="subscription-fields">
|
||||||
|
<legend id="subscription-editor-title">添加推送会话</legend>
|
||||||
|
<div class="subscription-settings">
|
||||||
|
<label>
|
||||||
|
<span>会话 ID</span>
|
||||||
|
<input id="subscription-session" name="session_id" list="session-options" maxlength="512" placeholder="选择已有会话或输入完整会话 ID" required />
|
||||||
|
</label>
|
||||||
|
<label class="subscription-choice">
|
||||||
|
<input id="subscription-enabled" type="checkbox" />
|
||||||
|
<span>开启事件推送</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<p class="subscription-hint">关闭总开关会保留事件选择;未选择事件时不会收到推送。令牌事件需在插件配置中填写事件版令牌。</p>
|
||||||
|
<div class="subscription-toolbar">
|
||||||
|
<strong id="subscription-selection-count" aria-live="polite">已选择 0 项</strong>
|
||||||
|
<div>
|
||||||
|
<button class="link-button" id="subscription-select-all" type="button">全选</button>
|
||||||
|
<button class="link-button" id="subscription-clear-all" type="button">清空选择</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div id="subscription-events"></div>
|
||||||
|
<div class="subscription-form-actions">
|
||||||
|
<button class="button button--primary" type="submit">保存推送配置</button>
|
||||||
|
<button class="button button--secondary" id="cancel-subscription" type="button">取消</button>
|
||||||
|
</div>
|
||||||
|
</fieldset>
|
||||||
|
</form>
|
||||||
<div class="table-wrap">
|
<div class="table-wrap">
|
||||||
<table>
|
<table>
|
||||||
<thead><tr><th>会话 ID</th><th>绑定区服</th><th>总开关</th><th>已订阅事件</th></tr></thead>
|
<thead><tr><th>会话 ID</th><th>绑定区服</th><th>总开关</th><th>已订阅事件</th><th class="actions">操作</th></tr></thead>
|
||||||
<tbody id="subscriptions-body"></tbody>
|
<tbody id="subscriptions-body"></tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -86,6 +86,24 @@ input:focus, select:focus { border-color: var(--focus); box-shadow: 0 0 0 3px co
|
|||||||
.section-intro { padding: 22px; border-bottom: 1px solid var(--border); }
|
.section-intro { padding: 22px; border-bottom: 1px solid var(--border); }
|
||||||
.section-intro--actions { display: flex; align-items: center; justify-content: space-between; gap: 20px; }
|
.section-intro--actions { display: flex; align-items: center; justify-content: space-between; gap: 20px; }
|
||||||
.section-intro h2 { margin: 0; font-size: 18px; }
|
.section-intro h2 { margin: 0; font-size: 18px; }
|
||||||
|
.subscription-editor { padding: 22px; border-bottom: 1px solid var(--border); background: var(--surface-subtle); }
|
||||||
|
.subscription-editor[hidden] { display: none; }
|
||||||
|
.subscription-editor fieldset { min-width: 0; margin: 0; padding: 0; border: 0; }
|
||||||
|
.subscription-editor legend { margin-bottom: 14px; font-size: 16px; font-weight: 700; }
|
||||||
|
.subscription-settings { display: grid; grid-template-columns: minmax(0, 1fr) auto; align-items: center; gap: 24px; }
|
||||||
|
.subscription-choice { display: flex; align-items: center; gap: 9px; cursor: pointer; text-align: left; }
|
||||||
|
.subscription-choice input { width: 17px; height: 17px; padding: 0; flex: 0 0 auto; accent-color: var(--accent); }
|
||||||
|
.subscription-choice span { overflow-wrap: anywhere; }
|
||||||
|
.subscription-hint { margin: 14px 0; color: var(--muted); font-size: 13px; }
|
||||||
|
.subscription-toolbar { display: flex; align-items: center; justify-content: space-between; gap: 12px; margin: 16px 0; font-size: 13px; }
|
||||||
|
.subscription-editor .subscription-event-group { margin-top: 16px; }
|
||||||
|
.subscription-event-group legend { margin-bottom: 10px; color: var(--muted); font-size: 13px; }
|
||||||
|
.subscription-event-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(230px, 1fr)); gap: 8px; }
|
||||||
|
.subscription-event-grid .subscription-choice { padding: 10px; border: 1px solid var(--border); border-radius: 8px; background: var(--surface); }
|
||||||
|
.subscription-event-grid .subscription-choice:has(input:checked) { border-color: var(--accent); background: var(--accent-soft); }
|
||||||
|
.subscription-form-actions { display: flex; gap: 12px; margin-top: 22px; }
|
||||||
|
.subscription-session-cell { overflow-wrap: anywhere; max-width: 300px; }
|
||||||
|
#subscription-session[readonly] { color: var(--muted); background: var(--surface-subtle); }
|
||||||
.cache-clear-actions { display: flex; flex: 0 0 auto; justify-content: flex-end; gap: 10px; }
|
.cache-clear-actions { display: flex; flex: 0 0 auto; justify-content: flex-end; gap: 10px; }
|
||||||
.cache-summary { display: grid; grid-template-columns: repeat(4, 1fr); border-bottom: 1px solid var(--border); background: var(--surface-subtle); }
|
.cache-summary { display: grid; grid-template-columns: repeat(4, 1fr); border-bottom: 1px solid var(--border); background: var(--surface-subtle); }
|
||||||
.cache-stat { display: flex; min-height: 100px; flex-direction: column; justify-content: center; padding: 18px 22px; }
|
.cache-stat { display: flex; min-height: 100px; flex-direction: column; justify-content: center; padding: 18px 22px; }
|
||||||
@@ -93,6 +111,7 @@ input:focus, select:focus { border-color: var(--focus); box-shadow: 0 0 0 3px co
|
|||||||
.cache-stat span, .cache-stat small { color: var(--muted); font-size: 12px; font-weight: 650; }
|
.cache-stat span, .cache-stat small { color: var(--muted); font-size: 12px; font-weight: 650; }
|
||||||
.cache-stat strong { margin: 3px 0; font-size: 21px; font-variant-numeric: tabular-nums; }
|
.cache-stat strong { margin: 3px 0; font-size: 21px; font-variant-numeric: tabular-nums; }
|
||||||
.cache-defaults { display: grid; grid-template-columns: minmax(240px, 1fr) minmax(240px, 1fr) auto; align-items: end; gap: 16px; padding: 22px; border-bottom: 1px solid var(--border); }
|
.cache-defaults { display: grid; grid-template-columns: minmax(240px, 1fr) minmax(240px, 1fr) auto; align-items: end; gap: 16px; padding: 22px; border-bottom: 1px solid var(--border); }
|
||||||
|
.cache-limits { grid-template-columns: repeat(3, minmax(180px, 1fr)) auto; }
|
||||||
.cache-group { border-bottom: 1px solid var(--border); }
|
.cache-group { border-bottom: 1px solid var(--border); }
|
||||||
.cache-group:last-child { border-bottom: 0; }
|
.cache-group:last-child { border-bottom: 0; }
|
||||||
.cache-group summary { padding: 18px 22px; cursor: pointer; font-size: 16px; font-weight: 750; }
|
.cache-group summary { padding: 18px 22px; cursor: pointer; font-size: 16px; font-weight: 750; }
|
||||||
@@ -136,6 +155,9 @@ tbody tr.is-editing { background: color-mix(in srgb, var(--accent-soft) 62%, tra
|
|||||||
.toast.is-error { border-left-color: var(--danger); }
|
.toast.is-error { border-left-color: var(--danger); }
|
||||||
|
|
||||||
@media (max-width: 760px) {
|
@media (max-width: 760px) {
|
||||||
|
.subscription-settings { grid-template-columns: 1fr; gap: 16px; }
|
||||||
|
.subscription-event-grid { grid-template-columns: 1fr; }
|
||||||
|
.subscription-session-cell { max-width: none; }
|
||||||
.shell { width: min(100% - 24px, 1260px); padding-top: 20px; }
|
.shell { width: min(100% - 24px, 1260px); padding-top: 20px; }
|
||||||
.page-header { align-items: stretch; flex-direction: column; }
|
.page-header { align-items: stretch; flex-direction: column; }
|
||||||
.section-intro--actions { align-items: stretch; flex-direction: column; }
|
.section-intro--actions { align-items: stretch; flex-direction: column; }
|
||||||
|
|||||||
Reference in New Issue
Block a user