feat(world): W-01 世界管理模块 — world 表 CRUD/列表查询/自动编码/mode 白名单与流转校验/编码字典幂等落库
This commit is contained in:
parent
ada95ae1d8
commit
22b18f556c
8
.gitignore
vendored
8
.gitignore
vendored
@ -1,14 +1,8 @@
|
|||||||
# Python
|
|
||||||
__pycache__/
|
__pycache__/
|
||||||
*.py[cod]
|
*.py[cod]
|
||||||
|
*.egg-info/
|
||||||
build/
|
build/
|
||||||
dist/
|
dist/
|
||||||
*.egg-info/
|
|
||||||
*.egg
|
|
||||||
|
|
||||||
# 生成物
|
|
||||||
models/mysql.ddl.sql
|
models/mysql.ddl.sql
|
||||||
wwwroot/world/ # xls2ui 生成的 CRUD 目录(build 产物)
|
|
||||||
*.swp
|
*.swp
|
||||||
*.swo
|
*.swo
|
||||||
.DS_Store
|
|
||||||
|
|||||||
77
README.md
77
README.md
@ -1,45 +1,46 @@
|
|||||||
# world 世界管理模块
|
# world 世界定义与管理模块(W-01 世界管理)
|
||||||
|
|
||||||
世界定义与管理模块(W-01 世界管理),平台入口域根模块。
|
|
||||||
|
|
||||||
## 功能
|
## 功能
|
||||||
- world 表 CRUD(创建自动编码 W+时间戳+随机4位)
|
- world 表 CRUD(创建/更新/删除/查询)
|
||||||
- 列表查询:sqlPaging 分页 + 复合索引(org_id+mode+created_at)过滤,返回 {list, total}
|
- 分页列表查询(sqlPaging + 复合索引 idx_world_list)
|
||||||
- world.mode 白名单硬校验:draft / active / paused / archived
|
- 创建自动编码(W + 时间戳 + 随机4位,全局唯一,冲突重试 3 次)
|
||||||
- 模式流转规则校验:draft→active⇄paused、active/paused→archived、archived→active
|
- mode 白名单硬校验(draft/active/paused/archived)与流转规则校验
|
||||||
- 编码全局唯一:唯一索引 + 应用层前置校验 + 冲突重试(3 次)
|
- world_mode / world_type 编码字典幂等落库(appcodes + appcodes_kv)
|
||||||
- world_mode / world_type 编码字典经 init/data.json 幂等落库(appcodes + appcodes_kv)
|
|
||||||
|
|
||||||
## 数据表
|
## 数据表
|
||||||
- `world`:世界定义表(models/world.json,四段式 summary/fields/indexes/codes)
|
`world`(models/world.json 四段式 summary/fields/indexes/codes):
|
||||||
- 编码字典:`world_mode`(draft/active/paused/archived)、`world_type`(game/simulation/education/business/social)
|
- 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'
|
||||||
|
- world_type VARCHAR(32)
|
||||||
|
- owner_id / org_id VARCHAR(32)
|
||||||
|
- created_at / updated_at TIMESTAMP
|
||||||
|
- 复合索引 idx_world_list(org_id, mode, created_at)
|
||||||
|
|
||||||
## 接口
|
## 安装集成
|
||||||
REST 统一前缀 `/api/*`,错误结构 `{code, message, field, detail}`,分页 `{list, total}`:
|
1. 模块仓库克隆到宿主应用 pkgs/,`pip install .`
|
||||||
- `POST/GET /world/api/create_world.dspy` 创建(自动编码、mode 校验)
|
2. 宿主应用 `app/{app}.py` 引入 `from world.init import load_world` 并在 init() 调用 `load_world()`
|
||||||
- `POST /world/api/world_update.dspy` 更新(mode 流转校验、code 唯一校验)
|
3. 宿主应用定义 `get_module_dbname('world')` 映射到实际库名(禁止模块内硬编码 DBNAME)
|
||||||
- `POST /world/api/world_delete.dspy` 删除
|
4. `scripts/load_path.py` 注册 RBAC 路径(禁通配符)
|
||||||
- `GET /world/api/get_world.dspy` 单条查询
|
5. 构建:`bash build.sh`(生成 DDL、CRUD UI、链接 wwwroot)
|
||||||
- `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` 下拉数据源
|
|
||||||
|
|
||||||
## 安装与集成
|
## 关键接口(经 load_world() 注册)
|
||||||
1. `pip install .`(pyproject.toml,依赖 sqlor / bricks_for_python)
|
- `create_world(params)` → 自动编码;mode 默认 draft;非法 mode/流转 100% 拦截;编码冲突重试 3 次
|
||||||
2. 宿主应用 `from world.init import load_world`,init() 中 `load_world()` 挂载
|
- `update_world(params)` → 部分更新;mode 变更走流转校验;code 变更走全局唯一校验
|
||||||
3. 建表:build.sh 中 json2ddl 依据 models/world.json 生成 DDL;编码字典经 init/data.json 幂等落库
|
- `delete_world(params)` / `get_world(params)`
|
||||||
4. RBAC:运行 `scripts/load_path.py`(显式路径注册,禁通配符)
|
- `list_worlds(params)` → 分页 {list, total, page, page_size}
|
||||||
5. 前端:wwwroot/index.ui(入口)、menu.ui(菜单)、CRUD 由 json/world.json 经 xls2ui 生成
|
- `set_world_mode(params)` → 模式切换严格校验(draft→active⇄paused、active/paused→archived、archived→active)
|
||||||
|
- `get_world_mode_options()` / `get_world_type_options()` → 下拉数据源 [{value, text}]
|
||||||
|
|
||||||
## 目录结构
|
REST 前缀 `/api/*`:create_world.dspy / world_update.dspy / world_delete.dspy /
|
||||||
```
|
get_world.dspy / list_worlds.dspy / set_world_mode.dspy /
|
||||||
world/
|
get_search_status.dspy / get_search_world_type.dspy
|
||||||
├── world/ # Python 包(world.py 业务逻辑 / init.py 注册 / __init__.py 导出)
|
|
||||||
├── wwwroot/ # index.ui、menu.ui、api/*.dspy
|
## 错误与分页结构
|
||||||
├── models/world.json # 表定义(四段式)
|
- 错误:`{code, message, field, detail}`
|
||||||
├── json/world.json # CRUD 定义(tblname+params)
|
- 分页:`{list, total, page, page_size}`
|
||||||
├── init/data.json # 编码字典种子数据
|
|
||||||
├── scripts/load_path.py # RBAC 注册
|
## 依赖
|
||||||
├── skill/SKILL.md # 模块技能文档
|
sqlor(DBPools/sor.C/U/D/R/sqlPaging/sqlExe)、ahserver(ServerEnv)、appbase(appcodes/appcodes_kv)
|
||||||
└── pyproject.toml # 打包
|
|
||||||
```
|
|
||||||
|
|||||||
45
build.sh
45
build.sh
@ -1,18 +1,37 @@
|
|||||||
#!/bin/bash
|
#!/usr/bin/env bash
|
||||||
# world 模块构建脚本(四步安装①:DDL 生成;②:CRUD UI 生成;③:编码字典落库;④:RBAC 注册)
|
# world 模块构建脚本:生成 DDL、生成 CRUD UI、链接 wwwroot 到宿主应用
|
||||||
set -e
|
set -euo pipefail
|
||||||
cd "$(dirname "$0")"
|
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
|
MOD=world
|
||||||
|
|
||||||
echo "[world] ① 生成 DDL (json2ddl)"
|
# 定位宿主 Sage 根目录
|
||||||
json2ddl mysql ./models > models/mysql.ddl.sql 2>/dev/null || echo " json2ddl 不可用,跳过(部署时由应用 build.sh 统一执行)"
|
SAGE_ROOT=""
|
||||||
|
for candidate in "$SCRIPT_DIR/../.." "$HOME/repos/sage" "$HOME/sage"; do
|
||||||
|
if [ -d "$candidate/wwwroot" ] && [ -d "$candidate/py3/bin" ]; then
|
||||||
|
SAGE_ROOT="$(cd "$candidate" && pwd)"
|
||||||
|
break
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
if [ -z "$SAGE_ROOT" ]; then
|
||||||
|
echo "ERROR: sage root not found" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
PY="$SAGE_ROOT/py3/bin"
|
||||||
|
|
||||||
echo "[world] ② 生成 CRUD UI (xls2ui)"
|
echo "==> 1/4 生成 DDL"
|
||||||
xls2ui -m ../models -o ../wwwroot world ./json/*.json 2>/dev/null || echo " xls2ui 不可用,跳过(部署时由应用 build.sh 统一执行)"
|
if [ -d "$SCRIPT_DIR/models" ]; then
|
||||||
|
"$PY/python3" "$PY/json2ddl" mysql "$SCRIPT_DIR/models" > "$SCRIPT_DIR/models/mysql.ddl.sql" 2>/dev/null \
|
||||||
|
|| "$PY/json2ddl" mysql "$SCRIPT_DIR/models" > "$SCRIPT_DIR/models/mysql.ddl.sql"
|
||||||
|
fi
|
||||||
|
|
||||||
echo "[world] ③ 编码字典落库(init/data.json,应用初始化时幂等执行)"
|
echo "==> 2/4 生成 CRUD UI(xls2ui)"
|
||||||
echo " 由宿主应用初始化流程执行 appcodes/appcodes_kv 幂等插入"
|
if [ -d "$SCRIPT_DIR/json" ]; then
|
||||||
|
(cd "$SCRIPT_DIR" && "$PY/python3" "$PY/xls2ui" -m "$SCRIPT_DIR/models" -o "$SCRIPT_DIR/wwwroot" "$MOD" json/*.json) \
|
||||||
|
|| (cd "$SCRIPT_DIR" && "$PY/xls2ui" -m "$SCRIPT_DIR/models" -o "$SCRIPT_DIR/wwwroot" "$MOD" json/*.json)
|
||||||
|
fi
|
||||||
|
|
||||||
echo "[world] ④ RBAC 注册"
|
echo "==> 3/4 链接 wwwroot 到宿主应用"
|
||||||
python3 scripts/load_path.py || echo " load_path.py 需在 Sage 环境执行"
|
mkdir -p "$SAGE_ROOT/wwwroot/$MOD"
|
||||||
|
ln -sfn "$SCRIPT_DIR/wwwroot" "$SAGE_ROOT/wwwroot/$MOD"
|
||||||
|
|
||||||
echo "[world] build done"
|
echo "==> 4/4 完成:world 模块构建成功"
|
||||||
|
|||||||
46
docs/work-log.md
Normal file
46
docs/work-log.md
Normal file
@ -0,0 +1,46 @@
|
|||||||
|
# Work Log — 2025-01-xx
|
||||||
|
|
||||||
|
## Scope / Background
|
||||||
|
- 任务:W-01 世界管理(world 模块,new_dev,task_id gvpV_sE3EQghCY2yNovFT,元景项目初始迭代)
|
||||||
|
- 仓库:机构 modules/world/
|
||||||
|
- 目标:world 表 CRUD + 列表查询(sqlPaging+复合索引) + 创建自动编码 + mode 白名单/流转校验 +
|
||||||
|
world_mode 编码字典幂等落库 + /api/* REST + 统一错误结构 + 分页结构
|
||||||
|
|
||||||
|
## Timeline / Commits
|
||||||
|
(git log --oneline 输出见下方验证区)
|
||||||
|
|
||||||
|
## Key Technical Decisions
|
||||||
|
1. mode 校验集中在 world.py(MODE_WHITELIST + MODE_TRANSITIONS),所有入口(create/update/set_world_mode)统一拦截。
|
||||||
|
2. 自动编码 W+时间戳+随机4位;唯一索引兜底 + Duplicate key 重试 3 次。
|
||||||
|
3. 列表查询:count 与数据分离、显式列清单(排除 TEXT)、idx_world_list(org_id,mode,created_at) 复合索引。
|
||||||
|
4. 下拉数据源从 appcodes_kv 读(world_mode/world_type),字典不可用回退英文标签,避免硬编码中文。
|
||||||
|
5. init.py 单数/复数双注册(create_world/create_worlds),满足 CRUD 框架 dspy 包装器调用约定。
|
||||||
|
|
||||||
|
## Verification(QC 硬证据)
|
||||||
|
```
|
||||||
|
$ python3 -m py_compile world/world.py world/init.py world/__init__.py scripts/load_path.py
|
||||||
|
(无输出 = 通过)
|
||||||
|
|
||||||
|
$ grep -rn "^import\|^from" wwwroot/ --include='*.dspy'
|
||||||
|
(无输出 = 通过,dspy 零 import)
|
||||||
|
|
||||||
|
$ grep -rn "print\|uuid" wwwroot/ --include='*.dspy'
|
||||||
|
(无输出 = 通过)
|
||||||
|
|
||||||
|
$ python3 -c "import json; \
|
||||||
|
json.load(open('init/data.json')); json.load(open('models/world.json')); json.load(open('json/world.json'))"
|
||||||
|
(无输出 = 通过)
|
||||||
|
|
||||||
|
$ python3 -c "from setuptools import find_packages; print(find_packages(where='.'))"
|
||||||
|
['world'] # 仅包目录,根目录无 __init__.py,不会误打包
|
||||||
|
|
||||||
|
$ git log --oneline
|
||||||
|
<提交记录见交付件>
|
||||||
|
```
|
||||||
|
|
||||||
|
## Environment-Limited Checks
|
||||||
|
- 本环境无 MySQL/Sage 运行时,无法现场起服务 curl 验证;已用 py_compile + JSON load +
|
||||||
|
dspy grep 审计 + 打包 find_packages 做静态闭环验证。部署时按 build.sh 生成 DDL/CRUD UI 并落库。
|
||||||
|
|
||||||
|
## Current State
|
||||||
|
- 分支/提交:见 git log;模块代码已本地提交(QC 核验硬证据)。
|
||||||
@ -4,21 +4,48 @@
|
|||||||
"parentid": "world_mode",
|
"parentid": "world_mode",
|
||||||
"parentname": "世界模式",
|
"parentname": "世界模式",
|
||||||
"items": [
|
"items": [
|
||||||
{"k": "draft", "v": "草稿"},
|
{
|
||||||
{"k": "active", "v": "运行中"},
|
"k": "draft",
|
||||||
{"k": "paused", "v": "已暂停"},
|
"v": "草稿"
|
||||||
{"k": "archived", "v": "已归档"}
|
},
|
||||||
|
{
|
||||||
|
"k": "active",
|
||||||
|
"v": "运行中"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"k": "paused",
|
||||||
|
"v": "已暂停"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"k": "archived",
|
||||||
|
"v": "已归档"
|
||||||
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"parentid": "world_type",
|
"parentid": "world_type",
|
||||||
"parentname": "世界类型",
|
"parentname": "世界类型",
|
||||||
"items": [
|
"items": [
|
||||||
{"k": "game", "v": "游戏世界"},
|
{
|
||||||
{"k": "simulation", "v": "仿真世界"},
|
"k": "game",
|
||||||
{"k": "education", "v": "教育世界"},
|
"v": "游戏"
|
||||||
{"k": "business", "v": "商业世界"},
|
},
|
||||||
{"k": "social", "v": "社交世界"}
|
{
|
||||||
|
"k": "simulation",
|
||||||
|
"v": "仿真"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"k": "education",
|
||||||
|
"v": "教育"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"k": "business",
|
||||||
|
"v": "商业"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"k": "social",
|
||||||
|
"v": "社交"
|
||||||
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
|||||||
125
json/world.json
125
json/world.json
@ -1,41 +1,112 @@
|
|||||||
{
|
{
|
||||||
"tblname": "world",
|
"tblname": "world",
|
||||||
"title": "世界管理",
|
|
||||||
"params": {
|
"params": {
|
||||||
"sortby": ["created_at desc"],
|
"browserfields": {
|
||||||
"new_data_url": "{{entire_url('/world/api/create_world.dspy')}}",
|
"title": "世界列表",
|
||||||
"update_data_url": "{{entire_url('/world/api/world_update.dspy')}}",
|
"fields": [
|
||||||
"delete_data_url": "{{entire_url('/world/api/world_delete.dspy')}}",
|
{
|
||||||
"editable": {
|
"name": "id",
|
||||||
"new_data_url": "{{entire_url('../api/create_world.dspy')}}",
|
"title": "ID",
|
||||||
"update_data_url": "{{entire_url('../api/world_update.dspy')}}",
|
"hidden": true
|
||||||
"delete_data_url": "{{entire_url('../api/world_delete.dspy')}}"
|
|
||||||
},
|
},
|
||||||
"data_filter": {
|
{
|
||||||
"AND": [
|
"name": "code",
|
||||||
{"field": "name", "op": "LIKE", "var": "name_input"},
|
"title": "世界编码",
|
||||||
{"field": "mode", "op": "=", "var": "mode_input"},
|
"width": 130
|
||||||
{"field": "world_type", "op": "=", "var": "world_type_input"}
|
},
|
||||||
|
{
|
||||||
|
"name": "name",
|
||||||
|
"title": "世界名称",
|
||||||
|
"width": 200
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "mode",
|
||||||
|
"title": "世界模式",
|
||||||
|
"width": 100
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "world_type",
|
||||||
|
"title": "世界类型",
|
||||||
|
"width": 100
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "owner_id",
|
||||||
|
"title": "所属用户",
|
||||||
|
"width": 100,
|
||||||
|
"hidden": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "org_id",
|
||||||
|
"title": "所属组织",
|
||||||
|
"width": 100,
|
||||||
|
"hidden": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "created_at",
|
||||||
|
"title": "创建时间",
|
||||||
|
"width": 160
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "updated_at",
|
||||||
|
"title": "更新时间",
|
||||||
|
"width": 160
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"sorts": [
|
||||||
|
{
|
||||||
|
"field": "created_at",
|
||||||
|
"type": "desc"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"dataurl": "{{entire_url('/world/api/list_worlds.dspy')}}",
|
||||||
|
"alters": [
|
||||||
|
{
|
||||||
|
"field": "mode",
|
||||||
|
"uitype": "code",
|
||||||
|
"dataurl": "{{entire_url('/world/api/get_search_status.dspy')}}"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"field": "world_type",
|
||||||
|
"uitype": "code",
|
||||||
|
"dataurl": "{{entire_url('/world/api/get_search_world_type.dspy')}}"
|
||||||
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"filter_labels": {
|
"editable": {
|
||||||
"name_input": "世界名称",
|
"fields": [
|
||||||
"mode_input": "世界模式",
|
{
|
||||||
"world_type_input": "世界类型"
|
"name": "code",
|
||||||
|
"title": "世界编码",
|
||||||
|
"uitype": "text",
|
||||||
|
"required": false
|
||||||
},
|
},
|
||||||
"browserfields": {
|
{
|
||||||
"exclouded": ["id"],
|
"name": "name",
|
||||||
"alters": {
|
"title": "世界名称",
|
||||||
"mode": {
|
"uitype": "text",
|
||||||
|
"required": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "description",
|
||||||
|
"title": "世界描述",
|
||||||
|
"uitype": "textarea"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "mode",
|
||||||
|
"title": "世界模式",
|
||||||
"uitype": "code",
|
"uitype": "code",
|
||||||
"dataurl": "{{entire_url('../api/get_search_status.dspy')}}"
|
"dataurl": "{{entire_url('/world/api/get_search_status.dspy')}}"
|
||||||
},
|
},
|
||||||
"world_type": {
|
{
|
||||||
|
"name": "world_type",
|
||||||
|
"title": "世界类型",
|
||||||
"uitype": "code",
|
"uitype": "code",
|
||||||
"dataurl": "{{entire_url('../api/get_search_world_type.dspy')}}"
|
"dataurl": "{{entire_url('/world/api/get_search_world_type.dspy')}}"
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
]
|
||||||
},
|
},
|
||||||
"editexclouded": ["id", "code", "owner_id", "org_id", "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')}}"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -50,13 +50,13 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "owner_id",
|
"name": "owner_id",
|
||||||
"title": "创建人",
|
"title": "所属用户ID",
|
||||||
"type": "str",
|
"type": "str",
|
||||||
"length": 32
|
"length": 32
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "org_id",
|
"name": "org_id",
|
||||||
"title": "所属机构",
|
"title": "所属组织ID",
|
||||||
"type": "str",
|
"type": "str",
|
||||||
"length": 32,
|
"length": 32,
|
||||||
"default": "0"
|
"default": "0"
|
||||||
@ -70,7 +70,8 @@
|
|||||||
{
|
{
|
||||||
"name": "updated_at",
|
"name": "updated_at",
|
||||||
"title": "更新时间",
|
"title": "更新时间",
|
||||||
"type": "timestamp"
|
"type": "timestamp",
|
||||||
|
"nullable": "no"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"indexes": [
|
"indexes": [
|
||||||
|
|||||||
@ -5,7 +5,7 @@ build-backend = "setuptools.build_meta"
|
|||||||
[project]
|
[project]
|
||||||
name = "world"
|
name = "world"
|
||||||
version = "1.0.0"
|
version = "1.0.0"
|
||||||
description = "世界定义与管理模块(W-01 世界管理):world 表 CRUD、列表查询、创建自动编码、mode 白名单/流转校验、world_mode 编码字典"
|
description = "世界定义与管理模块(W-01 世界管理):world 表 CRUD、列表查询、自动编码、mode 白名单与流转校验"
|
||||||
requires-python = ">=3.8"
|
requires-python = ">=3.8"
|
||||||
dependencies = ["sqlor", "bricks_for_python"]
|
dependencies = ["sqlor", "bricks_for_python"]
|
||||||
|
|
||||||
|
|||||||
@ -1,37 +1,27 @@
|
|||||||
#!/usr/bin/env python3
|
#!/usr/bin/env python3
|
||||||
"""world 模块 RBAC 权限注册脚本(显式路径,禁通配符)。
|
# -*- coding: utf-8 -*-
|
||||||
|
"""world 模块 RBAC 路径注册脚本(显式注册,禁通配符)。
|
||||||
|
|
||||||
Usage: cd ~/repos/sage && ./py3/bin/python ~/repos/world/scripts/load_path.py
|
包含全部新 API/页面路径:wwwroot/index.ui、menu.ui 及 wwwroot/api/*.dspy。
|
||||||
|
角色:logined(需登录);menu.ui 为 any(预登录资源)。
|
||||||
|
执行:python scripts/load_path.py
|
||||||
"""
|
"""
|
||||||
import subprocess
|
|
||||||
import os
|
import os
|
||||||
import sys
|
import sys
|
||||||
|
|
||||||
|
|
||||||
def find_sage_root():
|
|
||||||
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'
|
MOD = 'world'
|
||||||
|
|
||||||
# 公开路径(无需登录)
|
# 显式路径注册(禁止 % / * 通配符)
|
||||||
PATHS_ANY = [
|
|
||||||
'/world/menu.ui',
|
|
||||||
]
|
|
||||||
|
|
||||||
# 登录路径(页面 + API + CRUD 自动生成目录,全部显式注册)
|
|
||||||
PATHS_LOGINED = [
|
PATHS_LOGINED = [
|
||||||
'/world',
|
'/world',
|
||||||
'/world/index.ui',
|
'/world/index.ui',
|
||||||
|
'/world/menu.ui',
|
||||||
|
'/world/world_list',
|
||||||
|
'/world/world_list/index.ui',
|
||||||
|
'/world/world_list/get_world_list.dspy',
|
||||||
|
'/world/world_list/add_world.dspy',
|
||||||
|
'/world/world_list/update_world.dspy',
|
||||||
|
'/world/world_list/delete_world.dspy',
|
||||||
'/world/api/create_world.dspy',
|
'/world/api/create_world.dspy',
|
||||||
'/world/api/world_update.dspy',
|
'/world/api/world_update.dspy',
|
||||||
'/world/api/world_delete.dspy',
|
'/world/api/world_delete.dspy',
|
||||||
@ -40,34 +30,50 @@ PATHS_LOGINED = [
|
|||||||
'/world/api/set_world_mode.dspy',
|
'/world/api/set_world_mode.dspy',
|
||||||
'/world/api/get_search_status.dspy',
|
'/world/api/get_search_status.dspy',
|
||||||
'/world/api/get_search_world_type.dspy',
|
'/world/api/get_search_world_type.dspy',
|
||||||
# CRUD 自动生成目录 world/(xls2ui 依据 json/world.json 生成)
|
]
|
||||||
'/world/world',
|
PATHS_ANY = [
|
||||||
'/world/world/index.ui',
|
'/world/menu.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):
|
def find_sage_root():
|
||||||
try:
|
candidates = [
|
||||||
r = subprocess.run([PYTHON, SET_PERM_SCRIPT, role, path], capture_output=True, text=True)
|
os.path.abspath(os.path.join(os.path.dirname(__file__), '..', '..', '..')),
|
||||||
return r.returncode == 0
|
os.path.expanduser('~/repos/sage'),
|
||||||
except Exception as e:
|
os.path.expanduser('~/sage'),
|
||||||
print(' set_role_perm error for %s: %s' % (path, e))
|
]
|
||||||
return False
|
for cand in candidates:
|
||||||
|
if os.path.isdir(os.path.join(cand, 'wwwroot')) and os.path.isdir(os.path.join(cand, 'py3', 'bin')):
|
||||||
|
return cand
|
||||||
def register_role_paths(role, paths):
|
return None
|
||||||
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():
|
def main():
|
||||||
total = register_role_paths('any', PATHS_ANY) + register_role_paths('logined', PATHS_LOGINED)
|
sage_root = find_sage_root()
|
||||||
print('Done. Total %d permission entries registered.' % total)
|
if not sage_root:
|
||||||
|
print('ERROR: sage root not found; register paths manually in central load_path.py')
|
||||||
|
sys.exit(1)
|
||||||
|
sys.path.insert(0, os.path.join(sage_root, 'py3', 'bin'))
|
||||||
|
try:
|
||||||
|
from load_path import register_paths # noqa
|
||||||
|
except ImportError:
|
||||||
|
# 中央 load_path.py 未提供可导入函数时,输出待注册清单供人工接入
|
||||||
|
print('INFO: central load_path.py has no importable register_paths; '
|
||||||
|
'falling back to central registration file (see sage/load_path.py)')
|
||||||
|
register_paths = None
|
||||||
|
if register_paths is not None:
|
||||||
|
for p in PATHS_ANY:
|
||||||
|
register_paths(p, 'any')
|
||||||
|
for p in PATHS_LOGINED:
|
||||||
|
register_paths(p, 'logined')
|
||||||
|
print('world RBAC paths registered: %d logined, %d any'
|
||||||
|
% (len(PATHS_LOGINED), len(PATHS_ANY)))
|
||||||
|
else:
|
||||||
|
print('world RBAC paths (register in sage/load_path.py):')
|
||||||
|
for p in PATHS_LOGINED:
|
||||||
|
print(' %s logined' % p)
|
||||||
|
for p in PATHS_ANY:
|
||||||
|
print(' %s any' % p)
|
||||||
|
|
||||||
|
|
||||||
if __name__ == '__main__':
|
if __name__ == '__main__':
|
||||||
|
|||||||
@ -40,12 +40,12 @@ REST 前缀 `/api/*`(wwwroot/api/*.dspy):
|
|||||||
## 陷阱
|
## 陷阱
|
||||||
- mode 硬校验:非法枚举一律返回 INVALID_MODE,绝不落库;流转规则见 MODE_TRANSITIONS
|
- mode 硬校验:非法枚举一律返回 INVALID_MODE,绝不落库;流转规则见 MODE_TRANSITIONS
|
||||||
- 编码唯一:唯一索引兜底 + 前置校验 + Duplicate key 冲突重试(自动编码场景)
|
- 编码唯一:唯一索引兜底 + 前置校验 + Duplicate key 冲突重试(自动编码场景)
|
||||||
- 列表查询 LIMIT/OFFSET 用整数内联,禁止 `${param}$`(会生成引号字符串)
|
- 列表查询 LIMIT/OFFSET 用整数内联(sqlPaging ns.page/ns.rows),禁止 `${param}$` 生成引号字符串
|
||||||
- 所有 .dspy 无 import(json/exception/format_exc/get_module_dbname/DBPools 为预载全局)
|
- 所有 .dspy 无 import(json/exception/format_exc/get_module_dbname/DBPools 为预载全局)
|
||||||
- 取库名用 get_module_dbname('world'),禁止硬编码 DBNAME
|
- 取库名用 get_module_dbname('world'),禁止硬编码 DBNAME
|
||||||
- 根目录不放 __init__.py;包目录为 world/world/;三处同步注册(world.py / __init__.py / init.py)
|
- 根目录不放 __init__.py;包目录为 world/world/;三处同步注册(world.py / __init__.py / init.py)
|
||||||
|
|
||||||
## 依赖
|
## 依赖
|
||||||
- sqlor(DBPools/sqlExe/sor.C/U/D/R)
|
- sqlor(DBPools/sqlExe/sor.C/U/D/R/sqlPaging)
|
||||||
- ahserver(ServerEnv、get_module_dbname)
|
- ahserver(ServerEnv、get_module_dbname)
|
||||||
- appbase(appcodes/appcodes_kv 编码字典)
|
- appbase(appcodes/appcodes_kv 编码字典)
|
||||||
|
|||||||
@ -1,24 +1,29 @@
|
|||||||
"""world 模块包(W-01 世界管理)。
|
# -*- coding: utf-8 -*-
|
||||||
|
"""world 世界定义与管理模块(W-01 世界管理)。
|
||||||
|
|
||||||
包目录为 world/world/,模块根目录不放置 __init__.py(规范要求)。
|
包目录 world/world/(模块根目录不放 __init__.py,规范要求)。
|
||||||
所有业务函数在此导出,保证 .dspy 调用时 NameError 不出现。
|
通过 load_world() 挂载到宿主应用(ServerEnv 注册业务函数)。
|
||||||
"""
|
"""
|
||||||
from world.world import (
|
from .init import load_world
|
||||||
|
from .world import (
|
||||||
create_world,
|
create_world,
|
||||||
update_world,
|
update_world,
|
||||||
delete_world,
|
delete_world,
|
||||||
get_world,
|
get_world,
|
||||||
list_worlds,
|
list_worlds,
|
||||||
set_world_mode,
|
set_world_mode,
|
||||||
|
get_world_mode_options,
|
||||||
|
get_world_type_options,
|
||||||
)
|
)
|
||||||
from world.init import load_world
|
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
|
'load_world',
|
||||||
'create_world',
|
'create_world',
|
||||||
'update_world',
|
'update_world',
|
||||||
'delete_world',
|
'delete_world',
|
||||||
'get_world',
|
'get_world',
|
||||||
'list_worlds',
|
'list_worlds',
|
||||||
'set_world_mode',
|
'set_world_mode',
|
||||||
'load_world',
|
'get_world_mode_options',
|
||||||
|
'get_world_type_options',
|
||||||
]
|
]
|
||||||
|
|||||||
@ -1,28 +1,27 @@
|
|||||||
"""world 模块初始化:将业务函数注册到 ServerEnv,供 .dspy/.ui 直接调用。
|
# -*- coding: utf-8 -*-
|
||||||
|
"""world 模块初始化:注册全部业务函数到 ServerEnv(load_world 为唯一集成点)。
|
||||||
|
|
||||||
三处同步注册:
|
三处同步注册约定:实现(world/world.py)→ 导出(world/__init__.py)→ 本文件 env.xxx = xxx。
|
||||||
① 实现:world/world.py(本模块函数定义)
|
新增/删除函数必须三处同步更新。
|
||||||
② 导出:world/__init__.py(import 行)
|
|
||||||
③ 注册:本文件 load_world()(env.xxx = xxx)
|
|
||||||
"""
|
"""
|
||||||
from appPublic.log import info
|
from ahserver import ServerEnv
|
||||||
from ahserver.serverenv import ServerEnv
|
|
||||||
|
|
||||||
from world.world import (
|
from .world import (
|
||||||
create_world,
|
create_world,
|
||||||
update_world,
|
update_world,
|
||||||
delete_world,
|
delete_world,
|
||||||
get_world,
|
get_world,
|
||||||
list_worlds,
|
list_worlds,
|
||||||
set_world_mode,
|
set_world_mode,
|
||||||
|
get_world_mode_options,
|
||||||
|
get_world_type_options,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def load_world():
|
def load_world():
|
||||||
"""挂载 world 模块到 ServerEnv(宿主应用 init() 中调用)。"""
|
"""注册 world 模块全部业务函数到 ServerEnv,供 .dspy/.ui 直接调用。"""
|
||||||
env = ServerEnv()
|
env = ServerEnv()
|
||||||
|
# 单数/复数双注册(CRUD 框架约定:dspy 包装器用复数表名)
|
||||||
# 单数 + CRUD 复数双注册(xls2ui 生成的 add_world.dspy 包装用复数名)
|
|
||||||
env.create_world = create_world
|
env.create_world = create_world
|
||||||
env.create_worlds = create_world
|
env.create_worlds = create_world
|
||||||
env.update_world = update_world
|
env.update_world = update_world
|
||||||
@ -30,8 +29,9 @@ def load_world():
|
|||||||
env.delete_world = delete_world
|
env.delete_world = delete_world
|
||||||
env.delete_worlds = delete_world
|
env.delete_worlds = delete_world
|
||||||
env.get_world = get_world
|
env.get_world = get_world
|
||||||
|
env.get_worlds = get_world
|
||||||
env.list_worlds = list_worlds
|
env.list_worlds = list_worlds
|
||||||
env.set_world_mode = set_world_mode
|
env.set_world_mode = set_world_mode
|
||||||
|
env.get_world_mode_options = get_world_mode_options
|
||||||
info('world module loaded: create/update/delete/get/list_worlds/set_world_mode registered')
|
env.get_world_type_options = get_world_type_options
|
||||||
return env
|
return env
|
||||||
|
|||||||
633
world/world.py
633
world/world.py
@ -1,398 +1,341 @@
|
|||||||
"""world 模块核心业务逻辑(W-01 世界管理)。
|
# -*- coding: utf-8 -*-
|
||||||
|
"""W-01 世界管理核心业务逻辑(world 模块)。
|
||||||
|
|
||||||
职责:
|
提供 world 表 CRUD、分页列表查询(sqlPaging + 复合索引)、创建自动编码、
|
||||||
- world 表 CRUD(创建自动编码 / 更新 / 删除 / 单条查询)
|
mode 白名单硬校验与流转规则校验、编码唯一冲突重试、编码字典下拉数据源。
|
||||||
- 列表查询(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,
|
统一错误结构:{code, message, field, detail}(WorldError)。
|
||||||
供 wwwroot/api/*.dspy 薄包装直接调用。
|
分页结构:{list, total, page, page_size}。
|
||||||
|
|
||||||
|
依赖:sqlor(DBPools)、ahserver(ServerEnv)、appbase(appcodes/appcodes_kv 编码字典)。
|
||||||
"""
|
"""
|
||||||
import time
|
|
||||||
import random
|
import random
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
from ahserver.serverenv import ServerEnv
|
from ahserver import ServerEnv
|
||||||
from appPublic.jsonConfig import getConfig
|
|
||||||
from appPublic.uniqueID import getID
|
try:
|
||||||
from appPublic.timeUtils import curDateString
|
from sqlor import DBPools
|
||||||
from appPublic.log import debug, error
|
except ImportError:
|
||||||
from sqlor.dbpools import DBPools
|
from sqlor.dbpools import DBPools
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
try:
|
||||||
# 常量:mode 白名单 + 流转规则
|
from appPublic.utils import getID, curDateString
|
||||||
# ---------------------------------------------------------------------------
|
except ImportError:
|
||||||
MODE_WHITELIST = ('draft', 'active', 'paused', 'archived')
|
from appPublic import getID, curDateString
|
||||||
|
|
||||||
# mode 流转规则:from -> 允许切换到的目标集合
|
MODE_WHITELIST = ('draft', 'active', 'paused', 'archived')
|
||||||
|
WORLD_TYPE_WHITELIST = ('game', 'simulation', 'education', 'business', 'social')
|
||||||
|
|
||||||
|
# 模式流转规则:draft→active⇄paused、active/paused→archived、archived→active
|
||||||
MODE_TRANSITIONS = {
|
MODE_TRANSITIONS = {
|
||||||
'draft': {'active'},
|
'draft': ('active',),
|
||||||
'active': {'paused', 'archived'},
|
'active': ('paused', 'archived'),
|
||||||
'paused': {'active', 'archived'},
|
'paused': ('active', 'archived'),
|
||||||
'archived': {'active'},
|
'archived': ('active',),
|
||||||
}
|
}
|
||||||
|
|
||||||
# 列表查询列(显式列清单,排除 description TEXT 大字段,避免全表扫描与 off-page I/O)
|
CODE_RETRY = 3 # 自动编码唯一冲突重试次数
|
||||||
LIST_FIELDS = 'id, code, name, mode, world_type, owner_id, org_id, created_at, updated_at'
|
LIST_SORTS = ('created_at', 'code', 'name', 'mode') # 列表排序白名单,防注入
|
||||||
|
LIST_PAGE_SIZE_MAX = 200
|
||||||
# 错误码
|
|
||||||
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'
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
class WorldError(Exception):
|
||||||
# 工具函数
|
"""业务异常,携带统一错误结构 {code, message, field, detail}。"""
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
def _err(code, message, field='', detail=''):
|
|
||||||
"""统一错误结构:{code, message, field, detail}。"""
|
|
||||||
return {'code': code, 'message': message, 'field': field, 'detail': detail}
|
|
||||||
|
|
||||||
|
def __init__(self, code='INTERNAL_ERROR', message='', field='', detail=''):
|
||||||
def _ok(message='ok', data=None):
|
super().__init__(message)
|
||||||
"""统一成功结构。"""
|
self.code = code
|
||||||
return {'code': 0, 'message': message, 'data': data or {}}
|
self.message = message
|
||||||
|
self.field = field
|
||||||
|
self.detail = detail
|
||||||
|
|
||||||
|
|
||||||
def _get_dbname():
|
def _get_dbname():
|
||||||
env = ServerEnv()
|
"""取 world 模块库名,禁止硬编码 DBNAME。"""
|
||||||
return env.get_module_dbname('world')
|
return ServerEnv().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():
|
def _gen_world_code():
|
||||||
"""生成世界编码:W + 时间戳 + 4 位随机数(可读、全局唯一概率极高)。"""
|
"""自动编码:W + 时间戳(YYYYMMDDHHMMSS) + 随机4位数字。全局唯一由唯一索引+重试兜底。"""
|
||||||
return 'W' + time.strftime('%Y%m%d%H%M%S') + format(random.randint(0, 9999), '04d')
|
ts = datetime.now().strftime('%Y%m%d%H%M%S')
|
||||||
|
suffix = ''.join(random.choice('0123456789') for _ in range(4))
|
||||||
|
return 'W' + ts + suffix
|
||||||
|
|
||||||
|
|
||||||
def _pick(row, fields):
|
def _check_mode(mode):
|
||||||
"""从查询结果行(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:
|
if mode not in MODE_WHITELIST:
|
||||||
return False, _err(
|
raise WorldError('INVALID_MODE', 'invalid world mode: ' + str(mode), 'mode',
|
||||||
ERR_INVALID_MODE,
|
'mode must be one of: ' + ', '.join(MODE_WHITELIST))
|
||||||
'非法世界模式: %s,合法枚举为 %s' % (mode, '/'.join(MODE_WHITELIST)),
|
|
||||||
|
|
||||||
|
def _check_world_type(world_type):
|
||||||
|
if world_type and world_type not in WORLD_TYPE_WHITELIST:
|
||||||
|
raise WorldError('INVALID_WORLD_TYPE', 'invalid world_type: ' + str(world_type),
|
||||||
|
'world_type',
|
||||||
|
'world_type must be one of: ' + ', '.join(WORLD_TYPE_WHITELIST))
|
||||||
|
|
||||||
|
|
||||||
|
def _check_transition(old_mode, new_mode):
|
||||||
|
_check_mode(old_mode)
|
||||||
|
_check_mode(new_mode)
|
||||||
|
if new_mode not in MODE_TRANSITIONS.get(old_mode, ()):
|
||||||
|
raise WorldError('INVALID_MODE_TRANSITION',
|
||||||
|
'mode transition not allowed: ' + old_mode + ' -> ' + new_mode,
|
||||||
'mode',
|
'mode',
|
||||||
)
|
'allowed transitions: draft->active, active<->paused, '
|
||||||
return True, None
|
'active/paused->archived, archived->active')
|
||||||
|
|
||||||
|
|
||||||
def _validate_transition(old_mode, new_mode):
|
def _to_int(val, default, minimum=1):
|
||||||
"""mode 流转规则校验。"""
|
try:
|
||||||
ok, err = _validate_mode(new_mode)
|
n = int(val)
|
||||||
if not ok:
|
except (TypeError, ValueError):
|
||||||
return False, err
|
n = default
|
||||||
allowed = MODE_TRANSITIONS.get(old_mode)
|
return n if n >= minimum else minimum
|
||||||
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):
|
def _row_to_dict(r):
|
||||||
"""编码唯一性前置校验(唯一索引为最终兜底)。"""
|
"""查询行(DictObject)转 dict,显式列清单(description 为 TEXT,列表查询不取)。"""
|
||||||
if exclude_id:
|
return {
|
||||||
rows = await sor.sqlExe(
|
'id': r.id,
|
||||||
'SELECT id FROM world WHERE code = ${code}$ AND id != ${exclude_id}$ LIMIT 1',
|
'code': r.code,
|
||||||
{'code': code, 'exclude_id': exclude_id},
|
'name': r.name,
|
||||||
)
|
'description': getattr(r, 'description', '') or '',
|
||||||
else:
|
'mode': r.mode,
|
||||||
rows = await sor.sqlExe(
|
'world_type': getattr(r, 'world_type', '') or '',
|
||||||
'SELECT id FROM world WHERE code = ${code}$ LIMIT 1',
|
'owner_id': getattr(r, 'owner_id', '') or '',
|
||||||
{'code': code},
|
'org_id': getattr(r, 'org_id', '') or '',
|
||||||
)
|
'created_at': str(r.created_at),
|
||||||
return not rows
|
'updated_at': str(r.updated_at),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
def _is_dup_key_error(msg):
|
async def create_world(params):
|
||||||
"""判断是否为唯一索引冲突(Duplicate entry ... for key ...)。"""
|
"""创建世界。
|
||||||
return 'Duplicate' in msg and 'key' in msg
|
|
||||||
|
|
||||||
|
- 自动编码:W+时间戳+随机4位(可显式传入 code)
|
||||||
# ---------------------------------------------------------------------------
|
- mode 默认 draft,非法 mode 100% 拦截不落库
|
||||||
# 业务函数:CRUD + 模式切换
|
- 编码全局唯一:唯一索引兜底 + 冲突重试 3 次
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
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()
|
dbname = _get_dbname()
|
||||||
user_id = ns.get('owner_id', '')
|
name = (params.get('name') or '').strip()
|
||||||
org_id = ns.get('org_id', '0')
|
if not name:
|
||||||
|
raise WorldError('INVALID_NAME', 'world name is required', 'name', '')
|
||||||
last_err = ''
|
mode = params.get('mode') or 'draft'
|
||||||
for _attempt in range(3): # 唯一索引冲突重试(最多 3 次)
|
_check_mode(mode)
|
||||||
code = ns.get('code') or _gen_world_code()
|
if mode != 'draft':
|
||||||
record = {
|
raise WorldError('INVALID_MODE_TRANSITION', 'new world must start at draft', 'mode',
|
||||||
|
'new world mode must be draft, then draft->active to activate')
|
||||||
|
world_type = (params.get('world_type') or '').strip()
|
||||||
|
_check_world_type(world_type)
|
||||||
|
code = (params.get('code') or '').strip()
|
||||||
|
org_id = params.get('org_id') or '0'
|
||||||
|
owner_id = params.get('owner_id') or '0'
|
||||||
|
description = params.get('description') or ''
|
||||||
|
now = curDateString()
|
||||||
|
ns = {
|
||||||
'id': getID(),
|
'id': getID(),
|
||||||
'code': code,
|
'code': code or _gen_world_code(),
|
||||||
'name': name,
|
'name': name,
|
||||||
|
'description': description,
|
||||||
'mode': mode,
|
'mode': mode,
|
||||||
'world_type': world_type,
|
'world_type': world_type,
|
||||||
'description': ns.get('description', ''),
|
'owner_id': owner_id,
|
||||||
'owner_id': user_id,
|
|
||||||
'org_id': org_id,
|
'org_id': org_id,
|
||||||
'created_at': now,
|
'created_at': now,
|
||||||
'updated_at': now,
|
'updated_at': now,
|
||||||
}
|
}
|
||||||
|
last_err = None
|
||||||
|
for attempt in range(CODE_RETRY):
|
||||||
|
if attempt > 0:
|
||||||
|
ns['code'] = _gen_world_code()
|
||||||
try:
|
try:
|
||||||
async with db.sqlorContext(dbname) as sor:
|
async with DBPools().sqlorContext(dbname) as sor:
|
||||||
if not await _check_code_unique(sor, code):
|
await sor.C('world', dict(ns))
|
||||||
if ns.get('code'):
|
return dict(ns)
|
||||||
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:
|
except Exception as e:
|
||||||
msg = str(e)
|
msg = str(e)
|
||||||
if _is_dup_key_error(msg):
|
if 'Duplicate' in msg or '1062' in msg:
|
||||||
if ns.get('code'):
|
last_err = e
|
||||||
return _err(ERR_CODE_EXISTS, '世界编码已存在: %s' % code, 'code', msg)
|
continue # 唯一冲突 → 换码重试
|
||||||
last_err = msg
|
raise WorldError('DB_ERROR', 'create world failed', '', msg) from e
|
||||||
continue # 唯一索引兜底冲突 → 重新生成重试
|
raise WorldError('CODE_CONFLICT', 'world code conflict after retries', 'code',
|
||||||
error('create_world failed: %s' % msg)
|
str(last_err) if last_err else '')
|
||||||
return _err(ERR_INTERNAL, '创建世界失败: %s' % msg, 'detail', msg)
|
|
||||||
return _err(ERR_CODE_EXISTS, '自动编码冲突重试耗尽', 'code', last_err)
|
|
||||||
|
|
||||||
|
|
||||||
async def update_world(params=None):
|
async def update_world(params):
|
||||||
"""更新世界(含 mode 流转校验、code 唯一校验)。部分更新语义。"""
|
"""部分更新。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()
|
dbname = _get_dbname()
|
||||||
async with db.sqlorContext(dbname) as sor:
|
wid = params.get('id') or params.get('world_id')
|
||||||
rows = await sor.R('world', {'id': world_id})
|
if not wid:
|
||||||
|
raise WorldError('INVALID_ID', 'world id is required', 'id', '')
|
||||||
|
async with DBPools().sqlorContext(dbname) as sor:
|
||||||
|
rows = await sor.R('world', {'id': wid})
|
||||||
if not rows:
|
if not rows:
|
||||||
return _err(ERR_NOT_FOUND, '世界不存在: %s' % world_id, 'id')
|
raise WorldError('WORLD_NOT_FOUND', 'world not found: ' + str(wid), 'id', '')
|
||||||
|
old = rows[0]
|
||||||
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 = {}
|
ns = {}
|
||||||
org_id = params.get('org_id')
|
if 'name' in params:
|
||||||
mode = params.get('mode')
|
name = (params.get('name') or '').strip()
|
||||||
world_type = params.get('world_type')
|
if not name:
|
||||||
keyword = params.get('keyword')
|
raise WorldError('INVALID_NAME', 'world name is required', 'name', '')
|
||||||
if org_id:
|
ns['name'] = name
|
||||||
conds.append('org_id = ${org_id}$')
|
if 'description' in params:
|
||||||
ns['org_id'] = org_id
|
ns['description'] = params.get('description') or ''
|
||||||
if mode:
|
if 'world_type' in params:
|
||||||
conds.append('mode = ${mode}$')
|
wt = (params.get('world_type') or '').strip()
|
||||||
ns['mode'] = mode
|
_check_world_type(wt)
|
||||||
if world_type:
|
ns['world_type'] = wt
|
||||||
conds.append('world_type = ${world_type}$')
|
if 'code' in params:
|
||||||
ns['world_type'] = world_type
|
new_code = (params.get('code') or '').strip()
|
||||||
if keyword:
|
if not new_code:
|
||||||
conds.append('(name LIKE ${keyword}$ OR code LIKE ${keyword}$)')
|
raise WorldError('INVALID_CODE', 'world code is required', 'code', '')
|
||||||
ns['keyword'] = '%%%s%%' % keyword
|
if new_code != old.code:
|
||||||
|
dup = await sor.sqlExe('SELECT id FROM world WHERE code = ${code}$ AND id <> ${id}$',
|
||||||
where = ''
|
{'code': new_code, 'id': wid})
|
||||||
if conds:
|
if dup:
|
||||||
where = ' WHERE ' + ' AND '.join(conds)
|
raise WorldError('CODE_EXISTS', 'world code already exists: ' + new_code, 'code', '')
|
||||||
|
ns['code'] = new_code
|
||||||
db = _get_pool()
|
if 'mode' in params:
|
||||||
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')
|
new_mode = params.get('mode')
|
||||||
if not world_id:
|
_check_transition(old.mode, new_mode)
|
||||||
return _err(ERR_VALIDATION, '缺少世界 ID', 'id')
|
ns['mode'] = new_mode
|
||||||
if not new_mode:
|
if not ns:
|
||||||
return _err(ERR_VALIDATION, '缺少目标模式', 'mode')
|
return {'id': wid, 'updated': False}
|
||||||
|
ns['id'] = wid
|
||||||
|
ns['updated_at'] = curDateString()
|
||||||
|
await sor.U('world', dict(ns))
|
||||||
|
return {'id': wid, 'updated': True}
|
||||||
|
|
||||||
db = _get_pool()
|
|
||||||
|
async def delete_world(params):
|
||||||
|
"""删除世界。"""
|
||||||
dbname = _get_dbname()
|
dbname = _get_dbname()
|
||||||
async with db.sqlorContext(dbname) as sor:
|
wid = params.get('id') or params.get('world_id')
|
||||||
rows = await sor.R('world', {'id': world_id})
|
if not wid:
|
||||||
|
raise WorldError('INVALID_ID', 'world id is required', 'id', '')
|
||||||
|
async with DBPools().sqlorContext(dbname) as sor:
|
||||||
|
await sor.D('world', {'id': wid})
|
||||||
|
return {'id': wid, 'deleted': True}
|
||||||
|
|
||||||
|
|
||||||
|
async def get_world(params):
|
||||||
|
"""按 id(或 code)查询单个世界。"""
|
||||||
|
dbname = _get_dbname()
|
||||||
|
wid = params.get('id') or params.get('world_id') or params.get('code')
|
||||||
|
if not wid:
|
||||||
|
raise WorldError('INVALID_ID', 'world id or code is required', 'id', '')
|
||||||
|
async with DBPools().sqlorContext(dbname) as sor:
|
||||||
|
rows = await sor.R('world', {'id': wid})
|
||||||
if not rows:
|
if not rows:
|
||||||
return _err(ERR_NOT_FOUND, '世界不存在: %s' % world_id, 'id')
|
rows = await sor.R('world', {'code': wid})
|
||||||
cur_mode = rows[0]['mode']
|
if not rows:
|
||||||
if cur_mode == new_mode:
|
raise WorldError('WORLD_NOT_FOUND', 'world not found', 'id', '')
|
||||||
return _err(ERR_INVALID_TRANSITION, '世界已处于 %s 模式,无需切换' % new_mode, 'mode')
|
return _row_to_dict(rows[0])
|
||||||
ok, err = _validate_transition(cur_mode, new_mode)
|
|
||||||
if not ok:
|
|
||||||
return err
|
async def list_worlds(params):
|
||||||
await sor.U('world', {'id': world_id, 'mode': new_mode, 'updated_at': curDateString()})
|
"""分页列表查询。
|
||||||
return _ok('模式切换成功', {'id': world_id, 'mode': new_mode})
|
|
||||||
|
性能:count 与数据查询分离;显式列清单(排除 TEXT description);
|
||||||
|
复合索引 idx_world_list(org_id, mode, created_at) 支撑过滤+排序。
|
||||||
|
"""
|
||||||
|
dbname = _get_dbname()
|
||||||
|
page = _to_int(params.get('page'), 1, 1)
|
||||||
|
page_size = _to_int(params.get('page_size') or params.get('rows'), 20, 1)
|
||||||
|
if page_size > LIST_PAGE_SIZE_MAX:
|
||||||
|
page_size = LIST_PAGE_SIZE_MAX
|
||||||
|
org_id = params.get('org_id') or '0'
|
||||||
|
mode = params.get('mode') or ''
|
||||||
|
keyword = params.get('keyword') or ''
|
||||||
|
|
||||||
|
where = ['org_id = ${org_id}$']
|
||||||
|
qargs = {'org_id': org_id}
|
||||||
|
if mode:
|
||||||
|
_check_mode(mode) # 非法 mode 100% 拦截
|
||||||
|
where.append('mode = ${mode}$')
|
||||||
|
qargs['mode'] = mode
|
||||||
|
if keyword:
|
||||||
|
where.append('(name LIKE ${keyword}$ OR code LIKE ${keyword}$)')
|
||||||
|
qargs['keyword'] = '%' + keyword + '%'
|
||||||
|
cond = ' AND '.join(where)
|
||||||
|
|
||||||
|
sort = params.get('sort') or 'created_at'
|
||||||
|
if sort not in LIST_SORTS:
|
||||||
|
sort = 'created_at'
|
||||||
|
order = (params.get('order') or 'desc').lower()
|
||||||
|
if order not in ('asc', 'desc'):
|
||||||
|
order = 'desc'
|
||||||
|
|
||||||
|
# count 与数据查询分离(显式列清单,排除 TEXT)
|
||||||
|
count_sql = 'SELECT COUNT(*) AS cnt FROM world WHERE ' + cond
|
||||||
|
data_sql = ('SELECT id, code, name, mode, world_type, owner_id, org_id, created_at, updated_at '
|
||||||
|
'FROM world WHERE ' + cond)
|
||||||
|
|
||||||
|
async with DBPools().sqlorContext(dbname) as sor:
|
||||||
|
cnt_rows = await sor.sqlExe(count_sql, qargs)
|
||||||
|
total = int(cnt_rows[0].cnt) if cnt_rows else 0
|
||||||
|
ns = dict(qargs)
|
||||||
|
ns.update({'page': page, 'rows': page_size, 'sort': sort, 'order': order})
|
||||||
|
result = await sor.sqlPaging(data_sql, ns)
|
||||||
|
items = [{
|
||||||
|
'id': r.id,
|
||||||
|
'code': r.code,
|
||||||
|
'name': r.name,
|
||||||
|
'mode': r.mode,
|
||||||
|
'world_type': getattr(r, 'world_type', '') or '',
|
||||||
|
'owner_id': getattr(r, 'owner_id', '') or '',
|
||||||
|
'org_id': getattr(r, 'org_id', '') or '',
|
||||||
|
'created_at': str(r.created_at),
|
||||||
|
'updated_at': str(r.updated_at),
|
||||||
|
} for r in result.rows]
|
||||||
|
return {'list': items, 'total': total, 'page': page, 'page_size': page_size}
|
||||||
|
|
||||||
|
|
||||||
|
async def set_world_mode(params):
|
||||||
|
"""模式切换:严格校验白名单 + 流转规则(draft→active⇄paused、active/paused→archived、archived→active)。"""
|
||||||
|
dbname = _get_dbname()
|
||||||
|
wid = params.get('id') or params.get('world_id')
|
||||||
|
new_mode = params.get('mode')
|
||||||
|
if not wid:
|
||||||
|
raise WorldError('INVALID_ID', 'world id is required', 'id', '')
|
||||||
|
if not new_mode:
|
||||||
|
raise WorldError('INVALID_MODE', 'mode is required', 'mode', '')
|
||||||
|
_check_mode(new_mode)
|
||||||
|
async with DBPools().sqlorContext(dbname) as sor:
|
||||||
|
rows = await sor.R('world', {'id': wid})
|
||||||
|
if not rows:
|
||||||
|
raise WorldError('WORLD_NOT_FOUND', 'world not found: ' + str(wid), 'id', '')
|
||||||
|
old_mode = rows[0].mode
|
||||||
|
_check_transition(old_mode, new_mode)
|
||||||
|
await sor.U('world', {'id': wid, 'mode': new_mode, 'updated_at': curDateString()})
|
||||||
|
return {'id': wid, 'mode': new_mode}
|
||||||
|
|
||||||
|
|
||||||
|
async def get_world_mode_options(prefix_all=True):
|
||||||
|
"""世界模式下拉数据源 [{value, text}],数据来自编码字典 world_mode。"""
|
||||||
|
return await _dict_options('world_mode', MODE_WHITELIST, prefix_all)
|
||||||
|
|
||||||
|
|
||||||
|
async def get_world_type_options(prefix_all=True):
|
||||||
|
"""世界类型下拉数据源 [{value, text}],数据来自编码字典 world_type。"""
|
||||||
|
return await _dict_options('world_type', WORLD_TYPE_WHITELIST, prefix_all)
|
||||||
|
|
||||||
|
|
||||||
|
async def _dict_options(parentid, fallback_keys, prefix_all):
|
||||||
|
"""从 appbase 编码字典 appcodes_kv 读下拉选项;字典不可用时回退英文标签(不硬编码中文)。"""
|
||||||
|
items = [{'value': '', 'text': 'All'}] if prefix_all else []
|
||||||
|
try:
|
||||||
|
dbname = ServerEnv().get_module_dbname('appbase')
|
||||||
|
async with DBPools().sqlorContext(dbname) as sor:
|
||||||
|
rows = await sor.sqlExe('SELECT k, v FROM appcodes_kv WHERE parentid = ${pid}$ ORDER BY id',
|
||||||
|
{'pid': parentid})
|
||||||
|
for r in rows:
|
||||||
|
items.append({'value': r.k, 'text': r.v})
|
||||||
|
except Exception:
|
||||||
|
# 字典表未就绪时回退英文标签,保证下拉可用
|
||||||
|
items = [{'value': '', 'text': 'All'}] if prefix_all else []
|
||||||
|
for k in fallback_keys:
|
||||||
|
items.append({'value': k, 'text': k})
|
||||||
|
return items
|
||||||
|
|||||||
@ -1,10 +1,15 @@
|
|||||||
"""创建世界:自动编码 + mode 白名单/流转校验 + 编码唯一冲突重试。
|
# -*- coding: utf-8 -*-
|
||||||
|
# create_world.dspy —— 创建世界(自动编码、mode 白名单硬校验、编码唯一冲突重试)
|
||||||
REST 契约:错误结构 {code, message, field, detail};成功 {code:0, message, data}。
|
# 预载全局:params_kw / json / format_exc / create_world / get_module_dbname
|
||||||
"""
|
debug('create_world.dspy: START params_kw=%s' % json.dumps(dict(params_kw), ensure_ascii=False))
|
||||||
try:
|
try:
|
||||||
result = await create_worlds(dict(params_kw))
|
result = await create_world(dict(params_kw))
|
||||||
return json.dumps(result, ensure_ascii=False)
|
return {"status": "ok", "data": result}
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
exception('create_world.dspy: %s' % e)
|
code = getattr(e, 'code', 'INTERNAL_ERROR')
|
||||||
return json.dumps({'code': 'INTERNAL_ERROR', 'message': '创建世界失败', 'field': '', 'detail': format_exc()}, ensure_ascii=False)
|
message = getattr(e, 'message', str(e)) or str(e)
|
||||||
|
field = getattr(e, 'field', '')
|
||||||
|
detail = getattr(e, 'detail', '') or ''
|
||||||
|
debug('create_world.dspy: ERROR %s %s' % (code, message))
|
||||||
|
exception(format_exc())
|
||||||
|
return {"status": "error", "code": code, "message": message, "field": field, "detail": detail}
|
||||||
|
|||||||
@ -1,13 +1,11 @@
|
|||||||
"""世界模式下拉数据源(world_mode 编码字典):返回 [{value, text}],首项为"全部"。"""
|
# -*- coding: utf-8 -*-
|
||||||
result = [{'value': '', 'text': '全部'}]
|
# get_search_status.dspy —— 世界模式下拉数据源 [{value, text}](首项"全部")
|
||||||
try:
|
try:
|
||||||
dbname = get_module_dbname('world')
|
items = await get_world_mode_options(True)
|
||||||
async with DBPools().sqlorContext(dbname) as sor:
|
return {"status": "ok", "data": items}
|
||||||
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:
|
except Exception as e:
|
||||||
exception('get_search_status.dspy: %s' % e)
|
debug('get_search_status.dspy: ERROR %s' % str(e))
|
||||||
return json.dumps(result, ensure_ascii=False)
|
exception(format_exc())
|
||||||
|
return {"status": "error", "code": getattr(e, 'code', 'INTERNAL_ERROR'),
|
||||||
|
"message": getattr(e, 'message', str(e)) or str(e),
|
||||||
|
"field": getattr(e, 'field', ''), "detail": getattr(e, 'detail', '') or ''}
|
||||||
|
|||||||
@ -1,13 +1,11 @@
|
|||||||
"""世界类型下拉数据源(world_type 编码字典):返回 [{value, text}],首项为"全部"。"""
|
# -*- coding: utf-8 -*-
|
||||||
result = [{'value': '', 'text': '全部'}]
|
# get_search_world_type.dspy —— 世界类型下拉数据源 [{value, text}](首项"全部")
|
||||||
try:
|
try:
|
||||||
dbname = get_module_dbname('world')
|
items = await get_world_type_options(True)
|
||||||
async with DBPools().sqlorContext(dbname) as sor:
|
return {"status": "ok", "data": items}
|
||||||
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:
|
except Exception as e:
|
||||||
exception('get_search_world_type.dspy: %s' % e)
|
debug('get_search_world_type.dspy: ERROR %s' % str(e))
|
||||||
return json.dumps(result, ensure_ascii=False)
|
exception(format_exc())
|
||||||
|
return {"status": "error", "code": getattr(e, 'code', 'INTERNAL_ERROR'),
|
||||||
|
"message": getattr(e, 'message', str(e)) or str(e),
|
||||||
|
"field": getattr(e, 'field', ''), "detail": getattr(e, 'detail', '') or ''}
|
||||||
|
|||||||
@ -1,7 +1,14 @@
|
|||||||
"""按 ID 查询单个世界。"""
|
# -*- coding: utf-8 -*-
|
||||||
|
# get_world.dspy —— 查询单个世界(id 或 code)
|
||||||
|
debug('get_world.dspy: START params_kw=%s' % json.dumps(dict(params_kw), ensure_ascii=False))
|
||||||
try:
|
try:
|
||||||
result = await get_world(dict(params_kw))
|
result = await get_world(dict(params_kw))
|
||||||
return json.dumps(result, ensure_ascii=False)
|
return {"status": "ok", "data": result}
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
exception('get_world.dspy: %s' % e)
|
code = getattr(e, 'code', 'INTERNAL_ERROR')
|
||||||
return json.dumps({'code': 'INTERNAL_ERROR', 'message': '查询世界失败', 'field': '', 'detail': format_exc()}, ensure_ascii=False)
|
message = getattr(e, 'message', str(e)) or str(e)
|
||||||
|
field = getattr(e, 'field', '')
|
||||||
|
detail = getattr(e, 'detail', '') or ''
|
||||||
|
debug('get_world.dspy: ERROR %s %s' % (code, message))
|
||||||
|
exception(format_exc())
|
||||||
|
return {"status": "error", "code": code, "message": message, "field": field, "detail": detail}
|
||||||
|
|||||||
@ -1,10 +1,15 @@
|
|||||||
"""世界列表查询:sqlPaging + 复合索引(org_id+mode+created_at)过滤。
|
# -*- coding: utf-8 -*-
|
||||||
|
# list_worlds.dspy —— 分页列表查询(sqlPaging + 复合索引 idx_world_list)
|
||||||
REST 契约:分页结构 {list, total, page, page_size};错误 {code, message, field, detail}。
|
# 分页结构:{list, total, page, page_size}
|
||||||
"""
|
debug('list_worlds.dspy: START params_kw=%s' % json.dumps(dict(params_kw), ensure_ascii=False))
|
||||||
try:
|
try:
|
||||||
data = await list_worlds(dict(params_kw))
|
result = await list_worlds(dict(params_kw))
|
||||||
return json.dumps(data, ensure_ascii=False)
|
return {"status": "ok", "data": result}
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
exception('list_worlds.dspy: %s' % e)
|
code = getattr(e, 'code', 'INTERNAL_ERROR')
|
||||||
return json.dumps({'code': 'INTERNAL_ERROR', 'message': '查询世界列表失败', 'field': '', 'detail': format_exc()}, ensure_ascii=False)
|
message = getattr(e, 'message', str(e)) or str(e)
|
||||||
|
field = getattr(e, 'field', '')
|
||||||
|
detail = getattr(e, 'detail', '') or ''
|
||||||
|
debug('list_worlds.dspy: ERROR %s %s' % (code, message))
|
||||||
|
exception(format_exc())
|
||||||
|
return {"status": "error", "code": code, "message": message, "field": field, "detail": detail}
|
||||||
|
|||||||
@ -1,10 +1,15 @@
|
|||||||
"""世界模式切换:严格流转规则校验。
|
# -*- coding: utf-8 -*-
|
||||||
|
# set_world_mode.dspy —— 模式切换(严格校验白名单 + 流转规则)
|
||||||
draft→active⇄paused、active/paused→archived、archived→active。
|
# 流转规则:draft→active⇄paused、active/paused→archived、archived→active
|
||||||
"""
|
debug('set_world_mode.dspy: START params_kw=%s' % json.dumps(dict(params_kw), ensure_ascii=False))
|
||||||
try:
|
try:
|
||||||
result = await set_world_mode(dict(params_kw))
|
result = await set_world_mode(dict(params_kw))
|
||||||
return json.dumps(result, ensure_ascii=False)
|
return {"status": "ok", "data": result}
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
exception('set_world_mode.dspy: %s' % e)
|
code = getattr(e, 'code', 'INTERNAL_ERROR')
|
||||||
return json.dumps({'code': 'INTERNAL_ERROR', 'message': '模式切换失败', 'field': '', 'detail': format_exc()}, ensure_ascii=False)
|
message = getattr(e, 'message', str(e)) or str(e)
|
||||||
|
field = getattr(e, 'field', '')
|
||||||
|
detail = getattr(e, 'detail', '') or ''
|
||||||
|
debug('set_world_mode.dspy: ERROR %s %s' % (code, message))
|
||||||
|
exception(format_exc())
|
||||||
|
return {"status": "error", "code": code, "message": message, "field": field, "detail": detail}
|
||||||
|
|||||||
@ -1,7 +1,14 @@
|
|||||||
"""删除世界。"""
|
# -*- coding: utf-8 -*-
|
||||||
|
# world_delete.dspy —— 删除世界
|
||||||
|
debug('world_delete.dspy: START params_kw=%s' % json.dumps(dict(params_kw), ensure_ascii=False))
|
||||||
try:
|
try:
|
||||||
result = await delete_worlds(dict(params_kw))
|
result = await delete_world(dict(params_kw))
|
||||||
return json.dumps(result, ensure_ascii=False)
|
return {"status": "ok", "data": result}
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
exception('world_delete.dspy: %s' % e)
|
code = getattr(e, 'code', 'INTERNAL_ERROR')
|
||||||
return json.dumps({'code': 'INTERNAL_ERROR', 'message': '删除世界失败', 'field': '', 'detail': format_exc()}, ensure_ascii=False)
|
message = getattr(e, 'message', str(e)) or str(e)
|
||||||
|
field = getattr(e, 'field', '')
|
||||||
|
detail = getattr(e, 'detail', '') or ''
|
||||||
|
debug('world_delete.dspy: ERROR %s %s' % (code, message))
|
||||||
|
exception(format_exc())
|
||||||
|
return {"status": "error", "code": code, "message": message, "field": field, "detail": detail}
|
||||||
|
|||||||
@ -1,7 +1,14 @@
|
|||||||
"""更新世界:部分更新 + mode 流转校验 + code 唯一校验。"""
|
# -*- coding: utf-8 -*-
|
||||||
|
# world_update.dspy —— 更新世界(部分更新;mode 变更走流转校验;code 变更走全局唯一校验)
|
||||||
|
debug('world_update.dspy: START params_kw=%s' % json.dumps(dict(params_kw), ensure_ascii=False))
|
||||||
try:
|
try:
|
||||||
result = await update_worlds(dict(params_kw))
|
result = await update_world(dict(params_kw))
|
||||||
return json.dumps(result, ensure_ascii=False)
|
return {"status": "ok", "data": result}
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
exception('world_update.dspy: %s' % e)
|
code = getattr(e, 'code', 'INTERNAL_ERROR')
|
||||||
return json.dumps({'code': 'INTERNAL_ERROR', 'message': '更新世界失败', 'field': '', 'detail': format_exc()}, ensure_ascii=False)
|
message = getattr(e, 'message', str(e)) or str(e)
|
||||||
|
field = getattr(e, 'field', '')
|
||||||
|
detail = getattr(e, 'detail', '') or ''
|
||||||
|
debug('world_update.dspy: ERROR %s %s' % (code, message))
|
||||||
|
exception(format_exc())
|
||||||
|
return {"status": "error", "code": code, "message": message, "field": field, "detail": detail}
|
||||||
|
|||||||
@ -1,30 +1,91 @@
|
|||||||
{
|
{
|
||||||
"widgettype": "VBox",
|
"widgettype": "VBox",
|
||||||
"options": {"width": "100%", "height": "100%", "padding": "20px"},
|
"options": {
|
||||||
|
"width": "100%",
|
||||||
|
"height": "100%",
|
||||||
|
"padding": "20px"
|
||||||
|
},
|
||||||
|
"subwidgets": [
|
||||||
|
{
|
||||||
|
"widgettype": "Text",
|
||||||
|
"options": {
|
||||||
|
"label": "世界管理",
|
||||||
|
"fontSize": "24px"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"widgettype": "ResponsableBox",
|
||||||
|
"options": {
|
||||||
|
"gap": "16px",
|
||||||
|
"minWidth": "250px"
|
||||||
|
},
|
||||||
"subwidgets": [
|
"subwidgets": [
|
||||||
{"widgettype": "Text", "options": {"label": "世界管理", "fontSize": "24px", "marginBottom": "12px"}},
|
|
||||||
{"widgettype": "ResponsableBox", "options": {"gap": "16px", "minWidth": "250px"}, "subwidgets": [
|
|
||||||
{
|
{
|
||||||
"widgettype": "VBox",
|
"widgettype": "VBox",
|
||||||
"options": {"backgroundColor": "#FFFFFF", "padding": "20px", "cursor": "pointer", "borderRadius": "6px", "boxShadow": "0 1px 4px rgba(0,0,0,0.1)"},
|
"options": {
|
||||||
"binds": [{"wid": "self", "event": "click", "actiontype": "urlwidget", "target": "app.world_content",
|
"backgroundColor": "#FFFFFF",
|
||||||
"options": {"url": "{{entire_url('/world/world')}}"}, "mode": "replace"}],
|
"padding": "20px",
|
||||||
|
"cursor": "pointer"
|
||||||
|
},
|
||||||
|
"binds": [
|
||||||
|
{
|
||||||
|
"wid": "self",
|
||||||
|
"event": "click",
|
||||||
|
"actiontype": "urlwidget",
|
||||||
|
"target": "app.world_content",
|
||||||
|
"options": {
|
||||||
|
"url": "{{entire_url('/world/world_list')}}"
|
||||||
|
},
|
||||||
|
"mode": "replace"
|
||||||
|
}
|
||||||
|
],
|
||||||
"subwidgets": [
|
"subwidgets": [
|
||||||
{"widgettype": "Text", "options": {"label": "世界列表", "fontSize": "16px"}},
|
{
|
||||||
{"widgettype": "Text", "options": {"label": "世界定义 CRUD:创建自动编码、模式白名单校验、编码全局唯一", "fontSize": "12px", "color": "#888"}}
|
"widgettype": "Text",
|
||||||
|
"options": {
|
||||||
|
"label": "世界列表"
|
||||||
|
}
|
||||||
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"widgettype": "VBox",
|
"widgettype": "VBox",
|
||||||
"options": {"backgroundColor": "#FFFFFF", "padding": "20px", "cursor": "pointer", "borderRadius": "6px", "boxShadow": "0 1px 4px rgba(0,0,0,0.1)"},
|
"options": {
|
||||||
"binds": [{"wid": "self", "event": "click", "actiontype": "urlwidget", "target": "app.world_content",
|
"backgroundColor": "#FFFFFF",
|
||||||
"options": {"url": "{{entire_url('/world/api/list_worlds.dspy')}}"}, "mode": "replace"}],
|
"padding": "20px",
|
||||||
|
"cursor": "pointer"
|
||||||
|
},
|
||||||
|
"binds": [
|
||||||
|
{
|
||||||
|
"wid": "self",
|
||||||
|
"event": "click",
|
||||||
|
"actiontype": "urlwidget",
|
||||||
|
"target": "app.world_content",
|
||||||
|
"options": {
|
||||||
|
"url": "{{entire_url('/world/api/set_world_mode.dspy')}}"
|
||||||
|
},
|
||||||
|
"mode": "replace"
|
||||||
|
}
|
||||||
|
],
|
||||||
"subwidgets": [
|
"subwidgets": [
|
||||||
{"widgettype": "Text", "options": {"label": "列表查询接口", "fontSize": "16px"}},
|
{
|
||||||
{"widgettype": "Text", "options": {"label": "sqlPaging 分页 + 复合索引过滤({list, total})", "fontSize": "12px", "color": "#888"}}
|
"widgettype": "Text",
|
||||||
|
"options": {
|
||||||
|
"label": "模式切换"
|
||||||
|
}
|
||||||
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
]},
|
]
|
||||||
{"widgettype": "VBox", "id": "world_content", "options": {"width": "100%", "flex": "1", "marginTop": "20px"}}
|
},
|
||||||
|
{
|
||||||
|
"widgettype": "VBox",
|
||||||
|
"id": "app.world_content",
|
||||||
|
"options": {
|
||||||
|
"width": "100%",
|
||||||
|
"flex": "1",
|
||||||
|
"marginTop": "20px"
|
||||||
|
}
|
||||||
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,7 +1,40 @@
|
|||||||
|
{
|
||||||
|
"widgettype": "VBox",
|
||||||
|
"options": {
|
||||||
|
"width": "100%",
|
||||||
|
"height": "100%",
|
||||||
|
"padding": "10px"
|
||||||
|
},
|
||||||
|
"subwidgets": [
|
||||||
|
{
|
||||||
|
"widgettype": "Text",
|
||||||
|
"options": {
|
||||||
|
"label": "世界管理",
|
||||||
|
"fontSize": "18px"
|
||||||
|
}
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"widgettype": "Menu",
|
"widgettype": "Menu",
|
||||||
"options": {"target": "PopupWindow", "popup_options": {"width": "70%", "height": "80%"}, "items": [
|
"options": {
|
||||||
{"name": "world_list", "label": "世界列表", "url": "{{entire_url('/world/world')}}"},
|
"width": "100%",
|
||||||
{"name": "world_index", "label": "世界管理首页", "url": "{{entire_url('/world/index.ui')}}"}
|
"items": [
|
||||||
]}
|
{
|
||||||
|
"name": "world_list",
|
||||||
|
"label": "世界列表",
|
||||||
|
"url": "{{entire_url('/world/world_list')}}"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "world_create",
|
||||||
|
"label": "创建世界",
|
||||||
|
"url": "{{entire_url('/world/api/create_world.dspy')}}"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "world_mode",
|
||||||
|
"label": "模式切换",
|
||||||
|
"url": "{{entire_url('/world/api/set_world_mode.dspy')}}"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user