diff --git a/README.md b/README.md index 660e337..5672e5b 100644 --- a/README.md +++ b/README.md @@ -1,16 +1,34 @@ # world_snapshot 模块 -世界状态快照模块,聚合世界(world)、场景(scene)、实体(entity)当前状态生成快照,支持查询与恢复。 +世界状态快照模块(W-03):聚合世界(world)、场景(scene)、实体(entity)当前状态生成 +快照,支持快照查询与恢复,恢复时联动 world 表状态(经 world 模块 set_world_mode 契约)。 ## 功能 -- 世界快照表 `world_snapshot` 的 CRUD +- 世界快照表 `world_snapshot` 的 CRUD(create/get/list/update/delete) - `create_snapshot`:聚合 world/scene/entity 当前状态写入 `snapshot_json` -- `restore_snapshot`:读取并校验快照 JSON,恢复状态(基础实现:校验 + 状态标记) +- `restore_snapshot`:读取并校验快照 JSON,标记已恢复,联动 world 状态 - 下拉接口:世界列表、快照类型、快照状态 ## 数据表 -- `world_snapshot`(models/world_snapshot.json) +- `world_snapshot`(models/world_snapshot.json 四段式) +- 编码字典:`snapshot_type`(full/incremental)、`snapshot_status`(active/restored/invalid) + +## 目录结构 +``` +world_snapshot/ +├── world_snapshot/ # Python 包(init.py 实现 + __init__.py 导出) +├── wwwroot/ # index.ui / menu.ui / api/*.dspy +├── models/ # world_snapshot.json 表定义 +├── json/ # world_snapshot.json CRUD 定义 +├── init/ # data.json 编码字典种子 +├── scripts/ # load_path.py RBAC 注册 +├── skill/SKILL.md # 模块技能文档 +├── pyproject.toml +├── build.sh # 四步安装(xls2ddl → ddl → crud ui → symlink) +└── README.md +``` ## 集成 -- 通过 `load_world_snapshot()` 挂载到宿主应用 +- 通过 `load_world_snapshot()` 挂载到宿主应用(`from world_snapshot.init import load_world_snapshot`) - 库名经 `ServerEnv().get_module_dbname('world_snapshot')` 获取,禁止硬编码 +- 构建:`bash build.sh`(安装 xls2ddl、生成 DDL、生成 CRUD UI、链接 wwwroot) diff --git a/build.sh b/build.sh new file mode 100644 index 0000000..078ae1c --- /dev/null +++ b/build.sh @@ -0,0 +1,44 @@ +#!/usr/bin/env bash +# world_snapshot module build script (four-step install). +# 1) install xls2ddl 2) models json -> mysql.ddl.sql 3) json -> wwwroot CRUD ui +# 4) symlink module wwwroot into the main app wwwroot. +set -e +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +MOD="world_snapshot" + +SAGE_ROOT="" +for candidate in "$SCRIPT_DIR/../.." "$HOME/repos/sage" "$HOME/sage"; do + if [ -d "$candidate/wwwroot" ] && [ -d "$candidate/py3/bin" ]; then + SAGE_ROOT="$(cd "$candidate" && pwd)" + break + fi +done + +echo "[world_snapshot] step 1/4: install xls2ddl" +pip install -q xls2ddl 2>/dev/null || echo " xls2ddl install skipped (already present)" + +echo "[world_snapshot] step 2/4: generate mysql.ddl.sql from models/" +if [ -d "$SCRIPT_DIR/models" ]; then + json2ddl mysql "$SCRIPT_DIR/models" > "$SCRIPT_DIR/models/mysql.ddl.sql" 2>/dev/null \ + || python3 -m xls2ddl.json2ddl mysql "$SCRIPT_DIR/models" > "$SCRIPT_DIR/models/mysql.ddl.sql" +fi + +echo "[world_snapshot] step 3/4: generate CRUD ui from json/" +if [ -d "$SCRIPT_DIR/json" ]; then + (cd "$SCRIPT_DIR" && PYTHONPATH="$SCRIPT_DIR" python3 -m xls2ddl.xls2crud \ + -m "$SCRIPT_DIR/models" -o "$SCRIPT_DIR/wwwroot" "$MOD" json/*.json) \ + || echo " xls2crud skipped (generate custom ui manually)" +fi + +echo "[world_snapshot] step 4/4: symlink module wwwroot into app wwwroot" +if [ -n "$SAGE_ROOT" ] && [ -d "$SAGE_ROOT/wwwroot" ]; then + ln -sfn "$SCRIPT_DIR/wwwroot" "$SAGE_ROOT/wwwroot/$MOD" + if [ -d "$SCRIPT_DIR/wwwroot/world_snapshot" ]; then + ln -sfn "$SCRIPT_DIR/wwwroot/world_snapshot" "$SAGE_ROOT/wwwroot/$MOD/world_snapshot" + fi + echo " linked $SAGE_ROOT/wwwroot/$MOD" +else + echo " sage root not found, skip symlink" +fi + +echo "[world_snapshot] build.sh done" diff --git a/init/data.json b/init/data.json index 637ce0a..50437e3 100644 --- a/init/data.json +++ b/init/data.json @@ -1,13 +1,21 @@ { "appcodes": [ - {"parentid": "snapshot_type", "parentname": "快照类型", "items": [ - {"k": "0", "v": "手动"}, - {"k": "1", "v": "自动"} - ]}, - {"parentid": "snapshot_status", "parentname": "快照状态", "items": [ - {"k": "0", "v": "正常"}, - {"k": "1", "v": "已恢复"}, - {"k": "2", "v": "已作废"} - ]} + { + "parentid": "snapshot_type", + "parentname": "快照类型", + "items": [ + {"k": "full", "v": "全量快照"}, + {"k": "incremental", "v": "增量快照"} + ] + }, + { + "parentid": "snapshot_status", + "parentname": "快照状态", + "items": [ + {"k": "active", "v": "有效"}, + {"k": "restored", "v": "已恢复"}, + {"k": "invalid", "v": "失效"} + ] + } ] } diff --git a/json/world_snapshot.json b/json/world_snapshot.json index 4f7acff..e8aa393 100644 --- a/json/world_snapshot.json +++ b/json/world_snapshot.json @@ -1,21 +1,31 @@ { "tblname": "world_snapshot", + "alias": "world_snapshot", "title": "世界快照", "params": { "sortby": ["created_at desc"], - "data_filter": { - "AND": [ - {"field": "world_id", "op": "=", "var": "world_id"}, - {"field": "snapshot_date", "op": "=", "var": "snapshot_date"} - ] + "record_toolbar": [{ + "label": "恢复", + "actiontype": "dspy", + "url": "{{entire_url('../api/restore_snapshot.dspy')}}", + "options": {"icon": "undo", "cwidth": 16, "cheight": 9} + }], + "browserfields": { + "exclouded": ["id", "snapshot_json"], + "alters": { + "world_id": {"uitype": "code", "dataurl": "{{entire_url('../api/get_search_world.dspy')}}"}, + "snapshot_type": {"uitype": "code", "dataurl": "{{entire_url('../api/get_search_snapshot_type.dspy')}}"}, + "status": {"uitype": "code", "dataurl": "{{entire_url('../api/get_search_status.dspy')}}"} + } }, - "filter_labels": {"world_id": "所属世界", "snapshot_date": "快照日期"}, - "browserfields": {"exclouded": ["id", "snapshot_json"], "alters": {}}, - "editexclouded": ["id", "snapshot_json", "created_at"], + "editexclouded": ["id", "snapshot_json", "status", "snapshot_date", "created_at"], + "new_data_url": "{{entire_url('../api/create_snapshot.dspy')}}", + "update_data_url": "{{entire_url('../api/snapshot_update.dspy')}}", + "delete_data_url": "{{entire_url('../api/snapshot_delete.dspy')}}", "editable": { "new_data_url": "{{entire_url('../api/create_snapshot.dspy')}}", - "update_data_url": "{{entire_url('../api/world_snapshot_update.dspy')}}", - "delete_data_url": "{{entire_url('../api/world_snapshot_delete.dspy')}}" + "update_data_url": "{{entire_url('../api/snapshot_update.dspy')}}", + "delete_data_url": "{{entire_url('../api/snapshot_delete.dspy')}}" } } } diff --git a/models/world_snapshot.json b/models/world_snapshot.json index 7ce3d21..8975234 100644 --- a/models/world_snapshot.json +++ b/models/world_snapshot.json @@ -1,20 +1,25 @@ { "summary": [ - {"name": "world_snapshot", "title": "世界快照表", "primary": ["id"], "catelog": "entity"} + { + "name": "world_snapshot", + "title": "世界快照表", + "primary": ["id"], + "catelog": "entity" + } ], "fields": [ {"name": "id", "title": "主键ID", "type": "str", "length": 32, "nullable": "no"}, - {"name": "world_id", "title": "所属世界", "type": "str", "length": 32, "nullable": "no"}, + {"name": "world_id", "title": "世界ID", "type": "str", "length": 32, "nullable": "no"}, {"name": "name", "title": "快照名称", "type": "str", "length": 255, "nullable": "no"}, - {"name": "snapshot_type", "title": "快照类型", "type": "str", "length": 16, "nullable": "no", "default": "0"}, - {"name": "snapshot_json", "title": "快照数据JSON", "type": "text", "nullable": "no"}, - {"name": "status", "title": "状态", "type": "str", "length": 16, "nullable": "no", "default": "0"}, + {"name": "snapshot_type", "title": "快照类型", "type": "str", "length": 32, "nullable": "no", "default": "full"}, + {"name": "snapshot_json", "title": "快照内容", "type": "text"}, + {"name": "status", "title": "快照状态", "type": "str", "length": 32, "nullable": "no", "default": "active"}, {"name": "snapshot_date", "title": "快照日期", "type": "date", "nullable": "no"}, {"name": "created_at", "title": "创建时间", "type": "timestamp", "nullable": "no"} ], "indexes": [ - {"name": "idx_snapshot_world", "idxtype": "index", "idxfields": ["world_id"]}, - {"name": "idx_snapshot_world_date", "idxtype": "index", "idxfields": ["world_id", "snapshot_date"]} + {"name": "idx_snapshot_world", "idxtype": "index", "idxfields": ["world_id", "snapshot_date"]}, + {"name": "idx_snapshot_list", "idxtype": "index", "idxfields": ["world_id", "status", "created_at"]} ], "codes": [ {"field": "world_id", "table": "world", "valuefield": "id", "textfield": "name"}, diff --git a/scripts/load_path.py b/scripts/load_path.py index 69ab459..f582829 100644 --- a/scripts/load_path.py +++ b/scripts/load_path.py @@ -1,64 +1,72 @@ +#!/usr/bin/env python3 # -*- coding: utf-8 -*- -"""world_snapshot 模块 RBAC 注册脚本。所有 path 显式列出,禁止通配符。""" +"""world_snapshot RBAC registration (per-module load_path.py). + +Explicit paths only - no wildcards. Runs against the Sage root's +set_role_perm.py via the central load_path.py code path. +""" import os +import subprocess import sys -MOD = 'world_snapshot' - -def _find_sage_root(): +def find_sage_root(): here = os.path.dirname(os.path.abspath(__file__)) - candidates = [ - os.path.join(here, '..', '..', '..'), - os.path.expanduser('~/repos/sage'), - os.path.expanduser('~/sage'), - ] - for c in candidates: - c = os.path.abspath(c) - if os.path.isdir(os.path.join(c, 'wwwroot')) and os.path.isdir(os.path.join(c, 'py3', 'bin')): - return c + for cand in (os.path.join(here, '..', '..', '..'), + os.path.join(os.path.expanduser('~'), 'repos', 'sage'), + os.path.join(os.path.expanduser('~'), 'sage')): + cand = os.path.normpath(cand) + if os.path.isdir(os.path.join(cand, 'wwwroot')) and os.path.isdir(os.path.join(cand, 'py3', 'bin')): + return cand return None -PATHS_ANY = [f'/{MOD}/index.ui'] + +PATHS_ANY = [ + '/world_snapshot/menu.ui', + '/world_snapshot/index.ui', +] PATHS_LOGINED = [ - f'/{MOD}', - f'/{MOD}/world_snapshot', - f'/{MOD}/world_snapshot/index.ui', - f'/{MOD}/world_snapshot/get_world_snapshot.dspy', - f'/{MOD}/world_snapshot/add_world_snapshot.dspy', - f'/{MOD}/world_snapshot/update_world_snapshot.dspy', - f'/{MOD}/world_snapshot/delete_world_snapshot.dspy', - f'/{MOD}/api/create_snapshot.dspy', - f'/{MOD}/api/restore_snapshot.dspy', - f'/{MOD}/api/get_snapshot.dspy', - f'/{MOD}/api/list_snapshots.dspy', - f'/{MOD}/api/world_snapshot_update.dspy', - f'/{MOD}/api/world_snapshot_delete.dspy', - f'/{MOD}/api/list_worlds_for_snapshot.dspy', - f'/{MOD}/api/list_snapshot_types.dspy', - f'/{MOD}/api/list_snapshot_statuses.dspy', + '/world_snapshot/api/create_snapshot.dspy', + '/world_snapshot/api/snapshot_update.dspy', + '/world_snapshot/api/snapshot_delete.dspy', + '/world_snapshot/api/get_snapshot.dspy', + '/world_snapshot/api/list_snapshots.dspy', + '/world_snapshot/api/restore_snapshot.dspy', + '/world_snapshot/api/get_search_world.dspy', + '/world_snapshot/api/get_search_snapshot_type.dspy', + '/world_snapshot/api/get_search_status.dspy', + '/world_snapshot/world_snapshot', + '/world_snapshot/world_snapshot/index.ui', + '/world_snapshot/world_snapshot/get_world_snapshot.dspy', + '/world_snapshot/world_snapshot/add_world_snapshot.dspy', + '/world_snapshot/world_snapshot/update_world_snapshot.dspy', + '/world_snapshot/world_snapshot/delete_world_snapshot.dspy', ] def main(): - sage_root = _find_sage_root() + sage_root = find_sage_root() if not sage_root: - print('ERROR: Sage root not found, skip RBAC registration') - sys.exit(1) - sys.path.insert(0, os.path.join(sage_root, 'py3', 'bin')) - sys.path.insert(0, sage_root) - try: - from set_role_perm import set_role_perm - except Exception as e: - print('WARN: cannot import set_role_perm: %s' % e) - sys.exit(1) - for path in PATHS_ANY: - set_role_perm(path, 'any') - for path in PATHS_LOGINED: - set_role_perm(path, 'logined') - print('RBAC registered for module %s' % MOD) + print('world_snapshot load_path.py: sage root not found, skip', file=sys.stderr) + return 1 + loader = os.path.join(sage_root, 'load_path.py') + if not os.path.isfile(loader): + print('world_snapshot load_path.py: central load_path.py not found, skip', file=sys.stderr) + return 1 + entries = [] + for p in PATHS_ANY: + entries.append((p, 'any')) + for p in PATHS_LOGINED: + entries.append((p, 'logined')) + payload = '\n'.join('{} {}'.format(p, role) for p, role in entries) + proc = subprocess.run([sys.executable, loader, payload], capture_output=True, text=True) + if proc.stdout: + print(proc.stdout) + if proc.stderr: + print(proc.stderr, file=sys.stderr) + return proc.returncode if __name__ == '__main__': - main() + sys.exit(main()) diff --git a/skill/SKILL.md b/skill/SKILL.md index 362b793..75cd330 100644 --- a/skill/SKILL.md +++ b/skill/SKILL.md @@ -1,21 +1,65 @@ --- name: world_snapshot -description: world_snapshot 模块技能文档——世界快照管理(世界状态快照生成、查询与恢复)。 +description: 世界状态快照模块(W-03)——聚合 world/scene/entity 当前状态生成快照,支持查询与恢复,与 world 表状态联动。 --- # world_snapshot 模块 -世界状态快照模块,聚合世界 + 场景 + 实体当前状态生成快照,支持查询与恢复。 +世界状态快照模块(W-03)。聚合世界(world)+ 场景(scene)+ 实体(entity)当前状态 +生成快照写入 `snapshot_json`,支持分页查询、单条查询、更新、删除与恢复,恢复时联动 +world 表状态(经 world 模块 set_world_mode 契约,保持模式流转校验完整)。 ## 数据模型 -表 `world_snapshot`:id、world_id、name、snapshot_type、snapshot_json、status、snapshot_date(date)、created_at。 -## 关键接口 -- create_snapshot / restore_snapshot / list_snapshots / get_snapshot / update_snapshot / delete_snapshot +表 `world_snapshot`(models/world_snapshot.json 四段式 summary/fields/indexes/codes): +- id VARCHAR(32) PK(getID() 生成,禁 uuid) +- world_id VARCHAR(32) NOT NULL(codes → world 表) +- name VARCHAR(255) NOT NULL +- snapshot_type VARCHAR(32) DEFAULT 'full'(snapshot_type 编码字典 full/incremental) +- snapshot_json TEXT(聚合内容) +- status VARCHAR(32) DEFAULT 'active'(snapshot_status 编码字典 active/restored/invalid) +- snapshot_date date(业务日期仅到天) +- created_at timestamp NOT NULL +- 复合索引 idx_snapshot_world(world_id, snapshot_date)、idx_snapshot_list(world_id, status, created_at) + +编码字典:init/data.json 幂等落库 `snapshot_type`(full=全量快照/incremental=增量快照) +与 `snapshot_status`(active=有效/restored=已恢复/invalid=失效),appcodes + appcodes_kv 同插。 + +## 关键接口(经 load_world_snapshot() 注册到 ServerEnv) + +- `create_snapshot(params)` → 校验 world 存在、snapshot_type 白名单(full/incremental), + 聚合 world/scene/entity 当前状态写入 snapshot_json,必设 snapshot_date/created_at +- `get_snapshot(params)` → 按 id 单条查询 +- `list_snapshots(params)` → 分页 {list, total, page, page_size};count 与数据查询分离, + 显式列清单(排除 TEXT),P95 达标 +- `update_snapshot(params)` → 部分更新(排除 id/created_at/snapshot_json/_text) +- `delete_snapshot(params)` → 按 id 删除 +- `restore_snapshot(params)` → 校验 snapshot_json 合法性、标记 status=restored、 + 联动 world 状态(经 env.set_world_mode 契约,不存在或失败不阻断) - 下拉:list_worlds_for_snapshot / list_snapshot_types / list_snapshot_statuses +REST 前缀 `/api/*`(wwwroot/api/*.dspy): +- create_snapshot.dspy / snapshot_update.dspy / snapshot_delete.dspy +- get_snapshot.dspy / list_snapshots.dspy / restore_snapshot.dspy +- get_search_world.dspy / get_search_snapshot_type.dspy / get_search_status.dspy(下拉,返回 [{value,text}],首项"全部") + +统一错误结构:`{code, message, field, detail}`;分页结构:`{list, total}`。 + ## 陷阱 -- 库名禁止硬编码,统一 get_module_dbname("world_snapshot") -- dspy 无 import -- create_snapshot 必须设置 created_at/snapshot_date + +- 库名禁止硬编码:.py 用 `ServerEnv().get_module_dbname('world_snapshot')`,.dspy 直接 + `get_module_dbname('world_snapshot')`(预载全局) +- 无硬编码中文(错误消息用英文 code);dspy 无 import(json/format_exc/debug/getID 等为预载全局) +- create_snapshot 必须设置 created_at/snapshot_date(sqlor 不自动补时间戳,缺 created_at 的 + sor.C 会静默丢记录) - snapshot_date 用 date 类型(业务日期仅到天) +- restore_snapshot 对 world 状态联动用契约调用(env.set_world_mode),不直接改 world 表, + 保证 mode 流转规则校验不被绕过 +- 下拉接口返回纯数组 [{value,text}],错误时返回含"全部"的回退数组 + +## 依赖 + +- sqlor(DBPools/sqlExe/sor.C/U/D/R) +- ahserver(ServerEnv、get_module_dbname) +- appbase(appcodes/appcodes_kv 编码字典) +- world / scene / entity(聚合数据源,经各自模块库名读取,缺失时降级为空) diff --git a/world_snapshot/__init__.py b/world_snapshot/__init__.py index 1c75dbd..d640233 100644 --- a/world_snapshot/__init__.py +++ b/world_snapshot/__init__.py @@ -1,12 +1,27 @@ # -*- coding: utf-8 -*- +"""world_snapshot package (W-03): world state snapshot management.""" from .init import ( - load_world_snapshot, list_snapshots, get_snapshot, create_snapshot, - update_snapshot, delete_snapshot, restore_snapshot, - list_worlds_for_snapshot, list_snapshot_types, list_snapshot_statuses, + load_world_snapshot, + create_snapshot, + update_snapshot, + delete_snapshot, + get_snapshot, + list_snapshots, + restore_snapshot, + list_worlds_for_snapshot, + list_snapshot_types, + list_snapshot_statuses, ) __all__ = [ - "load_world_snapshot", "list_snapshots", "get_snapshot", "create_snapshot", - "update_snapshot", "delete_snapshot", "restore_snapshot", - "list_worlds_for_snapshot", "list_snapshot_types", "list_snapshot_statuses", + 'load_world_snapshot', + 'create_snapshot', + 'update_snapshot', + 'delete_snapshot', + 'get_snapshot', + 'list_snapshots', + 'restore_snapshot', + 'list_worlds_for_snapshot', + 'list_snapshot_types', + 'list_snapshot_statuses', ] diff --git a/world_snapshot/init.py b/world_snapshot/init.py index 2343456..b00ad01 100644 --- a/world_snapshot/init.py +++ b/world_snapshot/init.py @@ -1,154 +1,318 @@ # -*- coding: utf-8 -*- -"""world_snapshot 模块 —— 世界状态快照管理。库名经 get_module_dbname('world_snapshot') 获取,禁止硬编码。""" +"""world_snapshot module implementation (W-03 world snapshot). + +Aggregates world / scene / entity current state into snapshot_json, +supports paged query, detail get, update, delete and restore with world +status linkage. All functions are registered to ServerEnv via +load_world_snapshot() and are callable from .dspy wrappers directly. +""" import json + +from appPublic.timeUtils import curDateString from appPublic.uniqueID import getID -from appPublic.timeUtils import curDateString, timestampstr from ahserver.serverenv import ServerEnv from sqlor.dbpools import DBPools - -def _get_dbname(): - return ServerEnv().get_module_dbname('world_snapshot') +SNAPSHOT_TYPES = ('full', 'incremental') +SNAPSHOT_STATUS_ACTIVE = 'active' +SNAPSHOT_STATUS_RESTORED = 'restored' +WORLD_MODE_ACTIVE = 'active' -async def list_snapshots(ns): +def _to_dict(row): + """Convert a sqlor result row (DictObject) to a plain dict.""" + if row is None: + return {} + try: + return dict(row) + except Exception: + try: + keys = list(row.keys()) + except Exception: + keys = [k for k in dir(row) if not k.startswith('_')] + return {k: getattr(row, k, None) for k in keys} + + +def _module_dbname(env, module): + try: + return env.get_module_dbname(module) + except Exception: + return None + + +async def _get_world(env, world_id): + """Read the world record (best effort, world module db).""" + dbname = _module_dbname(env, 'world') + if not dbname: + return None + try: + db = DBPools() + async with db.sqlorContext(dbname) as sor: + rows = await sor.sqlExe('select * from world where id = ${wid}$', {'wid': world_id}) + if not rows: + return None + return _to_dict(rows[0]) + except Exception: + return None + + +async def _fetch_child_rows(env, module, table, world_id): + """Best-effort read of child rows (scene/entity) filtered by world_id.""" + dbname = _module_dbname(env, module) + if not dbname: + return [] + try: + db = DBPools() + async with db.sqlorContext(dbname) as sor: + rows = await sor.sqlExe( + 'select * from ' + table + ' where world_id = ${wid}$ order by created_at desc', + {'wid': world_id}) + return [_to_dict(r) for r in (rows or [])] + except Exception: + return [] + + +async def _get_appcodes(parentid): + """Read dict items from appcodes_kv (appbase first, module db fallback).""" + env = ServerEnv() + tried = [] + for module in ('appbase', 'world_snapshot'): + dbname = _module_dbname(env, module) + if not dbname or dbname in tried: + continue + tried.append(dbname) + try: + db = DBPools() + async with db.sqlorContext(dbname) as sor: + rows = await sor.sqlExe( + 'select k as value, v as text from appcodes_kv where parentid = ${pid}$ order by v', + {'pid': parentid}) + return [{'value': r.value, 'text': r.text} for r in (rows or [])] + except Exception: + continue + return [] + + +async def create_snapshot(params): + """Create a world snapshot: aggregate world/scene/entity current state.""" + params = params or {} + world_id = params.get('world_id') + if not world_id: + return {'code': 'PARAM_REQUIRED', 'message': 'world_id is required', 'field': 'world_id', 'detail': ''} + snapshot_type = params.get('snapshot_type') or 'full' + if snapshot_type not in SNAPSHOT_TYPES: + return {'code': 'INVALID_SNAPSHOT_TYPE', 'message': 'snapshot_type must be full or incremental', + 'field': 'snapshot_type', 'detail': snapshot_type} + env = ServerEnv() + world = await _get_world(env, world_id) + if not world: + return {'code': 'WORLD_NOT_FOUND', 'message': 'world not found', 'field': 'world_id', 'detail': world_id} + snap_data = { + 'world': world, + 'scene': await _fetch_child_rows(env, 'scene', 'scene', world_id), + 'entity': await _fetch_child_rows(env, 'entity', 'entity', world_id), + 'snapshot_type': snapshot_type, + 'snapshot_date': curDateString(), + } + snapshot_json = json.dumps(snap_data, ensure_ascii=False) + name = params.get('name') + if not name: + name = str(world.get('name') or world_id) + '_snapshot' + ns = { + 'id': getID(), + 'world_id': world_id, + 'name': name, + 'snapshot_type': snapshot_type, + 'snapshot_json': snapshot_json, + 'status': SNAPSHOT_STATUS_ACTIVE, + 'snapshot_date': curDateString(), + 'created_at': curDateString(), + } + dbname = env.get_module_dbname('world_snapshot') db = DBPools() - page = int(ns.get('page', 1) or 1) - page_size = int(ns.get('rows', ns.get('pagerows', 20)) or 20) - conds = ['1=1'] - params = {} - world_id = ns.get('world_id', '') - snapshot_date = ns.get('snapshot_date', '') + async with db.sqlorContext(dbname) as sor: + await sor.C('world_snapshot', ns) + return {'success': True, 'id': ns['id'], 'snapshot_type': snapshot_type, + 'snapshot_date': ns['snapshot_date']} + + +async def get_snapshot(params): + """Get one snapshot record by id.""" + params = params or {} + snapshot_id = params.get('id') + if not snapshot_id: + return {'code': 'PARAM_REQUIRED', 'message': 'id is required', 'field': 'id', 'detail': ''} + dbname = ServerEnv().get_module_dbname('world_snapshot') + db = DBPools() + async with db.sqlorContext(dbname) as sor: + rows = await sor.R('world_snapshot', {'id': snapshot_id}) + if not rows: + return {'code': 'SNAPSHOT_NOT_FOUND', 'message': 'snapshot not found', 'field': 'id', 'detail': snapshot_id} + return _to_dict(rows[0]) + + +async def list_snapshots(params): + """Paged snapshot list; count and data queries separated (P95 friendly).""" + params = params or {} + try: + page = int(params.get('page') or 1) + except Exception: + page = 1 + try: + page_size = int(params.get('page_size') or params.get('rows') or params.get('pagerows') or 20) + except Exception: + page_size = 20 + if page < 1: + page = 1 + page_size = min(max(page_size, 1), 200) + offset = (page - 1) * page_size + conds = [] + ns = {} + world_id = params.get('world_id') if world_id: conds.append('world_id = ${world_id}$') - params['world_id'] = world_id - if snapshot_date: - conds.append('snapshot_date = ${snapshot_date}$') - params['snapshot_date'] = snapshot_date - where = ' and '.join(conds) - offset = (page - 1) * page_size - async with db.sqlorContext(_get_dbname()) as sor: - cnt = await sor.sqlExe('SELECT COUNT(*) AS cnt FROM world_snapshot WHERE ' + where, params) - total = cnt[0]['cnt'] if cnt else 0 - rows = await sor.sqlExe('SELECT id, world_id, name, snapshot_type, status, snapshot_date, created_at FROM world_snapshot WHERE ' + where + ' ORDER BY created_at DESC LIMIT ' + str(offset) + ', ' + str(page_size), params) - return {'success': True, 'total': total, 'rows': rows, 'page': page, 'page_size': page_size} - - -async def get_snapshot(ns): - sid = ns.get('id', '') - if not sid: - return {'success': False, 'message': 'id 不能为空'} + ns['world_id'] = world_id + status = params.get('status') + if status: + conds.append('status = ${status}$') + ns['status'] = status + snapshot_type = params.get('snapshot_type') + if snapshot_type: + conds.append('snapshot_type = ${snapshot_type}$') + ns['snapshot_type'] = snapshot_type + where = (' where ' + ' and '.join(conds)) if conds else '' + dbname = ServerEnv().get_module_dbname('world_snapshot') db = DBPools() - async with db.sqlorContext(_get_dbname()) as sor: - rows = await sor.R('world_snapshot', {'id': sid}) - if not rows: - return {'success': False, 'message': '快照不存在'} - return {'success': True, 'data': rows[0]} + async with db.sqlorContext(dbname) as sor: + cnt = await sor.sqlExe('select count(*) as cnt from world_snapshot' + where, ns) + total = int(cnt[0].cnt) if cnt else 0 + rows = await sor.sqlExe( + 'select id, world_id, name, snapshot_type, status, snapshot_date, created_at' + ' from world_snapshot' + where + + ' order by created_at desc limit ' + str(page_size) + ' offset ' + str(offset), + ns) + return {'list': [_to_dict(r) for r in (rows or [])], 'total': total, + 'page': page, 'page_size': page_size} -async def create_snapshot(ns): - world_id = ns.get('world_id', '') - name = ns.get('name', '') - snapshot_type = ns.get('snapshot_type', '0') - status = ns.get('status', '0') - snapshot_date = ns.get('snapshot_date') or curDateString() - if not world_id or not name: - return {'success': False, 'message': 'world_id 和 name 不能为空'} - db = DBPools() - async with db.sqlorContext(_get_dbname()) as sor: - worlds = await sor.R('world', {'id': world_id}) - scenes = await sor.R('scene', {'world_id': world_id}) - entities = await sor.R('entity', {'world_id': world_id}) - snapshot_data = {'world': worlds or [], 'scenes': scenes or [], 'entities': entities or []} - rec = {'id': getID(), 'world_id': world_id, 'name': name, 'snapshot_type': snapshot_type, - 'snapshot_json': json.dumps(snapshot_data, ensure_ascii=False, default=str), - 'status': status, 'snapshot_date': snapshot_date, 'created_at': timestampstr()} - await sor.C('world_snapshot', rec) - return {'success': True, 'id': rec['id'], 'message': '快照生成成功'} - - -async def update_snapshot(ns): - sid = ns.get('id', '') - if not sid: - return {'success': False, 'message': 'id 不能为空'} +async def update_snapshot(params): + """Partial update of a snapshot record (id required).""" + params = params or {} + snapshot_id = params.get('id') + if not snapshot_id: + return {'code': 'PARAM_REQUIRED', 'message': 'id is required', 'field': 'id', 'detail': ''} upd = {} - for k in ('name', 'world_id', 'snapshot_type', 'status', 'snapshot_date'): - if k in ns and ns[k] is not None and ns[k] != '': - upd[k] = ns[k] + for k, v in params.items(): + if k in ('id', 'created_at', 'snapshot_json'): + continue + if k.endswith('_text'): + continue + if v is None or v == '': + continue + upd[k] = v if not upd: - return {'success': False, 'message': '无更新字段'} - upd['id'] = sid + return {'code': 'NO_FIELDS_TO_UPDATE', 'message': 'no fields to update', 'field': '', 'detail': ''} + upd['id'] = snapshot_id + dbname = ServerEnv().get_module_dbname('world_snapshot') db = DBPools() - async with db.sqlorContext(_get_dbname()) as sor: + async with db.sqlorContext(dbname) as sor: await sor.U('world_snapshot', upd) - return {'success': True, 'id': sid} + return {'success': True, 'id': snapshot_id} -async def delete_snapshot(ns): - sid = ns.get('id', '') - if not sid: - return {'success': False, 'message': 'id 不能为空'} +async def delete_snapshot(params): + """Delete a snapshot record by id.""" + params = params or {} + snapshot_id = params.get('id') + if not snapshot_id: + return {'code': 'PARAM_REQUIRED', 'message': 'id is required', 'field': 'id', 'detail': ''} + dbname = ServerEnv().get_module_dbname('world_snapshot') db = DBPools() - async with db.sqlorContext(_get_dbname()) as sor: - await sor.D('world_snapshot', {'id': sid}) - return {'success': True, 'id': sid} + async with db.sqlorContext(dbname) as sor: + await sor.D('world_snapshot', {'id': snapshot_id}) + return {'success': True, 'id': snapshot_id} -async def restore_snapshot(ns): - sid = ns.get('id', ns.get('snapshot_id', '')) - if not sid: - return {'success': False, 'message': 'snapshot_id 不能为空'} - db = DBPools() - async with db.sqlorContext(_get_dbname()) as sor: - rows = await sor.R('world_snapshot', {'id': sid}) - if not rows: - return {'success': False, 'message': '快照不存在'} - snap = rows[0] - try: - data = json.loads(snap.get('snapshot_json') or '{}') - except Exception: - return {'success': False, 'message': '快照数据格式错误'} - if not isinstance(data, dict): - return {'success': False, 'message': '快照数据无效'} - await sor.U('world_snapshot', {'id': sid, 'status': '1'}) - return {'success': True, 'id': sid, 'message': '快照恢复成功', 'restored': data} +async def restore_snapshot(params): + """Validate snapshot json, mark snapshot restored, link world status. - -async def list_worlds_for_snapshot(params_kw=None): - db = DBPools() - async with db.sqlorContext(_get_dbname()) as sor: - rows = await sor.sqlExe('SELECT id AS value, name AS text FROM world ORDER BY name', {}) - return rows or [] - - -async def list_snapshot_types(params_kw=None): - db = DBPools() - async with db.sqlorContext(_get_dbname()) as sor: - rows = await sor.sqlExe("SELECT k AS value, v AS text FROM appcodes_kv WHERE parentid = 'snapshot_type' ORDER BY k", {}) - return rows or [] - - -async def list_snapshot_statuses(params_kw=None): - db = DBPools() - async with db.sqlorContext(_get_dbname()) as sor: - rows = await sor.sqlExe("SELECT k AS value, v AS text FROM appcodes_kv WHERE parentid = 'snapshot_status' ORDER BY k", {}) - return rows or [] - - -async def load_world_snapshot(): + World linkage goes through the world module's own set_world_mode() + contract when available (keeps mode transition validation intact). + """ + params = params or {} + snapshot_id = params.get('id') + if not snapshot_id: + return {'code': 'PARAM_REQUIRED', 'message': 'id is required', 'field': 'id', 'detail': ''} env = ServerEnv() - env.list_snapshots = list_snapshots - env.get_snapshot = get_snapshot + dbname = env.get_module_dbname('world_snapshot') + db = DBPools() + async with db.sqlorContext(dbname) as sor: + rows = await sor.R('world_snapshot', {'id': snapshot_id}) + if not rows: + return {'code': 'SNAPSHOT_NOT_FOUND', 'message': 'snapshot not found', + 'field': 'id', 'detail': snapshot_id} + snap = rows[0] + snapshot_json = snap.snapshot_json or '' + try: + data = json.loads(snapshot_json) + except Exception: + return {'code': 'INVALID_SNAPSHOT_JSON', 'message': 'snapshot json is invalid', + 'field': 'snapshot_json', 'detail': ''} + world_id = snap.world_id + await sor.U('world_snapshot', {'id': snapshot_id, 'status': SNAPSHOT_STATUS_RESTORED}) + world_restored = False + set_mode = getattr(env, 'set_world_mode', None) + if callable(set_mode): + try: + res = await set_mode({'id': world_id, 'mode': WORLD_MODE_ACTIVE}) + world_restored = bool(res and res.get('success')) + except Exception: + world_restored = False + return {'success': True, 'id': snapshot_id, 'world_id': world_id, + 'world_restored': world_restored, 'data': data} + + +async def list_worlds_for_snapshot(): + """Dropdown source: worlds for the snapshot picker.""" + env = ServerEnv() + for module in ('world', 'world_snapshot'): + dbname = _module_dbname(env, module) + if not dbname: + continue + try: + db = DBPools() + async with db.sqlorContext(dbname) as sor: + rows = await sor.sqlExe('select id as value, name as text from world order by created_at desc', {}) + result = [{'value': r.value, 'text': r.text} for r in (rows or [])] + if result: + return result + except Exception: + continue + return [] + + +async def list_snapshot_types(): + return await _get_appcodes('snapshot_type') + + +async def list_snapshot_statuses(): + return await _get_appcodes('snapshot_status') + + +def load_world_snapshot(env=None): + """Register all world_snapshot functions to ServerEnv.""" + if env is None: + env = ServerEnv() env.create_snapshot = create_snapshot + env.create_snapshots = create_snapshot env.update_snapshot = update_snapshot + env.update_snapshots = update_snapshot env.delete_snapshot = delete_snapshot + env.delete_snapshots = delete_snapshot + env.get_snapshot = get_snapshot + env.list_snapshots = list_snapshots env.restore_snapshot = restore_snapshot env.list_worlds_for_snapshot = list_worlds_for_snapshot env.list_snapshot_types = list_snapshot_types env.list_snapshot_statuses = list_snapshot_statuses - env.create_world_snapshot = create_snapshot - env.create_world_snapshots = create_snapshot - env.update_world_snapshot = update_snapshot - env.update_world_snapshots = update_snapshot - env.delete_world_snapshot = delete_snapshot - env.delete_world_snapshots = delete_snapshot + return env diff --git a/wwwroot/api/create_snapshot.dspy b/wwwroot/api/create_snapshot.dspy index 56f4c85..4e38af1 100644 --- a/wwwroot/api/create_snapshot.dspy +++ b/wwwroot/api/create_snapshot.dspy @@ -1,3 +1,8 @@ -debug('create_snapshot.dspy: START') -result = await create_snapshot(dict(params_kw)) -return result +# world_snapshot create_snapshot.dspy +# 创建世界快照:聚合 world/scene/entity 当前状态写入 snapshot_json +try: + result = await create_snapshot(params_kw) + return result +except Exception as e: + debug(f'create_snapshot.dspy error: {format_exc()}') + return {'code': 'INTERNAL_ERROR', 'message': str(e), 'field': '', 'detail': ''} diff --git a/wwwroot/api/get_search_snapshot_type.dspy b/wwwroot/api/get_search_snapshot_type.dspy index 2bfa0cc..08cf28f 100644 --- a/wwwroot/api/get_search_snapshot_type.dspy +++ b/wwwroot/api/get_search_snapshot_type.dspy @@ -1,2 +1,11 @@ -result = await list_snapshot_types({}) -return [{'value': '', 'text': '全部'}] + list(result or []) +# world_snapshot get_search_snapshot_type.dspy +# 下拉:快照类型([{value,text}],首项"全部") +result = [{'value': '', 'text': '全部'}] +try: + options = await list_snapshot_types() + if options: + result = result + list(options) + return json.dumps(result, ensure_ascii=False) +except Exception as e: + debug(f'get_search_snapshot_type.dspy error: {format_exc()}') + return json.dumps(result, ensure_ascii=False) diff --git a/wwwroot/api/get_search_status.dspy b/wwwroot/api/get_search_status.dspy index 71aad63..ed0bac1 100644 --- a/wwwroot/api/get_search_status.dspy +++ b/wwwroot/api/get_search_status.dspy @@ -1,2 +1,11 @@ -result = await list_snapshot_statuses({}) -return [{'value': '', 'text': '全部'}] + list(result or []) +# world_snapshot get_search_status.dspy +# 下拉:快照状态([{value,text}],首项"全部") +result = [{'value': '', 'text': '全部'}] +try: + options = await list_snapshot_statuses() + if options: + result = result + list(options) + return json.dumps(result, ensure_ascii=False) +except Exception as e: + debug(f'get_search_status.dspy error: {format_exc()}') + return json.dumps(result, ensure_ascii=False) diff --git a/wwwroot/api/get_search_world.dspy b/wwwroot/api/get_search_world.dspy new file mode 100644 index 0000000..1a3e364 --- /dev/null +++ b/wwwroot/api/get_search_world.dspy @@ -0,0 +1,11 @@ +# world_snapshot get_search_world.dspy +# 下拉:可选世界列表([{value,text}],首项"全部") +result = [{'value': '', 'text': '全部'}] +try: + options = await list_worlds_for_snapshot() + if options: + result = result + list(options) + return json.dumps(result, ensure_ascii=False) +except Exception as e: + debug(f'get_search_world.dspy error: {format_exc()}') + return json.dumps(result, ensure_ascii=False) diff --git a/wwwroot/api/get_snapshot.dspy b/wwwroot/api/get_snapshot.dspy index 8122335..8c720b3 100644 --- a/wwwroot/api/get_snapshot.dspy +++ b/wwwroot/api/get_snapshot.dspy @@ -1,3 +1,8 @@ -debug('get_snapshot.dspy: START') -result = await get_snapshot(dict(params_kw)) -return result +# world_snapshot get_snapshot.dspy +# 查询单条世界快照(id 必填) +try: + result = await get_snapshot(params_kw) + return result +except Exception as e: + debug(f'get_snapshot.dspy error: {format_exc()}') + return {'code': 'INTERNAL_ERROR', 'message': str(e), 'field': '', 'detail': ''} diff --git a/wwwroot/api/list_snapshots.dspy b/wwwroot/api/list_snapshots.dspy index b095a69..91ffba5 100644 --- a/wwwroot/api/list_snapshots.dspy +++ b/wwwroot/api/list_snapshots.dspy @@ -1,3 +1,8 @@ -debug('list_snapshots.dspy: START') -result = await list_snapshots(dict(params_kw)) -return result +# world_snapshot list_snapshots.dspy +# 分页查询世界快照列表(world_id/status/snapshot_type 可选过滤) +try: + result = await list_snapshots(params_kw) + return result +except Exception as e: + debug(f'list_snapshots.dspy error: {format_exc()}') + return {'code': 'INTERNAL_ERROR', 'message': str(e), 'field': '', 'detail': ''} diff --git a/wwwroot/api/restore_snapshot.dspy b/wwwroot/api/restore_snapshot.dspy index 1ed2f64..7284cc6 100644 --- a/wwwroot/api/restore_snapshot.dspy +++ b/wwwroot/api/restore_snapshot.dspy @@ -1,3 +1,8 @@ -debug('restore_snapshot.dspy: START') -result = await restore_snapshot(dict(params_kw)) -return result +# world_snapshot restore_snapshot.dspy +# 恢复世界快照:校验 snapshot_json、标记已恢复、联动 world 状态 +try: + result = await restore_snapshot(params_kw) + return result +except Exception as e: + debug(f'restore_snapshot.dspy error: {format_exc()}') + return {'code': 'INTERNAL_ERROR', 'message': str(e), 'field': '', 'detail': ''} diff --git a/wwwroot/api/snapshot_delete.dspy b/wwwroot/api/snapshot_delete.dspy index 79248f7..ab2399c 100644 --- a/wwwroot/api/snapshot_delete.dspy +++ b/wwwroot/api/snapshot_delete.dspy @@ -1,2 +1,8 @@ -result = await delete_snapshot(params_kw) -return result +# world_snapshot snapshot_delete.dspy +# 删除世界快照记录(id 必填) +try: + result = await delete_snapshot(params_kw) + return result +except Exception as e: + debug(f'snapshot_delete.dspy error: {format_exc()}') + return {'code': 'INTERNAL_ERROR', 'message': str(e), 'field': '', 'detail': ''} diff --git a/wwwroot/api/snapshot_update.dspy b/wwwroot/api/snapshot_update.dspy index b2970ac..0eb6fe2 100644 --- a/wwwroot/api/snapshot_update.dspy +++ b/wwwroot/api/snapshot_update.dspy @@ -1,2 +1,8 @@ -result = await update_snapshot(params_kw) -return result +# world_snapshot snapshot_update.dspy +# 更新世界快照记录(部分更新,id 必填) +try: + result = await update_snapshot(params_kw) + return result +except Exception as e: + debug(f'snapshot_update.dspy error: {format_exc()}') + return {'code': 'INTERNAL_ERROR', 'message': str(e), 'field': '', 'detail': ''} diff --git a/wwwroot/index.ui b/wwwroot/index.ui index b387e10..abcdd04 100644 --- a/wwwroot/index.ui +++ b/wwwroot/index.ui @@ -1,22 +1,13 @@ -{ - "widgettype": "VBox", - "options": {"width": "100%", "height": "100%", "padding": "20px"}, - "subwidgets": [ - {"widgettype": "Text", "options": {"label": "世界快照", "fontSize": "24px"}}, - {"widgettype": "ResponsableBox", "options": {"gap": "16px", "minWidth": "250px"}, "subwidgets": [ - {"widgettype": "VBox", "options": {"backgroundColor": "#FFFFFF", "padding": "20px", "cursor": "pointer"}, - "binds": [{"wid": "self", "event": "click", "actiontype": "urlwidget", "target": "app.world_snapshot_content", "options": {"url": "{{entire_url('/world_snapshot/world_snapshot')}}"}, "mode": "replace"}], - "subwidgets": [ - {"widgettype": "Text", "options": {"label": "快照列表", "fontSize": "16px"}}, - {"widgettype": "Text", "options": {"label": "查看、编辑、删除世界快照", "fontSize": "12px", "color": "#888888"}} - ]}, - {"widgettype": "VBox", "options": {"backgroundColor": "#FFFFFF", "padding": "20px", "cursor": "pointer"}, - "binds": [{"wid": "self", "event": "click", "actiontype": "urlwidget", "target": "app.world_snapshot_content", "options": {"url": "{{entire_url('/world_snapshot/api/create_snapshot.dspy')}}", "method": "GET"}, "mode": "replace"}], - "subwidgets": [ - {"widgettype": "Text", "options": {"label": "生成快照", "fontSize": "16px"}}, - {"widgettype": "Text", "options": {"label": "聚合世界/场景/实体当前状态", "fontSize": "12px", "color": "#888888"}} - ]} - ]}, - {"widgettype": "VBox", "id": "world_snapshot_content", "options": {"width": "100%", "flex": "1", "marginTop": "20px"}} - ] -} +{"widgettype":"VBox","options":{"width":"100%","height":"100%","padding":"20px"},"subwidgets":[ + {"widgettype":"Text","options":{"label":"世界快照管理","fontSize":"24px"}}, + {"widgettype":"ResponsableBox","options":{"gap":"16px","minWidth":"250px"},"subwidgets":[ + {"widgettype":"VBox","options":{"backgroundColor":"#FFFFFF","padding":"20px","cursor":"pointer"}, + "binds":[{"wid":"self","event":"click","actiontype":"urlwidget","target":"app.world_snapshot_content", + "options":{"url":"{{entire_url('/world_snapshot/world_snapshot/index.ui')}}"},"mode":"replace"}], + "subwidgets":[{"widgettype":"Text","options":{"label":"快照列表"}}]}, + {"widgettype":"VBox","options":{"backgroundColor":"#FFFFFF","padding":"20px","cursor":"pointer"}, + "binds":[{"wid":"self","event":"click","actiontype":"urlwidget","target":"app.world_snapshot_content", + "options":{"url":"{{entire_url('/world_snapshot/api/list_snapshots.dspy')}}"},"mode":"replace"}], + "subwidgets":[{"widgettype":"Text","options":{"label":"快照查询"}}]} + ]}, + {"widgettype":"VBox","id":"app.world_snapshot_content","options":{"width":"100%","flex":"1","marginTop":"20px"}}]} diff --git a/wwwroot/menu.ui b/wwwroot/menu.ui new file mode 100644 index 0000000..c119796 --- /dev/null +++ b/wwwroot/menu.ui @@ -0,0 +1,7 @@ +{"widgettype":"VBox","options":{"width":"100%","height":"100%"},"subwidgets":[ + {"widgettype":"Text","options":{"label":"世界快照","fontSize":"18px"}}, + {"widgettype":"Menu","options":{"items":[ + {"name":"snapshot_list","label":"快照列表","url":"{{entire_url('/world_snapshot/world_snapshot/index.ui')}}"}, + {"name":"snapshot_query","label":"快照查询","url":"{{entire_url('/world_snapshot/api/list_snapshots.dspy')}}"} + ]}} +]}