# -*- 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()