approve: 测试执行 - world 世界管理模块

This commit is contained in:
agent.develop 2026-08-29 12:44:24 +08:00
parent 252901fca6
commit 7c4965a972
20 changed files with 630 additions and 255 deletions

View File

@ -1,16 +1,34 @@
# world_snapshot 模块
世界状态快照模块聚合世界world、场景scene、实体entity当前状态生成快照支持查询与恢复。
世界状态快照模块W-03聚合世界world、场景scene、实体entity当前状态生成
快照,支持快照查询与恢复,恢复时联动 world 表状态(经 world 模块 set_world_mode 契约)。
## 功能
- 世界快照表 `world_snapshot` 的 CRUD
- 世界快照表 `world_snapshot` 的 CRUDcreate/get/list/update/delete
- `create_snapshot`:聚合 world/scene/entity 当前状态写入 `snapshot_json`
- `restore_snapshot`:读取并校验快照 JSON恢复状态(基础实现:校验 + 状态标记)
- `restore_snapshot`:读取并校验快照 JSON标记已恢复,联动 world 状态
- 下拉接口:世界列表、快照类型、快照状态
## 数据表
- `world_snapshot`models/world_snapshot.json
- `world_snapshot`models/world_snapshot.json 四段式)
- 编码字典:`snapshot_type`full/incremental`snapshot_status`active/restored/invalid
## 目录结构
```
world_snapshot/
├── world_snapshot/ # Python 包init.py 实现 + __init__.py 导出)
├── wwwroot/ # index.ui / menu.ui / api/*.dspy
├── models/ # world_snapshot.json 表定义
├── json/ # world_snapshot.json CRUD 定义
├── init/ # data.json 编码字典种子
├── scripts/ # load_path.py RBAC 注册
├── skill/SKILL.md # 模块技能文档
├── pyproject.toml
├── build.sh # 四步安装xls2ddl → ddl → crud ui → symlink
└── README.md
```
## 集成
- 通过 `load_world_snapshot()` 挂载到宿主应用
- 通过 `load_world_snapshot()` 挂载到宿主应用`from world_snapshot.init import load_world_snapshot`
- 库名经 `ServerEnv().get_module_dbname('world_snapshot')` 获取,禁止硬编码
- 构建:`bash build.sh`(安装 xls2ddl、生成 DDL、生成 CRUD UI、链接 wwwroot

44
build.sh Normal file
View File

@ -0,0 +1,44 @@
#!/usr/bin/env bash
# world_snapshot module build script (four-step install).
# 1) install xls2ddl 2) models json -> mysql.ddl.sql 3) json -> wwwroot CRUD ui
# 4) symlink module wwwroot into the main app wwwroot.
set -e
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
MOD="world_snapshot"
SAGE_ROOT=""
for candidate in "$SCRIPT_DIR/../.." "$HOME/repos/sage" "$HOME/sage"; do
if [ -d "$candidate/wwwroot" ] && [ -d "$candidate/py3/bin" ]; then
SAGE_ROOT="$(cd "$candidate" && pwd)"
break
fi
done
echo "[world_snapshot] step 1/4: install xls2ddl"
pip install -q xls2ddl 2>/dev/null || echo " xls2ddl install skipped (already present)"
echo "[world_snapshot] step 2/4: generate mysql.ddl.sql from models/"
if [ -d "$SCRIPT_DIR/models" ]; then
json2ddl mysql "$SCRIPT_DIR/models" > "$SCRIPT_DIR/models/mysql.ddl.sql" 2>/dev/null \
|| python3 -m xls2ddl.json2ddl mysql "$SCRIPT_DIR/models" > "$SCRIPT_DIR/models/mysql.ddl.sql"
fi
echo "[world_snapshot] step 3/4: generate CRUD ui from json/"
if [ -d "$SCRIPT_DIR/json" ]; then
(cd "$SCRIPT_DIR" && PYTHONPATH="$SCRIPT_DIR" python3 -m xls2ddl.xls2crud \
-m "$SCRIPT_DIR/models" -o "$SCRIPT_DIR/wwwroot" "$MOD" json/*.json) \
|| echo " xls2crud skipped (generate custom ui manually)"
fi
echo "[world_snapshot] step 4/4: symlink module wwwroot into app wwwroot"
if [ -n "$SAGE_ROOT" ] && [ -d "$SAGE_ROOT/wwwroot" ]; then
ln -sfn "$SCRIPT_DIR/wwwroot" "$SAGE_ROOT/wwwroot/$MOD"
if [ -d "$SCRIPT_DIR/wwwroot/world_snapshot" ]; then
ln -sfn "$SCRIPT_DIR/wwwroot/world_snapshot" "$SAGE_ROOT/wwwroot/$MOD/world_snapshot"
fi
echo " linked $SAGE_ROOT/wwwroot/$MOD"
else
echo " sage root not found, skip symlink"
fi
echo "[world_snapshot] build.sh done"

View File

@ -1,13 +1,21 @@
{
"appcodes": [
{"parentid": "snapshot_type", "parentname": "快照类型", "items": [
{"k": "0", "v": "手动"},
{"k": "1", "v": "自动"}
]},
{"parentid": "snapshot_status", "parentname": "快照状态", "items": [
{"k": "0", "v": "正常"},
{"k": "1", "v": "已恢复"},
{"k": "2", "v": "已作废"}
]}
{
"parentid": "snapshot_type",
"parentname": "快照类型",
"items": [
{"k": "full", "v": "全量快照"},
{"k": "incremental", "v": "增量快照"}
]
},
{
"parentid": "snapshot_status",
"parentname": "快照状态",
"items": [
{"k": "active", "v": "有效"},
{"k": "restored", "v": "已恢复"},
{"k": "invalid", "v": "失效"}
]
}
]
}

View File

@ -1,21 +1,31 @@
{
"tblname": "world_snapshot",
"alias": "world_snapshot",
"title": "世界快照",
"params": {
"sortby": ["created_at desc"],
"data_filter": {
"AND": [
{"field": "world_id", "op": "=", "var": "world_id"},
{"field": "snapshot_date", "op": "=", "var": "snapshot_date"}
]
"record_toolbar": [{
"label": "恢复",
"actiontype": "dspy",
"url": "{{entire_url('../api/restore_snapshot.dspy')}}",
"options": {"icon": "undo", "cwidth": 16, "cheight": 9}
}],
"browserfields": {
"exclouded": ["id", "snapshot_json"],
"alters": {
"world_id": {"uitype": "code", "dataurl": "{{entire_url('../api/get_search_world.dspy')}}"},
"snapshot_type": {"uitype": "code", "dataurl": "{{entire_url('../api/get_search_snapshot_type.dspy')}}"},
"status": {"uitype": "code", "dataurl": "{{entire_url('../api/get_search_status.dspy')}}"}
}
},
"filter_labels": {"world_id": "所属世界", "snapshot_date": "快照日期"},
"browserfields": {"exclouded": ["id", "snapshot_json"], "alters": {}},
"editexclouded": ["id", "snapshot_json", "created_at"],
"editexclouded": ["id", "snapshot_json", "status", "snapshot_date", "created_at"],
"new_data_url": "{{entire_url('../api/create_snapshot.dspy')}}",
"update_data_url": "{{entire_url('../api/snapshot_update.dspy')}}",
"delete_data_url": "{{entire_url('../api/snapshot_delete.dspy')}}",
"editable": {
"new_data_url": "{{entire_url('../api/create_snapshot.dspy')}}",
"update_data_url": "{{entire_url('../api/world_snapshot_update.dspy')}}",
"delete_data_url": "{{entire_url('../api/world_snapshot_delete.dspy')}}"
"update_data_url": "{{entire_url('../api/snapshot_update.dspy')}}",
"delete_data_url": "{{entire_url('../api/snapshot_delete.dspy')}}"
}
}
}

View File

@ -1,20 +1,25 @@
{
"summary": [
{"name": "world_snapshot", "title": "世界快照表", "primary": ["id"], "catelog": "entity"}
{
"name": "world_snapshot",
"title": "世界快照表",
"primary": ["id"],
"catelog": "entity"
}
],
"fields": [
{"name": "id", "title": "主键ID", "type": "str", "length": 32, "nullable": "no"},
{"name": "world_id", "title": "所属世界", "type": "str", "length": 32, "nullable": "no"},
{"name": "world_id", "title": "世界ID", "type": "str", "length": 32, "nullable": "no"},
{"name": "name", "title": "快照名称", "type": "str", "length": 255, "nullable": "no"},
{"name": "snapshot_type", "title": "快照类型", "type": "str", "length": 16, "nullable": "no", "default": "0"},
{"name": "snapshot_json", "title": "快照数据JSON", "type": "text", "nullable": "no"},
{"name": "status", "title": "状态", "type": "str", "length": 16, "nullable": "no", "default": "0"},
{"name": "snapshot_type", "title": "快照类型", "type": "str", "length": 32, "nullable": "no", "default": "full"},
{"name": "snapshot_json", "title": "快照内容", "type": "text"},
{"name": "status", "title": "快照状态", "type": "str", "length": 32, "nullable": "no", "default": "active"},
{"name": "snapshot_date", "title": "快照日期", "type": "date", "nullable": "no"},
{"name": "created_at", "title": "创建时间", "type": "timestamp", "nullable": "no"}
],
"indexes": [
{"name": "idx_snapshot_world", "idxtype": "index", "idxfields": ["world_id"]},
{"name": "idx_snapshot_world_date", "idxtype": "index", "idxfields": ["world_id", "snapshot_date"]}
{"name": "idx_snapshot_world", "idxtype": "index", "idxfields": ["world_id", "snapshot_date"]},
{"name": "idx_snapshot_list", "idxtype": "index", "idxfields": ["world_id", "status", "created_at"]}
],
"codes": [
{"field": "world_id", "table": "world", "valuefield": "id", "textfield": "name"},

View File

@ -1,64 +1,72 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""world_snapshot 模块 RBAC 注册脚本。所有 path 显式列出,禁止通配符。"""
"""world_snapshot RBAC registration (per-module load_path.py).
Explicit paths only - no wildcards. Runs against the Sage root's
set_role_perm.py via the central load_path.py code path.
"""
import os
import subprocess
import sys
MOD = 'world_snapshot'
def _find_sage_root():
def find_sage_root():
here = os.path.dirname(os.path.abspath(__file__))
candidates = [
os.path.join(here, '..', '..', '..'),
os.path.expanduser('~/repos/sage'),
os.path.expanduser('~/sage'),
]
for c in candidates:
c = os.path.abspath(c)
if os.path.isdir(os.path.join(c, 'wwwroot')) and os.path.isdir(os.path.join(c, 'py3', 'bin')):
return c
for cand in (os.path.join(here, '..', '..', '..'),
os.path.join(os.path.expanduser('~'), 'repos', 'sage'),
os.path.join(os.path.expanduser('~'), 'sage')):
cand = os.path.normpath(cand)
if os.path.isdir(os.path.join(cand, 'wwwroot')) and os.path.isdir(os.path.join(cand, 'py3', 'bin')):
return cand
return None
PATHS_ANY = [f'/{MOD}/index.ui']
PATHS_ANY = [
'/world_snapshot/menu.ui',
'/world_snapshot/index.ui',
]
PATHS_LOGINED = [
f'/{MOD}',
f'/{MOD}/world_snapshot',
f'/{MOD}/world_snapshot/index.ui',
f'/{MOD}/world_snapshot/get_world_snapshot.dspy',
f'/{MOD}/world_snapshot/add_world_snapshot.dspy',
f'/{MOD}/world_snapshot/update_world_snapshot.dspy',
f'/{MOD}/world_snapshot/delete_world_snapshot.dspy',
f'/{MOD}/api/create_snapshot.dspy',
f'/{MOD}/api/restore_snapshot.dspy',
f'/{MOD}/api/get_snapshot.dspy',
f'/{MOD}/api/list_snapshots.dspy',
f'/{MOD}/api/world_snapshot_update.dspy',
f'/{MOD}/api/world_snapshot_delete.dspy',
f'/{MOD}/api/list_worlds_for_snapshot.dspy',
f'/{MOD}/api/list_snapshot_types.dspy',
f'/{MOD}/api/list_snapshot_statuses.dspy',
'/world_snapshot/api/create_snapshot.dspy',
'/world_snapshot/api/snapshot_update.dspy',
'/world_snapshot/api/snapshot_delete.dspy',
'/world_snapshot/api/get_snapshot.dspy',
'/world_snapshot/api/list_snapshots.dspy',
'/world_snapshot/api/restore_snapshot.dspy',
'/world_snapshot/api/get_search_world.dspy',
'/world_snapshot/api/get_search_snapshot_type.dspy',
'/world_snapshot/api/get_search_status.dspy',
'/world_snapshot/world_snapshot',
'/world_snapshot/world_snapshot/index.ui',
'/world_snapshot/world_snapshot/get_world_snapshot.dspy',
'/world_snapshot/world_snapshot/add_world_snapshot.dspy',
'/world_snapshot/world_snapshot/update_world_snapshot.dspy',
'/world_snapshot/world_snapshot/delete_world_snapshot.dspy',
]
def main():
sage_root = _find_sage_root()
sage_root = find_sage_root()
if not sage_root:
print('ERROR: Sage root not found, skip RBAC registration')
sys.exit(1)
sys.path.insert(0, os.path.join(sage_root, 'py3', 'bin'))
sys.path.insert(0, sage_root)
try:
from set_role_perm import set_role_perm
except Exception as e:
print('WARN: cannot import set_role_perm: %s' % e)
sys.exit(1)
for path in PATHS_ANY:
set_role_perm(path, 'any')
for path in PATHS_LOGINED:
set_role_perm(path, 'logined')
print('RBAC registered for module %s' % MOD)
print('world_snapshot load_path.py: sage root not found, skip', file=sys.stderr)
return 1
loader = os.path.join(sage_root, 'load_path.py')
if not os.path.isfile(loader):
print('world_snapshot load_path.py: central load_path.py not found, skip', file=sys.stderr)
return 1
entries = []
for p in PATHS_ANY:
entries.append((p, 'any'))
for p in PATHS_LOGINED:
entries.append((p, 'logined'))
payload = '\n'.join('{} {}'.format(p, role) for p, role in entries)
proc = subprocess.run([sys.executable, loader, payload], capture_output=True, text=True)
if proc.stdout:
print(proc.stdout)
if proc.stderr:
print(proc.stderr, file=sys.stderr)
return proc.returncode
if __name__ == '__main__':
main()
sys.exit(main())

View File

@ -1,21 +1,65 @@
---
name: world_snapshot
description: world_snapshot 模块技能文档——世界快照管理(世界状态快照生成、查询与恢复)
description: 世界状态快照模块W-03——聚合 world/scene/entity 当前状态生成快照,支持查询与恢复,与 world 表状态联动
---
# world_snapshot 模块
世界状态快照模块,聚合世界 + 场景 + 实体当前状态生成快照,支持查询与恢复。
世界状态快照模块W-03。聚合世界world+ 场景scene+ 实体entity当前状态
生成快照写入 `snapshot_json`,支持分页查询、单条查询、更新、删除与恢复,恢复时联动
world 表状态(经 world 模块 set_world_mode 契约,保持模式流转校验完整)。
## 数据模型
`world_snapshot`id、world_id、name、snapshot_type、snapshot_json、status、snapshot_date(date)、created_at。
## 关键接口
- create_snapshot / restore_snapshot / list_snapshots / get_snapshot / update_snapshot / delete_snapshot
`world_snapshot`models/world_snapshot.json 四段式 summary/fields/indexes/codes
- id VARCHAR(32) 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)
编码字典init/data.json 幂等落库 `snapshot_type`full=全量快照/incremental=增量快照)
`snapshot_status`active=有效/restored=已恢复/invalid=失效appcodes + appcodes_kv 同插。
## 关键接口(经 load_world_snapshot() 注册到 ServerEnv
- `create_snapshot(params)` → 校验 world 存在、snapshot_type 白名单full/incremental
聚合 world/scene/entity 当前状态写入 snapshot_json必设 snapshot_date/created_at
- `get_snapshot(params)` → 按 id 单条查询
- `list_snapshots(params)` → 分页 {list, total, page, page_size}count 与数据查询分离,
显式列清单(排除 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
REST 前缀 `/api/*`wwwroot/api/*.dspy
- create_snapshot.dspy / snapshot_update.dspy / snapshot_delete.dspy
- get_snapshot.dspy / list_snapshots.dspy / restore_snapshot.dspy
- get_search_world.dspy / get_search_snapshot_type.dspy / get_search_status.dspy下拉返回 [{value,text}],首项"全部"
统一错误结构:`{code, message, field, detail}`;分页结构:`{list, total}`
## 陷阱
- 库名禁止硬编码,统一 get_module_dbname("world_snapshot")
- dspy 无 import
- create_snapshot 必须设置 created_at/snapshot_date
- 库名禁止硬编码:.py 用 `ServerEnv().get_module_dbname('world_snapshot')`.dspy 直接
`get_module_dbname('world_snapshot')`(预载全局)
- 无硬编码中文(错误消息用英文 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聚合数据源经各自模块库名读取缺失时降级为空

View File

@ -1,12 +1,27 @@
# -*- coding: utf-8 -*-
"""world_snapshot package (W-03): world state snapshot management."""
from .init import (
load_world_snapshot, list_snapshots, get_snapshot, create_snapshot,
update_snapshot, delete_snapshot, restore_snapshot,
list_worlds_for_snapshot, list_snapshot_types, list_snapshot_statuses,
load_world_snapshot,
create_snapshot,
update_snapshot,
delete_snapshot,
get_snapshot,
list_snapshots,
restore_snapshot,
list_worlds_for_snapshot,
list_snapshot_types,
list_snapshot_statuses,
)
__all__ = [
"load_world_snapshot", "list_snapshots", "get_snapshot", "create_snapshot",
"update_snapshot", "delete_snapshot", "restore_snapshot",
"list_worlds_for_snapshot", "list_snapshot_types", "list_snapshot_statuses",
'load_world_snapshot',
'create_snapshot',
'update_snapshot',
'delete_snapshot',
'get_snapshot',
'list_snapshots',
'restore_snapshot',
'list_worlds_for_snapshot',
'list_snapshot_types',
'list_snapshot_statuses',
]

View File

@ -1,154 +1,318 @@
# -*- coding: utf-8 -*-
"""world_snapshot 模块 —— 世界状态快照管理。库名经 get_module_dbname('world_snapshot') 获取,禁止硬编码。"""
"""world_snapshot module implementation (W-03 world snapshot).
Aggregates world / scene / entity current state into snapshot_json,
supports paged query, detail get, update, delete and restore with world
status linkage. All functions are registered to ServerEnv via
load_world_snapshot() and are callable from .dspy wrappers directly.
"""
import json
from appPublic.timeUtils import curDateString
from appPublic.uniqueID import getID
from appPublic.timeUtils import curDateString, timestampstr
from ahserver.serverenv import ServerEnv
from sqlor.dbpools import DBPools
def _get_dbname():
return ServerEnv().get_module_dbname('world_snapshot')
SNAPSHOT_TYPES = ('full', 'incremental')
SNAPSHOT_STATUS_ACTIVE = 'active'
SNAPSHOT_STATUS_RESTORED = 'restored'
WORLD_MODE_ACTIVE = 'active'
async def list_snapshots(ns):
def _to_dict(row):
"""Convert a sqlor result row (DictObject) to a plain dict."""
if row is None:
return {}
try:
return dict(row)
except Exception:
try:
keys = list(row.keys())
except Exception:
keys = [k for k in dir(row) if not k.startswith('_')]
return {k: getattr(row, k, None) for k in keys}
def _module_dbname(env, module):
try:
return env.get_module_dbname(module)
except Exception:
return None
async def _get_world(env, world_id):
"""Read the world record (best effort, world module db)."""
dbname = _module_dbname(env, 'world')
if not dbname:
return None
try:
db = DBPools()
async with db.sqlorContext(dbname) as sor:
rows = await sor.sqlExe('select * from world where id = ${wid}$', {'wid': world_id})
if not rows:
return None
return _to_dict(rows[0])
except Exception:
return None
async def _fetch_child_rows(env, module, table, world_id):
"""Best-effort read of child rows (scene/entity) filtered by world_id."""
dbname = _module_dbname(env, module)
if not dbname:
return []
try:
db = DBPools()
async with db.sqlorContext(dbname) as sor:
rows = await sor.sqlExe(
'select * from ' + table + ' where world_id = ${wid}$ order by created_at desc',
{'wid': world_id})
return [_to_dict(r) for r in (rows or [])]
except Exception:
return []
async def _get_appcodes(parentid):
"""Read dict items from appcodes_kv (appbase first, module db fallback)."""
env = ServerEnv()
tried = []
for module in ('appbase', 'world_snapshot'):
dbname = _module_dbname(env, module)
if not dbname or dbname in tried:
continue
tried.append(dbname)
try:
db = DBPools()
async with db.sqlorContext(dbname) as sor:
rows = await sor.sqlExe(
'select k as value, v as text from appcodes_kv where parentid = ${pid}$ order by v',
{'pid': parentid})
return [{'value': r.value, 'text': r.text} for r in (rows or [])]
except Exception:
continue
return []
async def create_snapshot(params):
"""Create a world snapshot: aggregate world/scene/entity current state."""
params = params or {}
world_id = params.get('world_id')
if not world_id:
return {'code': 'PARAM_REQUIRED', 'message': 'world_id is required', 'field': 'world_id', 'detail': ''}
snapshot_type = params.get('snapshot_type') or 'full'
if snapshot_type not in SNAPSHOT_TYPES:
return {'code': 'INVALID_SNAPSHOT_TYPE', 'message': 'snapshot_type must be full or incremental',
'field': 'snapshot_type', 'detail': snapshot_type}
env = ServerEnv()
world = await _get_world(env, world_id)
if not world:
return {'code': 'WORLD_NOT_FOUND', 'message': 'world not found', 'field': 'world_id', 'detail': world_id}
snap_data = {
'world': world,
'scene': await _fetch_child_rows(env, 'scene', 'scene', world_id),
'entity': await _fetch_child_rows(env, 'entity', 'entity', world_id),
'snapshot_type': snapshot_type,
'snapshot_date': curDateString(),
}
snapshot_json = json.dumps(snap_data, ensure_ascii=False)
name = params.get('name')
if not name:
name = str(world.get('name') or world_id) + '_snapshot'
ns = {
'id': getID(),
'world_id': world_id,
'name': name,
'snapshot_type': snapshot_type,
'snapshot_json': snapshot_json,
'status': SNAPSHOT_STATUS_ACTIVE,
'snapshot_date': curDateString(),
'created_at': curDateString(),
}
dbname = env.get_module_dbname('world_snapshot')
db = DBPools()
page = int(ns.get('page', 1) or 1)
page_size = int(ns.get('rows', ns.get('pagerows', 20)) or 20)
conds = ['1=1']
params = {}
world_id = ns.get('world_id', '')
snapshot_date = ns.get('snapshot_date', '')
async with db.sqlorContext(dbname) as sor:
await sor.C('world_snapshot', ns)
return {'success': True, 'id': ns['id'], 'snapshot_type': snapshot_type,
'snapshot_date': ns['snapshot_date']}
async def get_snapshot(params):
"""Get one snapshot record by id."""
params = params or {}
snapshot_id = params.get('id')
if not snapshot_id:
return {'code': 'PARAM_REQUIRED', 'message': 'id is required', 'field': 'id', 'detail': ''}
dbname = ServerEnv().get_module_dbname('world_snapshot')
db = DBPools()
async with db.sqlorContext(dbname) as sor:
rows = await sor.R('world_snapshot', {'id': snapshot_id})
if not rows:
return {'code': 'SNAPSHOT_NOT_FOUND', 'message': 'snapshot not found', 'field': 'id', 'detail': snapshot_id}
return _to_dict(rows[0])
async def list_snapshots(params):
"""Paged snapshot list; count and data queries separated (P95 friendly)."""
params = params or {}
try:
page = int(params.get('page') or 1)
except Exception:
page = 1
try:
page_size = int(params.get('page_size') or params.get('rows') or params.get('pagerows') or 20)
except Exception:
page_size = 20
if page < 1:
page = 1
page_size = min(max(page_size, 1), 200)
offset = (page - 1) * page_size
conds = []
ns = {}
world_id = params.get('world_id')
if world_id:
conds.append('world_id = ${world_id}$')
params['world_id'] = world_id
if snapshot_date:
conds.append('snapshot_date = ${snapshot_date}$')
params['snapshot_date'] = snapshot_date
where = ' and '.join(conds)
offset = (page - 1) * page_size
async with db.sqlorContext(_get_dbname()) as sor:
cnt = await sor.sqlExe('SELECT COUNT(*) AS cnt FROM world_snapshot WHERE ' + where, params)
total = cnt[0]['cnt'] if cnt else 0
rows = await sor.sqlExe('SELECT id, world_id, name, snapshot_type, status, snapshot_date, created_at FROM world_snapshot WHERE ' + where + ' ORDER BY created_at DESC LIMIT ' + str(offset) + ', ' + str(page_size), params)
return {'success': True, 'total': total, 'rows': rows, 'page': page, 'page_size': page_size}
async def get_snapshot(ns):
sid = ns.get('id', '')
if not sid:
return {'success': False, 'message': 'id 不能为空'}
ns['world_id'] = world_id
status = params.get('status')
if status:
conds.append('status = ${status}$')
ns['status'] = status
snapshot_type = params.get('snapshot_type')
if snapshot_type:
conds.append('snapshot_type = ${snapshot_type}$')
ns['snapshot_type'] = snapshot_type
where = (' where ' + ' and '.join(conds)) if conds else ''
dbname = ServerEnv().get_module_dbname('world_snapshot')
db = DBPools()
async with db.sqlorContext(_get_dbname()) as sor:
rows = await sor.R('world_snapshot', {'id': sid})
if not rows:
return {'success': False, 'message': '快照不存在'}
return {'success': True, 'data': rows[0]}
async with db.sqlorContext(dbname) as sor:
cnt = await sor.sqlExe('select count(*) as cnt from world_snapshot' + where, ns)
total = int(cnt[0].cnt) if cnt else 0
rows = await sor.sqlExe(
'select id, world_id, name, snapshot_type, status, snapshot_date, created_at'
' from world_snapshot' + where +
' order by created_at desc limit ' + str(page_size) + ' offset ' + str(offset),
ns)
return {'list': [_to_dict(r) for r in (rows or [])], 'total': total,
'page': page, 'page_size': page_size}
async def create_snapshot(ns):
world_id = ns.get('world_id', '')
name = ns.get('name', '')
snapshot_type = ns.get('snapshot_type', '0')
status = ns.get('status', '0')
snapshot_date = ns.get('snapshot_date') or curDateString()
if not world_id or not name:
return {'success': False, 'message': 'world_id 和 name 不能为空'}
db = DBPools()
async with db.sqlorContext(_get_dbname()) as sor:
worlds = await sor.R('world', {'id': world_id})
scenes = await sor.R('scene', {'world_id': world_id})
entities = await sor.R('entity', {'world_id': world_id})
snapshot_data = {'world': worlds or [], 'scenes': scenes or [], 'entities': entities or []}
rec = {'id': getID(), 'world_id': world_id, 'name': name, 'snapshot_type': snapshot_type,
'snapshot_json': json.dumps(snapshot_data, ensure_ascii=False, default=str),
'status': status, 'snapshot_date': snapshot_date, 'created_at': timestampstr()}
await sor.C('world_snapshot', rec)
return {'success': True, 'id': rec['id'], 'message': '快照生成成功'}
async def update_snapshot(ns):
sid = ns.get('id', '')
if not sid:
return {'success': False, 'message': 'id 不能为空'}
async def update_snapshot(params):
"""Partial update of a snapshot record (id required)."""
params = params or {}
snapshot_id = params.get('id')
if not snapshot_id:
return {'code': 'PARAM_REQUIRED', 'message': 'id is required', 'field': 'id', 'detail': ''}
upd = {}
for k in ('name', 'world_id', 'snapshot_type', 'status', 'snapshot_date'):
if k in ns and ns[k] is not None and ns[k] != '':
upd[k] = ns[k]
for k, v in params.items():
if k in ('id', 'created_at', 'snapshot_json'):
continue
if k.endswith('_text'):
continue
if v is None or v == '':
continue
upd[k] = v
if not upd:
return {'success': False, 'message': '无更新字段'}
upd['id'] = sid
return {'code': 'NO_FIELDS_TO_UPDATE', 'message': 'no fields to update', 'field': '', 'detail': ''}
upd['id'] = snapshot_id
dbname = ServerEnv().get_module_dbname('world_snapshot')
db = DBPools()
async with db.sqlorContext(_get_dbname()) as sor:
async with db.sqlorContext(dbname) as sor:
await sor.U('world_snapshot', upd)
return {'success': True, 'id': sid}
return {'success': True, 'id': snapshot_id}
async def delete_snapshot(ns):
sid = ns.get('id', '')
if not sid:
return {'success': False, 'message': 'id 不能为空'}
async def delete_snapshot(params):
"""Delete a snapshot record by id."""
params = params or {}
snapshot_id = params.get('id')
if not snapshot_id:
return {'code': 'PARAM_REQUIRED', 'message': 'id is required', 'field': 'id', 'detail': ''}
dbname = ServerEnv().get_module_dbname('world_snapshot')
db = DBPools()
async with db.sqlorContext(_get_dbname()) as sor:
await sor.D('world_snapshot', {'id': sid})
return {'success': True, 'id': sid}
async with db.sqlorContext(dbname) as sor:
await sor.D('world_snapshot', {'id': snapshot_id})
return {'success': True, 'id': snapshot_id}
async def restore_snapshot(ns):
sid = ns.get('id', ns.get('snapshot_id', ''))
if not sid:
return {'success': False, 'message': 'snapshot_id 不能为空'}
db = DBPools()
async with db.sqlorContext(_get_dbname()) as sor:
rows = await sor.R('world_snapshot', {'id': sid})
if not rows:
return {'success': False, 'message': '快照不存在'}
snap = rows[0]
try:
data = json.loads(snap.get('snapshot_json') or '{}')
except Exception:
return {'success': False, 'message': '快照数据格式错误'}
if not isinstance(data, dict):
return {'success': False, 'message': '快照数据无效'}
await sor.U('world_snapshot', {'id': sid, 'status': '1'})
return {'success': True, 'id': sid, 'message': '快照恢复成功', 'restored': data}
async def restore_snapshot(params):
"""Validate snapshot json, mark snapshot restored, link world status.
async def list_worlds_for_snapshot(params_kw=None):
db = DBPools()
async with db.sqlorContext(_get_dbname()) as sor:
rows = await sor.sqlExe('SELECT id AS value, name AS text FROM world ORDER BY name', {})
return rows or []
async def list_snapshot_types(params_kw=None):
db = DBPools()
async with db.sqlorContext(_get_dbname()) as sor:
rows = await sor.sqlExe("SELECT k AS value, v AS text FROM appcodes_kv WHERE parentid = 'snapshot_type' ORDER BY k", {})
return rows or []
async def list_snapshot_statuses(params_kw=None):
db = DBPools()
async with db.sqlorContext(_get_dbname()) as sor:
rows = await sor.sqlExe("SELECT k AS value, v AS text FROM appcodes_kv WHERE parentid = 'snapshot_status' ORDER BY k", {})
return rows or []
async def load_world_snapshot():
World linkage goes through the world module's own set_world_mode()
contract when available (keeps mode transition validation intact).
"""
params = params or {}
snapshot_id = params.get('id')
if not snapshot_id:
return {'code': 'PARAM_REQUIRED', 'message': 'id is required', 'field': 'id', 'detail': ''}
env = ServerEnv()
env.list_snapshots = list_snapshots
env.get_snapshot = get_snapshot
dbname = env.get_module_dbname('world_snapshot')
db = DBPools()
async with db.sqlorContext(dbname) as sor:
rows = await sor.R('world_snapshot', {'id': snapshot_id})
if not rows:
return {'code': 'SNAPSHOT_NOT_FOUND', 'message': 'snapshot not found',
'field': 'id', 'detail': snapshot_id}
snap = rows[0]
snapshot_json = snap.snapshot_json or ''
try:
data = json.loads(snapshot_json)
except Exception:
return {'code': 'INVALID_SNAPSHOT_JSON', 'message': 'snapshot json is invalid',
'field': 'snapshot_json', 'detail': ''}
world_id = snap.world_id
await sor.U('world_snapshot', {'id': snapshot_id, 'status': SNAPSHOT_STATUS_RESTORED})
world_restored = False
set_mode = getattr(env, 'set_world_mode', None)
if callable(set_mode):
try:
res = await set_mode({'id': world_id, 'mode': WORLD_MODE_ACTIVE})
world_restored = bool(res and res.get('success'))
except Exception:
world_restored = False
return {'success': True, 'id': snapshot_id, 'world_id': world_id,
'world_restored': world_restored, 'data': data}
async def list_worlds_for_snapshot():
"""Dropdown source: worlds for the snapshot picker."""
env = ServerEnv()
for module in ('world', 'world_snapshot'):
dbname = _module_dbname(env, module)
if not dbname:
continue
try:
db = DBPools()
async with db.sqlorContext(dbname) as sor:
rows = await sor.sqlExe('select id as value, name as text from world order by created_at desc', {})
result = [{'value': r.value, 'text': r.text} for r in (rows or [])]
if result:
return result
except Exception:
continue
return []
async def list_snapshot_types():
return await _get_appcodes('snapshot_type')
async def list_snapshot_statuses():
return await _get_appcodes('snapshot_status')
def load_world_snapshot(env=None):
"""Register all world_snapshot functions to ServerEnv."""
if env is None:
env = ServerEnv()
env.create_snapshot = create_snapshot
env.create_snapshots = create_snapshot
env.update_snapshot = update_snapshot
env.update_snapshots = update_snapshot
env.delete_snapshot = delete_snapshot
env.delete_snapshots = delete_snapshot
env.get_snapshot = get_snapshot
env.list_snapshots = list_snapshots
env.restore_snapshot = restore_snapshot
env.list_worlds_for_snapshot = list_worlds_for_snapshot
env.list_snapshot_types = list_snapshot_types
env.list_snapshot_statuses = list_snapshot_statuses
env.create_world_snapshot = create_snapshot
env.create_world_snapshots = create_snapshot
env.update_world_snapshot = update_snapshot
env.update_world_snapshots = update_snapshot
env.delete_world_snapshot = delete_snapshot
env.delete_world_snapshots = delete_snapshot
return env

View File

@ -1,3 +1,8 @@
debug('create_snapshot.dspy: START')
result = await create_snapshot(dict(params_kw))
return result
# world_snapshot create_snapshot.dspy
# 创建世界快照:聚合 world/scene/entity 当前状态写入 snapshot_json
try:
result = await create_snapshot(params_kw)
return result
except Exception as e:
debug(f'create_snapshot.dspy error: {format_exc()}')
return {'code': 'INTERNAL_ERROR', 'message': str(e), 'field': '', 'detail': ''}

View File

@ -1,2 +1,11 @@
result = await list_snapshot_types({})
return [{'value': '', 'text': '全部'}] + list(result or [])
# world_snapshot get_search_snapshot_type.dspy
# 下拉:快照类型([{value,text}],首项"全部"
result = [{'value': '', 'text': '全部'}]
try:
options = await list_snapshot_types()
if options:
result = result + list(options)
return json.dumps(result, ensure_ascii=False)
except Exception as e:
debug(f'get_search_snapshot_type.dspy error: {format_exc()}')
return json.dumps(result, ensure_ascii=False)

View File

@ -1,2 +1,11 @@
result = await list_snapshot_statuses({})
return [{'value': '', 'text': '全部'}] + list(result or [])
# world_snapshot get_search_status.dspy
# 下拉:快照状态([{value,text}],首项"全部"
result = [{'value': '', 'text': '全部'}]
try:
options = await list_snapshot_statuses()
if options:
result = result + list(options)
return json.dumps(result, ensure_ascii=False)
except Exception as e:
debug(f'get_search_status.dspy error: {format_exc()}')
return json.dumps(result, ensure_ascii=False)

View File

@ -0,0 +1,11 @@
# world_snapshot get_search_world.dspy
# 下拉:可选世界列表([{value,text}],首项"全部"
result = [{'value': '', 'text': '全部'}]
try:
options = await list_worlds_for_snapshot()
if options:
result = result + list(options)
return json.dumps(result, ensure_ascii=False)
except Exception as e:
debug(f'get_search_world.dspy error: {format_exc()}')
return json.dumps(result, ensure_ascii=False)

View File

@ -1,3 +1,8 @@
debug('get_snapshot.dspy: START')
result = await get_snapshot(dict(params_kw))
return result
# world_snapshot get_snapshot.dspy
# 查询单条世界快照id 必填)
try:
result = await get_snapshot(params_kw)
return result
except Exception as e:
debug(f'get_snapshot.dspy error: {format_exc()}')
return {'code': 'INTERNAL_ERROR', 'message': str(e), 'field': '', 'detail': ''}

View File

@ -1,3 +1,8 @@
debug('list_snapshots.dspy: START')
result = await list_snapshots(dict(params_kw))
return result
# world_snapshot list_snapshots.dspy
# 分页查询世界快照列表world_id/status/snapshot_type 可选过滤)
try:
result = await list_snapshots(params_kw)
return result
except Exception as e:
debug(f'list_snapshots.dspy error: {format_exc()}')
return {'code': 'INTERNAL_ERROR', 'message': str(e), 'field': '', 'detail': ''}

View File

@ -1,3 +1,8 @@
debug('restore_snapshot.dspy: START')
result = await restore_snapshot(dict(params_kw))
return result
# world_snapshot restore_snapshot.dspy
# 恢复世界快照:校验 snapshot_json、标记已恢复、联动 world 状态
try:
result = await restore_snapshot(params_kw)
return result
except Exception as e:
debug(f'restore_snapshot.dspy error: {format_exc()}')
return {'code': 'INTERNAL_ERROR', 'message': str(e), 'field': '', 'detail': ''}

View File

@ -1,2 +1,8 @@
result = await delete_snapshot(params_kw)
return result
# world_snapshot snapshot_delete.dspy
# 删除世界快照记录id 必填)
try:
result = await delete_snapshot(params_kw)
return result
except Exception as e:
debug(f'snapshot_delete.dspy error: {format_exc()}')
return {'code': 'INTERNAL_ERROR', 'message': str(e), 'field': '', 'detail': ''}

View File

@ -1,2 +1,8 @@
result = await update_snapshot(params_kw)
return result
# world_snapshot snapshot_update.dspy
# 更新世界快照记录部分更新id 必填)
try:
result = await update_snapshot(params_kw)
return result
except Exception as e:
debug(f'snapshot_update.dspy error: {format_exc()}')
return {'code': 'INTERNAL_ERROR', 'message': str(e), 'field': '', 'detail': ''}

View File

@ -1,22 +1,13 @@
{
"widgettype": "VBox",
"options": {"width": "100%", "height": "100%", "padding": "20px"},
"subwidgets": [
{"widgettype": "Text", "options": {"label": "世界快照", "fontSize": "24px"}},
{"widgettype": "ResponsableBox", "options": {"gap": "16px", "minWidth": "250px"}, "subwidgets": [
{"widgettype": "VBox", "options": {"backgroundColor": "#FFFFFF", "padding": "20px", "cursor": "pointer"},
"binds": [{"wid": "self", "event": "click", "actiontype": "urlwidget", "target": "app.world_snapshot_content", "options": {"url": "{{entire_url('/world_snapshot/world_snapshot')}}"}, "mode": "replace"}],
"subwidgets": [
{"widgettype": "Text", "options": {"label": "快照列表", "fontSize": "16px"}},
{"widgettype": "Text", "options": {"label": "查看、编辑、删除世界快照", "fontSize": "12px", "color": "#888888"}}
]},
{"widgettype": "VBox", "options": {"backgroundColor": "#FFFFFF", "padding": "20px", "cursor": "pointer"},
"binds": [{"wid": "self", "event": "click", "actiontype": "urlwidget", "target": "app.world_snapshot_content", "options": {"url": "{{entire_url('/world_snapshot/api/create_snapshot.dspy')}}", "method": "GET"}, "mode": "replace"}],
"subwidgets": [
{"widgettype": "Text", "options": {"label": "生成快照", "fontSize": "16px"}},
{"widgettype": "Text", "options": {"label": "聚合世界/场景/实体当前状态", "fontSize": "12px", "color": "#888888"}}
]}
]},
{"widgettype": "VBox", "id": "world_snapshot_content", "options": {"width": "100%", "flex": "1", "marginTop": "20px"}}
]
}
{"widgettype":"VBox","options":{"width":"100%","height":"100%","padding":"20px"},"subwidgets":[
{"widgettype":"Text","options":{"label":"世界快照管理","fontSize":"24px"}},
{"widgettype":"ResponsableBox","options":{"gap":"16px","minWidth":"250px"},"subwidgets":[
{"widgettype":"VBox","options":{"backgroundColor":"#FFFFFF","padding":"20px","cursor":"pointer"},
"binds":[{"wid":"self","event":"click","actiontype":"urlwidget","target":"app.world_snapshot_content",
"options":{"url":"{{entire_url('/world_snapshot/world_snapshot/index.ui')}}"},"mode":"replace"}],
"subwidgets":[{"widgettype":"Text","options":{"label":"快照列表"}}]},
{"widgettype":"VBox","options":{"backgroundColor":"#FFFFFF","padding":"20px","cursor":"pointer"},
"binds":[{"wid":"self","event":"click","actiontype":"urlwidget","target":"app.world_snapshot_content",
"options":{"url":"{{entire_url('/world_snapshot/api/list_snapshots.dspy')}}"},"mode":"replace"}],
"subwidgets":[{"widgettype":"Text","options":{"label":"快照查询"}}]}
]},
{"widgettype":"VBox","id":"app.world_snapshot_content","options":{"width":"100%","flex":"1","marginTop":"20px"}}]}

7
wwwroot/menu.ui Normal file
View File

@ -0,0 +1,7 @@
{"widgettype":"VBox","options":{"width":"100%","height":"100%"},"subwidgets":[
{"widgettype":"Text","options":{"label":"世界快照","fontSize":"18px"}},
{"widgettype":"Menu","options":{"items":[
{"name":"snapshot_list","label":"快照列表","url":"{{entire_url('/world_snapshot/world_snapshot/index.ui')}}"},
{"name":"snapshot_query","label":"快照查询","url":"{{entire_url('/world_snapshot/api/list_snapshots.dspy')}}"}
]}}
]}