- hexi core: message handling, rate limiting, cooldown, plugin manager - Custom plugins: BF stats, daily check-in, quotes, persona cards, etc. - Community plugins vendored under hexi/plugins with local fixes - Web admin frontends (learning-chat, persona-admin), unified hexi/web - Tests for rate_limit/cooldown/memes/persona; poetry.lock Co-Authored-By: Claude <noreply@anthropic.com>
321 lines
11 KiB
Python
321 lines
11 KiB
Python
# -*- coding: utf-8 -*-
|
||
"""战备(SQLite)数据存取层。
|
||
|
||
把原来写在 data/equipment.json 的战备数据迁移到 SQLite,供
|
||
- equipment.py 随机/组合抽取
|
||
- 战备管理后台(stratagem_admin.py) 的读/写/导入/导出
|
||
|
||
三层语义弱化为单表 + is_custom 标记:
|
||
- 从 data/equipment.json 首次播种的记录 is_custom=0(基础库)
|
||
- 管理后台手动新增的记录 is_custom=1
|
||
- 手动编辑都是直接改行,不另建覆盖层(当前不做 API 同步,因此单表即可)
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import json
|
||
import sqlite3
|
||
import time
|
||
from pathlib import Path
|
||
from typing import Any
|
||
|
||
from nonebot import logger
|
||
|
||
basic_path = Path(__file__).resolve().parent
|
||
DATA_DIR = basic_path / "data"
|
||
DB_PATH = DATA_DIR / "equipment.db"
|
||
SEED_PATH = DATA_DIR / "archive" / "equipment.json"
|
||
|
||
|
||
def _connect() -> sqlite3.Connection:
|
||
conn = sqlite3.connect(DB_PATH)
|
||
conn.row_factory = sqlite3.Row
|
||
return conn
|
||
|
||
|
||
def _now() -> str:
|
||
return time.strftime("%Y-%m-%d %H:%M:%S")
|
||
|
||
|
||
def _load_seed() -> list[dict[str, Any]]:
|
||
"""读取随包的 equipment.json 作为初始基础库。"""
|
||
if not SEED_PATH.exists():
|
||
return []
|
||
try:
|
||
data = json.loads(SEED_PATH.read_text(encoding="utf-8"))
|
||
return data if isinstance(data, list) else []
|
||
except Exception as e: # noqa: BLE001
|
||
logger.warning(f"读取 equipment.json 失败,跳过播种: {e}")
|
||
return []
|
||
|
||
|
||
def init_db() -> None:
|
||
"""建表(WAL 便于网页与 bot 并发读写),空表时从 equipment.json 播种。"""
|
||
DATA_DIR.mkdir(parents=True, exist_ok=True)
|
||
with _connect() as conn:
|
||
conn.execute("PRAGMA journal_mode=WAL")
|
||
conn.execute(
|
||
"""
|
||
CREATE TABLE IF NOT EXISTS stratagems (
|
||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||
name TEXT NOT NULL,
|
||
name_en TEXT NOT NULL DEFAULT '',
|
||
type TEXT NOT NULL DEFAULT 'blue',
|
||
backpack INTEGER NOT NULL DEFAULT 0,
|
||
icon TEXT NOT NULL DEFAULT '',
|
||
alias TEXT NOT NULL DEFAULT '[]',
|
||
is_custom INTEGER NOT NULL DEFAULT 0,
|
||
created_at TEXT,
|
||
updated_at TEXT,
|
||
UNIQUE(name)
|
||
)
|
||
"""
|
||
)
|
||
cur = conn.execute("SELECT COUNT(*) AS n FROM stratagems")
|
||
if cur.fetchone()["n"] == 0:
|
||
now = _now()
|
||
for item in _load_seed():
|
||
if not isinstance(item, dict):
|
||
continue
|
||
name = (item.get("name") or "").strip()
|
||
if not name:
|
||
continue
|
||
alias = item.get("alias") or []
|
||
if isinstance(alias, str):
|
||
alias = [a.strip() for a in alias.split(",") if a.strip()]
|
||
conn.execute(
|
||
"""
|
||
INSERT OR IGNORE INTO stratagems
|
||
(name, name_en, type, backpack, icon, alias, is_custom, created_at, updated_at)
|
||
VALUES (?,?,?,?,?,?,?,?,?)
|
||
""",
|
||
(
|
||
name,
|
||
item.get("name_en") or "",
|
||
item.get("type") or "blue",
|
||
1 if item.get("backpack") else 0,
|
||
item.get("path") or item.get("icon") or "",
|
||
json.dumps(alias, ensure_ascii=False),
|
||
0,
|
||
now,
|
||
now,
|
||
),
|
||
)
|
||
conn.commit()
|
||
|
||
|
||
def _row_to_item(row: sqlite3.Row) -> dict[str, Any]:
|
||
try:
|
||
alias = json.loads(row["alias"] or "[]")
|
||
if not isinstance(alias, list):
|
||
alias = []
|
||
except Exception: # noqa: BLE001
|
||
alias = []
|
||
return {
|
||
"id": row["id"],
|
||
"name": row["name"],
|
||
"name_en": row["name_en"] or "",
|
||
"type": row["type"] or "blue",
|
||
"backpack": bool(row["backpack"]),
|
||
"path": row["icon"] or "",
|
||
"icon": row["icon"] or "",
|
||
"alias": alias,
|
||
"is_custom": bool(row["is_custom"]),
|
||
}
|
||
|
||
|
||
def _normalize_alias(value: Any) -> list[str]:
|
||
if isinstance(value, str):
|
||
parts = [v.strip() for v in value.replace(",", ",").replace("、", ",").replace("\n", ",").split(",") if v.strip()]
|
||
elif isinstance(value, list):
|
||
parts = [v.strip() for v in value if isinstance(v, str) and v.strip()]
|
||
else:
|
||
parts = []
|
||
seen: set[str] = set()
|
||
out: list[str] = []
|
||
for p in parts:
|
||
if p not in seen:
|
||
seen.add(p)
|
||
out.append(p)
|
||
return out
|
||
|
||
|
||
def list_stratagems() -> list[dict[str, Any]]:
|
||
init_db()
|
||
with _connect() as conn:
|
||
rows = conn.execute("SELECT * FROM stratagems ORDER BY id").fetchall()
|
||
return [_row_to_item(r) for r in rows]
|
||
|
||
|
||
def count_stratagems() -> int:
|
||
init_db()
|
||
with _connect() as conn:
|
||
return int(conn.execute("SELECT COUNT(*) AS n FROM stratagems").fetchone()["n"])
|
||
|
||
|
||
def list_stratagems_page(page: int = 1, page_size: int = 20) -> tuple[list[dict[str, Any]], int]:
|
||
init_db()
|
||
total = count_stratagems()
|
||
offset = (page - 1) * page_size
|
||
with _connect() as conn:
|
||
rows = conn.execute(
|
||
"SELECT * FROM stratagems ORDER BY id LIMIT ? OFFSET ?",
|
||
(page_size, offset),
|
||
).fetchall()
|
||
return [_row_to_item(r) for r in rows], total
|
||
|
||
|
||
def get_stratagem(seed: int) -> dict[str, Any] | None:
|
||
init_db()
|
||
with _connect() as conn:
|
||
row = conn.execute("SELECT * FROM stratagems WHERE id=?", (seed,)).fetchone()
|
||
return _row_to_item(row) if row else None
|
||
|
||
|
||
def create_stratagem(item: dict[str, Any]) -> dict[str, Any]:
|
||
init_db()
|
||
name = (item.get("name") or "").strip()
|
||
if not name:
|
||
raise ValueError("name 不能为空")
|
||
now = _now()
|
||
with _connect() as conn:
|
||
cursor = conn.execute(
|
||
"""
|
||
INSERT INTO stratagems (name, name_en, type, backpack, icon, alias, is_custom, created_at, updated_at)
|
||
VALUES (?,?,?,?,?,?,?,?,?)
|
||
""",
|
||
(
|
||
name,
|
||
item.get("name_en") or "",
|
||
item.get("type") or "blue",
|
||
1 if item.get("backpack") else 0,
|
||
item.get("icon") or item.get("path") or "",
|
||
json.dumps(_normalize_alias(item.get("alias")), ensure_ascii=False),
|
||
1,
|
||
now,
|
||
now,
|
||
),
|
||
)
|
||
conn.commit()
|
||
new_id = cursor.lastrowid
|
||
row = get_stratagem(new_id)
|
||
assert row is not None
|
||
return row
|
||
|
||
|
||
def update_stratagem(seed: int, fields: dict[str, Any]) -> dict[str, Any] | None:
|
||
init_db()
|
||
if not fields:
|
||
return get_stratagem(seed)
|
||
# 只允许安全字段
|
||
allowed = {"name", "name_en", "type", "backpack", "icon", "path", "alias"}
|
||
updates: dict[str, Any] = {}
|
||
for k in allowed:
|
||
if k in fields:
|
||
updates[k] = fields[k]
|
||
if "alias" in updates:
|
||
updates["alias"] = json.dumps(_normalize_alias(updates["alias"]), ensure_ascii=False)
|
||
if "path" in updates and "icon" not in updates:
|
||
updates["icon"] = updates.pop("path")
|
||
elif "icon" in updates:
|
||
updates.pop("path", None)
|
||
if not updates:
|
||
return get_stratagem(seed)
|
||
updates["updated_at"] = _now()
|
||
sets = ", ".join(f"{k}=?" for k in updates)
|
||
params = list(updates.values()) + [seed]
|
||
with _connect() as conn:
|
||
cur = conn.execute(f"UPDATE stratagems SET {sets} WHERE id=?", params)
|
||
conn.commit()
|
||
if cur.rowcount == 0:
|
||
return None
|
||
return get_stratagem(seed)
|
||
|
||
|
||
def delete_stratagem(seed: int) -> bool:
|
||
init_db()
|
||
with _connect() as conn:
|
||
cur = conn.execute("DELETE FROM stratagems WHERE id=?", (seed,))
|
||
conn.commit()
|
||
return cur.rowcount > 0
|
||
|
||
|
||
def import_items(items: list[dict[str, Any]]) -> dict[str, int]:
|
||
"""按 id(存在则更新) 或 name(存在则更新) 合并导入;新增的记录标记为 custom。
|
||
|
||
返回 {added, updated, skipped}
|
||
"""
|
||
init_db()
|
||
added = 0
|
||
updated = 0
|
||
skipped = 0
|
||
now = _now()
|
||
with _connect() as conn:
|
||
for item in items:
|
||
if not isinstance(item, dict):
|
||
skipped += 1
|
||
continue
|
||
name = (item.get("name") or "").strip()
|
||
if not name:
|
||
skipped += 1
|
||
continue
|
||
seed = item.get("id")
|
||
existing: dict[str, Any] | None = None
|
||
if isinstance(seed, int):
|
||
row = conn.execute("SELECT * FROM stratagems WHERE id=?", (seed,)).fetchone()
|
||
existing = _row_to_item(row) if row else None
|
||
if existing is None:
|
||
row = conn.execute("SELECT * FROM stratagems WHERE name=?", (name,)).fetchone()
|
||
existing = _row_to_item(row) if row else None
|
||
if existing is None:
|
||
conn.execute(
|
||
"""
|
||
INSERT INTO stratagems (name, name_en, type, backpack, icon, alias, is_custom, created_at, updated_at)
|
||
VALUES (?,?,?,?,?,?,?,?,?)
|
||
""",
|
||
(
|
||
name,
|
||
item.get("name_en") or "",
|
||
item.get("type") or "blue",
|
||
1 if item.get("backpack") else 0,
|
||
item.get("icon") or item.get("path") or "",
|
||
json.dumps(_normalize_alias(item.get("alias")), ensure_ascii=False),
|
||
1,
|
||
now,
|
||
now,
|
||
),
|
||
)
|
||
added += 1
|
||
else:
|
||
fields = {
|
||
"name": name,
|
||
"name_en": (item.get("name_en") or existing.get("name_en") or ""),
|
||
"type": (item.get("type") or existing.get("type") or "blue"),
|
||
"backpack": bool(item.get("backpack", existing.get("backpack"))),
|
||
"icon": (item.get("icon") or item.get("path") or existing.get("icon") or ""),
|
||
"alias": item.get("alias", existing.get("alias")),
|
||
}
|
||
conn.execute(
|
||
"""
|
||
UPDATE stratagems
|
||
SET name=?, name_en=?, type=?, backpack=?, icon=?, alias=?, updated_at=?
|
||
WHERE id=?
|
||
""",
|
||
(
|
||
fields["name"],
|
||
fields["name_en"],
|
||
fields["type"],
|
||
1 if fields["backpack"] else 0,
|
||
fields["icon"],
|
||
json.dumps(_normalize_alias(fields["alias"]), ensure_ascii=False),
|
||
now,
|
||
existing["id"],
|
||
),
|
||
)
|
||
updated += 1
|
||
conn.commit()
|
||
return {"added": added, "updated": updated, "skipped": skipped}
|
||
|
||
|
||
def export_items() -> list[dict[str, Any]]:
|
||
return list_stratagems() |