feat(world_snapshot): world_snapshot 初始化提交(元景项目-初始迭代,开发产线产出)

This commit is contained in:
agent.develop 2026-08-28 13:34:51 +08:00
commit 5d94bb451c
27 changed files with 431 additions and 0 deletions

6
.gitignore vendored Normal file
View File

@ -0,0 +1,6 @@
__pycache__/
*.pyc
build/
*.egg-info/
dist/
wwwroot/world_snapshot/

16
README.md Normal file
View File

@ -0,0 +1,16 @@
# world_snapshot 模块
世界状态快照模块聚合世界world、场景scene、实体entity当前状态生成快照支持查询与恢复。
## 功能
- 世界快照表 `world_snapshot` 的 CRUD
- `create_snapshot`:聚合 world/scene/entity 当前状态写入 `snapshot_json`
- `restore_snapshot`:读取并校验快照 JSON恢复状态基础实现校验 + 状态标记)
- 下拉接口:世界列表、快照类型、快照状态
## 数据表
- `world_snapshot`models/world_snapshot.json
## 集成
- 通过 `load_world_snapshot()` 挂载到宿主应用
- 库名经 `ServerEnv().get_module_dbname('world_snapshot')` 获取,禁止硬编码

10
__init__.py Normal file
View File

@ -0,0 +1,10 @@
# -*- coding: utf-8 -*-
"""
仓库根目录兼容 shim
pip 打包pyproject.toml include = ["world_snapshot*"]只安装 world_snapshot/
实现源码位于 world_snapshot/init.py本文件仅在开发环境modules/ sys.path
import world_snapshot 转发到真正的子包避免与同名子包产生二义性
"""
from .world_snapshot import * # noqa: F401,F403
from .world_snapshot import __all__ # noqa: F401

14
init.py Normal file
View File

@ -0,0 +1,14 @@
# -*- coding: utf-8 -*-
"""
仓库根目录兼容 shim真实实现位于 world_snapshot/init.py
宿主应用集成方式二选一
from world_snapshot.init import load_world_snapshot # pip 安装后
from world_snapshot import load_world_snapshot # 包 __init__ 已 re-export
"""
from .world_snapshot.init import * # noqa: F401,F403
from .world_snapshot.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,
)

13
init/data.json Normal file
View File

@ -0,0 +1,13 @@
{
"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": "已作废"}
]}
]
}

21
json/world_snapshot.json Normal file
View File

@ -0,0 +1,21 @@
{
"tblname": "world_snapshot",
"title": "世界快照",
"params": {
"sortby": ["created_at desc"],
"data_filter": {
"AND": [
{"field": "world_id", "op": "=", "var": "world_id"},
{"field": "snapshot_date", "op": "=", "var": "snapshot_date"}
]
},
"filter_labels": {"world_id": "所属世界", "snapshot_date": "快照日期"},
"browserfields": {"exclouded": ["id", "snapshot_json"], "alters": {}},
"editexclouded": ["id", "snapshot_json", "created_at"],
"editable": {
"new_data_url": "{{entire_url('../api/create_snapshot.dspy')}}",
"update_data_url": "{{entire_url('../api/world_snapshot_update.dspy')}}",
"delete_data_url": "{{entire_url('../api/world_snapshot_delete.dspy')}}"
}
}
}

View File

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

13
pyproject.toml Normal file
View File

@ -0,0 +1,13 @@
[build-system]
requires = ["setuptools>=45", "wheel"]
build-backend = "setuptools.build_meta"
[project]
name = "world_snapshot"
version = "1.0.0"
requires-python = ">=3.8"
dependencies = ["sqlor", "bricks_for_python"]
[tool.setuptools.packages.find]
where = ["."]
include = ["world_snapshot*"]

64
scripts/load_path.py Normal file
View File

