# -*- coding: utf-8 -*- """M5 Web 编辑器的 SQLite 存储层。 表 events:group(PK)/title/theme/status/ir_json/updated_at/updated_by/notes。 末次写入生效(设计接受),不做锁。 表 sessions:token(PK)/user/expires_at——登录会话(cookie 只存随机 token,不存口令)。 """ import json import os import sqlite3 # DB 路径:容器内用 STORY_DB_PATH 指向挂载卷(持久化);本地默认同目录。 _DB_PATH = os.environ.get("STORY_DB_PATH") or \ os.path.join(os.path.dirname(os.path.abspath(__file__)), "story_events.db") STATUSES = ("pending", "confirmed", "discarded") def _conn(path=None): c = sqlite3.connect(path or _DB_PATH) c.row_factory = sqlite3.Row return c def init_db(path=None): d = os.path.dirname(path or _DB_PATH) if d and not os.path.isdir(d): os.makedirs(d, exist_ok=True) with _conn(path) as c: c.execute( """CREATE TABLE IF NOT EXISTS events ( "group" TEXT PRIMARY KEY, title TEXT, theme TEXT, scene TEXT DEFAULT '', status TEXT NOT NULL DEFAULT 'pending', ir_json TEXT NOT NULL, updated_at TEXT, updated_by TEXT, notes TEXT )""" ) # 旧库迁移:补 scene 列(海选审核按场景分组的归类维度,镜像自 ir.scene)。 try: c.execute("ALTER TABLE events ADD COLUMN scene TEXT DEFAULT ''") except sqlite3.OperationalError: pass # 已存在 c.execute( """CREATE TABLE IF NOT EXISTS sessions ( token TEXT PRIMARY KEY, user TEXT NOT NULL, expires_at REAL NOT NULL )""" ) # ---------- 会话 ---------- def create_session(token, user, expires_at, path=None): with _conn(path) as c: c.execute("INSERT OR REPLACE INTO sessions (token, user, expires_at) " "VALUES (?,?,?)", (token, user, expires_at)) def get_session_user(token, now, path=None): """token 有效返回用户名;过期则顺手删除并返回 None。""" with _conn(path) as c: r = c.execute("SELECT user, expires_at FROM sessions WHERE token=?", (token,)).fetchone() if not r: return None if r["expires_at"] < now: c.execute("DELETE FROM sessions WHERE token=?", (token,)) return None return r["user"] def delete_session(token, path=None): with _conn(path) as c: c.execute("DELETE FROM sessions WHERE token=?", (token,)) def purge_sessions(now, path=None): with _conn(path) as c: c.execute("DELETE FROM sessions WHERE expires_at < ?", (now,)) def list_events(status=None, path=None): """列表(不含 ir_json,轻量)。""" sql = ('SELECT "group", title, theme, scene, status, updated_at, updated_by, notes ' "FROM events") args = [] if status and status != "all": sql += " WHERE status = ?" args.append(status) sql += " ORDER BY updated_at DESC" with _conn(path) as c: return [dict(r) for r in c.execute(sql, args).fetchall()] def get_event(group, path=None): with _conn(path) as c: r = c.execute('SELECT * FROM events WHERE "group" = ?', (group,)).fetchone() if not r: return None d = dict(r) d["ir"] = json.loads(d.pop("ir_json")) return d def upsert_event(ir, by, now, notes=None, keep_status=True, path=None): """插入或更新。已存在时默认保留状态(仅刷新 ir/title/theme/元信息)。""" group = ir["id"] title = ir.get("title", "") theme = ir.get("theme", "") scene = ir.get("scene", "") or "" # 海选审核分组维度,镜像自 ir.scene ir_str = json.dumps(ir, ensure_ascii=False) with _conn(path) as c: exists = c.execute('SELECT status FROM events WHERE "group" = ?', (group,)).fetchone() if exists: c.execute( 'UPDATE events SET title=?, theme=?, scene=?, ir_json=?, updated_at=?, ' 'updated_by=?, notes=COALESCE(?, notes) WHERE "group"=?', (title, theme, scene, ir_str, now, by, notes, group), ) else: c.execute( 'INSERT INTO events ("group", title, theme, scene, status, ir_json, ' "updated_at, updated_by, notes) VALUES (?,?,?,?,?,?,?,?,?)", (group, title, theme, scene, "pending", ir_str, now, by, notes or ""), ) return group def set_status(group, status, by, now, path=None): if status not in STATUSES: raise ValueError("非法状态: %r" % status) with _conn(path) as c: cur = c.execute( 'UPDATE events SET status=?, updated_at=?, updated_by=? WHERE "group"=?', (status, now, by, group), ) return cur.rowcount > 0 def confirmed_events(path=None): """所有 confirmed 事件的 (group, ir) 列表,供导出编译。""" with _conn(path) as c: rows = c.execute( 'SELECT "group", ir_json FROM events WHERE status=? ORDER BY "group"', ("confirmed",), ).fetchall() return [(r["group"], json.loads(r["ir_json"])) for r in rows]