This commit is contained in:
qsc
2026-09-07 14:31:57 +08:00
parent 2cf54a5e6c
commit 670164b804
11 changed files with 658 additions and 202 deletions
+10
View File
@@ -1,5 +1,15 @@
## 更新记录 ## 更新记录
### version: 3.4.72026-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.62026-09-06): ### version: 3.4.62026-09-06):
本地避雷记录新增按 AstrBot 会话隔离的数据范围。新增、查看、查询、修改和删除均使用当前消息的 `unified_msg_origin`,修改与删除同时校验会话 ID 和记录 ID,其他会话即使知道记录 ID 也无法访问或操作。 本地避雷记录新增按 AstrBot 会话隔离的数据范围。新增、查看、查询、修改和删除均使用当前消息的 `unified_msg_origin`,修改与删除同时校验会话 ID 和记录 ID,其他会话即使知道记录 ID 也无法访问或操作。
+9 -7
View File
@@ -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 查询入口成功取得的原始 JSONToken 和 Ticket 不写入缓存键或正文,凭据摘要只用于避免更换账号后误用旧缓存。同一缓存键的并发请求通过异步锁合并。读取顺序为“内存热缓存 → SQLite → 上游接口”;两级缓存使用同一项 TTL,不存在单独的内存保留时间。内存超过 WebUI 配置的条数时只淘汰内存副本,后续仍可从 SQLite 读取。缓存过期后优先刷新,上游失败时允许使用七天内仍可解析的旧数据兜底 `api_response_cache` 仅保存通过 JX3API 查询入口成功取得的原始 JSONToken 和 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
View File
@@ -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
View File
@@ -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]}"
-3
View File
@@ -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
View File
@@ -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:
+5 -1
View File
@@ -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
View File
@@ -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"
+144 -9
View File
@@ -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();
+41 -8
View File
@@ -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>
+22
View File
@@ -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; }