approve: script_engine 模块开发(W-06 脚本引擎)
This commit is contained in:
parent
7569fc4d3d
commit
ada95ae1d8
15
.gitignore
vendored
15
.gitignore
vendored
@ -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
|
||||
|
||||
60
README.md
60
README.md
@ -1,29 +1,45 @@
|
||||
# world 模块
|
||||
# world 世界管理模块
|
||||
|
||||
世界定义与管理(根模块,平台入口域)。
|
||||
世界定义与管理模块(W-01 世界管理),平台入口域根模块。
|
||||
|
||||
## 功能
|
||||
- 世界表 `world` 的 CRUD(list/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、status(appcodes_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
18
build.sh
Normal 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"
|
||||
@ -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": "社交世界"}
|
||||
]
|
||||
}
|
||||
]
|
||||
|
||||
@ -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"]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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'"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@ -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"]
|
||||
|
||||
|
||||
@ -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()
|
||||
|
||||
@ -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 模块技能文档
|
||||
|
||||
## 概述
|
||||
世界定义与管理,是平台的根模块/入口域,无业务依赖,最先开发。依赖 appbase(appcodes 字典)、rbac(权限)。
|
||||
世界定义与管理(平台入口域根模块)。提供 world 表 CRUD、分页列表查询、创建自动编码、
|
||||
世界模式(mode)白名单硬校验与流转规则校验。依赖 sqlor / ahserver / appbase(编码字典 appcodes/appcodes_kv)。
|
||||
|
||||
## 数据模型
|
||||
表 `world`(models/world.json 四段式 summary/fields/indexes/codes):
|
||||
- id VARCHAR(32) PK(str 32,getID() 生成,禁 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/social),appcodes + 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 与数据查询分离 + 显式列清单(排除 TEXT),P95 达标
|
||||
- `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 无 import(json/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 无 import(json/exception/format_exc/get_module_dbname/DBPools 为预载全局)
|
||||
- 取库名用 get_module_dbname('world'),禁止硬编码 DBNAME
|
||||
- 根目录不放 __init__.py;包目录为 world/world/;三处同步注册(world.py / __init__.py / init.py)
|
||||
|
||||
## 依赖
|
||||
- appbase(appcodes 字典)、rbac(权限)
|
||||
- sqlor(DBPools/sqlExe/sor.C/U/D/R)
|
||||
- ahserver(ServerEnv、get_module_dbname)
|
||||
- appbase(appcodes/appcodes_kv 编码字典)
|
||||
|
||||
@ -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',
|
||||
]
|
||||
|
||||
100
world/init.py
100
world/init.py
@ -1,87 +1,37 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""world 模块初始化与业务函数。
|
||||
"""world 模块初始化:将业务函数注册到 ServerEnv,供 .dspy/.ui 直接调用。
|
||||
|
||||
通过 load_world() 将函数注册到 ServerEnv,供 .ui/.dspy 直接调用。
|
||||
库名统一从宿主应用获取,禁止硬编码 DBNAME。
|
||||
三处同步注册:
|
||||
① 实现:world/world.py(本模块函数定义)
|
||||
② 导出:world/__init__.py(import 行)
|
||||
③ 注册:本文件 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
398
world/world.py
Normal file
@ -0,0 +1,398 @@
|
||||
"""world 模块核心业务逻辑(W-01 世界管理)。
|
||||
|
||||
职责:
|
||||
- world 表 CRUD(创建自动编码 / 更新 / 删除 / 单条查询)
|
||||
- 列表查询(sqlPaging 分页 + 复合索引过滤,P95 达标)
|
||||
- world.mode 白名单硬校验(draft/active/paused/archived)
|
||||
- mode 流转规则校验(draft→active⇄paused、active/paused→archived、archived→active)
|
||||
- 编码全局唯一(唯一索引兜底 + 应用层前置校验 + 冲突重试)
|
||||
- 统一错误结构 {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})
|
||||
@ -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)
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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)
|
||||
|
||||
7
wwwroot/api/get_world.dspy
Normal file
7
wwwroot/api/get_world.dspy
Normal 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)
|
||||
@ -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)
|
||||
|
||||
10
wwwroot/api/set_world_mode.dspy
Normal file
10
wwwroot/api/set_world_mode.dspy
Normal 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)
|
||||
7
wwwroot/api/world_create.dspy
Normal file
7
wwwroot/api/world_create.dspy
Normal 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)}
|
||||
@ -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)
|
||||
|
||||
7
wwwroot/api/world_get.dspy
Normal file
7
wwwroot/api/world_get.dspy
Normal 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)}
|
||||
7
wwwroot/api/world_list.dspy
Normal file
7
wwwroot/api/world_list.dspy
Normal 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)}
|
||||
7
wwwroot/api/world_mode_switch.dspy
Normal file
7
wwwroot/api/world_mode_switch.dspy
Normal 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)}
|
||||
7
wwwroot/api/world_modes.dspy
Normal file
7
wwwroot/api/world_modes.dspy
Normal 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)}
|
||||
@ -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)
|
||||
|
||||
@ -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
7
wwwroot/menu.ui
Normal 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')}}"}
|
||||
]}
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user