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
+250 -120
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,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,
+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: