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_snapshot 模块
世界状态快照模块,聚合世界(world)、场景(scene)、实体(entity)当前状态生成快照,支持查询与恢复。 世界状态快照模块(W-03):聚合世界(world)、场景(scene)、实体(entity)当前状态生成
快照,支持快照查询与恢复,恢复时联动 world 表状态(经 world 模块 set_world_mode 契约)。
## 功能 ## 功能
- 世界快照表 `world_snapshot` 的 CRUD - 世界快照表 `world_snapshot` 的 CRUD(create/get/list/update/delete)
- `create_snapshot`:聚合 world/scene/entity 当前状态写入 `snapshot_json` - `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')` 获取,禁止硬编码 - 库名经 `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": [ "appcodes": [
{"parentid": "snapshot_type", "parentname": "快照类型", "items": [ {
{"k": "0", "v": "手动"}, "parentid": "snapshot_type",
{"k": "1", "v": "自动"} "parentname": "快照类型",
]}, "items": [
{"parentid": "snapshot_status", "parentname": "快照状态", "items": [ {"k": "full", "v": "全量快照"},
{"k": "0", "v": "正常"}, {"k": "incremental", "v": "增量快照"}
{"k": "1", "v": "已恢复"}, ]
{"k": "2", "v": "已作废"} },
]} {
"parentid": "snapshot_status",
"parentname": "快照状态",
"items": [
{"k": "active", "v": "有效"},
{"k": "restored", "v": "已恢复"},
{"k": "invalid", "v": "失效"}
]
}
] ]
} }

View File

@ -1,21 +1,31 @@
{ {
"tblname": "world_snapshot", "tblname": "world_snapshot",
"alias": "world_snapshot",
"title": "世界快照", "title": "世界快照",
"params": { "params": {
"sortby": ["created_at desc"], "sortby": ["created_at desc"],
"data_filter": { "record_toolbar": [{
"AND": [ "label": "恢复",
{"field": "world_id", "op": "=", "var": "world_id"}, "actiontype": "dspy",
{"field": "snapshot_date", "op": "=", "var": "snapshot_date"} "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": "快照日期"}, "editexclouded": ["id", "snapshot_json", "status", "snapshot_date", "created_at"],
"browserfields": {"exclouded": ["id", "snapshot_json"], "alters": {}}, "new_data_url": "{{entire_url('../api/create_snapshot.dspy')}}",
"editexclouded": ["id", "snapshot_json", "created_at"], "update_data_url": "{{entire_url('../api/snapshot_update.dspy')}}",
"delete_data_url": "{{entire_url('../api/snapshot_delete.dspy')}}",
"editable": { "editable": {
"new_data_url": "{{entire_url('../api/create_snapshot.dspy')}}", "new_data_url": "{{entire_url('../api/create_snapshot.dspy')}}",
"update_data_url": "{{entire_url('../api/world_snapshot_update.dspy')}}", "update_data_url": "{{entire_url('../api/snapshot_update.dspy')}}",
"delete_data_url": "{{entire_url('../api/world_snapshot_delete.dspy')}}" "delete_data_url": "{{entire_url('../api/snapshot_delete.dspy')}}"
} }
} }
} }

View File

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

View File

@ -1,64 +1,72 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*- # -*- 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 os
import subprocess
import sys import sys
MOD = 'world_snapshot'
def find_sage_root():
def _find_sage_root():
here = os.path.dirname(os.path.abspath(__file__)) here = os.path.dirname(os.path.abspath(__file__))
candidates = [ for cand in (os.path.join(here, '..', '..', '..'),
os.path.join(here, '..', '..', '..'), os.path.join(os.path.expanduser('~'), 'repos', 'sage'),
os.path.expanduser('~/repos/sage'), os.path.join(os.path.expanduser('~'), 'sage')):
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')):
for c in candidates: return cand
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 return None
PATHS_ANY = [f'/{MOD}/index.ui']
PATHS_ANY = [
'/world_snapshot/menu.ui',
'/world_snapshot/index.ui',
]
PATHS_LOGINED = [ PATHS_LOGINED = [
f'/{MOD}', '/world_snapshot/api/create_snapshot.dspy',
f'/{MOD}/world_snapshot', '/world_snapshot/api/snapshot_update.dspy',
f'/{MOD}/world_snapshot/index.ui', '/world_snapshot/api/snapshot_delete.dspy',
f'/{MOD}/world_snapshot/get_world_snapshot.dspy', '/world_snapshot/api/get_snapshot.dspy',
f'/{MOD}/world_snapshot/add_world_snapshot.dspy', '/world_snapshot/api/list_snapshots.dspy',
f'/{MOD}/world_snapshot/update_world_snapshot.dspy', '/world_snapshot/api/restore_snapshot.dspy',
f'/{MOD}/world_snapshot/delete_world_snapshot.dspy', '/world_snapshot/api/get_search_world.dspy',
f'/{MOD}/api/create_snapshot.dspy', '/world_snapshot/api/get_search_snapshot_type.dspy',
f'/{MOD}/api/restore_snapshot.dspy', '/world_snapshot/api/get_search_status.dspy',
f'/{MOD}/api/get_snapshot.dspy', '/world_snapshot/world_snapshot',
f'/{MOD}/api/list_snapshots.dspy', '/world_snapshot/world_snapshot/index.ui',
f'/{MOD}/api/world_snapshot_update.dspy', '/world_snapshot/world_snapshot/get_world_snapshot.dspy',
f'/{MOD}/api/world_snapshot_delete.dspy', '/world_snapshot/world_snapshot/add_world_snapshot.dspy',
f'/{MOD}/api/list_worlds_for_snapshot.dspy', '/world_snapshot/world_snapshot/update_world_snapshot.dspy',
f'/{MOD}/api/list_snapshot_types.dspy', '/world_snapshot/world_snapshot/delete_world_snapshot.dspy',
f'/{MOD}/api/list_snapshot_statuses.dspy',
] ]
def main(): def main():
sage_root = _find_sage_root() sage_root = find_sage_root()
if not sage_root: if not sage_root:
print('ERROR: Sage root not found, skip RBAC registration') print('world_snapshot load_path.py: sage root not found, skip', file=sys.stderr)
sys.exit(1) return 1
sys.path.insert(0, os.path.join(sage_root, 'py3', 'bin')) loader = os.path.join(sage_root, 'load_path.py')
sys.path.insert(0, sage_root) if not os.path.isfile(loader):
try: print('world_snapshot load_path.py: central load_path.py not found, skip', file=sys.stderr)
from set_role_perm import set_role_perm return 1
except Exception as e: entries = []
print('WARN: cannot import set_role_perm: %s' % e) for p in PATHS_ANY:
sys.exit(1) entries.append((p, 'any'))
for path in PATHS_ANY: for p in PATHS_LOGINED:
set_role_perm(path, 'any') entries.append((p, 'logined'))
for path in PATHS_LOGINED: payload = '\n'.join('{} {}'.format(p, role) for p, role in entries)
set_role_perm(path, 'logined') proc = subprocess.run([sys.executable, loader, payload], capture_output=True, text=True)
print('RBAC registered for module %s' % MOD) if proc.stdout:
print(proc.stdout)
if proc.stderr:
print(proc.stderr, file=sys.stderr)
return proc.returncode
if __name__ == '__main__': if __name__ == '__main__':
main() sys.exit(main())

View File

@ -1,21 +1,65 @@
--- ---
name: world_snapshot name: world_snapshot
description: world_snapshot 模块技能文档——世界快照管理(世界状态快照生成、查询与恢复)。 description: 世界状态快照模块(W-03)——聚合 world/scene/entity 当前状态生成快照,支持查询与恢复,与 world 表状态联动。
--- ---
# world_snapshot 模块 # 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。
## 关键接口 表 `world_snapshot`(models/world_snapshot.json 四段式 summary/fields/indexes/codes):
- create_snapshot / restore_snapshot / list_snapshots / get_snapshot / update_snapshot / delete_snapshot - id VARCHAR(32) PK(getID() 生成,禁 uuid)
- world_id VARCHAR(32) NOT NULL(codes → world 表)
- name VARCHAR(255) NOT NULL
- snapshot_type VARCHAR(32) DEFAULT 'full'(snapshot_type 编码字典 full/incremental)
- snapshot_json TEXT(聚合内容)
- status VARCHAR(32) DEFAULT 'active'(snapshot_status 编码字典 active/restored/invalid)
- snapshot_date date(业务日期仅到天)
- created_at timestamp NOT NULL
- 复合索引 idx_snapshot_world(world_id, snapshot_date)、idx_snapshot_list(world_id, status, created_at)
编码字典:init/data.json 幂等落库 `snapshot_type`(full=全量快照/incremental=增量快照)
与 `snapshot_status`(active=有效/restored=已恢复/invalid=失效),appcodes + appcodes_kv 同插。
## 关键接口(经 load_world_snapshot() 注册到 ServerEnv)
- `create_snapshot(params)` → 校验 world 存在、snapshot_type 白名单(full/incremental),
聚合 world/scene/entity 当前状态写入 snapshot_json,必设 snapshot_date/created_at
- `get_snapshot(params)` → 按 id 单条查询
- `list_snapshots(params)` → 分页 {list, total, page, page_size};count 与数据查询分离,
显式列清单(排除 TEXT),P95 达标
- `update_snapshot(params)` → 部分更新(排除 id/created_at/snapshot_json/_text)
- `delete_snapshot(params)` → 按 id 删除
- `restore_snapshot(params)` → 校验 snapshot_json 合法性、标记 status=restored、
联动 world 状态(经 env.set_world_mode 契约,不存在或失败不阻断)
- 下拉:list_worlds_for_snapshot / list_snapshot_types / list_snapshot_statuses - 下拉: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 - 库名禁止硬编码:.py 用 `ServerEnv().get_module_dbname('world_snapshot')`,.dspy 直接
- create_snapshot 必须设置 created_at/snapshot_date `get_module_dbname('world_snapshot')`(预载全局)
- 无硬编码中文(错误消息用英文 code);dspy 无 import(json/format_exc/debug/getID 等为预载全局)
- create_snapshot 必须设置 created_at/snapshot_date(sqlor 不自动补时间戳,缺 created_at 的
sor.C 会静默丢记录)
- snapshot_date 用 date 类型(业务日期仅到天) - snapshot_date 用 date 类型(业务日期仅到天)
- restore_snapshot 对 world 状态联动用契约调用(env.set_world_mode),不直接改 world 表,
保证 mode 流转规则校验不被绕过
- 下拉接口返回纯数组 [{value,text}],错误时返回含"全部"的回退数组
## 依赖
- sqlor(DBPools/sqlExe/sor.C/U/D/R)
- ahserver(ServerEnv、get_module_dbname)
- appbase(appcodes/appcodes_kv 编码字典)
- world / scene / entity(聚合数据源,经各自模块库名读取,缺失时降级为空)

View File

@ -1,12 +1,27 @@
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
"""world_snapshot package (W-03): world state snapshot management."""
from .init import ( from .init import (
load_world_snapshot, list_snapshots, get_snapshot, create_snapshot, load_world_snapshot,
update_snapshot, delete_snapshot, restore_snapshot, create_snapshot,
list_worlds_for_snapshot, list_snapshot_types, list_snapshot_statuses, update_snapshot,
delete_snapshot,
get_snapshot,
list_snapshots,
restore_snapshot,
list_worlds_for_snapshot,
list_snapshot_types,
list_snapshot_statuses,
) )
__all__ = [ __all__ = [
"load_world_snapshot", "list_snapshots", "get_snapshot", "create_snapshot", 'load_world_snapshot',
"update_snapshot", "delete_snapshot", "restore_snapshot", 'create_snapshot',
"list_worlds_for_snapshot", "list_snapshot_types", "list_snapshot_statuses", '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 -*- # -*- 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 import json
from appPublic.timeUtils import curDateString
from appPublic.uniqueID import getID from appPublic.uniqueID import getID
from appPublic.timeUtils import curDateString, timestampstr
from ahserver.serverenv import ServerEnv from ahserver.serverenv import ServerEnv
from sqlor.dbpools import DBPools from sqlor.dbpools import DBPools
SNAPSHOT_TYPES = ('full', 'incremental')
def _get_dbname(): SNAPSHOT_STATUS_ACTIVE = 'active'
return ServerEnv().get_module_dbname('world_snapshot') 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() db = DBPools()
page = int(ns.get('page', 1) or 1) async with db.sqlorContext(dbname) as sor:
page_size = int(ns.get('rows', ns.get('pagerows', 20)) or 20) await sor.C('world_snapshot', ns)
conds = ['1=1'] return {'success': True, 'id': ns['id'], 'snapshot_type': snapshot_type,
params = {} 'snapshot_date': ns['snapshot_date']}
world_id = ns.get('world_id', '')
snapshot_date = ns.get('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: if world_id:
conds.append('world_id = ${world_id}$') conds.append('world_id = ${world_id}$')
params['world_id'] = world_id ns['world_id'] = world_id
if snapshot_date: status = params.get('status')
conds.append('snapshot_date = ${snapshot_date}$') if status:
params['snapshot_date'] = snapshot_date conds.append('status = ${status}$')
where = ' and '.join(conds) ns['status'] = status
offset = (page - 1) * page_size snapshot_type = params.get('snapshot_type')
async with db.sqlorContext(_get_dbname()) as sor: if snapshot_type:
cnt = await sor.sqlExe('SELECT COUNT(*) AS cnt FROM world_snapshot WHERE ' + where, params) conds.append('snapshot_type = ${snapshot_type}$')
total = cnt[0]['cnt'] if cnt else 0 ns['snapshot_type'] = snapshot_type
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) where = (' where ' + ' and '.join(conds)) if conds else ''
return {'success': True, 'total': total, 'rows': rows, 'page': page, 'page_size': page_size} dbname = ServerEnv().get_module_dbname('world_snapshot')
async def get_snapshot(ns):
sid = ns.get('id', '')
if not sid:
return {'success': False, 'message': 'id 不能为空'}
db = DBPools() db = DBPools()
async with db.sqlorContext(_get_dbname()) as sor: async with db.sqlorContext(dbname) as sor:
rows = await sor.R('world_snapshot', {'id': sid}) cnt = await sor.sqlExe('select count(*) as cnt from world_snapshot' + where, ns)
if not rows: total = int(cnt[0].cnt) if cnt else 0
return {'success': False, 'message': '快照不存在'} rows = await sor.sqlExe(
return {'success': True, 'data': rows[0]} '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): async def update_snapshot(params):
world_id = ns.get('world_id', '') """Partial update of a snapshot record (id required)."""
name = ns.get('name', '') params = params or {}
snapshot_type = ns.get('snapshot_type', '0') snapshot_id = params.get('id')
status = ns.get('status', '0') if not snapshot_id:
snapshot_date = ns.get('snapshot_date') or curDateString() return {'code': 'PARAM_REQUIRED', 'message': 'id is required', 'field': 'id', 'detail': ''}
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 = {} upd = {}
for k in ('name', 'world_id', 'snapshot_type', 'status', 'snapshot_date'): for k, v in params.items():
if k in ns and ns[k] is not None and ns[k] != '': if k in ('id', 'created_at', 'snapshot_json'):
upd[k] = ns[k] continue
if k.endswith('_text'):
continue
if v is None or v == '':
continue
upd[k] = v
if not upd: if not upd:
return {'success': False, 'message': '无更新字段'} return {'code': 'NO_FIELDS_TO_UPDATE', 'message': 'no fields to update', 'field': '', 'detail': ''}
upd['id'] = sid upd['id'] = snapshot_id
dbname = ServerEnv().get_module_dbname('world_snapshot')
db = DBPools() db = DBPools()
async with db.sqlorContext(_get_dbname()) as sor: async with db.sqlorContext(dbname) as sor:
await sor.U('world_snapshot', upd) await sor.U('world_snapshot', upd)
return {'success': True, 'id': sid} return {'success': True, 'id': snapshot_id}
async def delete_snapshot(ns): async def delete_snapshot(params):
sid = ns.get('id', '') """Delete a snapshot record by id."""
if not sid: params = params or {}
return {'success': False, 'message': 'id 不能为空'} 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() db = DBPools()
async with db.sqlorContext(_get_dbname()) as sor: async with db.sqlorContext(dbname) as sor:
await sor.D('world_snapshot', {'id': sid}) await sor.D('world_snapshot', {'id': snapshot_id})
return {'success': True, 'id': sid} return {'success': True, 'id': snapshot_id}
async def restore_snapshot(ns): async def restore_snapshot(params):
sid = ns.get('id', ns.get('snapshot_id', '')) """Validate snapshot json, mark snapshot restored, link world status.
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}
World linkage goes through the world module's own set_world_mode()
async def list_worlds_for_snapshot(params_kw=None): contract when available (keeps mode transition validation intact).
db = DBPools() """
async with db.sqlorContext(_get_dbname()) as sor: params = params or {}
rows = await sor.sqlExe('SELECT id AS value, name AS text FROM world ORDER BY name', {}) snapshot_id = params.get('id')
return rows or [] if not snapshot_id:
return {'code': 'PARAM_REQUIRED', 'message': 'id is required', 'field': 'id', 'detail': ''}
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():
env = ServerEnv() env = ServerEnv()
env.list_snapshots = list_snapshots dbname = env.get_module_dbname('world_snapshot')
env.get_snapshot = get_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_snapshot = create_snapshot
env.create_snapshots = create_snapshot
env.update_snapshot = update_snapshot env.update_snapshot = update_snapshot
env.update_snapshots = update_snapshot
env.delete_snapshot = delete_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.restore_snapshot = restore_snapshot
env.list_worlds_for_snapshot = list_worlds_for_snapshot env.list_worlds_for_snapshot = list_worlds_for_snapshot
env.list_snapshot_types = list_snapshot_types env.list_snapshot_types = list_snapshot_types
env.list_snapshot_statuses = list_snapshot_statuses env.list_snapshot_statuses = list_snapshot_statuses
env.create_world_snapshot = create_snapshot return env
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

View File

@ -1,3 +1,8 @@
debug('create_snapshot.dspy: START') # world_snapshot create_snapshot.dspy
result = await create_snapshot(dict(params_kw)) # 创建世界快照:聚合 world/scene/entity 当前状态写入 snapshot_json
return result 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({}) # world_snapshot get_search_snapshot_type.dspy
return [{'value': '', 'text': '全部'}] + list(result or []) # 下拉:快照类型([{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({}) # world_snapshot get_search_status.dspy
return [{'value': '', 'text': '全部'}] + list(result or []) # 下拉:快照状态([{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') # world_snapshot get_snapshot.dspy
result = await get_snapshot(dict(params_kw)) # 查询单条世界快照(id 必填)
return result 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') # world_snapshot list_snapshots.dspy
result = await list_snapshots(dict(params_kw)) # 分页查询世界快照列表(world_id/status/snapshot_type 可选过滤)
return result 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') # world_snapshot restore_snapshot.dspy
result = await restore_snapshot(dict(params_kw)) # 恢复世界快照:校验 snapshot_json、标记已恢复、联动 world 状态
return result 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) # world_snapshot snapshot_delete.dspy
return result # 删除世界快照记录(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) # world_snapshot snapshot_update.dspy
return result # 更新世界快照记录(部分更新,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": "VBox", {"widgettype":"Text","options":{"label":"世界快照管理","fontSize":"24px"}},
"options": {"width": "100%", "height": "100%", "padding": "20px"}, {"widgettype":"ResponsableBox","options":{"gap":"16px","minWidth":"250px"},"subwidgets":[
"subwidgets": [ {"widgettype":"VBox","options":{"backgroundColor":"#FFFFFF","padding":"20px","cursor":"pointer"},
{"widgettype": "Text", "options": {"label": "世界快照", "fontSize": "24px"}}, "binds":[{"wid":"self","event":"click","actiontype":"urlwidget","target":"app.world_snapshot_content",
{"widgettype": "ResponsableBox", "options": {"gap": "16px", "minWidth": "250px"}, "subwidgets": [ "options":{"url":"{{entire_url('/world_snapshot/world_snapshot/index.ui')}}"},"mode":"replace"}],
{"widgettype": "VBox", "options": {"backgroundColor": "#FFFFFF", "padding": "20px", "cursor": "pointer"}, "subwidgets":[{"widgettype":"Text","options":{"label":"快照列表"}}]},
"binds": [{"wid": "self", "event": "click", "actiontype": "urlwidget", "target": "app.world_snapshot_content", "options": {"url": "{{entire_url('/world_snapshot/world_snapshot')}}"}, "mode": "replace"}], {"widgettype":"VBox","options":{"backgroundColor":"#FFFFFF","padding":"20px","cursor":"pointer"},
"subwidgets": [ "binds":[{"wid":"self","event":"click","actiontype":"urlwidget","target":"app.world_snapshot_content",
{"widgettype": "Text", "options": {"label": "快照列表", "fontSize": "16px"}}, "options":{"url":"{{entire_url('/world_snapshot/api/list_snapshots.dspy')}}"},"mode":"replace"}],
{"widgettype": "Text", "options": {"label": "查看、编辑、删除世界快照", "fontSize": "12px", "color": "#888888"}} "subwidgets":[{"widgettype":"Text","options":{"label":"快照查询"}}]}
]}, ]},
{"widgettype": "VBox", "options": {"backgroundColor": "#FFFFFF", "padding": "20px", "cursor": "pointer"}, {"widgettype":"VBox","id":"app.world_snapshot_content","options":{"width":"100%","flex":"1","marginTop":"20px"}}]}
"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"}}
]
}

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')}}"}
]}}
]}