approve: world_snapshot 模块开发(W-04 快照管理)

This commit is contained in:
agent.develop 2026-08-29 16:22:06 +08:00
parent 7c4965a972
commit 9007acb798
22 changed files with 682 additions and 496 deletions

View File

@ -1,34 +1,55 @@
# world_snapshot 模块
# world_snapshot 世界状态快照模块W-04 快照管理)
世界状态快照模块W-03聚合世界world、场景scene、实体entity当前状态生成
快照,支持快照查询与恢复,恢复时联动 world 表状态(经 world 模块 set_world_mode 契约)。
## 用途
聚合 world/scene/entity 当前状态生成世界快照支持分页查询、详情查看checksum 一致性校验)、
更新、删除与恢复(事务写回 scene/entity失败整体回滚无脏数据
## 功能
- 世界快照表 `world_snapshot` 的 CRUDcreate/get/list/update/delete
- `create_snapshot`:聚合 world/scene/entity 当前状态写入 `snapshot_json`
- `restore_snapshot`:读取并校验快照 JSON标记已恢复联动 world 状态
- 下拉接口:世界列表、快照类型、快照状态
- 快照生成:同一事务批量聚合 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`models/world_snapshot.json 四段式)
- 编码字典:`snapshot_type`full/incremental`snapshot_status`active/restored/invalid
## 目录结构
`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 导出)
├── wwwroot/ # index.ui / menu.ui / api/*.dspy
├── world_snapshot/ # Python 包init.py 核心逻辑 + __init__.py
├── models/ # world_snapshot.json 表定义
├── json/ # world_snapshot.json CRUD 定义
├── init/ # data.json 编码字典种子
├── scripts/ # load_path.py RBAC 注册
├── 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
├── build.sh # 四步安装xls2ddl → ddl → crud ui → symlink
└── README.md
└── pyproject.toml
```
## 集成
- 通过 `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

View File

@ -0,0 +1,48 @@
# 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 冒烟

View File

@ -1,21 +1,13 @@
{
"appcodes": [
{
"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": "失效"}
]
}
]
}
"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": "增量快照"}
]}
]
}

View File

@ -1,31 +1,36 @@
{
"tblname": "world_snapshot",
"alias": "world_snapshot",
"title": "世界快照",
"params": {
"sortby": ["created_at desc"],
"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')}}"}
}
},
"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/snapshot_update.dspy')}}",
"delete_data_url": "{{entire_url('../api/snapshot_delete.dspy')}}"
}
}
}
"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": ""
}
}

View File

@ -1,29 +1,32 @@
{
"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": "快照内容", "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", "snapshot_date"]},
{"name": "idx_snapshot_list", "idxtype": "index", "idxfields": ["world_id", "status", "created_at"]}
],
"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'"}
]
}
"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'"}
]
}

View File

@ -5,6 +5,7 @@ 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"]

View File

