fxdyz
This commit is contained in:
+250
-120
@@ -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
@@ -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]}"
|
||||
|
||||
@@ -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
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user