@ -0,0 +1,64 @@
# -*- coding: utf-8 -*-
"""world_snapshot 模块 RBAC 注册脚本。所有 path 显式列出,禁止通配符。"""
import os
import sys
MOD = 'world_snapshot'
def _find_sage_root():
here = os.path.dirname(os.path.abspath(__file__))
candidates = [
os.path.join(here, '..', '..', '..'),
os.path.expanduser('~/repos/sage'),
os.path.expanduser('~/sage'),
]
for c in candidates:
c = os.path.abspath(c)
if os.path.isdir(os.path.join(c, 'wwwroot')) and os.path.isdir(os.path.join(c, 'py3', 'bin')):
return c
return None
PATHS_ANY = [f'/{MOD}/index.ui']
PATHS_LOGINED = [
f'/{MOD}',
f'/{MOD}/world_snapshot',
f'/{MOD}/world_snapshot/index.ui',
f'/{MOD}/world_snapshot/get_world_snapshot.dspy',
f'/{MOD}/world_snapshot/add_world_snapshot.dspy',
f'/{MOD}/world_snapshot/update_world_snapshot.dspy',
f'/{MOD}/world_snapshot/delete_world_snapshot.dspy',
f'/{MOD}/api/create_snapshot.dspy',
f'/{MOD}/api/restore_snapshot.dspy',
f'/{MOD}/api/get_snapshot.dspy',
f'/{MOD}/api/list_snapshots.dspy',
f'/{MOD}/api/world_snapshot_update.dspy',
f'/{MOD}/api/world_snapshot_delete.dspy',
f'/{MOD}/api/list_worlds_for_snapshot.dspy',
f'/{MOD}/api/list_snapshot_types.dspy',
f'/{MOD}/api/list_snapshot_statuses.dspy',
]
def main():
sage_root = _find_sage_root()
if not sage_root:
print('ERROR: Sage root not found, skip RBAC registration')
sys.exit(1)
sys.path.insert(0, os.path.join(sage_root, 'py3', 'bin'))
sys.path.insert(0, sage_root)
try:
from set_role_perm import set_role_perm
except Exception as e:
print('WARN: cannot import set_role_perm: %s' % e)
sys.exit(1)
for path in PATHS_ANY:
set_role_perm(path, 'any')
for path in PATHS_LOGINED:
set_role_perm(path, 'logined')
print('RBAC registered for module %s' % MOD)
if __name__ == '__main__':
main()

21
skill/SKILL.md Normal file
View File

