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):
本地避雷记录新增按 AstrBot 会话隔离的数据范围。新增、查看、查询、修改和删除均使用当前消息的 `unified_msg_origin`,修改与删除同时校验会话 ID 和记录 ID,其他会话即使知道记录 ID 也无法访问或操作。
+9 -7
View File
@@ -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 查询入口成功取得的原始 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)后按最近最少使用顺序清理。
@@ -561,7 +563,7 @@ git diff --check
## 当前版本状态
以下内容是对 v3.4.6 当前源码的静态核对结果,部署和二次开发前应注意:
以下内容是对 v3.4.7 当前源码的静态核对结果,部署和二次开发前应注意:
1. 查询图片使用浅色高对比主题和放大的内容区域;渲染清晰度、JPEG/PNG 格式及 JPEG 质量由 `image_render_quality` 配置组控制,提高清晰度或使用 PNG 会增加图片体积与渲染耗时。
2. 所有 HTML 渲染图片底部都会显示数据时间;最终图片缓存命中后保留原时间并跳过接口请求和重新渲染。要让下一次查询同时获取最新上游数据并重新生成图片,需要一并清除或关闭对应接口缓存和图片缓存。
+204 -74
View File
@@ -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,23 +479,14 @@ class CacheService:
self,
cache_key: str,
endpoint: str,
allow_expired: bool = False,
) -> tuple[Any | None, int | None, int | None]:
now = int(time.time())
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
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)
created_at, expires_at, payload, _ = memory
else:
row = await self._sqlite.fetch_one(
"""
SELECT payload, created_at, expires_at
@@ -445,35 +497,90 @@ class CacheService:
)
if not row:
return None, None, None
expires_at = int(row["expires_at"])
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 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"]))
data = (
json.loads(payload) if ttl > 0 and effective_expiry > now else None
)
except json.JSONDecodeError:
await self._sqlite.delete("api_response_cache", "cache_key=?", (cache_key,))
return None, None, None
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
self._remember(cache_key, created_at, expires_at, str(row["payload"]))
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
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,6 +592,7 @@ class CacheService:
payload = self._json(data)
now = int(time.time())
expires_at = now + ttl_seconds
async with self._api_storage_lock:
await self._sqlite.execute(
"""
INSERT INTO api_response_cache(
@@ -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),
(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 = 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,22 +870,42 @@ 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
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),
)
await self._sqlite.delete(
"api_response_cache",
"expires_at<=?",
(now - self.STALE_RETENTION_SECONDS,),
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:
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(
str(row["cache_key"]),
self.image_dir / str(row["file_name"]),
cache_key, self.image_dir / str(expired["file_name"])
)
async def clear(self, cache_type: str) -> dict[str, int]:
@@ -788,12 +913,13 @@ class CacheService:
raise ValueError("清理类型仅支持 api、image 或 all")
removed = {"api": 0, "image": 0}
if cache_type in {"api", "all"}:
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._memory.clear()
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,6 +940,7 @@ class CacheService:
raise ValueError("缓存项目不能为空")
if cache_type == "api":
async with self._api_storage_lock:
rows = await self._sqlite.fetch_all(
"SELECT cache_key FROM api_response_cache WHERE endpoint=?",
(cache_name,),
@@ -824,7 +951,7 @@ class CacheService:
(cache_name,),
)
for row in rows:
self._memory.pop(str(row["cache_key"]), None)
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,
+137 -45
View File
@@ -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]}"
-3
View File
@@ -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}")
+39 -8
View File
@@ -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:
+5 -1
View File
@@ -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()
+1 -1
View File
@@ -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"
+144 -9
View File
@@ -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();
+41 -8
View File
@@ -106,7 +106,7 @@
<div class="section-intro section-intro--actions">
<div>
<h2>查询与图片缓存</h2>
<p>接口数据以 JSON 保存到 SQLite,渲染图片保存到插件数据目录。图片命中时会直接发送,不再请求其上游接口;缓存时间使用秒,填写 0 可关闭对应缓存。</p>
<p>接口数据以 JSON 保存到 SQLite,渲染图片保存到插件数据目录。图片命中时会直接发送,不再请求其上游接口;缓存时间使用秒,填写 0 可关闭对应缓存。每 60 秒自动清理过期缓存,接口请求失败时不使用过期数据。</p>
</div>
<div class="cache-clear-actions">
<button class="button button--secondary" id="clear-api-cache" type="button">清空接口缓存</button>
@@ -115,9 +115,9 @@
</div>
<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="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>
@@ -135,8 +135,12 @@
<form class="cache-defaults cache-limits" id="cache-limit-form">
<label>
<span>接口内存缓存最大条数</span>
<input id="api-memory-limit" type="number" min="1" max="100000" step="1" required />
<span>接口内存缓存最大容量(MB</span>
<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>
<span>图片缓存最大容量(MB</span>
@@ -212,12 +216,41 @@
</section>
<section class="panel" id="subscriptions-panel" role="tabpanel" hidden>
<div class="section-intro">
<div><h2>所有会话事件状态</h2><p>总开关和订阅事件均来自本地订阅表</p></div>
<div class="section-intro section-intro--actions">
<div><h2>会话事件推送</h2><p>为会话配置推送总开关和订阅事件,保存后立即生效。推送仍受会话访问模式及绑定区服限制;删除仅移除该会话的推送配置</p></div>
<button class="button button--primary" id="add-subscription" type="button">添加推送会话</button>
</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">
<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>
</table>
</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--actions { display: flex; align-items: center; justify-content: space-between; gap: 20px; }
.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-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; }
@@ -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 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-limits { grid-template-columns: repeat(3, minmax(180px, 1fr)) auto; }
.cache-group { border-bottom: 1px solid var(--border); }
.cache-group:last-child { border-bottom: 0; }
.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); }
@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; }
.page-header { align-items: stretch; flex-direction: column; }
.section-intro--actions { align-items: stretch; flex-direction: column; }