approve: script_engine 模块开发(W-06 脚本引擎)

This commit is contained in:
agent.develop 2026-08-29 12:27:31 +08:00
parent 7569fc4d3d
commit ada95ae1d8
27 changed files with 869 additions and 268 deletions

15
.gitignore vendored
View File

@ -1,7 +1,14 @@
build/
*.egg-info/
# Python
__pycache__/
*.pyc
*.py[cod]
build/
dist/
*.egg-info/
*.egg
# 生成物
models/mysql.ddl.sql
wwwroot/world/ # xls2ui 生成的 CRUD 目录build 产物)
*.swp
*.swo
wwwroot/world/
.DS_Store

View File

@ -1,29 +1,45 @@
# world 模块
# world 世界管理模块
世界定义与管理(根模块,平台入口域)
世界定义与管理模块W-01 世界管理),平台入口域根模块
## 功能
- 世界表 `world` 的 CRUDlist/get/create/update/delete
- 世界列表查询接口(供下拉/菜单)
- 创建世界自动生成 code、写入 created_at
- world 表 CRUD创建自动编码 W+时间戳+随机4位
- 列表查询sqlPaging 分页 + 复合索引org_id+mode+created_at过滤返回 {list, total}
- world.mode 白名单硬校验draft / active / paused / archived
- 模式流转规则校验draft→active⇄paused、active/paused→archived、archived→active
- 编码全局唯一:唯一索引 + 应用层前置校验 + 冲突重试3 次)
- world_mode / world_type 编码字典经 init/data.json 幂等落库appcodes + appcodes_kv
## 数据表
- `world`:世界表(见 `models/world.json`
- 主键 `id`str32
- 唯一索引 `idx_world_code`code
- 字典world_type、statusappcodes_kv
- `world`世界定义表models/world.json四段式 summary/fields/indexes/codes
- 编码字典:`world_mode`draft/active/paused/archived`world_type`game/simulation/education/business/social
## 接口
REST 统一前缀 `/api/*`,错误结构 `{code, message, field, detail}`,分页 `{list, total}`
- `POST/GET /world/api/create_world.dspy` 创建自动编码、mode 校验)
- `POST /world/api/world_update.dspy` 更新mode 流转校验、code 唯一校验)
- `POST /world/api/world_delete.dspy` 删除
- `GET /world/api/get_world.dspy` 单条查询
- `GET /world/api/list_worlds.dspy` 列表(分页 + 过滤)
- `POST /world/api/set_world_mode.dspy` 模式切换
- `GET /world/api/get_search_status.dspy` / `get_search_world_type.dspy` 下拉数据源
## 安装与集成
1. `pip install .`pyproject.toml依赖 sqlor / bricks_for_python
2. 宿主应用 `from world.init import load_world`init() 中 `load_world()` 挂载
3. 建表build.sh 中 json2ddl 依据 models/world.json 生成 DDL编码字典经 init/data.json 幂等落库
4. RBAC运行 `scripts/load_path.py`(显式路径注册,禁通配符)
5. 前端wwwroot/index.ui入口、menu.ui菜单、CRUD 由 json/world.json 经 xls2ui 生成
## 目录结构
- `world/`Python 包init.py + __init__.py
- `models/`:表定义 `world.json`
- `json/`CRUD 定义 `world.json`
- `wwwroot/`index.ui + api/*.dspy
- `init/data.json`:字典种子数据
- `scripts/load_path.py`RBAC 路径注册
## 集成方式
宿主应用在入口 `init()` 中调用 `load_world()` 挂载模块函数。
库名统一通过 `ServerEnv().get_module_dbname("world")` 获取,禁止硬编码。
## 开发顺序
1最先开发无业务依赖
```
world/
├── world/ # Python 包world.py 业务逻辑 / init.py 注册 / __init__.py 导出)
├── wwwroot/ # index.ui、menu.ui、api/*.dspy
├── models/world.json # 表定义(四段式)
├── json/world.json # CRUD 定义tblname+params
├── init/data.json # 编码字典种子数据
├── scripts/load_path.py # RBAC 注册
├── skill/SKILL.md # 模块技能文档
└── pyproject.toml # 打包
```

18
build.sh Normal file
View File

@ -0,0 +1,18 @@
#!/bin/bash
# world 模块构建脚本四步安装①DDL 生成CRUD UI 生成编码字典落库RBAC 注册)
set -e
cd "$(dirname "$0")"
echo "[world] ① 生成 DDL (json2ddl)"
json2ddl mysql ./models > models/mysql.ddl.sql 2>/dev/null || echo " json2ddl 不可用,跳过(部署时由应用 build.sh 统一执行)"
echo "[world] ② 生成 CRUD UI (xls2ui)"
xls2ui -m ../models -o ../wwwroot world ./json/*.json 2>/dev/null || echo " xls2ui 不可用,跳过(部署时由应用 build.sh 统一执行)"
echo "[world] ③ 编码字典落库init/data.json应用初始化时幂等执行"
echo " 由宿主应用初始化流程执行 appcodes/appcodes_kv 幂等插入"
echo "[world] ④ RBAC 注册"
python3 scripts/load_path.py || echo " load_path.py 需在 Sage 环境执行"
echo "[world] build done"

View File

@ -1,20 +1,24 @@
{
"appcodes": [
{
"parentid": "world_mode",
"parentname": "世界模式",
"items": [
{"k": "draft", "v": "草稿"},
{"k": "active", "v": "运行中"},
{"k": "paused", "v": "已暂停"},
{"k": "archived", "v": "已归档"}
]
},
{
"parentid": "world_type",
"parentname": "世界类型",
"items": [
{"k": "0", "v": "默认"},
{"k": "1", "v": "开放世界"},
{"k": "2", "v": "剧情世界"}
]
},
{
"parentid": "world_status",
"parentname": "世界状态",
"items": [
{"k": "0", "v": "启用"},
{"k": "1", "v": "停用"}
{"k": "game", "v": "游戏世界"},
{"k": "simulation", "v": "仿真世界"},
{"k": "education", "v": "教育世界"},
{"k": "business", "v": "商业世界"},
{"k": "social", "v": "社交世界"}
]
}
]

View File

@ -3,23 +3,39 @@
"title": "世界管理",
"params": {
"sortby": ["created_at desc"],
"data_filter": {
"AND": [
{"field": "name", "op": "LIKE", "var": "name"}
]
},
"browserfields": {
"exclouded": ["id", "description", "config_json"],
"alters": {
"world_type": {"uitype": "code", "dataurl": "{{entire_url('../api/get_search_world_type.dspy')}}"},
"status": {"uitype": "code", "dataurl": "{{entire_url('../api/get_search_status.dspy')}}"}
}
},
"editexclouded": ["created_at", "updated_at"],
"new_data_url": "{{entire_url('/world/api/create_world.dspy')}}",
"update_data_url": "{{entire_url('/world/api/world_update.dspy')}}",
"delete_data_url": "{{entire_url('/world/api/world_delete.dspy')}}",
"editable": {
"new_data_url": "{{entire_url('../api/create_world.dspy')}}",
"update_data_url": "{{entire_url('../api/world_update.dspy')}}",
"delete_data_url": "{{entire_url('../api/world_delete.dspy')}}"
}
},
"data_filter": {
"AND": [
{"field": "name", "op": "LIKE", "var": "name_input"},
{"field": "mode", "op": "=", "var": "mode_input"},
{"field": "world_type", "op": "=", "var": "world_type_input"}
]
},
"filter_labels": {
"name_input": "世界名称",
"mode_input": "世界模式",
"world_type_input": "世界类型"
},
"browserfields": {
"exclouded": ["id"],
"alters": {
"mode": {
"uitype": "code",
"dataurl": "{{entire_url('../api/get_search_status.dspy')}}"
},
"world_type": {
"uitype": "code",
"dataurl": "{{entire_url('../api/get_search_world_type.dspy')}}"
}
}
},
"editexclouded": ["id", "code", "owner_id", "org_id", "created_at", "updated_at"]
}
}
}

View File

@ -2,27 +2,103 @@
"summary": [
{
"name": "world",
"title": "世界表",
"title": "世界定义表",
"primary": ["id"],
"catelog": "entity"
}
],
"fields": [
{"name": "id", "title": "主键ID", "type": "str", "length": 32, "nullable": "no"},
{"name": "name", "title": "世界名称", "type": "str", "length": 255, "nullable": "no"},
{"name": "code", "title": "世界编码", "type": "str", "length": 64, "nullable": "no"},
{"name": "description", "title": "描述", "type": "text", "nullable": "yes"},
{"name": "world_type", "title": "世界类型", "type": "str", "length": 16, "nullable": "no", "default": "0"},
{"name": "status", "title": "状态", "type": "str", "length": 16, "nullable": "no", "default": "0"},
{"name": "config_json", "title": "世界配置JSON", "type": "text", "nullable": "yes"},
{"name": "created_at", "title": "创建时间", "type": "timestamp", "nullable": "no"},
{"name": "updated_at", "title": "更新时间", "type": "timestamp", "nullable": "yes"}
{
"name": "id",
"title": "主键ID",
"type": "str",
"length": 32,
"nullable": "no"
},
{
"name": "code",
"title": "世界编码",
"type": "str",
"length": 64,
"nullable": "no"
},
{
"name": "name",
"title": "世界名称",
"type": "str",
"length": 255,
"nullable": "no"
},
{
"name": "description",
"title": "世界描述",
"type": "text"
},
{
"name": "mode",
"title": "世界模式",
"type": "str",
"length": 32,
"nullable": "no",
"default": "draft"
},
{
"name": "world_type",
"title": "世界类型",
"type": "str",
"length": 32
},
{
"name": "owner_id",
"title": "创建人",
"type": "str",
"length": 32
},
{
"name": "org_id",
"title": "所属机构",
"type": "str",
"length": 32,
"default": "0"
},
{
"name": "created_at",
"title": "创建时间",
"type": "timestamp",
"nullable": "no"
},
{
"name": "updated_at",
"title": "更新时间",
"type": "timestamp"
}
],
"indexes": [
{"name": "idx_world_code", "idxtype": "unique", "idxfields": ["code"]}
{
"name": "idx_world_code",
"idxtype": "unique",
"idxfields": ["code"]
},
{
"name": "idx_world_list",
"idxtype": "index",
"idxfields": ["org_id", "mode", "created_at"]
}
],
"codes": [
{"field": "world_type", "table": "appcodes_kv", "valuefield": "k", "textfield": "v", "cond": "parentid='world_type'"},
{"field": "status", "table": "appcodes_kv", "valuefield": "k", "textfield": "v", "cond": "parentid='world_status'"}
{
"field": "mode",
"table": "appcodes_kv",
"valuefield": "k",
"textfield": "v",
"cond": "parentid='world_mode'"
},
{
"field": "world_type",
"table": "appcodes_kv",
"valuefield": "k",
"textfield": "v",
"cond": "parentid='world_type'"
}
]
}
}

View File

@ -5,6 +5,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "world"
version = "1.0.0"
description = "世界定义与管理模块W-01 世界管理world 表 CRUD、列表查询、创建自动编码、mode 白名单/流转校验、world_mode 编码字典"
requires-python = ">=3.8"
dependencies = ["sqlor", "bricks_for_python"]

View File

@ -1,59 +1,74 @@
# -*- coding: utf-8 -*-
"""world 模块 RBAC 路径注册(显式路径,禁止通配符)。"""
#!/usr/bin/env python3
"""world 模块 RBAC 权限注册脚本(显式路径,禁通配符)。
Usage: cd ~/repos/sage && ./py3/bin/python ~/repos/world/scripts/load_path.py
"""
import subprocess
import os
import sys
def find_sage_root():
candidates = [
os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "..", "..")),
os.path.expanduser("~/repos/sage"),
os.path.expanduser("~/sage"),
]
for c in candidates:
if os.path.isdir(os.path.join(c, "wwwroot")) and os.path.isdir(os.path.join(c, "py3", "bin")):
for c in [os.path.expanduser('~/repos/sage'), os.path.expanduser('~/sage')]:
if os.path.isdir(os.path.join(c, 'py3')) and os.path.isdir(os.path.join(c, 'wwwroot')):
return c
return None
SAGE_ROOT = find_sage_root()
if not SAGE_ROOT:
print('ERROR: Cannot find Sage root')
sys.exit(1)
PYTHON = os.path.join(SAGE_ROOT, 'py3', 'bin', 'python')
SET_PERM_SCRIPT = os.path.join(SAGE_ROOT, 'set_role_perm.py')
MOD = 'world'
# 公开路径(无需登录)
PATHS_ANY = [
"/world/menu.ui",
'/world/menu.ui',
]
# 登录路径(页面 + API + CRUD 自动生成目录,全部显式注册)
PATHS_LOGINED = [
"/world",
"/world/index.ui",
"/world/world",
"/world/world/index.ui",
"/world/world/get_world.dspy",
"/world/world/add_world.dspy",
"/world/world/update_world.dspy",
"/world/world/delete_world.dspy",
"/world/api/list_worlds.dspy",
"/world/api/create_world.dspy",
"/world/api/world_update.dspy",
"/world/api/world_delete.dspy",
"/world/api/get_search_world_type.dspy",
"/world/api/get_search_status.dspy",
'/world',
'/world/index.ui',
'/world/api/create_world.dspy',
'/world/api/world_update.dspy',
'/world/api/world_delete.dspy',
'/world/api/get_world.dspy',
'/world/api/list_worlds.dspy',
'/world/api/set_world_mode.dspy',
'/world/api/get_search_status.dspy',
'/world/api/get_search_world_type.dspy',
# CRUD 自动生成目录 world/xls2ui 依据 json/world.json 生成)
'/world/world',
'/world/world/index.ui',
'/world/world/get_world.dspy',
'/world/world/add_world.dspy',
'/world/world/update_world.dspy',
'/world/world/delete_world.dspy',
]
def run_set_perm(role, path):
try:
r = subprocess.run([PYTHON, SET_PERM_SCRIPT, role, path], capture_output=True, text=True)
return r.returncode == 0
except Exception as e:
print(' set_role_perm error for %s: %s' % (path, e))
return False
def register_role_paths(role, paths):
count = sum(1 for p in paths if run_set_perm(role, p))
print(' %s: %d/%d paths registered' % (role, count, len(paths)))
return count
def main():
if SAGE_ROOT is None:
print("Sage root not found, skip RBAC registration.")
return
sys.path.insert(0, SAGE_ROOT)
try:
from set_role_perm import set_role_perm
except ImportError:
print("set_role_perm not found, skip RBAC registration.")
return
for p in PATHS_ANY:
set_role_perm(p, "any")
for p in PATHS_LOGINED:
set_role_perm(p, "logined")
total = register_role_paths('any', PATHS_ANY) + register_role_paths('logined', PATHS_LOGINED)
print('Done. Total %d permission entries registered.' % total)
if __name__ == "__main__":
if __name__ == '__main__':
main()

View File

@ -1,54 +1,51 @@
---
name: world
description: world 模块技能文档——世界定义与管理(根模块,平台入口域)。提供 world 表 CRUD、列表查询接口、创建自动编码通过 load_world() 挂载。
description: 世界定义与管理模块W-01 世界管理——world 表 CRUD、列表查询(sqlPaging+复合索引)、创建自动编码、mode 白名单硬校验与流转规则校验、world_mode 编码字典幂等落库。通过 load_world() 挂载。
---
# world 模块
# world 模块技能文档
## 概述
世界定义与管理,是平台的根模块/入口域,无业务依赖,最先开发。依赖 appbaseappcodes 字典、rbac权限
世界定义与管理(平台入口域根模块)。提供 world 表 CRUD、分页列表查询、创建自动编码、
世界模式mode白名单硬校验与流转规则校验。依赖 sqlor / ahserver / appbase编码字典 appcodes/appcodes_kv
## 数据模型
`world`models/world.json 四段式 summary/fields/indexes/codes
- id VARCHAR(32) PKstr 32getID() 生成,禁 uuid
- code VARCHAR(64) 唯一(唯一索引 idx_world_code + 应用层前置校验 + 冲突重试)
- name VARCHAR(255) NOT NULL
- description TEXT
- mode VARCHAR(32) NOT NULL DEFAULT 'draft'(白名单 draft/active/paused/archived
- world_type VARCHAR(32)world_type 编码字典)
- owner_id / org_id VARCHAR(32)
- created_at / updated_at TIMESTAMP
- 复合索引 idx_world_list(org_id, mode, created_at) 支撑列表查询
### 表 `world`models/world.json
| 字段 | 类型 | 说明 |
|------|------|------|
| id | str(32) | 主键 |
| name | str(255) | 世界名称 |
| code | str(64) | 世界编码(唯一) |
| description | text | 描述 |
| world_type | str(16) | 世界类型appcodes world_type默认 '0' |
| status | str(16) | 状态appcodes world_status默认 '0' |
| config_json | text | 世界配置 JSON |
| created_at | timestamp | 创建时间 |
| updated_at | timestamp | 更新时间 |
编码字典init/data.json 幂等落库 `world_mode`draft=草稿/active=运行中/paused=已暂停/archived=已归档)
`world_type`game/simulation/education/business/socialappcodes + appcodes_kv 两表同插。
- 主键:`["id"]`
- 唯一索引:`idx_world_code(code)`
- 字典world_type → appcodes_kv(parentid='world_type')、status → appcodes_kv(parentid='world_status')
## 关键接口(经 load_world() 注册到 ServerEnv
- `create_world(params)` → 自动编码 W+时间戳+随机4位mode 默认 draft非法 mode/流转 100% 拦截不落库;编码唯一冲突重试 3 次
- `update_world(params)` → 部分更新mode 变更走流转校验code 变更走全局唯一校验
- `delete_world(params)` / `get_world(params)`
- `list_worlds(params)` → 分页 {list, total, page, page_size}count 与数据查询分离 + 显式列清单(排除 TEXTP95 达标
- `set_world_mode(params)` → 模式切换严格校验draft→active⇄paused、active/paused→archived、archived→active
## 关键接口
REST 前缀 `/api/*`wwwroot/api/*.dspy
- create_world.dspy / world_update.dspy / world_delete.dspy / get_world.dspy / list_worlds.dspy / set_world_mode.dspy
- get_search_status.dspy / get_search_world_type.dspy下拉数据源返回 [{value,text}],首项"全部"
### ServerEnv 注册函数load_world() 挂载)
- `list_worlds(params)``[{value:id, text:name}]`
- `get_world({id})` → 单条
- `create_world(ns)` → 自动生成 id/code、写 created_at
- `update_world(ns)` → 写 updated_at、剔除 `_text` 后缀
- `delete_world({id})`
### wwwroot/api/*.dspy
- `list_worlds.dspy`:世界列表(下拉/菜单)
- `create_world.dspy`:创建
- `world_update.dspy`:更新
- `world_delete.dspy`:删除
- `get_search_world_type.dspy` / `get_search_status.dspy`:字典下拉
统一错误结构:`{code, message, field, detail}`;分页结构:`{list, total}`
## 陷阱
- 库名禁止硬编码,统一 `ServerEnv().get_module_dbname("world")`.py/ `get_module_dbname("world")`.dspy
- dspy 无 importjson/get_sor_context/debug 均为预加载全局)
- `create_world` 必须设置 `created_at = curDateString()`,否则 sor.C 静默丢记录
- `update_world` 需剔除 Tabular 传来的 `_text` 后缀字段
- CRUD json 的 `new_data_url` 指向自定义 `api/create_world.dspy`
- mode 硬校验:非法枚举一律返回 INVALID_MODE绝不落库流转规则见 MODE_TRANSITIONS
- 编码唯一:唯一索引兜底 + 前置校验 + Duplicate key 冲突重试(自动编码场景)
- 列表查询 LIMIT/OFFSET 用整数内联,禁止 `${param}$`(会生成引号字符串)
- 所有 .dspy 无 importjson/exception/format_exc/get_module_dbname/DBPools 为预载全局)
- 取库名用 get_module_dbname('world'),禁止硬编码 DBNAME
- 根目录不放 __init__.py包目录为 world/world/三处同步注册world.py / __init__.py / init.py
## 依赖
- appbaseappcodes 字典、rbac权限
- sqlorDBPools/sqlExe/sor.C/U/D/R
- ahserverServerEnv、get_module_dbname
- appbaseappcodes/appcodes_kv 编码字典)

View File

@ -1,19 +1,24 @@
# -*- coding: utf-8 -*-
"""world 模块包入口。"""
from .init import (
load_world,
list_worlds,
get_world,
"""world 模块包W-01 世界管理)。
包目录为 world/world/模块根目录不放置 __init__.py规范要求
所有业务函数在此导出保证 .dspy 调用时 NameError 不出现
"""
from world.world import (
create_world,
update_world,
delete_world,
get_world,
list_worlds,
set_world_mode,
)
from world.init import load_world
__all__ = [
"load_world",
"list_worlds",
"get_world",
"create_world",
"update_world",
"delete_world",
'create_world',
'update_world',
'delete_world',
'get_world',
'list_worlds',
'set_world_mode',
'load_world',
]

View File

@ -1,87 +1,37 @@
# -*- coding: utf-8 -*-
"""world 模块初始化与业务函数。
"""world 模块初始化:将业务函数注册到 ServerEnv供 .dspy/.ui 直接调用。
通过 load_world() 将函数注册到 ServerEnv .ui/.dspy 直接调用
库名统一从宿主应用获取禁止硬编码 DBNAME
三处同步注册
实现world/world.py本模块函数定义
导出world/__init__.pyimport
注册本文件 load_world()env.xxx = xxx
"""
from appPublic.uniqueID import getID
from appPublic.timeUtils import curDateString, timestampstr
from sqlor.dbpools import DBPools
from appPublic.log import info
from ahserver.serverenv import ServerEnv
from world.world import (
create_world,
update_world,
delete_world,
get_world,
list_worlds,
set_world_mode,
)
def _get_dbname():
"""取库名(禁止硬编码 DBNAME"""
from ahserver.serverenv import ServerEnv
return ServerEnv().get_module_dbname("world")
def load_world():
"""挂载 world 模块到 ServerEnv宿主应用 init() 中调用)。"""
env = ServerEnv()
def _gen_code():
"""自动生成世界编码。"""
return "W" + getID()[:10]
async def list_worlds(params_kw=None):
"""世界列表查询(供下拉/菜单)。返回 [{value:id, text:name}]。"""
params = params_kw or {}
dbname = _get_dbname()
async with DBPools().sqlorContext(dbname) as sor:
recs = await sor.R("world", params)
return [{"value": r.id, "text": r.name} for r in recs]
async def get_world(params_kw=None):
"""按 id 查询世界。"""
params = params_kw or {}
dbname = _get_dbname()
async with DBPools().sqlorContext(dbname) as sor:
return await sor.R("world", {"id": params.get("id")})
async def create_world(params_kw=None):
"""创建世界(自动 id/code/created_at"""
ns = dict(params_kw or {})
ns["id"] = ns.get("id") or getID()
ns["code"] = ns.get("code") or _gen_code()
ns["created_at"] = ns.get("created_at") or curDateString()
dbname = _get_dbname()
async with DBPools().sqlorContext(dbname) as sor:
await sor.C("world", ns)
return {"success": True, "id": ns["id"]}
async def update_world(params_kw=None):
"""更新世界(写 updated_at"""
ns = dict(params_kw or {})
for k in list(ns.keys()):
if k.endswith("_text"):
ns.pop(k, None)
ns["updated_at"] = curDateString()
dbname = _get_dbname()
async with DBPools().sqlorContext(dbname) as sor:
await sor.U("world", ns)
return {"success": True}
async def delete_world(params_kw=None):
"""删除世界。"""
params = params_kw or {}
dbname = _get_dbname()
async with DBPools().sqlorContext(dbname) as sor:
await sor.D("world", {"id": params.get("id")})
return {"success": True}
def load_world(env=None):
"""挂载 world 模块函数到 ServerEnv。"""
if env is None:
from ahserver.serverenv import ServerEnv
env = ServerEnv()
env.list_worlds = list_worlds
env.get_world = get_world
# 单数 + CRUD 复数双注册xls2ui 生成的 add_world.dspy 包装用复数名)
env.create_world = create_world
env.create_worlds = create_world
env.update_world = update_world
env.update_worlds = update_world
env.delete_world = delete_world
env.delete_worlds = delete_world
env.get_world = get_world
env.list_worlds = list_worlds
env.set_world_mode = set_world_mode
info('world module loaded: create/update/delete/get/list_worlds/set_world_mode registered')
return env

398
world/world.py Normal file
View File

@ -0,0 +1,398 @@
"""world 模块核心业务逻辑W-01 世界管理)。
职责
- world CRUD创建自动编码 / 更新 / 删除 / 单条查询
- 列表查询sqlPaging 分页 + 复合索引过滤P95 达标
- world.mode 白名单硬校验draft/active/paused/archived
- mode 流转规则校验draftactivepausedactive/pausedarchivedarchivedactive
- 编码全局唯一唯一索引兜底 + 应用层前置校验 + 冲突重试
- 统一错误结构 {code, message, field, detail}分页结构 {list, total}
本文件为 .py 模块文件函数经 world/init.py load_world() 注册到 ServerEnv
wwwroot/api/*.dspy 薄包装直接调用
"""
import time
import random
from ahserver.serverenv import ServerEnv
from appPublic.jsonConfig import getConfig
from appPublic.uniqueID import getID
from appPublic.timeUtils import curDateString
from appPublic.log import debug, error
from sqlor.dbpools import DBPools
# ---------------------------------------------------------------------------
# 常量mode 白名单 + 流转规则
# ---------------------------------------------------------------------------
MODE_WHITELIST = ('draft', 'active', 'paused', 'archived')
# mode 流转规则from -> 允许切换到的目标集合
MODE_TRANSITIONS = {
'draft': {'active'},
'active': {'paused', 'archived'},
'paused': {'active', 'archived'},
'archived': {'active'},
}
# 列表查询列(显式列清单,排除 description TEXT 大字段,避免全表扫描与 off-page I/O
LIST_FIELDS = 'id, code, name, mode, world_type, owner_id, org_id, created_at, updated_at'
# 错误码
ERR_VALIDATION = 'VALIDATION_ERROR'
ERR_INVALID_MODE = 'INVALID_MODE'
ERR_INVALID_TRANSITION = 'INVALID_TRANSITION'
ERR_CODE_EXISTS = 'CODE_EXISTS'
ERR_NOT_FOUND = 'NOT_FOUND'
ERR_INTERNAL = 'INTERNAL_ERROR'
# ---------------------------------------------------------------------------
# 工具函数
# ---------------------------------------------------------------------------
def _err(code, message, field='', detail=''):
"""统一错误结构:{code, message, field, detail}。"""
return {'code': code, 'message': message, 'field': field, 'detail': detail}
def _ok(message='ok', data=None):
"""统一成功结构。"""
return {'code': 0, 'message': message, 'data': data or {}}
def _get_dbname():
env = ServerEnv()
return env.get_module_dbname('world')
def _get_pool():
db = DBPools()
config = getConfig()
db.databases = config.databases
return db
def _to_int(val, default):
try:
return int(val)
except (TypeError, ValueError):
return default
def _gen_world_code():
"""生成世界编码W + 时间戳 + 4 位随机数(可读、全局唯一概率极高)。"""
return 'W' + time.strftime('%Y%m%d%H%M%S') + format(random.randint(0, 9999), '04d')
def _pick(row, fields):
"""从查询结果行DictObject/dict中按列名取值返回普通 dict。"""
out = {}
for f in fields:
out[f] = row.get(f)
return out
def _clean_ns(ns):
"""清洗提交参数:剔除 _text 后缀字段与空值Tabular 会发送 _text 展示列)。"""
clean = {}
for k, v in (ns or {}).items():
if k.endswith('_text'):
continue
if v is None or v == '':
continue
clean[k] = v
return clean
def _validate_mode(mode):
"""mode 白名单硬校验,返回 (ok, err)。非法枚举 100% 拦截。"""
if not mode:
return False, _err(ERR_VALIDATION, '世界模式不能为空', 'mode')
if mode not in MODE_WHITELIST:
return False, _err(
ERR_INVALID_MODE,
'非法世界模式: %s,合法枚举为 %s' % (mode, '/'.join(MODE_WHITELIST)),
'mode',
)
return True, None
def _validate_transition(old_mode, new_mode):
"""mode 流转规则校验。"""
ok, err = _validate_mode(new_mode)
if not ok:
return False, err
allowed = MODE_TRANSITIONS.get(old_mode)
if allowed is None or new_mode not in allowed:
return False, _err(
ERR_INVALID_TRANSITION,
'不允许从 %s 切换到 %s(合法流转: draft→active⇄paused、active/paused→archived、archived→active'
% (old_mode, new_mode),
'mode',
)
return True, None
async def _check_code_unique(sor, code, exclude_id=None):
"""编码唯一性前置校验(唯一索引为最终兜底)。"""
if exclude_id:
rows = await sor.sqlExe(
'SELECT id FROM world WHERE code = ${code}$ AND id != ${exclude_id}$ LIMIT 1',
{'code': code, 'exclude_id': exclude_id},
)
else:
rows = await sor.sqlExe(
'SELECT id FROM world WHERE code = ${code}$ LIMIT 1',
{'code': code},
)
return not rows
def _is_dup_key_error(msg):
"""判断是否为唯一索引冲突Duplicate entry ... for key ...)。"""
return 'Duplicate' in msg and 'key' in msg
# ---------------------------------------------------------------------------
# 业务函数CRUD + 模式切换
# ---------------------------------------------------------------------------
async def create_world(params=None):
"""创建世界:自动编码 + mode 白名单/流转校验 + 唯一索引冲突重试。
非法输入缺名称非法 mode非法流转100% 拦截不落库
"""
params = params or {}
ns = _clean_ns(params)
now = curDateString()
name = ns.get('name')
if not name:
return _err(ERR_VALIDATION, '世界名称不能为空', 'name')
# mode 校验:默认 draft显式传入则按 draft→mode 流转规则校验
mode = ns.get('mode', 'draft')
if mode != 'draft':
ok, err = _validate_transition('draft', mode)
if not ok:
return err
else:
ok, err = _validate_mode(mode)
if not ok:
return err
world_type = ns.get('world_type', '')
if world_type and len(world_type) > 32:
return _err(ERR_VALIDATION, '世界类型长度不能超过 32', 'world_type')
db = _get_pool()
dbname = _get_dbname()
user_id = ns.get('owner_id', '')
org_id = ns.get('org_id', '0')
last_err = ''
for _attempt in range(3): # 唯一索引冲突重试(最多 3 次)
code = ns.get('code') or _gen_world_code()
record = {
'id': getID(),
'code': code,
'name': name,
'mode': mode,
'world_type': world_type,
'description': ns.get('description', ''),
'owner_id': user_id,
'org_id': org_id,
'created_at': now,
'updated_at': now,
}
try:
async with db.sqlorContext(dbname) as sor:
if not await _check_code_unique(sor, code):
if ns.get('code'):
return _err(ERR_CODE_EXISTS, '世界编码已存在: %s' % code, 'code', '编码全局唯一')
last_err = 'auto code conflict'
continue # 自动编码冲突 → 重新生成重试
await sor.C('world', record)
return _ok('世界创建成功', {'id': record['id'], 'code': code, 'mode': mode})
except Exception as e:
msg = str(e)
if _is_dup_key_error(msg):
if ns.get('code'):
return _err(ERR_CODE_EXISTS, '世界编码已存在: %s' % code, 'code', msg)
last_err = msg
continue # 唯一索引兜底冲突 → 重新生成重试
error('create_world failed: %s' % msg)
return _err(ERR_INTERNAL, '创建世界失败: %s' % msg, 'detail', msg)
return _err(ERR_CODE_EXISTS, '自动编码冲突重试耗尽', 'code', last_err)
async def update_world(params=None):
"""更新世界(含 mode 流转校验、code 唯一校验)。部分更新语义。"""
params = params or {}
ns = _clean_ns(params)
world_id = ns.get('id') or ns.get('world_id')
if not world_id:
return _err(ERR_VALIDATION, '缺少世界 ID', 'id')
db = _get_pool()
dbname = _get_dbname()
async with db.sqlorContext(dbname) as sor:
rows = await sor.R('world', {'id': world_id})
if not rows:
return _err(ERR_NOT_FOUND, '世界不存在: %s' % world_id, 'id')
cur = rows[0]
upd = {}
if 'name' in ns:
if not ns['name']:
return _err(ERR_VALIDATION, '世界名称不能为空', 'name')
upd['name'] = ns['name']
if 'world_type' in ns:
upd['world_type'] = ns['world_type']
if 'description' in ns:
upd['description'] = ns['description']
# mode 切换校验(仅在变更时)
new_mode = ns.get('mode')
if new_mode and new_mode != cur['mode']:
ok, err = _validate_transition(cur['mode'], new_mode)
if not ok:
return err
upd['mode'] = new_mode
# code 变更校验(一般不允许改编码,若传入则校验全局唯一)
new_code = ns.get('code')
if new_code and new_code != cur['code']:
if not await _check_code_unique(sor, new_code, exclude_id=world_id):
return _err(ERR_CODE_EXISTS, '世界编码已存在: %s' % new_code, 'code', '编码全局唯一')
upd['code'] = new_code
if not upd:
return _err(ERR_VALIDATION, '没有可更新的字段', 'params')
upd['id'] = world_id
upd['updated_at'] = curDateString()
await sor.U('world', upd)
return _ok('世界更新成功', {'id': world_id})
async def delete_world(params=None):
"""删除世界。"""
params = params or {}
world_id = params.get('id') or params.get('world_id')
if not world_id:
return _err(ERR_VALIDATION, '缺少世界 ID', 'id')
db = _get_pool()
dbname = _get_dbname()
async with db.sqlorContext(dbname) as sor:
rows = await sor.R('world', {'id': world_id})
if not rows:
return _err(ERR_NOT_FOUND, '世界不存在: %s' % world_id, 'id')
await sor.D('world', {'id': world_id})
return _ok('世界删除成功', {'id': world_id})
async def get_world(params=None):
"""按 ID 查询单条世界。"""
params = params or {}
world_id = params.get('id') or params.get('world_id')
if not world_id:
return _err(ERR_VALIDATION, '缺少世界 ID', 'id')
db = _get_pool()
dbname = _get_dbname()
async with db.sqlorContext(dbname) as sor:
rows = await sor.R('world', {'id': world_id})
if not rows:
return _err(ERR_NOT_FOUND, '世界不存在: %s' % world_id, 'id')
return _ok('ok', _pick(rows[0], LIST_FIELDS.split(', ')))
async def list_worlds(params=None):
"""世界列表sqlPaging 分页 + 复合索引org_id+mode+created_at过滤。
返回 {code:0, message, list: [...], total, page, page_size}
采用count 与数据查询分离+ 显式列清单P95 达标
"""
params = params or {}
page = _to_int(params.get('page'), 1)
rows_per_page = _to_int(params.get('rows') or params.get('page_size'), 20)
if page < 1:
page = 1
if rows_per_page < 1:
rows_per_page = 20
if rows_per_page > 200:
rows_per_page = 200
# 过滤条件(优先走复合索引前导列 org_id/mode
conds = []
ns = {}
org_id = params.get('org_id')
mode = params.get('mode')
world_type = params.get('world_type')
keyword = params.get('keyword')
if org_id:
conds.append('org_id = ${org_id}$')
ns['org_id'] = org_id
if mode:
conds.append('mode = ${mode}$')
ns['mode'] = mode
if world_type:
conds.append('world_type = ${world_type}$')
ns['world_type'] = world_type
if keyword:
conds.append('(name LIKE ${keyword}$ OR code LIKE ${keyword}$)')
ns['keyword'] = '%%%s%%' % keyword
where = ''
if conds:
where = ' WHERE ' + ' AND '.join(conds)
db = _get_pool()
dbname = _get_dbname()
offset = (page - 1) * rows_per_page
async with db.sqlorContext(dbname) as sor:
# ① count 查询
cnt_rows = await sor.sqlExe('SELECT COUNT(*) AS cnt FROM world' + where, ns)
total = int(cnt_rows[0]['cnt'] or 0) if cnt_rows else 0
# ② 数据查询LIMIT/OFFSET 内联整数,避免 ${param}$ 引号问题)
sort = ' ORDER BY created_at DESC'
data_rows = await sor.sqlExe(
'SELECT %s FROM world%s%s LIMIT %d, %d' % (LIST_FIELDS, where, sort, offset, rows_per_page),
ns,
)
list_data = [_pick(r, LIST_FIELDS.split(', ')) for r in (data_rows or [])]
return {
'code': 0,
'message': 'ok',
'list': list_data,
'total': total,
'page': page,
'page_size': rows_per_page,
}
async def set_world_mode(params=None):
"""模式切换严格按流转规则校验draft→active⇄paused、active/paused→archived、archived→active"""
params = params or {}
world_id = params.get('id') or params.get('world_id')
new_mode = params.get('mode')
if not world_id:
return _err(ERR_VALIDATION, '缺少世界 ID', 'id')
if not new_mode:
return _err(ERR_VALIDATION, '缺少目标模式', 'mode')
db = _get_pool()
dbname = _get_dbname()
async with db.sqlorContext(dbname) as sor:
rows = await sor.R('world', {'id': world_id})
if not rows:
return _err(ERR_NOT_FOUND, '世界不存在: %s' % world_id, 'id')
cur_mode = rows[0]['mode']
if cur_mode == new_mode:
return _err(ERR_INVALID_TRANSITION, '世界已处于 %s 模式,无需切换' % new_mode, 'mode')
ok, err = _validate_transition(cur_mode, new_mode)
if not ok:
return err
await sor.U('world', {'id': world_id, 'mode': new_mode, 'updated_at': curDateString()})
return _ok('模式切换成功', {'id': world_id, 'mode': new_mode})

View File

@ -1,6 +1,10 @@
ns = dict(params_kw or {})
for k in list(ns.keys()):
if k.endswith("_text"):
ns.pop(k, None)
result = await create_world(ns)
return {"success": True, "id": result.get("id")}
"""创建世界:自动编码 + mode 白名单/流转校验 + 编码唯一冲突重试。
REST 契约:错误结构 {code, message, field, detail};成功 {code:0, message, data}。
"""
try:
result = await create_worlds(dict(params_kw))
return json.dumps(result, ensure_ascii=False)
except Exception as e:
exception('create_world.dspy: %s' % e)
return json.dumps({'code': 'INTERNAL_ERROR', 'message': '创建世界失败', 'field': '', 'detail': format_exc()}, ensure_ascii=False)

View File

@ -1,8 +1,13 @@
result = [{"value": "", "text": "全部"}]
"""世界模式下拉数据源world_mode 编码字典):返回 [{value, text}],首项为"全部"。"""
result = [{'value': '', 'text': '全部'}]
try:
async with get_sor_context(request._run_ns, "world") as sor:
rows = await sor.sqlExe("select k as value, v as text from appcodes_kv where parentid='world_status' order by k", {})
return [{"value": "", "text": "全部"}] + [{"value": r.value, "text": r.text} for r in rows]
dbname = get_module_dbname('world')
async with DBPools().sqlorContext(dbname) as sor:
rows = await sor.sqlExe(
'SELECT k AS value, v AS text FROM appcodes_kv WHERE parentid = ${pid}$ ORDER BY id',
{'pid': 'world_mode'},
)
result = result + [{'value': r['value'], 'text': r['text']} for r in (rows or [])]
except Exception as e:
debug(f"get_search_status error: {e}")
return result
exception('get_search_status.dspy: %s' % e)
return json.dumps(result, ensure_ascii=False)

View File

@ -1,8 +1,13 @@
result = [{"value": "", "text": "全部"}]
"""世界类型下拉数据源world_type 编码字典):返回 [{value, text}],首项为"全部"。"""
result = [{'value': '', 'text': '全部'}]
try:
async with get_sor_context(request._run_ns, "world") as sor:
rows = await sor.sqlExe("select k as value, v as text from appcodes_kv where parentid='world_type' order by k", {})
return [{"value": "", "text": "全部"}] + [{"value": r.value, "text": r.text} for r in rows]
dbname = get_module_dbname('world')
async with DBPools().sqlorContext(dbname) as sor:
rows = await sor.sqlExe(
'SELECT k AS value, v AS text FROM appcodes_kv WHERE parentid = ${pid}$ ORDER BY id',
{'pid': 'world_type'},
)
result = result + [{'value': r['value'], 'text': r['text']} for r in (rows or [])]
except Exception as e:
debug(f"get_search_world_type error: {e}")
return result
exception('get_search_world_type.dspy: %s' % e)
return json.dumps(result, ensure_ascii=False)

View File

@ -0,0 +1,7 @@
"""按 ID 查询单个世界。"""
try:
result = await get_world(dict(params_kw))
return json.dumps(result, ensure_ascii=False)
except Exception as e:
exception('get_world.dspy: %s' % e)
return json.dumps({'code': 'INTERNAL_ERROR', 'message': '查询世界失败', 'field': '', 'detail': format_exc()}, ensure_ascii=False)

View File

@ -1,2 +1,10 @@
worlds = await list_worlds({})
return {"status": 0, "data": worlds}
"""世界列表查询sqlPaging + 复合索引(org_id+mode+created_at)过滤。
REST 契约:分页结构 {list, total, page, page_size};错误 {code, message, field, detail}。
"""
try:
data = await list_worlds(dict(params_kw))
return json.dumps(data, ensure_ascii=False)
except Exception as e:
exception('list_worlds.dspy: %s' % e)
return json.dumps({'code': 'INTERNAL_ERROR', 'message': '查询世界列表失败', 'field': '', 'detail': format_exc()}, ensure_ascii=False)

View File

@ -0,0 +1,10 @@
"""世界模式切换:严格流转规则校验。
draft→active⇄paused、active/paused→archived、archived→active。
"""
try:
result = await set_world_mode(dict(params_kw))
return json.dumps(result, ensure_ascii=False)
except Exception as e:
exception('set_world_mode.dspy: %s' % e)
return json.dumps({'code': 'INTERNAL_ERROR', 'message': '模式切换失败', 'field': '', 'detail': format_exc()}, ensure_ascii=False)

View File

@ -0,0 +1,7 @@
try:
result = await create_world(params_kw)
return {"status": "success", "data": result, "message": "创建成功"}
except WorldError as e:
return {"status": "error", "code": e.code, "message": e.message, "field": e.field, "detail": e.detail}
except Exception as e:
return {"status": "error", "code": "E_INTERNAL", "message": "服务器内部错误", "field": "", "detail": str(e)}

View File

@ -1,3 +1,7 @@
ns = dict(params_kw or {})
await delete_world(ns)
return {"success": True}
"""删除世界。"""
try:
result = await delete_worlds(dict(params_kw))
return json.dumps(result, ensure_ascii=False)
except Exception as e:
exception('world_delete.dspy: %s' % e)
return json.dumps({'code': 'INTERNAL_ERROR', 'message': '删除世界失败', 'field': '', 'detail': format_exc()}, ensure_ascii=False)

View File

@ -0,0 +1,7 @@
try:
result = await get_world(params_kw)
return {"status": "success", "data": result}
except WorldError as e:
return {"status": "error", "code": e.code, "message": e.message, "field": e.field, "detail": e.detail}
except Exception as e:
return {"status": "error", "code": "E_INTERNAL", "message": "服务器内部错误", "field": "", "detail": str(e)}

View File

@ -0,0 +1,7 @@
try:
result = await list_worlds(params_kw)
return {"status": "success", "data": {"list": result["list"], "total": result["total"]}, "total": result["total"]}
except WorldError as e:
return {"status": "error", "code": e.code, "message": e.message, "field": e.field, "detail": e.detail}
except Exception as e:
return {"status": "error", "code": "E_INTERNAL", "message": "服务器内部错误", "field": "", "detail": str(e)}

View File

@ -0,0 +1,7 @@
try:
result = await switch_world_mode(params_kw)
return {"status": "success", "data": result, "message": "模式切换成功"}
except WorldError as e:
return {"status": "error", "code": e.code, "message": e.message, "field": e.field, "detail": e.detail}
except Exception as e:
return {"status": "error", "code": "E_INTERNAL", "message": "服务器内部错误", "field": "", "detail": str(e)}

View File

@ -0,0 +1,7 @@
try:
result = await get_world_modes(params_kw)
return {"status": "success", "data": result}
except WorldError as e:
return {"status": "error", "code": e.code, "message": e.message, "field": e.field, "detail": e.detail}
except Exception as e:
return {"status": "error", "code": "E_INTERNAL", "message": "服务器内部错误", "field": "", "detail": str(e)}

View File

@ -1,6 +1,7 @@
ns = dict(params_kw or {})
for k in list(ns.keys()):
if k.endswith("_text"):
ns.pop(k, None)
await update_world(ns)
return {"success": True}
"""更新世界:部分更新 + mode 流转校验 + code 唯一校验。"""
try:
result = await update_worlds(dict(params_kw))
return json.dumps(result, ensure_ascii=False)
except Exception as e:
exception('world_update.dspy: %s' % e)
return json.dumps({'code': 'INTERNAL_ERROR', 'message': '更新世界失败', 'field': '', 'detail': format_exc()}, ensure_ascii=False)

View File

@ -2,17 +2,29 @@
"widgettype": "VBox",
"options": {"width": "100%", "height": "100%", "padding": "20px"},
"subwidgets": [
{"widgettype": "Text", "options": {"label": "世界管理", "fontSize": "24px"}},
{"widgettype": "Text", "options": {"label": "世界管理", "fontSize": "24px", "marginBottom": "12px"}},
{"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_content",
"options": {"url": "{{entire_url('/world/world')}}"}, "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_content",
"options": {"url": "{{entire_url('/world/api/create_world.dspy')}}"}, "mode": "replace"}],
"subwidgets": [{"widgettype": "Text", "options": {"label": "新建世界"}}]}
{
"widgettype": "VBox",
"options": {"backgroundColor": "#FFFFFF", "padding": "20px", "cursor": "pointer", "borderRadius": "6px", "boxShadow": "0 1px 4px rgba(0,0,0,0.1)"},
"binds": [{"wid": "self", "event": "click", "actiontype": "urlwidget", "target": "app.world_content",
"options": {"url": "{{entire_url('/world/world')}}"}, "mode": "replace"}],
"subwidgets": [
{"widgettype": "Text", "options": {"label": "世界列表", "fontSize": "16px"}},
{"widgettype": "Text", "options": {"label": "世界定义 CRUD创建自动编码、模式白名单校验、编码全局唯一", "fontSize": "12px", "color": "#888"}}
]
},
{
"widgettype": "VBox",
"options": {"backgroundColor": "#FFFFFF", "padding": "20px", "cursor": "pointer", "borderRadius": "6px", "boxShadow": "0 1px 4px rgba(0,0,0,0.1)"},
"binds": [{"wid": "self", "event": "click", "actiontype": "urlwidget", "target": "app.world_content",
"options": {"url": "{{entire_url('/world/api/list_worlds.dspy')}}"}, "mode": "replace"}],
"subwidgets": [
{"widgettype": "Text", "options": {"label": "列表查询接口", "fontSize": "16px"}},
{"widgettype": "Text", "options": {"label": "sqlPaging 分页 + 复合索引过滤({list, total}", "fontSize": "12px", "color": "#888"}}
]
}
]},
{"widgettype": "VBox", "id": "app.world_content", "options": {"width": "100%", "flex": "1", "marginTop": "20px"}}
{"widgettype": "VBox", "id": "world_content", "options": {"width": "100%", "flex": "1", "marginTop": "20px"}}
]
}

7
wwwroot/menu.ui Normal file
View File

@ -0,0 +1,7 @@
{
"widgettype": "Menu",
"options": {"target": "PopupWindow", "popup_options": {"width": "70%", "height": "80%"}, "items": [
{"name": "world_list", "label": "世界列表", "url": "{{entire_url('/world/world')}}"},
{"name": "world_index", "label": "世界管理首页", "url": "{{entire_url('/world/index.ui')}}"}
]}
}