@ -0,0 +1,21 @@
---
name: world_snapshot
description: world_snapshot 模块技能文档——世界快照管理(世界状态快照生成、查询与恢复)。
---
# world_snapshot 模块
世界状态快照模块,聚合世界 + 场景 + 实体当前状态生成快照,支持查询与恢复。
## 数据模型
`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
- 下拉list_worlds_for_snapshot / list_snapshot_types / list_snapshot_statuses
## 陷阱
- 库名禁止硬编码,统一 get_module_dbname("world_snapshot")
- dspy 无 import
- create_snapshot 必须设置 created_at/snapshot_date
- snapshot_date 用 date 类型(业务日期仅到天)

View File

@ -0,0 +1,12 @@
# -*- coding: utf-8 -*-
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,
)
__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",
]

154
world_snapshot/init.py Normal file
View File

@ -0,0 +1,154 @@
# -*- coding: utf-8 -*-
"""world_snapshot 模块 —— 世界状态快照管理。库名经 get_module_dbname('world_snapshot') 获取,禁止硬编码。"""
import json
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')
async def list_snapshots(ns):
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', '')
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 不能为空'}
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 def create_snapshot(ns):
world_id = ns.get('world_id', '')
name = ns.get('name', '')
snapshot_type = ns.get('snapshot_type', '0')
status = ns.get('status', '0')
snapshot_date = ns.get('snapshot_date') or curDateString()
if not world_id or not name:
return {'success': False, 'message': 'world_id 和 name 不能为空'}
db = DBPools()
async with db.sqlorContext(_get_dbname()) as sor:
worlds = await sor.R('world', {'id': world_id})
scenes = await sor.R('scene', {'world_id': world_id})
entities = await sor.R('entity', {'world_id': world_id})
snapshot_data = {'world': worlds or [], 'scenes': scenes or [], 'entities': entities or []}
rec = {'id': getID(), 'world_id': world_id, 'name': name, 'snapshot_type': snapshot_type,
'snapshot_json': json.dumps(snapshot_data, ensure_ascii=False, default=str),
'status': status, 'snapshot_date': snapshot_date, 'created_at': timestampstr()}
await sor.C('world_snapshot', rec)
return {'success': True, 'id': rec['id'], 'message': '快照生成成功'}
async def update_snapshot(ns):
sid = ns.get('id', '')
if not sid:
return {'success': False, 'message': 'id 不能为空'}
upd = {}
for k in ('name', 'world_id', 'snapshot_type', 'status', 'snapshot_date'):
if k in ns and ns[k] is not None and ns[k] != '':
upd[k] = ns[k]
if not upd:
return {'success': False, 'message': '无更新字段'}
upd['id'] = sid
db = DBPools()
async with db.sqlorContext(_get_dbname()) as sor:
await sor.U('world_snapshot', upd)
return {'success': True, 'id': sid}
async def delete_snapshot(ns):
sid = ns.get('id', '')
if not sid:
return {'success': False, 'message': 'id 不能为空'}
db = DBPools()
async with db.sqlorContext(_get_dbname()) as sor:
await sor.D('world_snapshot', {'id': sid})
return {'success': True, 'id': sid}
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 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():
env = ServerEnv()
env.list_snapshots = list_snapshots
env.get_snapshot = get_snapshot
env.create_snapshot = create_snapshot
env.update_snapshot = update_snapshot
env.delete_snapshot = delete_snapshot
env.restore_snapshot = restore_snapshot
env.list_worlds_for_snapshot = list_worlds_for_snapshot
env.list_snapshot_types = list_snapshot_types
env.list_snapshot_statuses = list_snapshot_statuses
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

View File

@ -0,0 +1,3 @@
debug('create_snapshot.dspy: START')
result = await create_snapshot(dict(params_kw))
return result

View File

@ -0,0 +1,2 @@
result = await list_snapshot_types({})
return [{'value': '', 'text': '全部'}] + list(result or [])

View File

@ -0,0 +1,2 @@
result = await list_snapshot_statuses({})
return [{'value': '', 'text': '全部'}] + list(result or [])

View File

@ -0,0 +1,2 @@
result = await list_worlds_for_snapshot({})
return [{'value': '', 'text': '全部'}] + list(result or [])

View File

@ -0,0 +1,3 @@
debug('get_snapshot.dspy: START')
result = await get_snapshot(dict(params_kw))
return result

View File

@ -0,0 +1,3 @@
debug('list_snapshot_statuses.dspy: START')
result = await list_snapshot_statuses()
return result

View File

@ -0,0 +1,3 @@
debug('list_snapshot_types.dspy: START')
result = await list_snapshot_types()
return result

View File

@ -0,0 +1,3 @@
debug('list_snapshots.dspy: START')
result = await list_snapshots(dict(params_kw))
return result

View File

@ -0,0 +1,3 @@
debug('list_worlds_for_snapshot.dspy: START')
result = await list_worlds_for_snapshot()
return result

View File

@ -0,0 +1,3 @@
debug('restore_snapshot.dspy: START')
result = await restore_snapshot(dict(params_kw))
return result

View File

@ -0,0 +1,2 @@
result = await delete_snapshot(params_kw)
return result

View File

@ -0,0 +1,2 @@
result = await update_snapshot(params_kw)
return result

View File

@ -0,0 +1,3 @@
debug('world_snapshot_delete.dspy: START')
result = await delete_snapshot(dict(params_kw))
return result

View File

@ -0,0 +1,7 @@
debug('world_snapshot_update.dspy: START')
ns = dict(params_kw)
for k in list(ns.keys()):
if k.endswith('_text'):
ns.pop(k, None)
result = await update_snapshot(ns)
return result

22
wwwroot/index.ui Normal file
View File

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