diff --git a/README.md b/README.md index f930383..660e337 100644 --- a/README.md +++ b/README.md @@ -1,55 +1,16 @@ -# world_snapshot 世界状态快照模块(W-04 快照管理) +# world_snapshot 模块 -## 用途 - -聚合 world/scene/entity 当前状态生成世界快照,支持分页查询、详情查看(checksum 一致性校验)、 -更新、删除与恢复(事务写回 scene/entity,失败整体回滚无脏数据)。 +世界状态快照模块,聚合世界(world)、场景(scene)、实体(entity)当前状态生成快照,支持查询与恢复。 ## 功能 - -- 快照生成:同一事务批量聚合 world/scene/entity + sha256 checksum + version 按世界递增 -- 快照查询:分页 `{list,total}`;单条含 checksum 数据一致性校验(损坏检测) -- 快照恢复:checksum 校验 → 先删后插写回 scene/entity → 世界状态联动;失败事务回滚 -- 编码字典 `snapshot_status` / `snapshot_type` 经 init/data.json 幂等落库 -- REST 统一前缀 `/api/*`,错误结构 `{code,message,field,detail}`,非法输入 100% 拦截不落库 +- 世界快照表 `world_snapshot` 的 CRUD +- `create_snapshot`:聚合 world/scene/entity 当前状态写入 `snapshot_json` +- `restore_snapshot`:读取并校验快照 JSON,恢复状态(基础实现:校验 + 状态标记) +- 下拉接口:世界列表、快照类型、快照状态 ## 数据表 +- `world_snapshot`(models/world_snapshot.json) -`world_snapshot`:id(32 PK), world_id(32), name(255), snapshot_type(32), snapshot_json(text), -checksum(64, sha256), version(int), status(32), snapshot_date(timestamp), created_at/updated_at(timestamp)。 -唯一索引 `(world_id, version)`。 - -## 安装 - -```bash -pip install . # 模块打包 -``` - -宿主应用集成: - -```python -from world_snapshot.init import load_world_snapshot - -def init(): - load_world_snapshot() -``` - -RBAC:`python3 scripts/load_path.py`(为 /world_snapshot/* 各 path 显式注册 logined 权限)。 - -## REST 端点 - -见 `skill/SKILL.md`(/world_snapshot/api/snapshot_*.dspy 系列)。 - -## 目录 - -``` -world_snapshot/ -├── world_snapshot/ # Python 包(init.py 核心逻辑 + __init__.py) -├── models/ # world_snapshot.json 表定义 -├── json/ # world_snapshot.json CRUD 定义 -├── init/data.json # 编码字典种子(snapshot_status/snapshot_type) -├── wwwroot/ # index.ui + snapshot_generate/restore.ui + api/*.dspy -├── scripts/load_path.py # RBAC 注册 -├── skill/SKILL.md # 模块技能文档 -└── pyproject.toml -``` +## 集成 +- 通过 `load_world_snapshot()` 挂载到宿主应用 +- 库名经 `ServerEnv().get_module_dbname('world_snapshot')` 获取,禁止硬编码 diff --git a/build.sh b/build.sh deleted file mode 100644 index 078ae1c..0000000 --- a/build.sh +++ /dev/null @@ -1,44 +0,0 @@ -#!/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/docs/work-log-2026-07-15.md b/docs/work-log-2026-07-15.md deleted file mode 100644 index 5081f19..0000000 --- a/docs/work-log-2026-07-15.md +++ /dev/null @@ -1,48 +0,0 @@ -# world_snapshot 模块开发(W-04 快照管理)— 工作日志 - -日期:2026-07-15(按当前系统日期) -模块仓库:`modules/world_snapshot/`(git 已 init) -任务:W-04 快照管理功能点开发(new_dev,迭代「元景项目-初始迭代」) - -## 范围与背景 - -W-03 已有基础版 world_snapshot(简单 CRUD + 恢复),本次按 W-04 需求升级为完整快照管理: - -1. **world_snapshot 表**:新增 `checksum`(sha256) 与 `version`(int) 字段;`(world_id, version)` 唯一索引 -2. **快照生成**:同一事务批量聚合 world/scene/entity + checksum 数据一致性校验 + version 按世界递增(max+1,行锁 `for update` 防并发重复) -3. **快照恢复**:同一事务 checksum 校验 → 先删后插写回 scene/entity → 世界 mode 联动 → 置 restored;任一步失败整体回滚无脏数据 -4. **编码字典**:snapshot_status / snapshot_type 经 `init/data.json` 幂等落库(appcodes 格式,parentid ≤22 字符约束满足) -5. **REST 规范**:统一前缀 `/api/*`(wwwroot/api/snapshot_*.dspy),错误结构 `{code,message,field,detail}`,分页 `{list,total}`,非法输入 100% 拦截不落库(白名单校验) - -## 提交清单 - -- models/world_snapshot.json —— 表定义(checksum/version/唯一索引/codes 引用) -- init/data.json —— 编码字典种子 -- world_snapshot/init.py —— 核心逻辑(create/get/list/update/delete/restore + 下拉数据源) -- world_snapshot/__init__.py —— 包导出(三处注册同步) -- json/world_snapshot.json —— CRUD 定义(editable + 顶层 new/update/delete/list_data_url) -- wwwroot/api/*.dspy —— 9 个 REST 薄封装 -- wwwroot/index.ui / snapshot_generate.ui / snapshot_restore.ui —— 前端页面 -- scripts/load_path.py —— RBAC 显式注册(无通配符) -- pyproject.toml / README.md / skill/SKILL.md - -## 关键技术决策 - -- **version 递增**:`select coalesce(max(version),0)+1` + 事务内 `for update` 行锁 world 行,配 `(world_id,version)` 唯一索引双保险,防并发重复 -- **checksum 校验**:sha256(snapshot_json),生成时写入;查询/恢复时重算比对,不匹配返回 `CHECKSUM_MISMATCH` -- **恢复事务**:`_restore_children` 先删后插(按 information_schema 字段过滤防脏列),异常冒泡 → sqlorContext 整体回滚 -- **非法输入拦截**:world_id 必填/长度、snapshot_type/status 白名单、page/page_size 整数化、name 长度,全部校验通过才落库 -- **取库名**:统一 `ServerEnv().get_module_dbname('world_snapshot')`(.py)与全局 `get_module_dbname()`(.dspy),无硬编码 - -## 验证 - -- init/data.json 用 `json.load` 自验(合法 JSON,真实种子数据) -- models/world_snapshot.json 符合四段式(summary/fields/indexes/codes) -- .dspy 无 import(仅调用 load_*() 导出的函数),符合 dspy audit 规范 -- .py 均用 sor.C/R/U/D/sqlExe 标准 API,未编造 save/list 等 -- 环境限制:无 Sage 测试环境(缺 ALIPAY_PUB 等环境变量无法全量启动),采用 py_compile + 静态审计验证 - -## 当前状态 - -- 分支:main(本地 commit 待 PM 审核后统一推送) -- 待办:宿主应用挂载 load_world_snapshot()、RBAC 执行 scripts/load_path.py、部署后 curl 冒烟 diff --git a/init/data.json b/init/data.json index 007e44c..637ce0a 100644 --- a/init/data.json +++ b/init/data.json @@ -1,13 +1,13 @@ { - "appcodes": [ - {"parentid": "snapshot_status", "parentname": "快照状态", "items": [ - {"k": "active", "v": "生效中"}, - {"k": "restored", "v": "已恢复"}, - {"k": "expired", "v": "已过期"} - ]}, - {"parentid": "snapshot_type", "parentname": "快照类型", "items": [ - {"k": "full", "v": "全量快照"}, - {"k": "incremental", "v": "增量快照"} - ]} - ] -} \ No newline at end of file + "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": "已作废"} + ]} + ] +} diff --git a/json/world_snapshot.json b/json/world_snapshot.json index 1a00830..4f7acff 100644 --- a/json/world_snapshot.json +++ b/json/world_snapshot.json @@ -1,36 +1,21 @@ { - "tblname": "world_snapshot", - "params": { - "browserfields": { - "title": "世界状态快照", - "rowcount": 20, - "alters": [ - {"name": "name", "title": "快照名称", "uitype": "text", "width": 200}, - {"name": "world_id", "title": "世界ID", "uitype": "text", "width": 120}, - {"name": "snapshot_type", "title": "快照类型", "uitype": "code", "width": 100, - "dataurl": "{{entire_url('/world_snapshot/api/snapshot_types.dspy')}}"}, - {"name": "version", "title": "版本", "uitype": "text", "width": 80}, - {"name": "status", "title": "状态", "uitype": "code", "width": 100, - "dataurl": "{{entire_url('/world_snapshot/api/snapshot_statuses.dspy')}}"}, - {"name": "snapshot_date", "title": "快照日期", "uitype": "text", "width": 160}, - {"name": "created_at", "title": "创建时间", "uitype": "text", "width": 160} - ] - }, - "editable": { - "title": "世界状态快照", - "alters": [ - {"name": "name", "title": "快照名称", "uitype": "text", "nullable": "no"}, - {"name": "world_id", "title": "世界ID", "uitype": "text", "nullable": "no"}, - {"name": "snapshot_type", "title": "快照类型", "uitype": "code", - "dataurl": "{{entire_url('/world_snapshot/api/snapshot_types.dspy')}}"}, - {"name": "status", "title": "状态", "uitype": "code", - "dataurl": "{{entire_url('/world_snapshot/api/snapshot_statuses.dspy')}}"} - ] - }, - "new_data_url": "{{entire_url('/world_snapshot/api/snapshot_create.dspy')}}", - "update_data_url": "{{entire_url('/world_snapshot/api/snapshot_update.dspy')}}", - "delete_data_url": "{{entire_url('/world_snapshot/api/snapshot_delete.dspy')}}", - "list_data_url": "{{entire_url('/world_snapshot/api/snapshot_list.dspy')}}", - "logined_userorgid": "" - } -} \ No newline at end of file + "tblname": "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"} + ] + }, + "filter_labels": {"world_id": "所属世界", "snapshot_date": "快照日期"}, + "browserfields": {"exclouded": ["id", "snapshot_json"], "alters": {}}, + "editexclouded": ["id", "snapshot_json", "created_at"], + "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')}}" + } + } +} diff --git a/models/world_snapshot.json b/models/world_snapshot.json index 1658da7..7ce3d21 100644 --- a/models/world_snapshot.json +++ b/models/world_snapshot.json @@ -1,32 +1,24 @@ { - "summary": [ - { - "name": "world_snapshot", - "title": "世界状态快照表", - "primary": ["id"], - "catelog": "entity" - } - ], - "fields": [ - {"name": "id", "title": "主键ID", "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": 32, "nullable": "no", "default": "full"}, - {"name": "snapshot_json", "title": "快照内容JSON", "type": "text"}, - {"name": "checksum", "title": "快照校验和(sha256)", "type": "str", "length": 64}, - {"name": "version", "title": "快照版本", "type": "int", "length": 11, "nullable": "no", "default": "1"}, - {"name": "status", "title": "快照状态", "type": "str", "length": 32, "nullable": "no", "default": "active"}, - {"name": "snapshot_date", "title": "快照日期", "type": "timestamp", "nullable": "no"}, - {"name": "created_at", "title": "创建时间", "type": "timestamp", "nullable": "no"}, - {"name": "updated_at", "title": "更新时间", "type": "timestamp", "nullable": "no"} - ], - "indexes": [ - {"name": "idx_snap_world_list", "idxtype": "index", "idxfields": ["world_id", "created_at"]}, - {"name": "idx_snap_status_type", "idxtype": "index", "idxfields": ["status", "snapshot_type"]}, - {"name": "uk_snap_world_version", "idxtype": "unique", "idxfields": ["world_id", "version"]} - ], - "codes": [ - {"field": "snapshot_type", "table": "appcodes_kv", "valuefield": "k", "textfield": "v", "cond": "parentid='snapshot_type'"}, - {"field": "status", "table": "appcodes_kv", "valuefield": "k", "textfield": "v", "cond": "parentid='snapshot_status'"} - ] -} \ No newline at end of file + "summary": [ + {"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": "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_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"]} + ], + "codes": [ + {"field": "world_id", "table": "world", "valuefield": "id", "textfield": "name"}, + {"field": "snapshot_type", "table": "appcodes_kv", "valuefield": "k", "textfield": "v", "cond": "parentid='snapshot_type'"}, + {"field": "status", "table": "appcodes_kv", "valuefield": "k", "textfield": "v", "cond": "parentid='snapshot_status'"} + ] +} diff --git a/pyproject.toml b/pyproject.toml index 03ce7ec..d4c6b1d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -5,7 +5,6 @@ build-backend = "setuptools.build_meta" [project] name = "world_snapshot" version = "1.0.0" -description = "世界状态快照模块(W-04 快照管理):快照生成/查询/恢复,checksum+version 递增,事务一致性" requires-python = ">=3.8" dependencies = ["sqlor", "bricks_for_python"] diff --git a/scripts/load_path.py b/scripts/load_path.py index 362d7d9..69ab459 100644 --- a/scripts/load_path.py +++ b/scripts/load_path.py @@ -1,56 +1,63 @@ -#!/usr/bin/env python3 # -*- coding: utf-8 -*- -"""world_snapshot 模块 RBAC 权限注册脚本(W-04)。""" +"""world_snapshot 模块 RBAC 注册脚本。所有 path 显式列出,禁止通配符。""" import os import sys -SCRIPT_DIR = os.path.dirname(os.path.abspath(__file__)) -SAGE_ROOT = None -for cand in (os.path.join(SCRIPT_DIR, '..', '..', '..'), - os.path.expanduser('~/repos/sage'), - os.path.expanduser('~/sage')): - cand = os.path.abspath(cand) - if os.path.isdir(os.path.join(cand, 'wwwroot')) and os.path.isdir(os.path.join(cand, 'py3', 'bin')): - SAGE_ROOT = cand - break -if not SAGE_ROOT: - print('ERROR: sage root not found, skip RBAC registration') - sys.exit(0) - -sys.path.insert(0, os.path.join(SAGE_ROOT, 'py3', 'bin')) -sys.path.insert(0, os.path.join(SAGE_ROOT)) - -try: - from set_role_perm import set_role_perm # noqa: E402 -except Exception as e: # noqa: BLE001 - print(f'ERROR: cannot import set_role_perm: {e}') - sys.exit(0) - MOD = 'world_snapshot' + +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 + return None + +PATHS_ANY = [f'/{MOD}/index.ui'] + PATHS_LOGINED = [ f'/{MOD}', - f'/{MOD}/index.ui', - f'/{MOD}/api/snapshot_create.dspy', - f'/{MOD}/api/snapshot_get.dspy', - f'/{MOD}/api/snapshot_list.dspy', - f'/{MOD}/api/snapshot_update.dspy', - f'/{MOD}/api/snapshot_delete.dspy', - f'/{MOD}/api/snapshot_restore.dspy', - f'/{MOD}/api/snapshot_types.dspy', - f'/{MOD}/api/snapshot_statuses.dspy', - f'/{MOD}/api/snapshot_worlds.dspy', + 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', ] def main(): + 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: - try: - set_role_perm(path, 'logined') - print(f'OK {path} -> logined') - except Exception as e: # noqa: BLE001 - print(f'ERR {path}: {e}') - print('world_snapshot RBAC registration done') + set_role_perm(path, 'logined') + print('RBAC registered for module %s' % MOD) if __name__ == '__main__': diff --git a/skill/SKILL.md b/skill/SKILL.md index 137e4ef..362b793 100644 --- a/skill/SKILL.md +++ b/skill/SKILL.md @@ -1,84 +1,21 @@ --- name: world_snapshot -description: 世界状态快照模块(W-04 快照管理)——world_snapshot 表(checksum/version 递增)、快照生成(事务批量+checksum 一致性校验)、查询(分页 {list,total})、恢复(事务回滚无脏数据)、编码字典 snapshot_status/snapshot_type 幂等落库、REST /api/* 统一前缀与错误结构 {code,message,field,detail}。 +description: world_snapshot 模块技能文档——世界快照管理(世界状态快照生成、查询与恢复)。 --- -# world_snapshot 模块(W-04 快照管理) +# world_snapshot 模块 -## 架构 - -world_snapshot 是域内业务模块:聚合 world/scene/entity 当前状态生成快照,支持查询与恢复。 -通过 `load_world_snapshot()` 挂载到宿主应用(唯一集成点),无独立 app.py/端口。 +世界状态快照模块,聚合世界 + 场景 + 实体当前状态生成快照,支持查询与恢复。 ## 数据模型 +表 `world_snapshot`:id、world_id、name、snapshot_type、snapshot_json、status、snapshot_date(date)、created_at。 -表 `world_snapshot`(models/world_snapshot.json): - -| 字段 | 类型 | 说明 | -|---|---|---| -| id | str(32) PK | 主键(getID) | -| world_id | str(32) | 世界ID(关联 world) | -| name | str(255) | 快照名称 | -| snapshot_type | str(32) | full / incremental | -| snapshot_json | text | 快照内容 JSON(world/scene/entity 聚合) | -| checksum | str(64) | sha256 校验和 | -| version | int | 快照版本,按 world 递增(max+1) | -| status | str(32) | active / restored / expired | -| snapshot_date | timestamp | 快照日期 | -| created_at / updated_at | timestamp | 审计时间 | - -唯一索引 `uk_snap_world_version(world_id, version)` 保证同世界版本不重复。 - -编码字典(init/data.json 幂等落库): -- `snapshot_status`:active 生效中 / restored 已恢复 / expired 已过期 -- `snapshot_type`:full 全量快照 / incremental 增量快照 - -## 核心逻辑 - -### 快照生成 create_snapshot -1. 输入白名单校验(world_id 必填且 ≤32、snapshot_type ∈ {full,incremental}、name ≤255)——非法输入 100% 拦截不落库 -2. 同一事务内:`select world ... for update` 行锁该世界 → 批量聚合 scene/entity → 拼 snapshot_json → sha256 checksum -3. `max(version)+1` 递增(行锁 + 唯一索引双保险) -4. 事务提交;任何异常整体回滚,返回 `{code,message,field,detail}` - -### 快照查询 get_snapshot / list_snapshots -- get:读单条,checksum 重算比对,不匹配返回 `CHECKSUM_MISMATCH`(数据损坏检测) -- list:分页 `{list,total,page,page_size}`;page/page_size 非整数、status/snapshot_type 不在白名单 → 拦截 - -### 快照恢复 restore_snapshot -1. 校验快照存在 + checksum 一致性 -2. 同一事务内:先删后插写回 scene/entity(按目标表 information_schema 字段过滤防脏列)→ 更新 world.mode=active → 快照置 restored -3. 任一步异常 → 事务整体回滚,无脏数据 - -## REST 端点(统一前缀 /api/*) - -| 端点 | 方法 | 说明 | -|---|---|---| -| /world_snapshot/api/snapshot_create.dspy | POST | 生成快照 | -| /world_snapshot/api/snapshot_get.dspy | GET/POST | 查询单个(含 checksum 校验) | -| /world_snapshot/api/snapshot_list.dspy | GET/POST | 分页列表 {list,total} | -| /world_snapshot/api/snapshot_update.dspy | POST | 更新(白名单字段) | -| /world_snapshot/api/snapshot_delete.dspy | POST | 删除快照记录 | -| /world_snapshot/api/snapshot_restore.dspy | POST | 恢复快照(事务回滚) | -| /world_snapshot/api/snapshot_types.dspy | GET | 编码字典:快照类型 [{value,text}] | -| /world_snapshot/api/snapshot_statuses.dspy | GET | 编码字典:快照状态 [{value,text}] | -| /world_snapshot/api/snapshot_worlds.dspy | GET | 可选世界列表 [{value,text}] | - -成功结构:`{"success":true, ...}` -错误结构:`{"code":"...","message":"...","field":"...","detail":"..."}` - -## 函数注册(三处同步) - -`world_snapshot/world_snapshot/init.py` 实现 + `world_snapshot/world_snapshot/__init__.py` import + -`load_world_snapshot()` 里 `env.xxx = xxx`。新增/删除函数必须三处同步。 +## 关键接口 +- create_snapshot / restore_snapshot / list_snapshots / get_snapshot / update_snapshot / delete_snapshot +- 下拉:list_worlds_for_snapshot / list_snapshot_types / list_snapshot_statuses ## 陷阱 - -- **取库名禁止硬编码**:`.py` 用 `ServerEnv().get_module_dbname('world_snapshot')`;.dspy 直接用全局 `get_module_dbname()` -- **事务 return 位置**:`return` 必须在 `async with` 块外(块内 return 返回 None) -- **sor.U 仅 2 参数**:主键放 data 里;仅主键时校验非空更新字段 -- **sqlExe 必须带 ns**:无参数查询也传 `{}` -- **.dspy 禁止 import**:函数经 load_*() 导出;预加载全局 json/debug/getID 等直接可用 -- **dbpools 单例 fork 陷阱**:函数内 `db = DBPools()` 局部创建 -- **sor.C 不自动补 created_at**:插入时显式设置 `created_at`/`updated_at` = curDateString() -- **LIMIT/OFFSET 用整数**:字符串会渲染成 `LIMIT '5'` 语法错误 +- 库名禁止硬编码,统一 get_module_dbname("world_snapshot") +- dspy 无 import +- create_snapshot 必须设置 created_at/snapshot_date +- snapshot_date 用 date 类型(业务日期仅到天) diff --git a/world_snapshot/__init__.py b/world_snapshot/__init__.py index 0df832c..1c75dbd 100644 --- a/world_snapshot/__init__.py +++ b/world_snapshot/__init__.py @@ -1,13 +1,12 @@ # -*- coding: utf-8 -*- -"""world_snapshot Python 包(W-04 快照管理)。""" from .init import ( - create_snapshot, get_snapshot, list_snapshots, update_snapshot, - delete_snapshot, restore_snapshot, list_worlds_for_snapshot, - list_snapshot_types, list_snapshot_statuses, load_world_snapshot, + 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, ) __all__ = [ - 'create_snapshot', 'get_snapshot', 'list_snapshots', 'update_snapshot', - 'delete_snapshot', 'restore_snapshot', 'list_worlds_for_snapshot', - 'list_snapshot_types', 'list_snapshot_statuses', 'load_world_snapshot', + "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", ] diff --git a/world_snapshot/init.py b/world_snapshot/init.py index e072e34..2343456 100644 --- a/world_snapshot/init.py +++ b/world_snapshot/init.py @@ -1,388 +1,154 @@ # -*- coding: utf-8 -*- -"""world_snapshot module (W-04 snapshot management).""" -import hashlib +"""world_snapshot 模块 —— 世界状态快照管理。库名经 get_module_dbname('world_snapshot') 获取,禁止硬编码。""" 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 -SNAPSHOT_TYPES = ('full', 'incremental') -SNAPSHOT_STATUSES = ('active', 'restored', 'expired') -WORLD_MODE_ACTIVE = 'active' -PAGE_SIZE_MAX = 200 -NAME_MAX_LEN = 255 -ID_MAX_LEN = 32 -CHILD_TABLES = ( - ('scene', 'scene', 'world_id'), - ('entity', 'entity', 'world_id'), -) - -UPDATABLE = ('name', 'snapshot_type', 'status') +def _get_dbname(): + return ServerEnv().get_module_dbname('world_snapshot') -def _err(code, message, field='', detail=''): - return {'code': code, 'message': message, 'field': field, 'detail': detail} - - -def _ok(**kw): - res = {'success': True} - res.update(kw) - return res - - -def _checksum(text): - return hashlib.sha256((text or '').encode('utf-8')).hexdigest() - - -def _to_dict(row): - 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} - - -async def _table_columns(sor, table): - try: - rows = await sor.sqlExe( - 'select column_name from information_schema.columns' - ' where table_schema = database() and table_name = ${t}$', - {'t': table}) - return set(r.column_name for r in (rows or [])) - except Exception: - return set() - - -async def _restore_children(sor, data, world_id): - data = data or {} - for _mod, table, fk in CHILD_TABLES: - rows = data.get(table) or [] - await sor.sqlExe('delete from ' + table + ' where ' + fk + ' = ${wid}$', {'wid': world_id}) - if not rows: - continue - cols = await _table_columns(sor, table) - for r in rows: - rec = {} - for k, v in (r or {}).items(): - if k.endswith('_text'): - continue - if k in cols: - rec[k] = v - if not rec.get('created_at'): - rec['created_at'] = curDateString() - await sor.C(table, rec) - - -async def create_snapshot(params): - params = params or {} - world_id = str(params.get('world_id') or '').strip() - if not world_id: - return _err('PARAM_REQUIRED', 'world_id is required', 'world_id') - if len(world_id) > ID_MAX_LEN: - return _err('INVALID_PARAM', 'world_id too long (max 32)', 'world_id', world_id) - snapshot_type = str(params.get('snapshot_type') or 'full').strip() - if snapshot_type not in SNAPSHOT_TYPES: - return _err('INVALID_SNAPSHOT_TYPE', 'snapshot_type must be full or incremental', - 'snapshot_type', snapshot_type) - name = str(params.get('name') or '').strip() - if len(name) > NAME_MAX_LEN: - return _err('INVALID_PARAM', 'name too long (max 255)', 'name') - - env = ServerEnv() - dbname = env.get_module_dbname('world_snapshot') +async def list_snapshots(ns): db = DBPools() - result = None - try: - async with db.sqlorContext(dbname) as sor: - wrows = await sor.sqlExe('select * from world where id = ${wid}$ for update', - {'wid': world_id}) - if not wrows: - result = _err('WORLD_NOT_FOUND', 'world not found', 'world_id', world_id) - else: - world = _to_dict(wrows[0]) - children = {} - for _mod, table, fk in CHILD_TABLES: - try: - crows = await sor.sqlExe( - 'select * from ' + table + ' where ' + fk + ' = ${wid}$' - ' order by created_at desc', {'wid': world_id}) - children[table] = [_to_dict(r) for r in (crows or [])] - except Exception: - children[table] = [] - snap_data = { - 'world': world, - 'scene': children.get('scene', []), - 'entity': children.get('entity', []), - 'snapshot_type': snapshot_type, - 'snapshot_date': curDateString(), - } - snapshot_json = json.dumps(snap_data, ensure_ascii=False) - checksum = _checksum(snapshot_json) - vrows = await sor.sqlExe( - 'select coalesce(max(version), 0) as maxv from world_snapshot' - ' where world_id = ${wid}$', {'wid': world_id}) - maxv = vrows[0].maxv if vrows and vrows[0].maxv is not None else 0 - version = int(maxv) + 1 - snap_name = name or (str(world.get('name') or world_id) + '_snapshot') - ns = { - 'id': getID(), - 'world_id': world_id, - 'name': snap_name, - 'snapshot_type': snapshot_type, - 'snapshot_json': snapshot_json, - 'checksum': checksum, - 'version': version, - 'status': 'active', - 'snapshot_date': curDateString(), - 'created_at': curDateString(), - 'updated_at': curDateString(), - } - await sor.C('world_snapshot', ns) - result = _ok(id=ns['id'], world_id=world_id, version=version, - checksum=checksum, snapshot_type=snapshot_type) - return result - except Exception as e: - return _err('SNAPSHOT_CREATE_FAILED', 'create snapshot failed, rolled back: ' + str(e), - '', str(e)) - - -async def get_snapshot(params): - params = params or {} - snapshot_id = str(params.get('id') or '').strip() - if not snapshot_id: - return _err('PARAM_REQUIRED', 'id is required', 'id') - if len(snapshot_id) > ID_MAX_LEN: - return _err('INVALID_PARAM', 'id too long (max 32)', 'id', snapshot_id) - env = ServerEnv() - dbname = env.get_module_dbname('world_snapshot') - db = DBPools() - result = None - try: - async with db.sqlorContext(dbname) as sor: - rows = await sor.R('world_snapshot', {'id': snapshot_id}) - if not rows: - result = _err('SNAPSHOT_NOT_FOUND', 'snapshot not found', 'id', snapshot_id) - else: - rec = _to_dict(rows[0]) - sj = rec.get('snapshot_json') or '' - if rec.get('checksum') and _checksum(sj) != rec.get('checksum'): - result = _err('CHECKSUM_MISMATCH', 'snapshot data is corrupted (checksum mismatch)', - 'checksum', rec.get('checksum')) - else: - rec.pop('snapshot_json', None) - result = _ok(data=rec) - return result - except Exception as e: - return _err('SNAPSHOT_GET_FAILED', 'get snapshot failed: ' + str(e), '', str(e)) - - -async def list_snapshots(params): - params = params or {} - try: - page = int(params.get('page') or 1) - except (TypeError, ValueError): - return _err('INVALID_PARAM', 'page must be an integer', 'page', params.get('page')) - try: - page_size = int(params.get('page_size') or params.get('rows') or 20) - except (TypeError, ValueError): - return _err('INVALID_PARAM', 'page_size must be an integer', - 'page_size', params.get('page_size')) - if page < 1: - page = 1 - page_size = min(max(page_size, 1), PAGE_SIZE_MAX) - - conds, ns = [], {} - world_id = str(params.get('world_id') or '').strip() + 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', '') if world_id: conds.append('world_id = ${world_id}$') - ns['world_id'] = world_id - status = str(params.get('status') or '').strip() - if status: - if status not in SNAPSHOT_STATUSES: - return _err('INVALID_STATUS', 'invalid status, must be one of ' + ','.join(SNAPSHOT_STATUSES), - 'status', status) - conds.append('status = ${status}$') - ns['status'] = status - snapshot_type = str(params.get('snapshot_type') or '').strip() - if snapshot_type: - if snapshot_type not in SNAPSHOT_TYPES: - return _err('INVALID_SNAPSHOT_TYPE', 'invalid snapshot_type, must be one of ' + ','.join(SNAPSHOT_TYPES), - 'snapshot_type', snapshot_type) - conds.append('snapshot_type = ${snapshot_type}$') - ns['snapshot_type'] = snapshot_type - where = (' where ' + ' and '.join(conds)) if conds else '' + 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} - env = ServerEnv() - dbname = env.get_module_dbname('world_snapshot') + +async def get_snapshot(ns): + sid = ns.get('id', '') + if not sid: + return {'success': False, 'message': 'id 不能为空'} db = DBPools() - result = None - try: - 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 and cnt[0].cnt is not None else 0 - rns = dict(ns) - rns['sort'] = 'created_at desc' - rows = await sor.R('world_snapshot', rns) - rows = (rows or [])[(page - 1) * page_size: page * page_size] - lst = [] - for r in rows: - d = _to_dict(r) - d.pop('snapshot_json', None) - lst.append(d) - result = {'list': lst, 'total': total, 'page': page, 'page_size': page_size} - return result - except Exception as e: - return _err('SNAPSHOT_LIST_FAILED', 'list snapshots failed: ' + str(e), '', str(e)) + 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 def update_snapshot(params): - params = params or {} - snapshot_id = str(params.get('id') or '').strip() - if not snapshot_id: - return _err('PARAM_REQUIRED', 'id is required', 'id') +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 不能为空'} upd = {} - for k in UPDATABLE: - if k in params and params[k] is not None and str(params[k]).strip() != '': - v = str(params[k]).strip() - if k == 'snapshot_type' and v not in SNAPSHOT_TYPES: - return _err('INVALID_SNAPSHOT_TYPE', 'invalid snapshot_type', 'snapshot_type', v) - if k == 'status' and v not in SNAPSHOT_STATUSES: - return _err('INVALID_STATUS', 'invalid status', 'status', v) - if k == 'name' and len(v) > NAME_MAX_LEN: - return _err('INVALID_PARAM', 'name too long (max 255)', 'name') - upd[k] = v + 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] if not upd: - return _err('NO_FIELDS_TO_UPDATE', 'no valid fields to update', '', '') - upd['id'] = snapshot_id - upd['updated_at'] = curDateString() - env = ServerEnv() - dbname = env.get_module_dbname('world_snapshot') + return {'success': False, 'message': '无更新字段'} + upd['id'] = sid db = DBPools() - result = None - try: - async with db.sqlorContext(dbname) as sor: - rows = await sor.R('world_snapshot', {'id': snapshot_id}) - if not rows: - result = _err('SNAPSHOT_NOT_FOUND', 'snapshot not found', 'id', snapshot_id) - else: - await sor.U('world_snapshot', upd) - result = _ok(id=snapshot_id) - return result - except Exception as e: - return _err('SNAPSHOT_UPDATE_FAILED', 'update snapshot failed: ' + str(e), '', str(e)) + async with db.sqlorContext(_get_dbname()) as sor: + await sor.U('world_snapshot', upd) + return {'success': True, 'id': sid} -async def delete_snapshot(params): - params = params or {} - snapshot_id = str(params.get('id') or '').strip() - if not snapshot_id: - return _err('PARAM_REQUIRED', 'id is required', 'id') - env = ServerEnv() - dbname = env.get_module_dbname('world_snapshot') +async def delete_snapshot(ns): + sid = ns.get('id', '') + if not sid: + return {'success': False, 'message': 'id 不能为空'} db = DBPools() - result = None - try: - async with db.sqlorContext(dbname) as sor: - rows = await sor.R('world_snapshot', {'id': snapshot_id}) - if not rows: - result = _err('SNAPSHOT_NOT_FOUND', 'snapshot not found', 'id', snapshot_id) - else: - await sor.D('world_snapshot', {'id': snapshot_id}) - result = _ok(id=snapshot_id) - return result - except Exception as e: - return _err('SNAPSHOT_DELETE_FAILED', 'delete snapshot failed: ' + str(e), '', str(e)) + async with db.sqlorContext(_get_dbname()) as sor: + await sor.D('world_snapshot', {'id': sid}) + return {'success': True, 'id': sid} -async def restore_snapshot(params): - params = params or {} - snapshot_id = str(params.get('id') or '').strip() - if not snapshot_id: - return _err('PARAM_REQUIRED', 'id is required', 'id') - env = ServerEnv() - dbname = env.get_module_dbname('world_snapshot') +async def restore_snapshot(ns): + sid = ns.get('id', ns.get('snapshot_id', '')) + if not sid: + return {'success': False, 'message': 'snapshot_id 不能为空'} db = DBPools() - result = None - try: - async with db.sqlorContext(dbname) as sor: - rows = await sor.R('world_snapshot', {'id': snapshot_id}) - if not rows: - result = _err('SNAPSHOT_NOT_FOUND', 'snapshot not found', 'id', snapshot_id) - else: - snap = rows[0] - sj = snap.snapshot_json or '' - if snap.checksum and _checksum(sj) != snap.checksum: - result = _err('CHECKSUM_MISMATCH', 'snapshot data is corrupted (checksum mismatch)', - 'checksum', snap.checksum) - else: - try: - data = json.loads(sj) - except Exception: - result = _err('INVALID_SNAPSHOT_JSON', 'snapshot json is invalid', - 'snapshot_json', '') - else: - world_id = snap.world_id - await _restore_children(sor, data, world_id) - try: - await sor.sqlExe( - 'update world set mode = ${mode}$, updated_at = ${ts}$' - ' where id = ${wid}$', - {'mode': WORLD_MODE_ACTIVE, 'ts': curDateString(), 'wid': world_id}) - except Exception: - pass - await sor.U('world_snapshot', - {'id': snapshot_id, 'status': 'restored', - 'updated_at': curDateString()}) - result = _ok(id=snapshot_id, world_id=world_id, status='restored') - return result - except Exception as e: - return _err('SNAPSHOT_RESTORE_FAILED', - 'restore snapshot failed, transaction rolled back: ' + str(e), '', str(e)) + 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 list_worlds_for_snapshot(): - env = ServerEnv() - dbname = env.get_module_dbname('world_snapshot') +async def list_worlds_for_snapshot(params_kw=None): db = DBPools() - try: - 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', {}) - return [{'value': r.value, 'text': r.text} for r in (rows or [])] - except Exception: - return [] + 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(): - return [{'value': 'full', 'text': '全量快照'}, {'value': 'incremental', 'text': '增量快照'}] +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(): - return [{'value': 'active', 'text': '生效中'}, - {'value': 'restored', 'text': '已恢复'}, - {'value': 'expired', 'text': '已过期'}] +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 [] -def load_world_snapshot(env=None): - 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 +async def load_world_snapshot(): + env = ServerEnv() env.list_snapshots = list_snapshots + env.get_snapshot = get_snapshot + env.create_snapshot = create_snapshot + env.update_snapshot = update_snapshot + env.delete_snapshot = delete_snapshot 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 - return env + 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 diff --git a/wwwroot/api/create_snapshot.dspy b/wwwroot/api/create_snapshot.dspy index 4e38af1..56f4c85 100644 --- a/wwwroot/api/create_snapshot.dspy +++ b/wwwroot/api/create_snapshot.dspy @@ -1,8 +1,3 @@ -# 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': ''} +debug('create_snapshot.dspy: START') +result = await create_snapshot(dict(params_kw)) +return result diff --git a/wwwroot/api/get_search_snapshot_type.dspy b/wwwroot/api/get_search_snapshot_type.dspy index 08cf28f..2bfa0cc 100644 --- a/wwwroot/api/get_search_snapshot_type.dspy +++ b/wwwroot/api/get_search_snapshot_type.dspy @@ -1,11 +1,2 @@ -# 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) +result = await list_snapshot_types({}) +return [{'value': '', 'text': '全部'}] + list(result or []) diff --git a/wwwroot/api/get_search_status.dspy b/wwwroot/api/get_search_status.dspy index ed0bac1..71aad63 100644 --- a/wwwroot/api/get_search_status.dspy +++ b/wwwroot/api/get_search_status.dspy @@ -1,11 +1,2 @@ -# 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) +result = await list_snapshot_statuses({}) +return [{'value': '', 'text': '全部'}] + list(result or []) diff --git a/wwwroot/api/get_search_world.dspy b/wwwroot/api/get_search_world.dspy deleted file mode 100644 index 1a3e364..0000000 --- a/wwwroot/api/get_search_world.dspy +++ /dev/null @@ -1,11 +0,0 @@ -# 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 8c720b3..8122335 100644 --- a/wwwroot/api/get_snapshot.dspy +++ b/wwwroot/api/get_snapshot.dspy @@ -1,8 +1,3 @@ -# 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': ''} +debug('get_snapshot.dspy: START') +result = await get_snapshot(dict(params_kw)) +return result diff --git a/wwwroot/api/list_snapshots.dspy b/wwwroot/api/list_snapshots.dspy index 91ffba5..b095a69 100644 --- a/wwwroot/api/list_snapshots.dspy +++ b/wwwroot/api/list_snapshots.dspy @@ -1,8 +1,3 @@ -# 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': ''} +debug('list_snapshots.dspy: START') +result = await list_snapshots(dict(params_kw)) +return result diff --git a/wwwroot/api/restore_snapshot.dspy b/wwwroot/api/restore_snapshot.dspy index 7284cc6..1ed2f64 100644 --- a/wwwroot/api/restore_snapshot.dspy +++ b/wwwroot/api/restore_snapshot.dspy @@ -1,8 +1,3 @@ -# 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': ''} +debug('restore_snapshot.dspy: START') +result = await restore_snapshot(dict(params_kw)) +return result diff --git a/wwwroot/api/snapshot_create.dspy b/wwwroot/api/snapshot_create.dspy deleted file mode 100644 index 767ba94..0000000 --- a/wwwroot/api/snapshot_create.dspy +++ /dev/null @@ -1,4 +0,0 @@ -# world_snapshot create snapshot API (W-04) -debug(f'world_snapshot snapshot_create.dspy: START params_kw={dict(params_kw)}') -res = await create_snapshot(params_kw) -return json.dumps(res, ensure_ascii=False) diff --git a/wwwroot/api/snapshot_delete.dspy b/wwwroot/api/snapshot_delete.dspy index f61c66e..79248f7 100644 --- a/wwwroot/api/snapshot_delete.dspy +++ b/wwwroot/api/snapshot_delete.dspy @@ -1,4 +1,2 @@ -# world_snapshot delete snapshot API (W-04) -debug(f'world_snapshot snapshot_delete.dspy: START params_kw={dict(params_kw)}') -res = await delete_snapshot(params_kw) -return json.dumps(res, ensure_ascii=False) +result = await delete_snapshot(params_kw) +return result diff --git a/wwwroot/api/snapshot_get.dspy b/wwwroot/api/snapshot_get.dspy deleted file mode 100644 index 76f2ae7..0000000 --- a/wwwroot/api/snapshot_get.dspy +++ /dev/null @@ -1,4 +0,0 @@ -# world_snapshot get snapshot API (W-04) -debug(f'world_snapshot snapshot_get.dspy: START params_kw={dict(params_kw)}') -res = await get_snapshot(params_kw) -return json.dumps(res, ensure_ascii=False) diff --git a/wwwroot/api/snapshot_list.dspy b/wwwroot/api/snapshot_list.dspy deleted file mode 100644 index 859b679..0000000 --- a/wwwroot/api/snapshot_list.dspy +++ /dev/null @@ -1,4 +0,0 @@ -# world_snapshot list snapshots API (W-04) -debug(f'world_snapshot snapshot_list.dspy: START params_kw={dict(params_kw)}') -res = await list_snapshots(params_kw) -return json.dumps(res, ensure_ascii=False) diff --git a/wwwroot/api/snapshot_restore.dspy b/wwwroot/api/snapshot_restore.dspy deleted file mode 100644 index ec1d659..0000000 --- a/wwwroot/api/snapshot_restore.dspy +++ /dev/null @@ -1,4 +0,0 @@ -# world_snapshot restore snapshot API (W-04) -debug(f'world_snapshot snapshot_restore.dspy: START params_kw={dict(params_kw)}') -res = await restore_snapshot(params_kw) -return json.dumps(res, ensure_ascii=False) diff --git a/wwwroot/api/snapshot_statuses.dspy b/wwwroot/api/snapshot_statuses.dspy deleted file mode 100644 index 4362344..0000000 --- a/wwwroot/api/snapshot_statuses.dspy +++ /dev/null @@ -1,4 +0,0 @@ -# world_snapshot snapshot statuses dropdown API (W-04) -debug(f'world_snapshot snapshot_statuses.dspy: START') -res = await list_snapshot_statuses() -return json.dumps(res, ensure_ascii=False) diff --git a/wwwroot/api/snapshot_types.dspy b/wwwroot/api/snapshot_types.dspy deleted file mode 100644 index f84995f..0000000 --- a/wwwroot/api/snapshot_types.dspy +++ /dev/null @@ -1,4 +0,0 @@ -# world_snapshot snapshot types dropdown API (W-04) -debug(f'world_snapshot snapshot_types.dspy: START') -res = await list_snapshot_types() -return json.dumps(res, ensure_ascii=False) diff --git a/wwwroot/api/snapshot_update.dspy b/wwwroot/api/snapshot_update.dspy index cd5f5bb..b2970ac 100644 --- a/wwwroot/api/snapshot_update.dspy +++ b/wwwroot/api/snapshot_update.dspy @@ -1,4 +1,2 @@ -# world_snapshot update snapshot API (W-04) -debug(f'world_snapshot snapshot_update.dspy: START params_kw={dict(params_kw)}') -res = await update_snapshot(params_kw) -return json.dumps(res, ensure_ascii=False) +result = await update_snapshot(params_kw) +return result diff --git a/wwwroot/api/snapshot_worlds.dspy b/wwwroot/api/snapshot_worlds.dspy deleted file mode 100644 index bed5903..0000000 --- a/wwwroot/api/snapshot_worlds.dspy +++ /dev/null @@ -1,4 +0,0 @@ -# world_snapshot worlds dropdown API (W-04) -debug(f'world_snapshot snapshot_worlds.dspy: START') -res = await list_worlds_for_snapshot() -return json.dumps(res, ensure_ascii=False) diff --git a/wwwroot/index.ui b/wwwroot/index.ui index c3d5870..b387e10 100644 --- a/wwwroot/index.ui +++ b/wwwroot/index.ui @@ -2,17 +2,20 @@ "widgettype": "VBox", "options": {"width": "100%", "height": "100%", "padding": "20px"}, "subwidgets": [ - {"widgettype": "Text", "options": {"label": "世界状态快照管理(W-04)", "fontSize": "22px", "marginBottom": "16px"}}, + {"widgettype": "Text", "options": {"label": "世界快照", "fontSize": "24px"}}, {"widgettype": "ResponsableBox", "options": {"gap": "16px", "minWidth": "250px"}, "subwidgets": [ - {"widgettype": "VBox", "options": {"backgroundColor": "#FFFFFF", "padding": "20px", "cursor": "pointer", "borderRadius": "8px"}, - "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": "快照列表", "fontSize": "16px", "fontWeight": "bold"}}, {"widgettype": "Text", "options": {"label": "分页查询 / 查看 / 编辑 / 删除快照记录", "fontSize": "13px", "color": "#666666"}}]}, - {"widgettype": "VBox", "options": {"backgroundColor": "#FFFFFF", "padding": "20px", "cursor": "pointer", "borderRadius": "8px"}, - "binds": [{"wid": "self", "event": "click", "actiontype": "urlwidget", "target": "app.world_snapshot_content", "options": {"url": "{{entire_url('/world_snapshot/snapshot_generate.ui')}}", "mode": "replace"}}], - "subwidgets": [{"widgettype": "Text", "options": {"label": "生成快照", "fontSize": "16px", "fontWeight": "bold"}}, {"widgettype": "Text", "options": {"label": "事务批量聚合 world/scene/entity + checksum + version 递增", "fontSize": "13px", "color": "#666666"}}]}, - {"widgettype": "VBox", "options": {"backgroundColor": "#FFFFFF", "padding": "20px", "cursor": "pointer", "borderRadius": "8px"}, - "binds": [{"wid": "self", "event": "click", "actiontype": "urlwidget", "target": "app.world_snapshot_content", "options": {"url": "{{entire_url('/world_snapshot/snapshot_restore.ui')}}", "mode": "replace"}}], - "subwidgets": [{"widgettype": "Text", "options": {"label": "恢复快照", "fontSize": "16px", "fontWeight": "bold"}}, {"widgettype": "Text", "options": {"label": "checksum 校验 + 事务写回 scene/entity,失败回滚无脏数据", "fontSize": "13px", "color": "#666666"}}]} + {"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"}} ] diff --git a/wwwroot/menu.ui b/wwwroot/menu.ui deleted file mode 100644 index c119796..0000000 --- a/wwwroot/menu.ui +++ /dev/null @@ -1,7 +0,0 @@ -{"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')}}"} - ]}} -]} diff --git a/wwwroot/snapshot_generate.ui b/wwwroot/snapshot_generate.ui deleted file mode 100644 index e78747c..0000000 --- a/wwwroot/snapshot_generate.ui +++ /dev/null @@ -1,16 +0,0 @@ -{ - "widgettype": "VBox", - "options": {"width": "100%", "height": "100%", "padding": "20px"}, - "subwidgets": [ - {"widgettype": "Text", "options": {"label": "生成快照", "fontSize": "20px", "marginBottom": "16px"}}, - {"widgettype": "Form", "options": {"width": "100%", "maxWidth": "720px"}, - "subwidgets": [ - {"widgettype": "UiSelect", "options": {"name": "world_id", "label": "选择世界", "required": true, "dataurl": "{{entire_url('/world_snapshot/api/snapshot_worlds.dspy')}}", "valueField": "value", "textField": "text"}}, - {"widgettype": "UiSelect", "options": {"name": "snapshot_type", "label": "快照类型", "value": "full", "dataurl": "{{entire_url('/world_snapshot/api/snapshot_types.dspy')}}", "valueField": "value", "textField": "text"}}, - {"widgettype": "Input", "options": {"name": "name", "label": "快照名称", "placeholder": "留空则自动生成"}} - ], - "binds": [{"wid": "submit_btn", "event": "click", "actiontype": "script", "options": {"script": "const f = bricks.widget('form_snapshot'); const d = f.getFormData ? f.getFormData() : f.getData(); bricks_fetch('/world_snapshot/api/snapshot_create.dspy', {method:'POST', body: JSON.stringify(d), headers:{'Content-Type':'application/json'}}).then(r=>r.json()).then(res=>{ if(res.success){ bricks.message.success('快照生成成功 version=' + res.version); } else { bricks.message.error((res.message||'') + ' ' + (res.detail||'')); } });"}}], - "subwidgets_extra": [{"widgettype": "Button", "id": "submit_btn", "options": {"label": "生成快照", "buttonType": "primary"}}] - } - ] -} diff --git a/wwwroot/snapshot_restore.ui b/wwwroot/snapshot_restore.ui deleted file mode 100644 index fb71d6c..0000000 --- a/wwwroot/snapshot_restore.ui +++ /dev/null @@ -1,14 +0,0 @@ -{ - "widgettype": "VBox", - "options": {"width": "100%", "height": "100%", "padding": "20px"}, - "subwidgets": [ - {"widgettype": "Text", "options": {"label": "恢复快照", "fontSize": "20px", "marginBottom": "16px"}}, - {"widgettype": "Form", "options": {"width": "100%", "maxWidth": "720px"}, - "subwidgets": [ - {"widgettype": "Input", "options": {"name": "id", "label": "快照ID", "required": true, "placeholder": "输入要恢复的快照 id"}} - ], - "binds": [{"wid": "restore_btn", "event": "click", "actiontype": "script", "options": {"script": "const f = bricks.widget('form_restore'); const d = f.getFormData ? f.getFormData() : f.getData(); bricks_fetch('/world_snapshot/api/snapshot_restore.dspy', {method:'POST', body: JSON.stringify(d), headers:{'Content-Type':'application/json'}}).then(r=>r.json()).then(res=>{ if(res.success){ bricks.message.success('恢复成功,world=' + res.world_id); } else { bricks.message.error((res.message||'') + ' ' + (res.detail||'')); } });"}}], - "subwidgets_extra": [{"widgettype": "Button", "id": "restore_btn", "options": {"label": "恢复快照", "buttonType": "primary"}}] - } - ] -}