From 670164b8042a859ab8e369a6cd85b5bbf05564ee Mon Sep 17 00:00:00 2001 From: qsc Date: Mon, 7 Sep 2026 14:31:57 +0800 Subject: [PATCH] fxdyz --- CHANGELOG.md | 10 + README.md | 16 +- core/cache.py | 370 +++++++++++++++++++---------- core/event_push.py | 182 ++++++++++---- core/jx3api_data.py | 3 - core/webui.py | 47 +++- main.py | 6 +- metadata.yaml | 2 +- pages/server-management/app.js | 153 +++++++++++- pages/server-management/index.html | 49 +++- pages/server-management/style.css | 22 ++ 11 files changed, 658 insertions(+), 202 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index fd76489..d691f53 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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): 本地避雷记录新增按 AstrBot 会话隔离的数据范围。新增、查看、查询、修改和删除均使用当前消息的 `unified_msg_origin`,修改与删除同时校验会话 ID 和记录 ID,其他会话即使知道记录 ID 也无法访问或操作。 diff --git a/README.md b/README.md index f0c7bd4..d719562 100644 --- a/README.md +++ b/README.md @@ -21,7 +21,7 @@ - 支持按 AstrBot 会话分别开启总开关和具体 JX3API 实时事件订阅。 - 复用 `aiohttp.ClientSession`,统一处理 GET、POST、JSON、图片和分页请求。 - JX3BOX 的 Node、Next2、CMS 请求统一封装,交易行基础物品数据支持本地快照缓存和过期兜底。 -- JX3API 查询使用内存与 SQLite 两级 JSON 缓存,HTML 查询图片使用本地文件缓存;缓存时间、接口内存条数及图片缓存总容量均可在 WebUI 配置。 +- JX3API 查询使用内存与 SQLite 两级 JSON 缓存,HTML 查询图片使用本地文件缓存;缓存时间、接口内存容量、SQLite 接口缓存条数及图片缓存总容量均可在 WebUI 配置。 - 内置 97 个中文触发指令和 51 个页面片段,通过公共布局与样式在本地组装为完整 HTML,并附带通用、沙盘、门派/心法和奇遇图标资源。 ## 数据来源 @@ -72,7 +72,7 @@ pip install -r data/plugins/astrbot_plugin_jx3/requirements.txt | `aiohttp` | 异步 HTTP 请求与连接复用 | | `aiofiles` | 异步读取 HTML 模板 | | `aiosqlite` | 异步访问本地 SQLite 数据库 | -| `matplotlib` | 当前依赖清单保留的绘图依赖;v3.4.6 业务代码未直接导入 | +| `matplotlib` | 当前依赖清单保留的绘图依赖;v3.4.7 业务代码未直接导入 | ## 插件配置 @@ -305,20 +305,22 @@ AstrBot 插件详情页中的“剑网三插件管理”通过 Plugin Pages 桥 | 页签 | 当前功能 | | --- | --- | | 会话控制 | 默认页签;在全部会话、白名单和黑名单之间切换,并维护会话 ID 与备注。空白名单不放行任何会话,空黑名单放行全部会话,策略同时作用于查询指令与事件推送 | -| 事件推送 | 查看所有会话的推送总开关及已订阅事件编号 | +| 事件推送 | 新增、编辑和删除会话推送配置;设置总开关并勾选具体事件,支持全选和清空选择,关闭总开关保留订阅选择 | | 区服绑定 | 使用自定义会话 ID 新增绑定;区服只能从标准区服下拉框选择,已有会话 ID 不可编辑 | | 区服别名 | 查看标准区服并行内维护别名;可使用随插件分发的 JSON 种子恢复默认 | | 心法别名 | 查看标准心法并维护最多 5 个别名;配装 ID 不在页面显示,可恢复默认 | | 缓存管理 | 分别配置接口数据和最终图片缓存时间、容量限制,查看占用并清理单项或全部缓存 | | 避雷迁移 | 把升级前保留在“历史公共数据”区的避雷记录迁移到指定会话 | +事件推送配置保存后立即用于后续事件分发,仍受会话访问模式和绑定区服限制。新增时可以选择已有会话或输入完整的 AstrBot 会话 ID,重复新增会提示编辑已有配置;编辑时会话 ID 只读。空事件选择表示不接收任何事件;删除仅移除该会话的推送配置,保留区服绑定、访问名单和其他会话数据。免费事件与令牌事件分组展示,令牌事件仍需配置 `jx3api_wss_token`。 + 页面顶部通过 JX3API `POST /token/stats` 展示当前 Token 的等级、已用次数、剩余次数和有效状态,成功结果在进程内保留 30 秒。普通打开或刷新 WebUI 时,区服目录、别名和绑定直接读取当前内存/SQLite 数据,不会请求区服状态接口;只有点击“刷新区服列表”时才会强制请求 `/server/status/check` 并更新当前区服目录。插件初始化时会执行一次 `server_list()` 建立用于参数消歧的有效区服目录:缓存有效时读取接口缓存,缓存不存在或已过期时才请求上游。 缓存管理的接口默认时间为 300 秒,图片默认时间为 600 秒;每个 JX3API 接口和每个图片指令都可以单独覆盖,填写 `0` 表示关闭,恢复默认则重新继承全局时间。图片指令优先查询最终图片缓存,命中后不会再调用上游接口或重新渲染;图文结果会连同正文一起复用。单独清除接口会同时删除该路径所有参数组合的内存与 SQLite 缓存,单独清除图片会删除该指令生成的缓存文件,两种操作均不改变已配置时间。 如果需要某条图片指令立即使用最新上游数据,应同时清除(或临时关闭)对应的接口缓存和图片缓存。只把接口缓存设为 `0` 时,已有最终图片仍可能直接命中;只把图片缓存设为 `0` 时,页面会重新渲染,但仍可能使用尚未过期的接口数据。 -接口内存热缓存默认最多 256 条,超出后按最近最少使用顺序淘汰;SQLite 中的接口缓存不受这项内存条数限制。图片二进制保存在 AstrBot 插件数据目录,默认总容量为 512 MB,超出后按最近最少使用顺序清理。随机名片、随机语录、吃喝选择和随机贴吧等接口默认不缓存,避雷查看和避雷查询也默认不缓存最终图片;这些项目仍可在 WebUI 中显式覆盖。 +接口内存缓存默认最多占用 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 个别名。 - `trade_item_cache`:JX3BOX 交易行基础物品数据缓存及更新时间。 - `cache_settings`:接口与图片指令的默认时间和单项覆盖配置。 -- `cache_limits`:接口内存条数和图片缓存总容量配置。 +- `cache_limits`:接口内存缓存总容量、SQLite 接口缓存最大条数及图片缓存总容量配置。 - `api_response_cache`:JX3API 原始 JSON、创建时间、过期时间和最近访问时间。 - `image_render_cache`:本地渲染图片文件的索引、大小、过期时间、最近访问时间及图文消息正文。 @@ -479,7 +481,7 @@ AstrBot 的渲染接口接收完整 HTML 字符串,因此插件不会依赖渲 `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)后按最近最少使用顺序清理。 @@ -561,7 +563,7 @@ git diff --check ## 当前版本状态 -以下内容是对 v3.4.6 当前源码的静态核对结果,部署和二次开发前应注意: +以下内容是对 v3.4.7 当前源码的静态核对结果,部署和二次开发前应注意: 1. 查询图片使用浅色高对比主题和放大的内容区域;渲染清晰度、JPEG/PNG 格式及 JPEG 质量由 `image_render_quality` 配置组控制,提高清晰度或使用 PNG 会增加图片体积与渲染耗时。 2. 所有 HTML 渲染图片底部都会显示数据时间;最终图片缓存命中后保留原时间并跳过接口请求和重新渲染。要让下一次查询同时获取最新上游数据并重新生成图片,需要一并清除或关闭对应接口缓存和图片缓存。 diff --git a/core/cache.py b/core/cache.py index a3c326c..b8ff2ce 100644 --- a/core/cache.py +++ b/core/cache.py @@ -1,6 +1,7 @@ from __future__ import annotations import asyncio +import contextlib import hashlib import json import os @@ -98,11 +99,13 @@ class CacheService: DEFAULT_API_TTL = 300 DEFAULT_IMAGE_TTL = 600 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 - MAX_MEMORY_ENTRIES_LIMIT = 100_000 + MAX_MEMORY_MB_LIMIT = 1024 + MAX_API_ENTRIES_LIMIT = 100_000 MAX_IMAGE_MB_LIMIT = 10_240 - STALE_RETENTION_SECONDS = 7 * 24 * 60 * 60 + CLEANUP_INTERVAL_SECONDS = 60 _SENSITIVE_KEYS = frozenset( {"token", "ticket", "authorization", "access_token", "jx3api_token"} ) @@ -129,9 +132,13 @@ class CacheService: self._sqlite = sqlite self.image_dir = Path(image_dir) self._settings: dict[tuple[str, str], int] = {} - self.max_memory_entries = self.DEFAULT_MAX_MEMORY_ENTRIES + self.max_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._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] = ( weakref.WeakValueDictionary() ) @@ -223,14 +230,37 @@ class CacheService: await self._sqlite.execute( "CREATE INDEX IF NOT EXISTS idx_api_cache_endpoint ON api_response_cache(endpoint)" ) + await self._sqlite.execute( + "CREATE INDEX IF NOT EXISTS idx_api_cache_lru ON api_response_cache(last_accessed_at)" + ) await self._sqlite.execute( "CREATE INDEX IF NOT EXISTS idx_image_cache_name ON image_render_cache(cache_name)" ) await self._load_settings() await self._load_limits() + async with self._api_storage_lock: + await self._enforce_api_limit() await self.cleanup_expired() - self._enforce_memory_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): rows = await self._sqlite.select_all("cache_settings") @@ -242,8 +272,18 @@ class CacheService: async def _load_limits(self): rows = await self._sqlite.select_all("cache_limits") limits = {str(row["limit_name"]): int(row["limit_value"]) for row in rows} - self.max_memory_entries = self._validated_memory_limit( - limits.get("api_memory_entries", self.DEFAULT_MAX_MEMORY_ENTRIES) + memory_limit_mb = self._validated_memory_limit_mb( + 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( 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 @classmethod - def _validated_memory_limit(cls, value: Any) -> int: + def _validated_memory_limit_mb(cls, value: Any) -> int: if isinstance(value, bool): - raise ValueError("接口内存缓存条数必须是整数") + raise ValueError("接口内存缓存容量必须是整数 MB") try: limit = int(value) except (TypeError, ValueError) as exc: - raise ValueError("接口内存缓存条数必须是整数") from exc - if limit < 1 or limit > cls.MAX_MEMORY_ENTRIES_LIMIT: - raise ValueError("接口内存缓存条数必须在 1 到 100000 之间") + raise ValueError("接口内存缓存容量必须是整数 MB") from exc + if limit < 1 or limit > cls.MAX_MEMORY_MB_LIMIT: + 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 @classmethod @@ -274,11 +326,18 @@ class CacheService: raise ValueError("图片缓存容量必须在 1 到 10240 MB 之间") return limit - async def set_limits(self, api_memory_entries: Any, image_max_mb: Any): - memory_limit = self._validated_memory_limit(api_memory_entries) + async def set_limits( + 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) 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), ): await self._sqlite.execute( @@ -290,9 +349,11 @@ class CacheService: """, (limit_name, limit_value), ) - self.max_memory_entries = memory_limit 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() def register_image_names(self, names: Iterable[str]): @@ -418,62 +479,108 @@ class CacheService: self, cache_key: str, endpoint: str, - allow_expired: bool = False, ) -> tuple[Any | None, int | None, int | None]: - now = int(time.time()) - ttl = self.get_ttl("api", endpoint) - memory = self._memory.get(cache_key) - if memory is not None: - created_at, expires_at, payload = memory + async with self._api_storage_lock: + now = time.time() + ttl = self.get_ttl("api", endpoint) + memory = self._memory.get(cache_key) + if memory is not None: + created_at, expires_at, payload, _ = memory + 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) - if allow_expired and effective_expiry <= now - self.STALE_RETENTION_SECONDS: - self._memory.pop(cache_key, None) - elif allow_expired or effective_expiry > now: - self._memory.move_to_end(cache_key) - try: - return json.loads(payload), effective_expiry, created_at - except json.JSONDecodeError: - self._memory.pop(cache_key, None) + try: + data = ( + json.loads(payload) if ttl > 0 and effective_expiry > now else None + ) + except json.JSONDecodeError: + data = None + if data is 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( - """ - SELECT payload, created_at, expires_at - FROM api_response_cache - WHERE cache_key=? AND endpoint=? - """, - (cache_key, endpoint), - ) - if not row: - return None, None, None - expires_at = int(row["expires_at"]) - created_at = int(row["created_at"]) - effective_expiry = min(expires_at, created_at + ttl) - if not allow_expired and effective_expiry <= now: - return None, effective_expiry, created_at - if allow_expired and effective_expiry <= now - self.STALE_RETENTION_SECONDS: - await self._sqlite.delete("api_response_cache", "cache_key=?", (cache_key,)) - return None, effective_expiry, created_at - try: - data = json.loads(str(row["payload"])) - except json.JSONDecodeError: - await self._sqlite.delete("api_response_cache", "cache_key=?", (cache_key,)) - return None, None, None + self._remember(cache_key, created_at, expires_at, payload) + await self._sqlite.execute( + "UPDATE api_response_cache SET last_accessed_at=? WHERE cache_key=?", + (now, cache_key), + ) + return data, effective_expiry, created_at - self._remember(cache_key, created_at, expires_at, str(row["payload"])) - await self._sqlite.execute( - "UPDATE api_response_cache SET last_accessed_at=? WHERE cache_key=?", - (now, cache_key), - ) - return data, effective_expiry, created_at - - def _remember(self, cache_key: str, created_at: int, expires_at: int, payload: str): - self._memory[cache_key] = (created_at, expires_at, payload) + def _remember( + self, + cache_key: str, + created_at: int, + expires_at: int, + payload: str | bytes, + ): + self._forget_memory(cache_key) + 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._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): - while len(self._memory) > self.max_memory_entries: - self._memory.popitem(last=False) + while self._memory_size_bytes > self.max_memory_bytes and self._memory: + _, 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( self, @@ -485,8 +592,9 @@ class CacheService: payload = self._json(data) now = int(time.time()) expires_at = now + ttl_seconds - await self._sqlite.execute( - """ + async with self._api_storage_lock: + await self._sqlite.execute( + """ INSERT INTO api_response_cache( cache_key, endpoint, payload, created_at, expires_at, last_accessed_at ) VALUES(?, ?, ?, ?, ?, ?) @@ -497,9 +605,10 @@ class CacheService: expires_at=excluded.expires_at, last_accessed_at=excluded.last_accessed_at """, - (cache_key, endpoint, payload, now, expires_at, now), - ) - self._remember(cache_key, now, expires_at, payload) + (cache_key, endpoint, payload, now, expires_at, time.time()), + ) + self._remember(cache_key, now, expires_at, payload) + await self._enforce_api_limit() return now async def request_api( @@ -509,7 +618,6 @@ class CacheService: requester: Callable[[], Awaitable[Any]], is_cacheable: Callable[[Any], bool], force_refresh: bool = False, - allow_stale: bool = True, ) -> tuple[Any, dict[str, Any]]: ttl = self.get_ttl("api", endpoint) cache_key = self.build_api_key(endpoint, params) @@ -544,7 +652,9 @@ class CacheService: lock = self._api_locks.setdefault(cache_key, asyncio.Lock()) async with lock: if not force_refresh: - cached, _, created_at = await self._read_api_payload(cache_key, endpoint) + cached, _, created_at = await self._read_api_payload( + cache_key, endpoint + ) if cached is not None: metadata["hit"] = True metadata["created_at"] = created_at @@ -553,12 +663,16 @@ class CacheService: ).hexdigest() return cached, metadata - stale, _, stale_created_at = await self._read_api_payload( - cache_key, - endpoint, - allow_expired=True, - ) - data = await requester() + data = None + try: + data = await requester() + finally: + if not is_cacheable(data): + 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): metadata["data_hash"] = hashlib.sha256( self._json(data).encode("utf-8") @@ -574,15 +688,6 @@ class CacheService: metadata["created_at"] = int(time.time()) logger.warning(f"写入接口缓存失败 endpoint={endpoint}: {exc}") return data, metadata - if stale is not None and allow_stale: - metadata["hit"] = True - metadata["stale"] = True - metadata["created_at"] = stale_created_at - metadata["data_hash"] = hashlib.sha256( - self._json(stale).encode("utf-8") - ).hexdigest() - logger.warning(f"JX3API 请求失败,使用过期缓存:{endpoint}") - return stale, metadata return data, metadata def build_image_key( @@ -765,35 +870,56 @@ class CacheService: async def cleanup_expired(self): now = int(time.time()) - self._memory = OrderedDict( - (key, value) for key, value in self._memory.items() if value[1] > now - ) - await self._sqlite.delete( - "api_response_cache", - "expires_at<=?", - (now - self.STALE_RETENTION_SECONDS,), - ) + async with self._api_storage_lock: + uncached_defaults = sorted(self._NO_CACHE_API_DEFAULTS) + placeholders = ",".join("?" for _ in uncached_defaults) + await self._sqlite.execute( + f""" + DELETE FROM api_response_cache + 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( "SELECT cache_key, file_name FROM image_render_cache WHERE expires_at<=?", (now,), ) for row in rows: - await self._delete_image_record( - str(row["cache_key"]), - self.image_dir / str(row["file_name"]), - ) + cache_key = str(row["cache_key"]) + async with self.image_lock(cache_key): + 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]: if cache_type not in {"api", "image", "all"}: raise ValueError("清理类型仅支持 api、image 或 all") removed = {"api": 0, "image": 0} if cache_type in {"api", "all"}: - row = await self._sqlite.fetch_one( - "SELECT COUNT(*) AS count FROM api_response_cache" - ) - removed["api"] = int((row or {}).get("count") or 0) - await self._sqlite.execute("DELETE FROM api_response_cache") - self._memory.clear() + async with self._api_storage_lock: + row = await self._sqlite.fetch_one( + "SELECT COUNT(*) AS count FROM api_response_cache" + ) + removed["api"] = int((row or {}).get("count") or 0) + await self._sqlite.execute("DELETE FROM api_response_cache") + self._clear_memory() if cache_type in {"image", "all"}: rows = await self._sqlite.fetch_all( "SELECT cache_key, file_name FROM image_render_cache" @@ -814,18 +940,19 @@ class CacheService: raise ValueError("缓存项目不能为空") if cache_type == "api": - rows = await self._sqlite.fetch_all( - "SELECT cache_key FROM api_response_cache WHERE endpoint=?", - (cache_name,), - ) - await self._sqlite.delete( - "api_response_cache", - "endpoint=?", - (cache_name,), - ) - for row in rows: - self._memory.pop(str(row["cache_key"]), None) - return len(rows) + async with self._api_storage_lock: + rows = await self._sqlite.fetch_all( + "SELECT cache_key FROM api_response_cache WHERE endpoint=?", + (cache_name,), + ) + await self._sqlite.delete( + "api_response_cache", + "endpoint=?", + (cache_name,), + ) + for row in rows: + self._forget_memory(str(row["cache_key"])) + return len(rows) rows = await self._sqlite.fetch_all( """ @@ -892,7 +1019,8 @@ class CacheService: "image": self.get_ttl("image", "*"), }, "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, }, "api": [ @@ -908,7 +1036,9 @@ class CacheService: "api_count": int((api_row or {}).get("count") or 0), "api_size_bytes": int((api_row or {}).get("size_bytes") or 0), "api_memory_count": len(self._memory), - "api_memory_limit": self.max_memory_entries, + "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_size_bytes": int((image_row or {}).get("size_bytes") or 0), "image_limit_bytes": self.max_image_bytes, diff --git a/core/event_push.py b/core/event_push.py index da0e5fd..61ac11b 100644 --- a/core/event_push.py +++ b/core/event_push.py @@ -3,7 +3,7 @@ import asyncio import contextlib import json from datetime import datetime -from typing import Any, Optional +from typing import Any from urllib.parse import parse_qsl, urlencode, urlsplit, urlunsplit import aiohttp @@ -13,10 +13,9 @@ from astrbot.api import AstrBotConfig, logger from astrbot.api.event import MessageChain from astrbot.api.star import Context -from .sqlite import AsyncSQLiteDB from .server_binding import ServerBindingService from .session_control import SessionControlService - +from .sqlite import AsyncSQLiteDB DEFAULT_WSS_URL = "wss://socket.nicemoe.cn" FREE_EVENT_ACTIONS = frozenset({2001, 2002, 2003, 2004, 2005, 2006}) @@ -68,17 +67,20 @@ SERVER_FIELDS = ( ("服务器", "server", "text"), ) EVENT_FIELDS = { - 1001: SERVER_FIELDS + ( + 1001: SERVER_FIELDS + + ( ("角色", "name", "text"), ("奇遇", "event", "text"), ("等级", "level", "text"), ("时间", "time", "time"), ), - 1002: SERVER_FIELDS + ( + 1002: SERVER_FIELDS + + ( ("地图", "map_name", "text"), ("刷新时间", "time", "time"), ), - 1003: SERVER_FIELDS + ( + 1003: SERVER_FIELDS + + ( ("名称", "name", "text"), ("地图", "map_name", "text"), ("马驹", "horse", "text"), @@ -87,79 +89,92 @@ EVENT_FIELDS = { ), 1004: SERVER_FIELDS + (("预告时间", "time", "time"),), 1005: SERVER_FIELDS + (("开启时间", "time", "time"),), - 1006: SERVER_FIELDS + ( + 1006: SERVER_FIELDS + + ( ("点名角色", "name", "list"), ("时间", "time", "time"), ), - 1007: SERVER_FIELDS + ( + 1007: SERVER_FIELDS + + ( ("燃放者", "sender", "text"), ("接收者", "receiver", "text"), ("烟花", "firework", "text"), ("地图", "map_name", "text"), ("时间", "time", "time"), ), - 1008: SERVER_FIELDS + ( + 1008: SERVER_FIELDS + + ( ("马驹", "name", "text"), ("地图", "map_name", "text"), ("预告时间", "time", "time"), ), - 1009: SERVER_FIELDS + ( + 1009: SERVER_FIELDS + + ( ("马驹", "name", "text"), ("地图", "map_name", "text"), ("刷新时间", "refresh_time", "time"), ), - 1010: SERVER_FIELDS + ( + 1010: SERVER_FIELDS + + ( ("马驹", "name", "text"), ("地图", "map_name", "text"), ("捕获角色", "capture_role_name", "text"), ("角色阵营", "capture_camp_name", "text"), ("捕获时间", "capture_time", "time"), ), - 1011: SERVER_FIELDS + ( + 1011: SERVER_FIELDS + + ( ("马驹", "name", "text"), ("竞拍角色", "auction_role_name", "text"), ("角色阵营", "auction_camp_name", "text"), ("成交金额", "auction_amount", "text"), ("拍卖时间", "auction_time", "time"), ), - 1012: SERVER_FIELDS + ( + 1012: SERVER_FIELDS + + ( ("角色", "role_name", "text"), ("副本", "map_name", "text"), ("物品", "item_name", "text"), ("时间", "time", "time"), ), - 1013: SERVER_FIELDS + ( + 1013: SERVER_FIELDS + + ( ("竞拍角色", "role_name", "text"), ("阵营", "camp_name", "text"), ("物品", "item_name", "text"), ("成交金额", "item_amount", "text"), ("时间", "time", "time"), ), - 1014: SERVER_FIELDS + ( + 1014: SERVER_FIELDS + + ( ("地图", "map_name", "text"), ("时间", "time", "time"), ), - 1015: SERVER_FIELDS + ( + 1015: SERVER_FIELDS + + ( ("角色所在服", "role_server", "text"), ("点名角色", "role_name", "text"), ("时间", "time", "time"), ), 1016: SERVER_FIELDS + (("预告时间", "time", "time"),), - 1017: SERVER_FIELDS + ( + 1017: SERVER_FIELDS + + ( ("阵营", "camp_name", "text"), ("帮会", "tong_name", "text"), ("角色", "role_name", "text"), ("据点", "castle_name", "text"), ("时间", "time", "time"), ), - 1101: SERVER_FIELDS + ( + 1101: SERVER_FIELDS + + ( ("战场类型", "battlefield_type", "text"), ("宣战帮会", "declaring_tong_name", "text"), ("应战帮会", "accepting_tong_name", "text"), ("领地帮会", "battlefield_tong_name", "text"), ("开始时间", "start_time", "time"), ), - 1102: SERVER_FIELDS + ( + 1102: SERVER_FIELDS + + ( ("战场类型", "battlefield_type", "text"), ("宣战帮会", "declaring_tong_name", "text"), ("应战帮会", "accepting_tong_name", "text"), @@ -168,87 +183,102 @@ EVENT_FIELDS = { ("获胜积分", "victory_score", "text"), ("结束时间", "end_time", "time"), ), - 1108: SERVER_FIELDS + ( + 1108: SERVER_FIELDS + + ( ("战场类型", "battlefield_type", "text"), ("宣战帮会", "declaring_tong_name", "text"), ("应战帮会", "accepting_tong_name", "text"), ("持续时长(小时)", "duration_hours", "text"), ("开始时间", "start_time", "time"), ), - 1109: SERVER_FIELDS + ( + 1109: SERVER_FIELDS + + ( ("战场类型", "battlefield_type", "text"), ("宣战帮会", "declaring_tong_name", "text"), ("应战帮会", "accepting_tong_name", "text"), ("结束时间", "end_time", "time"), ), - 1111: SERVER_FIELDS + ( + 1111: SERVER_FIELDS + + ( ("据点", "castle_name", "text"), ("阵营", "camp_name", "text"), ("时间", "time", "time"), ), - 1112: SERVER_FIELDS + ( + 1112: SERVER_FIELDS + + ( ("据点", "castle_name", "text"), ("时间", "time", "time"), ), - 1113: SERVER_FIELDS + ( + 1113: SERVER_FIELDS + + ( ("阵营", "camp_name", "text"), ("地图", "map_name", "text"), ("据点", "castle_name", "text"), ("时间", "time", "time"), ), - 1114: SERVER_FIELDS + ( + 1114: SERVER_FIELDS + + ( ("阵营", "camp_name", "text"), ("帮会", "tong_name", "text"), ("据点", "castle_name", "text"), ("时间", "time", "time"), ), - 1115: SERVER_FIELDS + ( + 1115: SERVER_FIELDS + + ( ("阵营", "camp_name", "text"), ("据点", "castle_name", "text"), ("时间", "time", "time"), ), - 1116: SERVER_FIELDS + ( + 1116: SERVER_FIELDS + + ( ("阵营", "camp_name", "text"), ("贡献帮会", "tong_name", "list"), ("时间", "time", "time"), ), - 1117: SERVER_FIELDS + ( + 1117: SERVER_FIELDS + + ( ("阵营", "camp_name", "text"), ("贡献帮会", "tong_name", "list"), ("时间", "time", "time"), ), - 1118: SERVER_FIELDS + ( + 1118: SERVER_FIELDS + + ( ("阵营", "camp_name", "text"), ("贡献帮会", "tong_name", "list"), ("时间", "time", "time"), ), - 1119: SERVER_FIELDS + ( + 1119: SERVER_FIELDS + + ( ("阵营", "camp_name", "text"), ("竞拍角色", "role_name", "text"), ("物品", "item_name", "text"), ("成交金额", "item_amount", "text"), ("时间", "time", "time"), ), - 1120: SERVER_FIELDS + ( + 1120: SERVER_FIELDS + + ( ("阵营", "camp_name", "text"), ("分红帮会", "tong_name", "list"), ("分红金额", "split_amount", "text"), ("时间", "time", "time"), ), - 1121: SERVER_FIELDS + ( + 1121: SERVER_FIELDS + + ( ("阵营", "camp_name", "text"), ("分红帮会", "tong_name", "list"), ("分红金额", "split_amount", "text"), ("时间", "time", "time"), ), - 1122: SERVER_FIELDS + ( + 1122: SERVER_FIELDS + + ( ("阵营", "camp_name", "text"), ("指挥帮会", "chief_tong_name", "text"), ("分红帮会", "tong_name", "list"), ("分红金额", "split_amount", "text"), ("时间", "time", "time"), ), - 2001: SERVER_FIELDS + ( + 2001: SERVER_FIELDS + + ( ("状态", "status", "status"), ("时间", "time", "time"), ), @@ -304,9 +334,9 @@ class EventPushService: self.session_control = session_control self.url = str(config.get("jx3api_wss", "") or DEFAULT_WSS_URL).strip() self.token = str(config.get("jx3api_wss_token", "") or "").strip() - self._runner: Optional[asyncio.Task] = None - self._session: Optional[ClientSession] = None - self._websocket: Optional[aiohttp.ClientWebSocketResponse] = None + self._runner: asyncio.Task | None = None + self._session: ClientSession | None = None + self._websocket: aiohttp.ClientWebSocketResponse | None = None self._stopping = asyncio.Event() async def initialize(self): @@ -321,8 +351,7 @@ class EventPushService: async def _init_subscription_table(self): columns = ",\n".join( - f"action_{action} INTEGER NOT NULL DEFAULT 0" - for action in EVENT_ACTIONS + f"action_{action} INTEGER NOT NULL DEFAULT 0" for action in EVENT_ACTIONS ) await self.sql.execute( f""" @@ -418,7 +447,7 @@ class EventPushService: if self._stopping.is_set(): break - delay = min(2 ** retry_count, 30) + delay = min(2**retry_count, 30) retry_count += 1 logger.info(f"JX3API 事件通道将在 {delay} 秒后重连") try: @@ -543,6 +572,72 @@ class EventPushService: 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( self, session_id: str, @@ -624,9 +719,7 @@ class EventPushService: switch = "开启" if row.get("enabled") == 1 else "关闭" selected = "、".join(subscriptions) if subscriptions else "无" return ( - f"事件推送总开关:{switch}\n" - f"已订阅事件:{selected}\n\n" - f"{self._usage_text()}" + f"事件推送总开关:{switch}\n已订阅事件:{selected}\n\n{self._usage_text()}" ) @staticmethod @@ -688,8 +781,7 @@ class EventPushService: @staticmethod def _event_list_text() -> str: free = "\n".join( - f"{action}:{EVENT_NAMES[action]}" - for action in sorted(FREE_EVENT_ACTIONS) + f"{action}:{EVENT_NAMES[action]}" for action in sorted(FREE_EVENT_ACTIONS) ) paid = "\n".join( f"{action}:{EVENT_NAMES[action]}" diff --git a/core/jx3api_data.py b/core/jx3api_data.py index 59a16a7..bd2f9b3 100644 --- a/core/jx3api_data.py +++ b/core/jx3api_data.py @@ -120,7 +120,6 @@ class JX3APIService: "/server/status/check", {"server": "", "type": "其他"}, force_refresh=force_refresh, - allow_stale=not force_refresh, ) if not isinstance(data, list): return [] @@ -250,7 +249,6 @@ class JX3APIService: params: Optional[Dict[str, Any]] = None, out: Optional[str] = "data", force_refresh: bool = False, - allow_stale: bool = True, ) -> tuple[Any, dict[str, Any]]: request_params = params or {} if not self._cache: @@ -279,7 +277,6 @@ class JX3APIService: lambda: self._base_request(api_path, request_params, out), self._is_cacheable_response, force_refresh=force_refresh, - allow_stale=allow_stale, ) except Exception as exc: logger.warning(f"接口缓存不可用,直接请求 JX3API endpoint={api_path}: {exc}") diff --git a/core/webui.py b/core/webui.py index 37d2cf8..13385c9 100644 --- a/core/webui.py +++ b/core/webui.py @@ -7,7 +7,7 @@ from typing import TYPE_CHECKING, Any from astrbot.api.star import Context 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: from .bilei_data import BiLeidata @@ -43,6 +43,18 @@ class WebUIService: def register(self, context: Context, plugin_name: str): routes = ( ("dashboard", self.dashboard, ["GET"], "读取会话管理数据"), + ( + "subscriptions/save", + self.save_subscription, + ["POST"], + "保存会话事件推送配置", + ), + ( + "subscriptions/delete", + self.delete_subscription, + ["POST"], + "删除会话事件推送配置", + ), ("bindings/save", self.save_binding, ["POST"], "保存会话区服绑定"), ("bindings/delete", self.delete_binding, ["POST"], "删除会话区服绑定"), ("aliases/save", self.save_aliases, ["POST"], "保存区服别名"), @@ -130,9 +142,8 @@ class WebUIService: "aliases": aliases, "kungfu": kungfu, "servers": self.server_binding.standard_servers(), - "events": { - str(action): name for action, name in EVENT_NAMES.items() - }, + "events": {str(action): name for action, name in EVENT_NAMES.items()}, + "free_event_actions": sorted(FREE_EVENT_ACTIONS), "session_control": session_control, "legacy_bilei": legacy_bilei, "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): try: payload = await self._json_payload() - server = self.server_binding.resolve_standard_server( - payload.get("server") - ) + server = self.server_binding.resolve_standard_server(payload.get("server")) if not server: raise ValueError("绑定区服必须选择标准区服") await self.server_binding.set_binding( @@ -277,7 +307,8 @@ class WebUIService: try: payload = await self._json_payload() 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"), ) except ValueError as exc: diff --git a/main.py b/main.py index ae44be4..4bf0300 100644 --- a/main.py +++ b/main.py @@ -26,7 +26,7 @@ PLUGIN_NAME = "astrbot_plugin_jx3" @register("astrbot_plugin_jx3", "fxdyz", "聚合剑网三游戏数据,提供查询、图片渲染、本地避雷和实时事件推送。", - "3.4.6", + "3.4.7", "https://github.com/qsc20001102/astrbot_plugin_jx3" ) class Jx3ApiPlugin(Star): @@ -84,6 +84,7 @@ class Jx3ApiPlugin(Star): except Exception as e: if self.event_push is not None: await self.event_push.stop() + await self.cache.stop() logger.exception("功能模块初始化失败") raise @@ -96,6 +97,9 @@ class Jx3ApiPlugin(Star): async def terminate(self): """可选择实现异步的插件销毁方法,当插件被卸载/停用时会调用。""" + if self.cache: + await self.cache.stop() + if self.event_push: await self.event_push.stop() diff --git a/metadata.yaml b/metadata.yaml index 7445c63..8645d56 100644 --- a/metadata.yaml +++ b/metadata.yaml @@ -1,7 +1,7 @@ name: astrbot_plugin_jx3 display_name: 剑网三游戏数据查询工具 desc: 聚合剑网三游戏数据,提供查询、图片渲染、本地避雷和实时事件推送。 -version: 3.4.6 +version: 3.4.7 author: 飞翔大野猪 repo: https://github.com/qsc20001102/astrbot_plugin_jx3 astrbot_version: ">=4.24.1" diff --git a/pages/server-management/app.js b/pages/server-management/app.js index 592aca6..8002c7f 100644 --- a/pages/server-management/app.js +++ b/pages/server-management/app.js @@ -6,18 +6,20 @@ const state = { kungfu: [], servers: [], events: {}, + free_event_actions: [], session_control: { mode: "all", entries: [] }, legacy_bilei: [], token_stats: null, cache: { 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: [], images: [], 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(); 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() { const body = byId("subscriptions-body"); const bindings = bindingMap(); if (!state.subscriptions.length) { - body.replaceChildren(emptyRow(4, "暂无事件订阅会话")); + body.replaceChildren(emptyRow(5, "暂无事件订阅会话,点击“添加推送会话”开始配置")); return; } body.replaceChildren(...state.subscriptions.map((item) => { @@ -491,10 +545,14 @@ function renderSubscriptions() { const server = document.createElement("td"); const enabled = document.createElement("td"); const actions = document.createElement("td"); + const controls = document.createElement("td"); session.dataset.label = "会话 ID"; server.dataset.label = "绑定区服"; enabled.dataset.label = "总开关"; actions.dataset.label = "已订阅事件"; + controls.dataset.label = "操作"; + controls.className = "actions"; + session.className = "subscription-session-cell"; session.textContent = item.session_id; server.textContent = bindings.get(item.session_id) || "未绑定(全部区服)"; const stateLabel = document.createElement("span"); @@ -514,7 +572,34 @@ function renderSubscriptions() { tags.textContent = "无"; } 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; })); } @@ -739,17 +824,20 @@ function renderCache() { const stats = cache.stats || {}; const apiDefault = cache.defaults?.api ?? 300; 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; - 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("image-cache-count").textContent = `${stats.image_count || 0} 张`; byId("image-cache-size").textContent = `${formatBytes(stats.image_size_bytes)} / ${formatBytes(stats.image_limit_bytes)}`; byId("api-default-ttl").value = String(apiDefault); 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("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} 秒`; renderCacheTable("api"); 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) => { event.preventDefault(); const saved = await mutate("bindings/save", { @@ -929,7 +1063,8 @@ byId("cache-limit-form").addEventListener("submit", async (event) => { submit.disabled = true; try { 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), }); await loadData(); diff --git a/pages/server-management/index.html b/pages/server-management/index.html index 846b1e9..00e1795 100644 --- a/pages/server-management/index.html +++ b/pages/server-management/index.html @@ -106,7 +106,7 @@

查询与图片缓存

-

接口数据以 JSON 保存到 SQLite,渲染图片保存到插件数据目录。图片命中时会直接发送,不再请求其上游接口;缓存时间使用秒,填写 0 可关闭对应缓存。

+

接口数据以 JSON 保存到 SQLite,渲染图片保存到插件数据目录。图片命中时会直接发送,不再请求其上游接口;缓存时间使用秒,填写 0 可关闭对应缓存。每 60 秒自动清理过期缓存,接口请求失败时不使用过期数据。

@@ -115,9 +115,9 @@
-
接口缓存(SQLite)0 条0 B
+
接口缓存(SQLite)0 / 256 条0 B
图片缓存0 张0 B
-
接口内存缓存0 / 256 条最久未使用优先淘汰
+
接口内存缓存0 B / 16 MB0 条,最久未使用优先淘汰
默认缓存时间300 / 600 秒接口 / 图片
@@ -135,8 +135,12 @@
+