@ -1,72 +1,57 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""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.
"""
"""world_snapshot 模块 RBAC 权限注册脚本W-04"""
import os
import subprocess
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)
def find_sage_root():
here = os.path.dirname(os.path.abspath(__file__))
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
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)
PATHS_ANY = [
'/world_snapshot/menu.ui',
'/world_snapshot/index.ui',
]
MOD = 'world_snapshot'
PATHS_LOGINED = [
'/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',
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',
]
def main():
sage_root = find_sage_root()
if not sage_root:
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
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')
if __name__ == '__main__':
sys.exit(main())
main()

View File

@ -1,65 +1,84 @@
---
name: world_snapshot
description: 世界状态快照模块W-03——聚合 world/scene/entity 当前状态生成快照,支持查询与恢复,与 world 表状态联动
description: 世界状态快照模块W-04 快照管理——world_snapshot 表checksum/version 递增)、快照生成(事务批量+checksum 一致性校验)、查询(分页 {list,total})、恢复(事务回滚无脏数据)、编码字典 snapshot_status/snapshot_type 幂等落库、REST /api/* 统一前缀与错误结构 {code,message,field,detail}
---
# world_snapshot 模块
# world_snapshot 模块W-04 快照管理)
世界状态快照模块W-03。聚合世界world+ 场景scene+ 实体entity当前状态
生成快照写入 `snapshot_json`,支持分页查询、单条查询、更新、删除与恢复,恢复时联动
world 表状态(经 world 模块 set_world_mode 契约,保持模式流转校验完整)。
## 架构
world_snapshot 是域内业务模块:聚合 world/scene/entity 当前状态生成快照,支持查询与恢复。
通过 `load_world_snapshot()` 挂载到宿主应用(唯一集成点),无独立 app.py/端口。
## 数据模型
`world_snapshot`models/world_snapshot.json 四段式 summary/fields/indexes/codes
- id VARCHAR(32) PKgetID() 生成,禁 uuid
- world_id VARCHAR(32) NOT NULLcodes → 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)
`world_snapshot`models/world_snapshot.json
编码字典init/data.json 幂等落库 `snapshot_type`full=全量快照/incremental=增量快照)
`snapshot_status`active=有效/restored=已恢复/invalid=失效appcodes + appcodes_kv 同插。
| 字段 | 类型 | 说明 |
|---|---|---|
| id | str(32) PK | 主键getID |
| world_id | str(32) | 世界ID关联 world |
| name | str(255) | 快照名称 |
| snapshot_type | str(32) | full / incremental |
| snapshot_json | text | 快照内容 JSONworld/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 | 审计时间 |
## 关键接口(经 load_world_snapshot() 注册到 ServerEnv
唯一索引 `uk_snap_world_version(world_id, version)` 保证同世界版本不重复。
- `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 与数据查询分离,
显式列清单(排除 TEXTP95 达标
- `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
编码字典init/data.json 幂等落库):
- `snapshot_status`active 生效中 / restored 已恢复 / expired 已过期
- `snapshot_type`full 全量快照 / incremental 增量快照
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}`
### 快照生成 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`。新增/删除函数必须三处同步。
## 陷阱
- 库名禁止硬编码:.py 用 `ServerEnv().get_module_dbname('world_snapshot')`.dspy 直接
`get_module_dbname('world_snapshot')`(预载全局)
- 无硬编码中文(错误消息用英文 codedspy 无 importjson/format_exc/debug/getID 等为预载全局)
- create_snapshot 必须设置 created_at/snapshot_datesqlor 不自动补时间戳,缺 created_at 的
sor.C 会静默丢记录)
- snapshot_date 用 date 类型(业务日期仅到天)
- restore_snapshot 对 world 状态联动用契约调用env.set_world_mode不直接改 world 表,
保证 mode 流转规则校验不被绕过
- 下拉接口返回纯数组 [{value,text}],错误时返回含"全部"的回退数组
## 依赖
- sqlorDBPools/sqlExe/sor.C/U/D/R
- ahserverServerEnv、get_module_dbname
- appbaseappcodes/appcodes_kv 编码字典)
- world / scene / entity聚合数据源经各自模块库名读取缺失时降级为空
- **取库名禁止硬编码**`.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'` 语法错误

View File

@ -1,27 +1,13 @@
# -*- coding: utf-8 -*-
"""world_snapshot package (W-03): world state snapshot management."""
"""world_snapshot Python 包W-04 快照管理)。"""
from .init import (
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,
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,
)
__all__ = [
'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',
'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',
]

View File

@ -1,11 +1,6 @@
# -*- coding: utf-8 -*-
"""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.
"""
"""world_snapshot module (W-04 snapshot management)."""
import hashlib
import json
from appPublic.timeUtils import curDateString
@ -14,13 +9,35 @@ from ahserver.serverenv import ServerEnv
from sqlor.dbpools import DBPools
SNAPSHOT_TYPES = ('full', 'incremental')
SNAPSHOT_STATUS_ACTIVE = 'active'
SNAPSHOT_STATUS_RESTORED = 'restored'
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 _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):
"""Convert a sqlor result row (DictObject) to a plain dict."""
if row is None:
return {}
try:
@ -33,274 +50,327 @@ def _to_dict(row):
return {k: getattr(row, k, None) for k in keys}
def _module_dbname(env, module):
async def _table_columns(sor, table):
try:
return env.get_module_dbname(module)
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 None
return set()
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})
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:
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 []
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):
"""Create a world snapshot: aggregate world/scene/entity current state."""
params = params or {}
world_id = params.get('world_id')
world_id = str(params.get('world_id') or '').strip()
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'
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 {'code': 'INVALID_SNAPSHOT_TYPE', 'message': 'snapshot_type must be full or incremental',
'field': 'snapshot_type', 'detail': snapshot_type}
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()
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()
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']}
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):
"""Get one snapshot record by id."""
params = params or {}
snapshot_id = params.get('id')
snapshot_id = str(params.get('id') or '').strip()
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}$')
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(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 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, 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 {'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(dbname) as sor:
await sor.U('world_snapshot', upd)
return {'success': True, 'id': snapshot_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(dbname) as sor:
await sor.D('world_snapshot', {'id': snapshot_id})
return {'success': True, 'id': snapshot_id}
async def restore_snapshot(params):
"""Validate snapshot json, mark snapshot restored, link world status.
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': ''}
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()
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}
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()
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 ''
env = ServerEnv()
dbname = env.get_module_dbname('world_snapshot')
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 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')
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
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')
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 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')
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 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')
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 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 []
dbname = env.get_module_dbname('world_snapshot')
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 def list_snapshot_types():
return await _get_appcodes('snapshot_type')
return [{'value': 'full', 'text': '全量快照'}, {'value': 'incremental', 'text': '增量快照'}]
async def list_snapshot_statuses():
return await _get_appcodes('snapshot_status')
return [{'value': 'active', 'text': '生效中'},
{'value': 'restored', 'text': '已恢复'},
{'value': 'expired', 'text': '已过期'}]
def load_world_snapshot(env=None):
"""Register all world_snapshot functions to ServerEnv."""
if env is None:
env = ServerEnv()
env.create_snapshot = create_snapshot

View File

@ -0,0 +1,4 @@
# 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)

View File

@ -1,8 +1,4 @@
# 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': ''}
# 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)

View File

@ -0,0 +1,4 @@
# 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)

View File

@ -0,0 +1,4 @@
# 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)

View File

@ -0,0 +1,4 @@
# 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)

View File

@ -0,0 +1,4 @@
# 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)

View File

@ -0,0 +1,4 @@
# 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)

View File

@ -1,8 +1,4 @@
# 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': ''}
# 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)

View File

@ -0,0 +1,4 @@
# 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)

View File

@ -1,13 +1,19 @@
{"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"}}]}
{
"widgettype": "VBox",
"options": {"width": "100%", "height": "100%", "padding": "20px"},
"subwidgets": [
{"widgettype": "Text", "options": {"label": "世界状态快照管理W-04", "fontSize": "22px", "marginBottom": "16px"}},
{"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", "id": "world_snapshot_content", "options": {"width": "100%", "flex": "1", "marginTop": "20px"}}
]
}

View File

@ -0,0 +1,16 @@
{
"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"}}]
}
]
}

View File

@ -0,0 +1,14 @@
{
"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"}}]
}
]
}