fix: 回填目标机现场好代码——远端main残缺/空导致部署后模块不可用
This commit is contained in:
parent
ecef031551
commit
4bd6be3e40
10
.gitignore
vendored
10
.gitignore
vendored
@ -1,10 +1,8 @@
|
||||
build/
|
||||
*.egg-info/
|
||||
__pycache__/
|
||||
*.pyc
|
||||
*.egg-info/
|
||||
build/
|
||||
dist/
|
||||
.eggs/
|
||||
*.swp
|
||||
*.swo
|
||||
wwwroot/scene_list/
|
||||
wwwroot/scene_import_list/
|
||||
wwwroot/scene/
|
||||
wwwroot/scene_import/
|
||||
|
||||
44
README.md
44
README.md
@ -1,33 +1,33 @@
|
||||
# scene 模块
|
||||
|
||||
场景管理(W-02):场景表 CRUD、列表查询、场景文件导入(JSON/JSON Lines/CSV)。
|
||||
场景管理,含场景导入。
|
||||
|
||||
## 功能
|
||||
- `scene` 表 CRUD(list/get/create/update/delete),分页 `{list, total}`
|
||||
- `scene_import` 表只读列表(导入记录)
|
||||
- 场景导入 `api/scene_import.dspy`:world_id + file → 解析 → 事务批量落库 → 写导入记录
|
||||
- 枚举字段 appcodes 字典(scene_type / scene_status / import_status)
|
||||
- 世界下拉(依赖 world 模块,world_id 逻辑关联,不加物理外键)
|
||||
- 场景表 `scene` 的 CRUD(list/get/create/update/delete)
|
||||
- 场景导入:`api/scene_import.dspy`(文件上传 → 解析 JSON/JSON Lines/CSV → 批量落库 → 写导入记录)
|
||||
- 枚举字段 appcodes 字典(scene_type / scene_status / import_status,cond parentid=)
|
||||
- 世界下拉(依赖 world 模块)
|
||||
|
||||
## 数据表
|
||||
- `scene`:id(str32 PK)、world_id(str32→world.id 逻辑关联)、name(255)、code(64 唯一)、
|
||||
description(text)、scene_type(16)、status(16)、config_json(text)、created_at、updated_at
|
||||
- `scene_import`:id(str32 PK)、world_id、file_name(255)、total/success/fail(int)、status(16)、created_at
|
||||
- `scene`(models/scene.json):场景表
|
||||
- 主键 `id`(str32)
|
||||
- 唯一索引 `idx_scene_code`(code)、索引 `idx_scene_world`(world_id)
|
||||
- 字典:world_id → world、scene_type/status → appcodes_kv
|
||||
- `scene_import`(models/scene_import.json):场景导入记录表(只读)
|
||||
- 主键 `id`(str32)、索引 `idx_scene_import_world`(world_id)
|
||||
- 字典:world_id → world、status → appcodes_kv(parentid='import_status')
|
||||
|
||||
## 接口(REST 统一前缀 /api/*,宿主应用挂载后路径为 /scene/api/*.dspy)
|
||||
- `GET/POST list_scenes.dspy` → `{status, data:{list, total}}`(支持 page/rows/sort/order/world_id/name/code/scene_type/status 过滤)
|
||||
- `GET get_scene.dspy?id=` → 单条
|
||||
- `POST create_scene.dspy` / `scene_update.dspy` / `scene_delete.dspy`
|
||||
- `POST scene_import.dspy`(multipart: world_id + file)→ `{status, data:{success,total,success_count,fail}}`
|
||||
- `GET get_search_world_id.dspy` / `get_search_scene_type.dspy` / `get_search_status.dspy` → `[{value,text}]`
|
||||
## 目录结构
|
||||
- `scene/`:Python 包(init.py + __init__.py)
|
||||
- `models/`:表定义 scene.json、scene_import.json
|
||||
- `json/`:CRUD 定义 scene.json、scene_import.json(只读)
|
||||
- `wwwroot/`:index.ui + import_page.ui + api/*.dspy
|
||||
- `init/data.json`:字典种子数据(scene_type / scene_status / import_status)
|
||||
- `scripts/load_path.py`:RBAC 路径注册
|
||||
|
||||
## 错误结构(统一)
|
||||
`{code, message, field, detail}`,code 取值:PARAM_REQUIRED / FIELD_REQUIRED / FIELD_TOO_LONG /
|
||||
WORLD_NOT_FOUND / DUPLICATE_CODE / PARSE_ERROR / DB_ERROR / NOT_FOUND。
|
||||
|
||||
## 集成
|
||||
宿主应用入口 `init()` 调用 `load_scene()`(from scene.init import load_scene)。
|
||||
库名统一 `ServerEnv().get_module_dbname("scene")`,world 表用 `get_module_dbname("world")`,禁止硬编码。
|
||||
## 集成方式
|
||||
宿主应用在入口 `init()` 中调用 `load_scene()` 挂载模块函数。
|
||||
库名统一通过 `ServerEnv().get_module_dbname("scene")` 获取,禁止硬编码。
|
||||
|
||||
## 开发顺序
|
||||
2(依赖 world)。
|
||||
|
||||
@ -1,46 +0,0 @@
|
||||
# scene 模块开发工作日志
|
||||
|
||||
日期:2026-07-19
|
||||
模块:scene(W-02 场景管理)
|
||||
任务:new_dev(新开发),迭代「元景项目-初始迭代」
|
||||
|
||||
## 范围 / 背景
|
||||
按 design 交付 modules/scene.md 与 design/scene.md 实现 W-02 场景管理:
|
||||
- scene.scene / scene_import 表 CRUD、列表查询(分页 {list,total})
|
||||
- 场景导入(api/scene_import.dspy 文件导入)
|
||||
- scene.world_id → world.id 逻辑关联(codes 段,不加外键)
|
||||
- 导入事务批量 + 失败回滚无脏数据
|
||||
- REST 统一前缀 /api/*,错误结构 {code,message,field,detail}
|
||||
- 非法输入 100% 拦截不落库
|
||||
|
||||
## 交付清单
|
||||
| 文件 | 说明 |
|
||||
|---|---|
|
||||
| models/scene.json | scene 表定义(id PK、idx_scene_code 唯一、idx_scene_world;codes 段 world/appcodes) |
|
||||
| models/scene_import.json | scene_import 表定义(只读导入记录) |
|
||||
| json/scene.json | scene CRUD 定义(editable 顶部 new/update/delete_data_url → 自定义 api/*.dspy) |
|
||||
| json/scene_import.json | scene_import 只读列表定义 |
|
||||
| init/data.json | appcodes 种子:scene_type / scene_status / import_status |
|
||||
| scene/init.py | 全部业务函数:list/get/create/update/delete/import_scenes/parse_scene_file/下拉 |
|
||||
| scene/__init__.py | 包导出(与 init.py、load_scene 三处同步注册) |
|
||||
| wwwroot/api/*.dspy | 9 个 REST 接口(/scene/api/*.dspy) |
|
||||
| wwwroot/index.ui | 场景管理入口(列表/导入/导入记录三卡片) |
|
||||
| wwwroot/import_page.ui | 导入页(Form:world_id + file) |
|
||||
| scripts/load_path.py | RBAC 显式路径注册(无通配符) |
|
||||
| pyproject.toml / README.md / .gitignore / skill/SKILL.md | 工程化配套 |
|
||||
|
||||
## 关键技术决策
|
||||
1. **world_id 逻辑关联**:models codes 段声明 world_id→world(id/name) 渲染下拉,不加物理外键;create/update/import 在 Python 层显式校验 world 存在(`_world_exists`),非法 world_id 100% 拦截。
|
||||
2. **导入事务回滚**:import_scenes 先对全部行做字段校验(name/code/world_id 必填、长度上限、文件内 code 去重),再在一个 `async with sqlorContext` 内逐条 sor.C;任一条异常 → 整个 context 回滚,仅写一条 status=2 的 scene_import 失败记录。无脏数据。
|
||||
3. **分页 {list,total}**:用 `sor.sqlPaging(sql, {page, rows, ...})`,支持白名单 sort/order(防注入),rows 上限 200。
|
||||
4. **错误结构统一**:`{code, message, field, detail}`;code 枚举见 SKILL.md。
|
||||
5. **REST 统一前缀**:所有接口放 wwwroot/api/*.dspy,宿主挂载后路径为 `/scene/api/*.dspy`;返回 `{status:'ok'|'error', data}`。
|
||||
6. **三处同步注册**:scene/__init__.py 导出 ← scene/init.py 实现 ← load_scene() env.xxx=xxx;.dspy 无 import 直接调用全局函数。
|
||||
|
||||
## 验证情况(环境受限)
|
||||
- 本工作空间无运行时环境(无 MySQL/ahserver/宿主应用),无法启动服务 curl 实测。
|
||||
- 已做静态自检:模型 JSON 四段式(summary/fields/indexes/codes);CRUD JSON 有 tblname + params.editable + 顶部 new/update/delete_data_url;dspy 无 import/print/uuid,均显式 return;init.py 用 sor.C/U/R/D/sqlPaging 标准 API(无编造 save/list/insert);未硬编码 DBNAME(全部 get_module_dbname)。
|
||||
- 待部署环境验证项:`pip install .`、json2ddl 建表、xls2ui 生成 CRUD UI、RBAC 注册、curl 各 /scene/api/*.dspy。
|
||||
|
||||
## 当前状态
|
||||
- 模块仓库 modules/scene/,未提交(交付后由 PM 审核通过统一 git 提交)。
|
||||
@ -1,33 +1,30 @@
|
||||
{
|
||||
"appcodes": [
|
||||
{
|
||||
"parentid": "scene_type",
|
||||
"parentname": "场景类型",
|
||||
"items": [
|
||||
{"k": "0", "v": "默认"},
|
||||
{"k": "1", "v": "室内"},
|
||||
{"k": "2", "v": "室外"},
|
||||
{"k": "3", "v": "城市"},
|
||||
{"k": "4", "v": "野外"}
|
||||
]
|
||||
},
|
||||
{
|
||||
"parentid": "scene_status",
|
||||
"parentname": "场景状态",
|
||||
"items": [
|
||||
{"k": "0", "v": "草稿"},
|
||||
{"k": "1", "v": "启用"},
|
||||
{"k": "2", "v": "停用"}
|
||||
]
|
||||
},
|
||||
{
|
||||
"parentid": "import_status",
|
||||
"parentname": "导入状态",
|
||||
"items": [
|
||||
{"k": "0", "v": "进行中"},
|
||||
{"k": "1", "v": "成功"},
|
||||
{"k": "2", "v": "失败"}
|
||||
]
|
||||
}
|
||||
]
|
||||
"appcodes": [
|
||||
{
|
||||
"parentid": "scene_type",
|
||||
"parentname": "场景类型",
|
||||
"items": [
|
||||
{"k": "0", "v": "默认"},
|
||||
{"k": "1", "v": "室内"},
|
||||
{"k": "2", "v": "室外"}
|
||||
]
|
||||
},
|
||||
{
|
||||
"parentid": "scene_status",
|
||||
"parentname": "场景状态",
|
||||
"items": [
|
||||
{"k": "0", "v": "启用"},
|
||||
{"k": "1", "v": "停用"}
|
||||
]
|
||||
},
|
||||
{
|
||||
"parentid": "import_status",
|
||||
"parentname": "导入状态",
|
||||
"items": [
|
||||
{"k": "0", "v": "成功"},
|
||||
{"k": "1", "v": "部分成功"},
|
||||
{"k": "2", "v": "失败"}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@ -1,21 +1,27 @@
|
||||
{
|
||||
"tblname": "scene",
|
||||
"params": {
|
||||
"browserfields": {
|
||||
"dataviewer": ["id", "world_id", "name", "code", "description", "scene_type", "status", "config_json", "created_at", "updated_at"],
|
||||
"databrowser": ["id", "world_id", "name", "code", "scene_type", "status", "created_at"],
|
||||
"alters": [
|
||||
{"field": "world_id", "label": "所属世界", "uitype": "code", "dataurl": "{{entire_url('../api/get_search_world_id.dspy')}}", "valueField": "value", "textField": "text", "required": true},
|
||||
{"field": "scene_type", "label": "场景类型", "uitype": "code", "dataurl": "{{entire_url('../api/get_search_scene_type.dspy')}}", "valueField": "value", "textField": "text"},
|
||||
{"field": "status", "label": "状态", "uitype": "code", "dataurl": "{{entire_url('../api/get_search_status.dspy')}}", "valueField": "value", "textField": "text"}
|
||||
]
|
||||
},
|
||||
"editable": {
|
||||
"editablefields": ["world_id", "name", "code", "description", "scene_type", "status", "config_json"],
|
||||
"editexclouded": ["id", "created_at", "updated_at"]
|
||||
},
|
||||
"new_data_url": "{{entire_url('../api/create_scene.dspy')}}",
|
||||
"update_data_url": "{{entire_url('../api/scene_update.dspy')}}",
|
||||
"delete_data_url": "{{entire_url('../api/scene_delete.dspy')}}"
|
||||
}
|
||||
"tblname": "scene",
|
||||
"title": "场景管理",
|
||||
"params": {
|
||||
"sortby": ["created_at desc"],
|
||||
"data_filter": {
|
||||
"AND": [
|
||||
{"field": "name", "op": "LIKE", "var": "name"},
|
||||
{"field": "world_id", "op": "=", "var": "world_id"}
|
||||
]
|
||||
},
|
||||
"browserfields": {
|
||||
"exclouded": ["id", "description", "config_json"],
|
||||
"alters": {
|
||||
"world_id": {"uitype": "code", "dataurl": "{{entire_url('../api/get_search_world_id.dspy')}}"},
|
||||
"scene_type": {"uitype": "code", "dataurl": "{{entire_url('../api/get_search_scene_type.dspy')}}"},
|
||||
"status": {"uitype": "code", "dataurl": "{{entire_url('../api/get_search_status.dspy')}}"}
|
||||
}
|
||||
},
|
||||
"editexclouded": ["created_at", "updated_at"],
|
||||
"editable": {
|
||||
"new_data_url": "{{entire_url('../api/create_scene.dspy')}}",
|
||||
"update_data_url": "{{entire_url('../api/scene_update.dspy')}}",
|
||||
"delete_data_url": "{{entire_url('../api/scene_delete.dspy')}}"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,14 +1,10 @@
|
||||
{
|
||||
"tblname": "scene_import",
|
||||
"params": {
|
||||
"browserfields": {
|
||||
"dataviewer": ["id", "world_id", "file_name", "total", "success", "fail", "status", "created_at"],
|
||||
"databrowser": ["id", "world_id", "file_name", "total", "success", "fail", "status", "created_at"],
|
||||
"alters": [
|
||||
{"field": "world_id", "label": "目标世界", "uitype": "code", "dataurl": "{{entire_url('../api/get_search_world_id.dspy')}}", "valueField": "value", "textField": "text"},
|
||||
{"field": "status", "label": "导入状态", "uitype": "code", "dataurl": "{{entire_url('../api/get_search_status.dspy')}}", "valueField": "value", "textField": "text"}
|
||||
]
|
||||
},
|
||||
"editable": {}
|
||||
}
|
||||
"tblname": "scene_import",
|
||||
"title": "场景导入记录",
|
||||
"params": {
|
||||
"sortby": ["created_at desc"],
|
||||
"browserfields": {
|
||||
"exclouded": ["id"]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,31 +1,31 @@
|
||||
{
|
||||
"summary": [
|
||||
{
|
||||
"table": "scene",
|
||||
"primary": ["id"],
|
||||
"engine": "InnoDB",
|
||||
"comment": "场景表"
|
||||
}
|
||||
],
|
||||
"fields": [
|
||||
{"name": "id", "type": "str(32)", "nullable": false, "default": "", "comment": "主键"},
|
||||
{"name": "world_id", "type": "str(32)", "nullable": false, "default": "", "comment": "所属世界(逻辑关联 world.id,不加外键)"},
|
||||
{"name": "name", "type": "str(255)", "nullable": false, "default": "", "comment": "场景名称"},
|
||||
{"name": "code", "type": "str(64)", "nullable": false, "default": "", "comment": "场景编码(唯一)"},
|
||||
{"name": "description", "type": "text", "nullable": true, "comment": "描述"},
|
||||
{"name": "scene_type", "type": "str(16)", "nullable": false, "default": "0", "comment": "场景类型(appcodes scene_type,默认 0)"},
|
||||
{"name": "status", "type": "str(16)", "nullable": false, "default": "0", "comment": "状态(appcodes scene_status,默认 0)"},
|
||||
{"name": "config_json", "type": "text", "nullable": true, "comment": "场景配置 JSON"},
|
||||
{"name": "created_at", "type": "timestamp", "nullable": false, "comment": "创建时间"},
|
||||
{"name": "updated_at", "type": "timestamp", "nullable": false, "comment": "更新时间"}
|
||||
],
|
||||
"indexes": [
|
||||
{"name": "idx_scene_code", "fields": ["code"], "unique": true},
|
||||
{"name": "idx_scene_world", "fields": ["world_id"], "unique": false}
|
||||
],
|
||||
"codes": [
|
||||
{"field": "world_id", "table": "world", "valuefield": "id", "textfield": "name"},
|
||||
{"field": "scene_type", "table": "appcodes_kv", "cond": "parentid='scene_type'", "valuefield": "k", "textfield": "v"},
|
||||
{"field": "status", "table": "appcodes_kv", "cond": "parentid='scene_status'", "valuefield": "k", "textfield": "v"}
|
||||
]
|
||||
"summary": [
|
||||
{
|
||||
"name": "scene",
|
||||
"title": "场景表",
|
||||
"primary": ["id"],
|
||||
"catelog": "entity"
|
||||
}
|
||||
],
|
||||
"fields": [
|
||||
{"name": "id", "title": "主键ID", "type": "str", "length": 32, "nullable": "no"},
|
||||
{"name": "world_id", "title": "所属世界", "type": "str", "length": 32, "nullable": "no"},
|
||||
{"name": "name", "title": "场景名称", "type": "str", "length": 255, "nullable": "no"},
|
||||
{"name": "code", "title": "场景编码", "type": "str", "length": 64, "nullable": "no"},
|
||||
{"name": "description", "title": "描述", "type": "text", "nullable": "yes"},
|
||||
{"name": "scene_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"}
|
||||
],
|
||||
"indexes": [
|
||||
{"name": "idx_scene_code", "idxtype": "unique", "idxfields": ["code"]},
|
||||
{"name": "idx_scene_world", "idxtype": "index", "idxfields": ["world_id"]}
|
||||
],
|
||||
"codes": [
|
||||
{"field": "world_id", "table": "world", "valuefield": "id", "textfield": "name"},
|
||||
{"field": "scene_type", "table": "appcodes_kv", "valuefield": "k", "textfield": "v", "cond": "parentid='scene_type'"},
|
||||
{"field": "status", "table": "appcodes_kv", "valuefield": "k", "textfield": "v", "cond": "parentid='scene_status'"}
|
||||
]
|
||||
}
|
||||
|
||||
@ -1,27 +1,27 @@
|
||||
{
|
||||
"summary": [
|
||||
{
|
||||
"table": "scene_import",
|
||||
"primary": ["id"],
|
||||
"engine": "InnoDB",
|
||||
"comment": "场景导入记录表(只读)"
|
||||
}
|
||||
],
|
||||
"fields": [
|
||||
{"name": "id", "type": "str(32)", "nullable": false, "default": "", "comment": "主键"},
|
||||
{"name": "world_id", "type": "str(32)", "nullable": false, "default": "", "comment": "目标世界(逻辑关联 world.id)"},
|
||||
{"name": "file_name", "type": "str(255)", "nullable": false, "default": "", "comment": "导入文件名"},
|
||||
{"name": "total", "type": "int", "nullable": false, "default": 0, "comment": "总条数"},
|
||||
{"name": "success", "type": "int", "nullable": false, "default": 0, "comment": "成功条数"},
|
||||
{"name": "fail", "type": "int", "nullable": false, "default": 0, "comment": "失败条数"},
|
||||
{"name": "status", "type": "str(16)", "nullable": false, "default": "0", "comment": "导入状态(appcodes import_status,0 进行中/1 成功/2 失败)"},
|
||||
{"name": "created_at", "type": "timestamp", "nullable": false, "comment": "导入时间"}
|
||||
],
|
||||
"indexes": [
|
||||
{"name": "idx_scene_import_world", "fields": ["world_id"], "unique": false}
|
||||
],
|
||||
"codes": [
|
||||
{"field": "world_id", "table": "world", "valuefield": "id", "textfield": "name"},
|
||||
{"field": "status", "table": "appcodes_kv", "cond": "parentid='import_status'", "valuefield": "k", "textfield": "v"}
|
||||
]
|
||||
"summary": [
|
||||
{
|
||||
"name": "scene_import",
|
||||
"title": "场景导入记录表",
|
||||
"primary": ["id"],
|
||||
"catelog": "entity"
|
||||
}
|
||||
],
|
||||
"fields": [
|
||||
{"name": "id", "title": "主键ID", "type": "str", "length": 32, "nullable": "no"},
|
||||
{"name": "world_id", "title": "目标世界", "type": "str", "length": 32, "nullable": "no"},
|
||||
{"name": "file_name", "title": "导入文件名", "type": "str", "length": 255, "nullable": "no"},
|
||||
{"name": "total", "title": "总条数", "type": "int", "nullable": "no", "default": "0"},
|
||||
{"name": "success", "title": "成功条数", "type": "int", "nullable": "no", "default": "0"},
|
||||
{"name": "fail", "title": "失败条数", "type": "int", "nullable": "no", "default": "0"},
|
||||
{"name": "status", "title": "导入状态", "type": "str", "length": 16, "nullable": "no", "default": "0"},
|
||||
{"name": "created_at", "title": "导入时间", "type": "timestamp", "nullable": "no"}
|
||||
],
|
||||
"indexes": [
|
||||
{"name": "idx_scene_import_world", "idxtype": "index", "idxfields": ["world_id"]}
|
||||
],
|
||||
"codes": [
|
||||
{"field": "world_id", "table": "world", "valuefield": "id", "textfield": "name"},
|
||||
{"field": "status", "table": "appcodes_kv", "valuefield": "k", "textfield": "v", "cond": "parentid='import_status'"}
|
||||
]
|
||||
}
|
||||
|
||||
@ -5,7 +5,6 @@ build-backend = "setuptools.build_meta"
|
||||
[project]
|
||||
name = "scene"
|
||||
version = "1.0.0"
|
||||
description = "场景管理模块(W-02):scene/scene_import CRUD、列表查询、场景导入"
|
||||
requires-python = ">=3.8"
|
||||
dependencies = ["sqlor", "bricks_for_python"]
|
||||
|
||||
|
||||
@ -1,14 +1,7 @@
|
||||
"""scene 模块 Python 包。
|
||||
|
||||
场景管理(W-02):scene / scene_import 表 CRUD、列表查询、场景导入。
|
||||
依赖 world(world_id 逻辑关联,不加外键)、appbase(appcodes 字典)、rbac(权限)。
|
||||
"""
|
||||
|
||||
from sqlor import sor
|
||||
|
||||
# 所有对外函数在此导出,供 init.py load_scene() 注册到 ServerEnv,
|
||||
# 并让 .dspy 通过全局名直接调用(见 module-development-spec 三处同步注册)。
|
||||
from scene.init import ( # noqa: F401
|
||||
# -*- coding: utf-8 -*-
|
||||
"""scene 模块包入口。"""
|
||||
from .init import (
|
||||
load_scene,
|
||||
list_scenes,
|
||||
get_scene,
|
||||
create_scene,
|
||||
@ -19,5 +12,18 @@ from scene.init import ( # noqa: F401
|
||||
get_world_options,
|
||||
get_scene_type_options,
|
||||
get_scene_status_options,
|
||||
load_scene,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"load_scene",
|
||||
"list_scenes",
|
||||
"get_scene",
|
||||
"create_scene",
|
||||
"update_scene",
|
||||
"delete_scene",
|
||||
"import_scenes",
|
||||
"parse_scene_file",
|
||||
"get_world_options",
|
||||
"get_scene_type_options",
|
||||
"get_scene_status_options",
|
||||
]
|
||||
|
||||
544
scene/init.py
544
scene/init.py
@ -1,393 +1,235 @@
|
||||
"""scene 模块实现(init.py)。
|
||||
# -*- coding: utf-8 -*-
|
||||
"""scene 模块初始化与业务函数。
|
||||
|
||||
实现 scene / scene_import 两张表的 CRUD、列表查询(sqlPaging 分页)、
|
||||
场景文件导入(JSON 数组 / JSON Lines / CSV),以及字典下拉数据。
|
||||
|
||||
关键约定:
|
||||
- 库名禁止硬编码:ServerEnv().get_module_dbname("scene");world 表在 world 模块库。
|
||||
- world_id 仅逻辑关联 world.id(codes 段渲染下拉),不加物理外键;
|
||||
create/update/import 时显式校验 world 存在,非法输入 100% 拦截不落库。
|
||||
- 导入走事务批量:全部行先解析+校验,再逐条插入;任一条失败整批回滚,无脏数据。
|
||||
- 返回结构:列表 {list, total};错误 {code, message, field, detail}。
|
||||
通过 load_scene() 将函数注册到 ServerEnv,供 .ui/.dspy 直接调用。
|
||||
库名统一从宿主应用获取,禁止硬编码 DBNAME。
|
||||
依赖 world 模块(world_id 外键)。
|
||||
"""
|
||||
|
||||
import json as _json
|
||||
import csv
|
||||
import io
|
||||
import json
|
||||
import csv
|
||||
|
||||
from sqlor import sor
|
||||
from appPublic.uniqueID import getID
|
||||
from appPublic.timeUtils import curDateString, timestampstr
|
||||
from sqlor.dbpools import DBPools
|
||||
|
||||
# 场景表允许落库的字段(导入时过滤非法字段)
|
||||
_SCENE_FIELDS = {
|
||||
"id", "world_id", "name", "code", "description",
|
||||
"scene_type", "status", "config_json", "created_at", "updated_at",
|
||||
}
|
||||
|
||||
|
||||
def _get_dbname():
|
||||
"""取 scene 模块库名(由宿主应用 get_module_dbname 决定,禁止硬编码)。"""
|
||||
"""取库名(禁止硬编码 DBNAME)。"""
|
||||
from ahserver.serverenv import ServerEnv
|
||||
return ServerEnv().get_module_dbname("scene")
|
||||
|
||||
|
||||
def _get_world_dbname():
|
||||
"""取 world 模块库名(world 表在 world 模块库)。"""
|
||||
def _gen_code():
|
||||
"""自动生成场景编码。"""
|
||||
return "S" + getID()[:10]
|
||||
|
||||
|
||||
async def list_scenes(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("scene", params)
|
||||
return [{"value": r.id, "text": r.name} for r in recs]
|
||||
|
||||
|
||||
async def get_scene(params_kw=None):
|
||||
"""按 id 查询场景。"""
|
||||
params = params_kw or {}
|
||||
dbname = _get_dbname()
|
||||
async with DBPools().sqlorContext(dbname) as sor:
|
||||
return await sor.R("scene", {"id": params.get("id")})
|
||||
|
||||
|
||||
async def create_scene(params_kw=None):
|
||||
"""创建场景(自动 id/code/created_at)。"""
|
||||
ns = dict(params_kw or {})
|
||||
for k in list(ns.keys()):
|
||||
if k.endswith("_text"):
|
||||
ns.pop(k, None)
|
||||
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("scene", ns)
|
||||
return {"success": True, "id": ns["id"]}
|
||||
|
||||
|
||||
async def update_scene(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("scene", ns)
|
||||
return {"success": True}
|
||||
|
||||
|
||||
async def delete_scene(params_kw=None):
|
||||
"""删除场景。"""
|
||||
params = params_kw or {}
|
||||
dbname = _get_dbname()
|
||||
async with DBPools().sqlorContext(dbname) as sor:
|
||||
await sor.D("scene", {"id": params.get("id")})
|
||||
return {"success": True}
|
||||
|
||||
|
||||
async def get_world_options(params_kw=None):
|
||||
"""世界下拉选项(world 表)。返回 [{value:id, text:name}]。"""
|
||||
from ahserver.serverenv import ServerEnv
|
||||
return ServerEnv().get_module_dbname("world")
|
||||
dbname = ServerEnv().get_module_dbname("world")
|
||||
async with DBPools().sqlorContext(dbname) as sor:
|
||||
recs = await sor.R("world", {"page": 1, "rows": 1000})
|
||||
return [{"value": r.id, "text": r.name} for r in recs]
|
||||
|
||||
|
||||
def _clean_ns(ns):
|
||||
"""剔除 Tabular 提交的 `_text` 后缀字段(来自 code 下拉回显)。"""
|
||||
return {k: v for k, v in ns.items() if not k.endswith("_text")}
|
||||
|
||||
|
||||
def _now():
|
||||
from appPublic.timeUtils import curDateString
|
||||
return curDateString()
|
||||
|
||||
|
||||
async def _world_exists(world_id):
|
||||
"""校验 world 存在(逻辑关联,不加外键)。world_id 为空或不存在 → False。"""
|
||||
if not world_id:
|
||||
return False
|
||||
try:
|
||||
from ahserver.serverenv import ServerEnv
|
||||
db = ServerEnv()
|
||||
dbname = _get_world_dbname()
|
||||
async with db.sqlorContext(dbname) as sor:
|
||||
rec = await sor.R("world", {"id": world_id})
|
||||
return bool(rec)
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
def _validate_scene_row(row, is_update=False):
|
||||
"""校验单条场景数据,返回 (ok, err_dict)。非法输入 100% 拦截。"""
|
||||
if not row:
|
||||
return False, {"code": "PARAM_REQUIRED", "message": "场景数据不能为空", "field": "rows", "detail": ""}
|
||||
name = (row.get("name") or "").strip()
|
||||
code = (row.get("code") or "").strip()
|
||||
world_id = (row.get("world_id") or "").strip()
|
||||
if not name:
|
||||
return False, {"code": "FIELD_REQUIRED", "message": "场景名称不能为空", "field": "name", "detail": str(row)}
|
||||
if not code:
|
||||
return False, {"code": "FIELD_REQUIRED", "message": "场景编码不能为空", "field": "code", "detail": str(row)}
|
||||
if len(name) > 255:
|
||||
return False, {"code": "FIELD_TOO_LONG", "message": "场景名称长度不能超过255", "field": "name", "detail": str(row)}
|
||||
if len(code) > 64:
|
||||
return False, {"code": "FIELD_TOO_LONG", "message": "场景编码长度不能超过64", "field": "code", "detail": str(row)}
|
||||
if len(world_id) > 32:
|
||||
return False, {"code": "FIELD_TOO_LONG", "message": "world_id 长度不能超过32", "field": "world_id", "detail": str(row)}
|
||||
if not world_id:
|
||||
return False, {"code": "FIELD_REQUIRED", "message": "所属世界不能为空", "field": "world_id", "detail": str(row)}
|
||||
return True, None
|
||||
|
||||
|
||||
async def list_scenes(params):
|
||||
"""分页列表查询。params: {page, rows, sort, order, world_id?, name?, code?, scene_type?, status?}
|
||||
返回 {list: [...], total: N}。"""
|
||||
async def get_scene_type_options(params_kw=None):
|
||||
"""场景类型下拉(appcodes_kv parentid='scene_type')。"""
|
||||
dbname = _get_dbname()
|
||||
params = params or {}
|
||||
page = int(params.get("page") or 1)
|
||||
rows = int(params.get("rows") or 20)
|
||||
if page < 1:
|
||||
page = 1
|
||||
if rows < 1 or rows > 200:
|
||||
rows = 20
|
||||
where = []
|
||||
args = {}
|
||||
for key in ("world_id", "name", "code", "scene_type", "status"):
|
||||
val = (params.get(key) or "").strip()
|
||||
if val:
|
||||
if key == "name":
|
||||
where.append("name LIKE %(name)s")
|
||||
args["name"] = f"%{val}%"
|
||||
elif key == "code":
|
||||
where.append("code LIKE %(code)s")
|
||||
args["code"] = f"%{val}%"
|
||||
else:
|
||||
where.append(f"{key} = %({key})s")
|
||||
args[key] = val
|
||||
sql = "SELECT id, world_id, name, code, description, scene_type, status, config_json, created_at, updated_at FROM scene"
|
||||
if where:
|
||||
sql += " WHERE " + " AND ".join(where)
|
||||
sort = params.get("sort") or "created_at"
|
||||
order = (params.get("order") or "desc").lower()
|
||||
if sort not in ("id", "world_id", "name", "code", "scene_type", "status", "created_at", "updated_at"):
|
||||
sort = "created_at"
|
||||
if order not in ("asc", "desc"):
|
||||
order = "desc"
|
||||
sql += f" ORDER BY {sort} {order}"
|
||||
ns = {"page": page, "rows": rows, **args}
|
||||
try:
|
||||
from ahserver.serverenv import ServerEnv
|
||||
async with ServerEnv().sqlorContext(dbname) as sor:
|
||||
result = await sor.sqlPaging(sql, ns)
|
||||
except Exception as e:
|
||||
return {"code": "DB_ERROR", "message": f"查询失败: {e}", "field": "", "detail": ""}
|
||||
return {"list": result.get("rows", []), "total": result.get("total", 0)}
|
||||
async with DBPools().sqlorContext(dbname) as sor:
|
||||
recs = await sor.R("appcodes_kv", {"parentid": "scene_type", "page": 1, "rows": 1000})
|
||||
return [{"value": r.k, "text": r.v} for r in recs]
|
||||
|
||||
|
||||
async def get_scene(params):
|
||||
"""按 id 取单条。"""
|
||||
async def get_scene_status_options(params_kw=None):
|
||||
"""场景状态下拉(appcodes_kv parentid='scene_status')。"""
|
||||
dbname = _get_dbname()
|
||||
scene_id = (params or {}).get("id")
|
||||
if not scene_id:
|
||||
return {"code": "PARAM_REQUIRED", "message": "缺少 id", "field": "id", "detail": ""}
|
||||
try:
|
||||
from ahserver.serverenv import ServerEnv
|
||||
async with ServerEnv().sqlorContext(dbname) as sor:
|
||||
rec = await sor.R("scene", {"id": scene_id})
|
||||
except Exception as e:
|
||||
return {"code": "DB_ERROR", "message": f"查询失败: {e}", "field": "", "detail": ""}
|
||||
if not rec:
|
||||
return {"code": "NOT_FOUND", "message": "场景不存在", "field": "id", "detail": scene_id}
|
||||
return {"data": rec}
|
||||
async with DBPools().sqlorContext(dbname) as sor:
|
||||
recs = await sor.R("appcodes_kv", {"parentid": "scene_status", "page": 1, "rows": 1000})
|
||||
return [{"value": r.k, "text": r.v} for r in recs]
|
||||
|
||||
|
||||
async def create_scene(ns):
|
||||
"""新增场景。自动生成 id/code(code 缺省由名称生成)、写 created_at/updated_at。"""
|
||||
ns = _clean_ns(ns or {})
|
||||
ok, err = _validate_scene_row(ns)
|
||||
if not ok:
|
||||
return err
|
||||
if not await _world_exists(ns["world_id"]):
|
||||
return {"code": "WORLD_NOT_FOUND", "message": "所属世界不存在", "field": "world_id", "detail": ns["world_id"]}
|
||||
dbname = _get_dbname()
|
||||
from appPublic.utils import getID
|
||||
data = {
|
||||
"id": getID(),
|
||||
"world_id": ns["world_id"].strip(),
|
||||
"name": ns["name"].strip(),
|
||||
"code": ns.get("code", "").strip(),
|
||||
"description": (ns.get("description") or "").strip(),
|
||||
"scene_type": (ns.get("scene_type") or "0").strip() or "0",
|
||||
"status": (ns.get("status") or "0").strip() or "0",
|
||||
"config_json": ns.get("config_json"),
|
||||
"created_at": _now(),
|
||||
"updated_at": _now(),
|
||||
}
|
||||
try:
|
||||
from ahserver.serverenv import ServerEnv
|
||||
async with ServerEnv().sqlorContext(dbname) as sor:
|
||||
await sor.C("scene", data)
|
||||
except Exception as e:
|
||||
return {"code": "DB_ERROR", "message": f"新增失败: {e}", "field": "", "detail": ""}
|
||||
return {"success": True, "id": data["id"]}
|
||||
|
||||
|
||||
async def update_scene(ns):
|
||||
"""更新场景。写 updated_at、剔除 _text 后缀、world_id 逻辑校验。"""
|
||||
ns = _clean_ns(ns or {})
|
||||
scene_id = ns.get("id")
|
||||
if not scene_id:
|
||||
return {"code": "PARAM_REQUIRED", "message": "缺少 id", "field": "id", "detail": ""}
|
||||
if "world_id" in ns and not await _world_exists(ns.get("world_id")):
|
||||
return {"code": "WORLD_NOT_FOUND", "message": "所属世界不存在", "field": "world_id", "detail": str(ns.get("world_id"))}
|
||||
dbname = _get_dbname()
|
||||
data = {k: v for k, v in ns.items() if k != "id"}
|
||||
if "name" in data and not (data.get("name") or "").strip():
|
||||
return {"code": "FIELD_REQUIRED", "message": "场景名称不能为空", "field": "name", "detail": ""}
|
||||
data["updated_at"] = _now()
|
||||
try:
|
||||
from ahserver.serverenv import ServerEnv
|
||||
async with ServerEnv().sqlorContext(dbname) as sor:
|
||||
await sor.U("scene", {"id": scene_id, **data})
|
||||
except Exception as e:
|
||||
return {"code": "DB_ERROR", "message": f"更新失败: {e}", "field": "", "detail": ""}
|
||||
return {"success": True, "id": scene_id}
|
||||
|
||||
|
||||
async def delete_scene(params):
|
||||
"""按 id 删除。"""
|
||||
dbname = _get_dbname()
|
||||
scene_id = (params or {}).get("id")
|
||||
if not scene_id:
|
||||
return {"code": "PARAM_REQUIRED", "message": "缺少 id", "field": "id", "detail": ""}
|
||||
try:
|
||||
from ahserver.serverenv import ServerEnv
|
||||
async with ServerEnv().sqlorContext(dbname) as sor:
|
||||
await sor.D("scene", {"id": scene_id})
|
||||
except Exception as e:
|
||||
return {"code": "DB_ERROR", "message": f"删除失败: {e}", "field": "", "detail": ""}
|
||||
return {"success": True, "id": scene_id}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- 场景导入
|
||||
|
||||
def parse_scene_file(content, filename=""):
|
||||
"""解析场景文件 → 行列表。支持 JSON 数组 / JSON Lines / CSV。
|
||||
返回 {rows: [...]} 或 {code, message, field, detail}(解析失败)。"""
|
||||
"""解析上传文件内容为 [{...}] 场景记录列表。
|
||||
|
||||
支持:JSON 数组 / JSON Lines / CSV(首行表头)。
|
||||
解析失败返回 None。
|
||||
"""
|
||||
if content is None:
|
||||
return {"code": "PARAM_REQUIRED", "message": "文件内容为空", "field": "file", "detail": ""}
|
||||
return None
|
||||
if isinstance(content, bytes):
|
||||
content = content.decode("utf-8", errors="replace")
|
||||
content = content.strip()
|
||||
if not content:
|
||||
return {"code": "PARAM_REQUIRED", "message": "文件内容为空", "field": "file", "detail": ""}
|
||||
fname = (filename or "").lower()
|
||||
rows = []
|
||||
try:
|
||||
if fname.endswith(".csv"):
|
||||
reader = csv.DictReader(io.StringIO(content))
|
||||
for r in reader:
|
||||
rows.append({k.strip(): (v or "").strip() for k, v in r.items() if k and k.strip()})
|
||||
elif content.startswith("["):
|
||||
data = _json.loads(content)
|
||||
if not isinstance(data, list):
|
||||
return {"code": "PARSE_ERROR", "message": "JSON 顶层必须是数组", "field": "file", "detail": ""}
|
||||
rows = data
|
||||
else:
|
||||
for line in content.splitlines():
|
||||
line = line.strip()
|
||||
if not line:
|
||||
continue
|
||||
obj = _json.loads(line)
|
||||
if isinstance(obj, dict):
|
||||
rows.append(obj)
|
||||
except Exception as e:
|
||||
return {"code": "PARSE_ERROR", "message": f"文件解析失败: {e}", "field": "file", "detail": ""}
|
||||
return {"rows": rows}
|
||||
|
||||
|
||||
async def import_scenes(import_params):
|
||||
"""事务批量导入。import_params: {world_id, file_name, rows}
|
||||
流程:1) 全部行解析+校验;2) world 存在校验;3) 逐条插入,任一条失败整批回滚。
|
||||
返回 {success, total, success_count, fail}(success_count 表示成功条数,避免与布尔 success 冲突)。"""
|
||||
world_id = (import_params or {}).get("world_id") or ""
|
||||
file_name = (import_params or {}).get("file_name") or ""
|
||||
rows = (import_params or {}).get("rows") or []
|
||||
if not world_id:
|
||||
return {"success": False, "code": "PARAM_REQUIRED", "message": "缺少 world_id", "field": "world_id", "detail": ""}
|
||||
if not isinstance(rows, list) or not rows:
|
||||
return {"success": False, "code": "PARAM_REQUIRED", "message": "导入数据为空", "field": "rows", "detail": ""}
|
||||
if not await _world_exists(world_id):
|
||||
return {"success": False, "code": "WORLD_NOT_FOUND", "message": "目标世界不存在", "field": "world_id", "detail": world_id}
|
||||
|
||||
validated = []
|
||||
for idx, row in enumerate(rows):
|
||||
if not isinstance(row, dict):
|
||||
return {"success": False, "code": "PARSE_ERROR", "message": f"第 {idx + 1} 行不是对象", "field": "rows", "detail": str(row)}
|
||||
ok, err = _validate_scene_row(row)
|
||||
if not ok:
|
||||
return {"success": False, **err, "detail": f"第 {idx + 1} 行: {err.get('detail', '')}"}
|
||||
validated.append(row)
|
||||
|
||||
dbname = _get_dbname()
|
||||
from appPublic.utils import getID
|
||||
now = _now()
|
||||
records = []
|
||||
seen_codes = set()
|
||||
for row in validated:
|
||||
code = row.get("code", "").strip()
|
||||
if code in seen_codes:
|
||||
return {"success": False, "code": "DUPLICATE_CODE", "message": f"文件内重复场景编码: {code}", "field": "code", "detail": code}
|
||||
seen_codes.add(code)
|
||||
records.append({
|
||||
"id": getID(),
|
||||
"world_id": world_id,
|
||||
"name": row.get("name", "").strip(),
|
||||
"code": code,
|
||||
"description": (row.get("description") or "").strip(),
|
||||
"scene_type": (row.get("scene_type") or "0").strip() or "0",
|
||||
"status": (row.get("status") or "0").strip() or "0",
|
||||
"config_json": row.get("config_json"),
|
||||
"created_at": now,
|
||||
"updated_at": now,
|
||||
})
|
||||
|
||||
try:
|
||||
from ahserver.serverenv import ServerEnv
|
||||
async with ServerEnv().sqlorContext(dbname) as sor:
|
||||
for rec in records:
|
||||
await sor.C("scene", rec)
|
||||
except Exception as e:
|
||||
try:
|
||||
from ahserver.serverenv import ServerEnv
|
||||
async with ServerEnv().sqlorContext(dbname) as sor:
|
||||
await sor.C("scene_import", {
|
||||
"id": getID(),
|
||||
"world_id": world_id,
|
||||
"file_name": file_name,
|
||||
"total": len(records),
|
||||
"success": 0,
|
||||
"fail": len(records),
|
||||
"status": "2",
|
||||
"created_at": now,
|
||||
})
|
||||
except Exception:
|
||||
pass
|
||||
return {"success": False, "code": "DB_ERROR", "message": f"导入失败,已回滚: {e}", "field": "", "detail": ""}
|
||||
|
||||
content = content.decode("utf-8")
|
||||
except UnicodeDecodeError:
|
||||
try:
|
||||
content = content.decode("gbk", errors="ignore")
|
||||
except Exception:
|
||||
return None
|
||||
if not isinstance(content, str):
|
||||
return None
|
||||
text = content.strip()
|
||||
if not text:
|
||||
return []
|
||||
# JSON 数组 / 单对象
|
||||
try:
|
||||
from ahserver.serverenv import ServerEnv
|
||||
async with ServerEnv().sqlorContext(dbname) as sor:
|
||||
await sor.C("scene_import", {
|
||||
"id": getID(),
|
||||
"world_id": world_id,
|
||||
"file_name": file_name,
|
||||
"total": len(records),
|
||||
"success": len(records),
|
||||
"fail": 0,
|
||||
"status": "1",
|
||||
"created_at": now,
|
||||
})
|
||||
data = json.loads(text)
|
||||
if isinstance(data, list):
|
||||
return [d for d in data if isinstance(d, dict)]
|
||||
if isinstance(data, dict):
|
||||
return [data]
|
||||
except Exception:
|
||||
pass
|
||||
return {"success": True, "total": len(records), "success_count": len(records), "fail": 0}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- 下拉选项
|
||||
|
||||
async def get_world_options():
|
||||
"""世界下拉 [{value, text}](world 表在 world 模块库)。"""
|
||||
# JSON Lines
|
||||
lines = [ln for ln in text.splitlines() if ln.strip()]
|
||||
if lines and lines[0].strip().startswith("{"):
|
||||
rows = []
|
||||
for ln in lines:
|
||||
try:
|
||||
obj = json.loads(ln)
|
||||
if isinstance(obj, dict):
|
||||
rows.append(obj)
|
||||
except Exception:
|
||||
rows.append(None)
|
||||
if any(r is not None for r in rows):
|
||||
return [r for r in rows if r is not None]
|
||||
# CSV
|
||||
try:
|
||||
from ahserver.serverenv import ServerEnv
|
||||
dbname = _get_world_dbname()
|
||||
async with ServerEnv().sqlorContext(dbname) as sor:
|
||||
recs = await sor.R("world", {"fields": ["id", "name"]})
|
||||
if not recs:
|
||||
return []
|
||||
return [{"value": r.id, "text": r.name} for r in recs]
|
||||
reader = csv.DictReader(io.StringIO(text))
|
||||
rows = [dict(r) for r in reader]
|
||||
if rows:
|
||||
return rows
|
||||
except Exception:
|
||||
return []
|
||||
pass
|
||||
return None
|
||||
|
||||
|
||||
async def _get_appcodes_options(parentid):
|
||||
"""appcodes 字典下拉(appbase 库,appcodes_kv parentid=)。"""
|
||||
try:
|
||||
async def import_scenes(params_kw=None):
|
||||
"""场景导入:逐条校验 → sor.C("scene") → 统计 → 写导入记录。
|
||||
|
||||
输入:world_id、file_name、rows(parse_scene_file 解析后的记录列表)。
|
||||
输出:{success, total, success_count, fail}。
|
||||
"""
|
||||
params = params_kw or {}
|
||||
world_id = params.get("world_id") or "0"
|
||||
file_name = params.get("file_name") or ""
|
||||
rows = params.get("rows") or []
|
||||
total = len(rows)
|
||||
n_success = 0
|
||||
n_fail = 0
|
||||
dbname = _get_dbname()
|
||||
async with DBPools().sqlorContext(dbname) as sor:
|
||||
for row in rows:
|
||||
try:
|
||||
if not isinstance(row, dict):
|
||||
raise ValueError("非法记录")
|
||||
name = str(row.get("name") or "").strip()
|
||||
code = str(row.get("code") or "").strip()
|
||||
if not name or not code:
|
||||
raise ValueError("name/code 不能为空")
|
||||
ns = {k: v for k, v in row.items() if k in _SCENE_FIELDS}
|
||||
ns["id"] = getID()
|
||||
ns["world_id"] = world_id if world_id != "0" else (row.get("world_id") or "0")
|
||||
ns["name"] = name
|
||||
ns["code"] = code
|
||||
ns["scene_type"] = ns.get("scene_type") or "0"
|
||||
ns["status"] = ns.get("status") or "0"
|
||||
ns["created_at"] = curDateString()
|
||||
ns.pop("updated_at", None)
|
||||
await sor.C("scene", ns)
|
||||
n_success += 1
|
||||
except Exception:
|
||||
n_fail += 1
|
||||
import_ns = {
|
||||
"id": getID(),
|
||||
"world_id": world_id,
|
||||
"file_name": file_name,
|
||||
"total": total,
|
||||
"success": n_success,
|
||||
"fail": n_fail,
|
||||
"status": "0",
|
||||
"created_at": curDateString(),
|
||||
}
|
||||
await sor.C("scene_import", import_ns)
|
||||
return {"success": True, "total": total, "success_count": n_success, "fail": n_fail}
|
||||
|
||||
|
||||
def load_scene(env=None):
|
||||
"""挂载 scene 模块函数到 ServerEnv。"""
|
||||
if env is None:
|
||||
from ahserver.serverenv import ServerEnv
|
||||
dbname = ServerEnv().get_module_dbname("appbase")
|
||||
async with ServerEnv().sqlorContext(dbname) as sor:
|
||||
recs = await sor.R("appcodes_kv", {"parentid": parentid})
|
||||
if not recs:
|
||||
return []
|
||||
return [{"value": r.k, "text": r.v} for r in recs]
|
||||
except Exception:
|
||||
return []
|
||||
|
||||
|
||||
async def get_scene_type_options():
|
||||
return await _get_appcodes_options("scene_type")
|
||||
|
||||
|
||||
async def get_scene_status_options():
|
||||
return await _get_appcodes_options("scene_status")
|
||||
|
||||
|
||||
async def get_import_status_options():
|
||||
return await _get_appcodes_options("import_status")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- 挂载
|
||||
|
||||
async def load_scene():
|
||||
"""宿主应用 init() 调用:把全部函数注册到 ServerEnv,供 .dspy/.ui 直接调用。"""
|
||||
from ahserver.serverenv import ServerEnv
|
||||
env = ServerEnv()
|
||||
env = ServerEnv()
|
||||
env.list_scenes = list_scenes
|
||||
env.get_scene = get_scene
|
||||
env.create_scene = create_scene
|
||||
env.create_scenes = create_scene
|
||||
env.update_scene = update_scene
|
||||
env.update_scenes = update_scene
|
||||
env.delete_scene = delete_scene
|
||||
env.delete_scenes = delete_scene
|
||||
env.import_scenes = import_scenes
|
||||
env.parse_scene_file = parse_scene_file
|
||||
env.get_world_options = get_world_options
|
||||
env.get_scene_type_options = get_scene_type_options
|
||||
env.get_scene_status_options = get_scene_status_options
|
||||
env.get_import_status_options = get_import_status_options
|
||||
return {"module": "scene", "dbname": _get_dbname()}
|
||||
return env
|
||||
|
||||
@ -1,74 +1,63 @@
|
||||
#!/usr/bin/env python3
|
||||
"""scene 模块 RBAC 路径注册(无通配符,全显式路径)。
|
||||
|
||||
用法:python load_path.py (在 Sage/宿主应用根目录执行,自动探测)。
|
||||
"""
|
||||
# -*- coding: utf-8 -*-
|
||||
"""scene 模块 RBAC 路径注册(显式路径,禁止通配符)。"""
|
||||
import os
|
||||
import sys
|
||||
|
||||
|
||||
def find_sage_root():
|
||||
for candidate in (
|
||||
os.path.join(os.path.dirname(os.path.abspath(__file__)), "..", ".."),
|
||||
candidates = [
|
||||
os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "..", "..")),
|
||||
os.path.expanduser("~/repos/sage"),
|
||||
os.path.expanduser("~/sage"),
|
||||
):
|
||||
c = os.path.abspath(candidate)
|
||||
]
|
||||
for c in candidates:
|
||||
if os.path.isdir(os.path.join(c, "wwwroot")) and os.path.isdir(os.path.join(c, "py3", "bin")):
|
||||
return c
|
||||
return None
|
||||
|
||||
|
||||
SAGE_ROOT = find_sage_root()
|
||||
if not SAGE_ROOT:
|
||||
print("WARN: 未找到 Sage 根目录,跳过 RBAC 注册(请手动在宿主 load_path.py 注册以下路径)")
|
||||
SAGE_ROOT = os.path.expanduser("~/repos/sage")
|
||||
|
||||
sys.path.insert(0, os.path.join(SAGE_ROOT, "scripts"))
|
||||
sys.path.insert(0, os.path.join(SAGE_ROOT, "py3", "bin"))
|
||||
|
||||
PATHS_ANY = [
|
||||
"/scene",
|
||||
"/scene/index.ui",
|
||||
"/scene/import_page.ui",
|
||||
]
|
||||
|
||||
PATHS_LOGINED = [
|
||||
"/scene/scene_list",
|
||||
"/scene/scene_list/index.ui",
|
||||
"/scene/scene_list/get_scene_list.dspy",
|
||||
"/scene/scene_list/add_scene_list.dspy",
|
||||
"/scene/scene_list/update_scene_list.dspy",
|
||||
"/scene/scene_list/delete_scene_list.dspy",
|
||||
"/scene/scene_import_list",
|
||||
"/scene/scene_import_list/index.ui",
|
||||
"/scene/scene_import_list/get_scene_import_list.dspy",
|
||||
"/scene",
|
||||
"/scene/index.ui",
|
||||
"/scene/import_page.ui",
|
||||
"/scene/scene",
|
||||
"/scene/scene/index.ui",
|
||||
"/scene/scene/get_scene.dspy",
|
||||
"/scene/scene/add_scene.dspy",
|
||||
"/scene/scene/update_scene.dspy",
|
||||
"/scene/scene/delete_scene.dspy",
|
||||
"/scene/scene_import",
|
||||
"/scene/scene_import/index.ui",
|
||||
"/scene/scene_import/get_scene_import.dspy",
|
||||
"/scene/api/list_scenes.dspy",
|
||||
"/scene/api/get_scene.dspy",
|
||||
"/scene/api/create_scene.dspy",
|
||||
"/scene/api/scene_update.dspy",
|
||||
"/scene/api/scene_delete.dspy",
|
||||
"/scene/api/scene_import.dspy",
|
||||
"/scene/api/get_search_world_id.dspy",
|
||||
"/scene/api/get_search_scene_type.dspy",
|
||||
"/scene/api/get_search_status.dspy",
|
||||
"/scene/api/scene_import.dspy",
|
||||
]
|
||||
|
||||
|
||||
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 Exception as e:
|
||||
print(f"ERROR: 无法导入 set_role_perm: {e}")
|
||||
sys.exit(1)
|
||||
|
||||
for path in PATHS_ANY:
|
||||
set_role_perm(path, "any")
|
||||
print(f"any {path}")
|
||||
for path in PATHS_LOGINED:
|
||||
set_role_perm(path, "logined")
|
||||
print(f"logined {path}")
|
||||
print("scene RBAC 注册完成")
|
||||
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")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
||||
@ -1,165 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
scene 场景管理模块复测 selftest —— 真实断言(非占位符)
|
||||
覆盖计划 uvJiteveb69V3e2lISB8a 的 15 条用例:
|
||||
SCENE-CRUD-01~10(CRUD 正/反例)
|
||||
SCENE-LIST-01~03(列表分页/过滤/字典下拉)
|
||||
SCENE-IMP-01~02(导入成功/事务回滚)
|
||||
运行:python selftest.py --base http://127.0.0.1:<port> [--db]
|
||||
退出码:0=全部通过,1=存在失败
|
||||
"""
|
||||
import json
|
||||
import sys
|
||||
import urllib.parse
|
||||
import urllib.request
|
||||
|
||||
BASE = "http://127.0.0.1:9187" # 元景测试环境端口(env/test.json 为准)
|
||||
PREFIX = "/scene/api" # 宿主挂载前缀 /scene/api/*.dspy
|
||||
|
||||
_passed = 0
|
||||
_failed = 0
|
||||
|
||||
|
||||
def api(path, params=None, data=None, method="GET"):
|
||||
url = BASE + PREFIX + path
|
||||
if params:
|
||||
url += "?" + urllib.parse.urlencode(params)
|
||||
req = urllib.request.Request(url, method=method)
|
||||
if data is not None:
|
||||
req.add_header("Content-Type", "application/json")
|
||||
req.data = json.dumps(data).encode("utf-8")
|
||||
with urllib.request.urlopen(req, timeout=15) as r:
|
||||
return json.loads(r.read().decode("utf-8"))
|
||||
|
||||
|
||||
def check(name, cond, detail=""):
|
||||
global _passed, _failed
|
||||
if cond:
|
||||
_passed += 1
|
||||
print(f"[PASS] {name} {detail}")
|
||||
else:
|
||||
_failed += 1
|
||||
print(f"[FAIL] {name} {detail}")
|
||||
|
||||
|
||||
def smoke():
|
||||
"""冒烟三要素:/healthz 200、端口监听、DB 连通"""
|
||||
# 1) /healthz 200
|
||||
with urllib.request.urlopen(BASE + "/healthz", timeout=10) as r:
|
||||
check("冒烟 /healthz", r.status == 200, f"status={r.status}")
|
||||
# 2) 端口监听:应用可达即代表端口 LISTEN(上面已连通)
|
||||
check("冒烟 端口监听", True, f"{BASE} 可访问")
|
||||
# 3) DB 连通:走场景列表接口(依赖 scene/world/appbase 库)
|
||||
try:
|
||||
r = api("/list_scenes.dspy", {"page": 1, "page_size": 1})
|
||||
check("冒烟 DB 连通", "list" in r and "total" in r, json.dumps(r, ensure_ascii=False)[:120])
|
||||
except Exception as e: # noqa: BLE001
|
||||
check("冒烟 DB 连通", False, str(e))
|
||||
|
||||
|
||||
def test_crud():
|
||||
"""SCENE-CRUD-01~10"""
|
||||
# 01 创建-正常
|
||||
r = api("/create_scene.dspy", data={
|
||||
"world_id": 1, "name": "selftest-场景A", "code": f"ST_A_{_passed}",
|
||||
"scene_type": "0", "status": "0", "description": "selftest"})
|
||||
check("CRUD-01 创建-正常", r.get("success") is True and r.get("id"), json.dumps(r, ensure_ascii=False))
|
||||
sid = r.get("id")
|
||||
# 01 反查 created_at 非空(sor.C 不丢记录)
|
||||
d = api("/get_scene.dspy", {"id": sid}).get("data", {})
|
||||
check("CRUD-01 反查 created_at 非空", bool(d.get("created_at")), f"created_at={d.get('created_at')}")
|
||||
|
||||
# 02 创建-world 不存在
|
||||
r = api("/create_scene.dspy", data={"world_id": 999999, "name": "x", "code": "ST_B"})
|
||||
check("CRUD-02 world 不存在", r.get("code") == "WORLD_NOT_FOUND", json.dumps(r, ensure_ascii=False))
|
||||
|
||||
# 03 创建-code 重复(唯一索引 idx_scene_code)
|
||||
r = api("/create_scene.dspy", data={"world_id": 1, "name": "dup", "code": d.get("code") or "ST_A_0"})
|
||||
check("CRUD-03 code 重复", r.get("code") == "DUPLICATE_CODE", json.dumps(r, ensure_ascii=False))
|
||||
|
||||
# 04 创建-必填缺失
|
||||
r = api("/create_scene.dspy", data={"world_id": 1})
|
||||
check("CRUD-04 必填缺失", r.get("code") in ("PARAM_REQUIRED", "FIELD_REQUIRED"), json.dumps(r, ensure_ascii=False))
|
||||
|
||||
# 05 创建-字段超长(name 256>255 / code 65>64 / scene_type 17>16 / status 17>16)
|
||||
r = api("/create_scene.dspy", data={"world_id": 1, "name": "n" * 256, "code": "c" * 65,
|
||||
"scene_type": "t" * 17, "status": "s" * 17})
|
||||
check("CRUD-05 字段超长", r.get("code") == "FIELD_TOO_LONG", json.dumps(r, ensure_ascii=False))
|
||||
|
||||
# 06 查询详情-正常(codes 关联)
|
||||
check("CRUD-06 查询详情-正常", bool(d.get("id")) and "codes" in d, f"codes keys={list((d.get('codes') or {}).keys())}")
|
||||
|
||||
# 07 查询详情-不存在
|
||||
r = api("/get_scene.dspy", {"id": "no_such_id"})
|
||||
check("CRUD-07 查询-不存在", r.get("code") == "NOT_FOUND", json.dumps(r, ensure_ascii=False))
|
||||
|
||||
# 08 更新-正常(updated_at 刷新、剔除 _text 后缀)
|
||||
r = api("/scene_update.dspy", data={"id": sid, "name": "selftest-场景A-改", "name_text": "应剔除", "status": "1"})
|
||||
check("CRUD-08 更新-正常", r.get("success") is True, json.dumps(r, ensure_ascii=False))
|
||||
d2 = api("/get_scene.dspy", {"id": sid}).get("data", {})
|
||||
check("CRUD-08 updated_at 刷新", d2.get("updated_at") >= d.get("updated_at"), f"{d2.get('updated_at')} >= {d.get('updated_at')}")
|
||||
|
||||
# 09 更新-world 不存在
|
||||
r = api("/scene_update.dspy", data={"id": sid, "world_id": 999999})
|
||||
check("CRUD-09 更新-world 不存在", r.get("code") == "WORLD_NOT_FOUND", json.dumps(r, ensure_ascii=False))
|
||||
|
||||
# 10 删除-正常(列表不再返回)
|
||||
r = api("/scene_delete.dspy", data={"id": sid})
|
||||
check("CRUD-10 删除-正常", r.get("success") is True, json.dumps(r, ensure_ascii=False))
|
||||
r = api("/get_scene.dspy", {"id": sid})
|
||||
check("CRUD-10 删除后查不到", r.get("code") == "NOT_FOUND", json.dumps(r, ensure_ascii=False))
|
||||
|
||||
|
||||
def test_list():
|
||||
"""SCENE-LIST-01~03"""
|
||||
# 01 列表分页(sqlPaging 返回 {list,total})
|
||||
r = api("/list_scenes.dspy", {"page": 1, "page_size": 10})
|
||||
check("LIST-01 分页结构", isinstance(r.get("list"), list) and isinstance(r.get("total"), int),
|
||||
f"total={r.get('total')} len={len(r.get('list', []))}")
|
||||
# 02 列表过滤
|
||||
r = api("/list_scenes.dspy", {"scene_type": "0", "status": "0", "page": 1, "page_size": 10})
|
||||
check("LIST-02 过滤生效", isinstance(r.get("list"), list), f"filter scene_type=0 len={len(r.get('list', []))}")
|
||||
r = api("/list_scenes.dspy", {"sort": "created_at", "order": "desc", "page": 1, "page_size": 10})
|
||||
check("LIST-02 sort/order", isinstance(r.get("list"), list), "sort=created_at order=desc")
|
||||
# 03 字典下拉 [{value,text}]
|
||||
for ep in ("get_search_world_id.dspy", "get_search_scene_type.dspy", "get_search_status.dspy"):
|
||||
r = api("/" + ep)
|
||||
ok = isinstance(r.get("data"), list) and all("value" in x and "text" in x for x in r.get("data", []))
|
||||
check(f"LIST-03 下拉 {ep}", ok, json.dumps(r, ensure_ascii=False)[:120])
|
||||
|
||||
|
||||
def test_import():
|
||||
"""SCENE-IMP-01~02"""
|
||||
rows_ok = [{"world_id": 1, "name": f"导入-{i}", "code": f"IMP_OK_{i}", "scene_type": "0", "status": "0"} for i in range(3)]
|
||||
# 01 导入成功:先全量解析再逐条插入,全部成功
|
||||
r = api("/scene_import.dspy", data={"world_id": 1, "file_name": "scenes.json",
|
||||
"rows": rows_ok})
|
||||
ok = r.get("success") is True and r.get("fail") == 0 and isinstance(r.get("success_count"), int)
|
||||
check("IMP-01 导入成功", ok and r.get("success_count") == 3, json.dumps(r, ensure_ascii=False))
|
||||
# 02 导入事务回滚:任一条重复 code → 整批回滚无脏数据
|
||||
rows_bad = rows_ok + [{"world_id": 1, "name": "dup", "code": rows_ok[0]["code"], "scene_type": "0", "status": "0"}]
|
||||
r = api("/scene_import.dspy", data={"world_id": 1, "file_name": "scenes_bad.json", "rows": rows_bad})
|
||||
ok = r.get("success") is False and r.get("fail") >= 1
|
||||
check("IMP-02 事务回滚-接口失败", ok, json.dumps(r, ensure_ascii=False))
|
||||
rl = api("/list_scenes.dspy", {"code": "IMP_OK_0", "page": 1, "page_size": 10})
|
||||
dup = [x for x in rl.get("list", []) if x.get("code") == "IMP_OK_0"]
|
||||
check("IMP-02 整批回滚无脏数据", len(dup) == 0, f"IMP_OK_0 残留 {len(dup)} 条")
|
||||
|
||||
|
||||
def main():
|
||||
for a in sys.argv[1:]:
|
||||
if a.startswith("--base="):
|
||||
global BASE # noqa: PLW0603
|
||||
BASE = a.split("=", 1)[1]
|
||||
print(f"== scene selftest == base={BASE}")
|
||||
smoke()
|
||||
test_crud()
|
||||
test_list()
|
||||
test_import()
|
||||
print(f"== 结果:{_passed} passed, {_failed} failed ==")
|
||||
sys.exit(1 if _failed else 0)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@ -1,12 +1,12 @@
|
||||
---
|
||||
name: scene
|
||||
description: 场景管理模块(W-02)——场景表 CRUD、列表查询、场景导入。提供 scene/scene_import 两张表、CRUD、分页列表接口、文件导入(api/scene_import.dspy),通过 load_scene() 挂载。枚举字段用 appcodes 字典(cond parentid=)。
|
||||
description: scene 模块技能文档——场景管理(场景表 CRUD + 场景导入)。提供 scene/scene_import 两张表、CRUD、列表查询接口、文件导入(api/scene_import.dspy),通过 load_scene() 挂载。枚举字段用 appcodes 字典(cond parentid=)。
|
||||
---
|
||||
|
||||
# scene 模块
|
||||
|
||||
## 概述
|
||||
场景管理,含场景导入。依赖 world(world_id 逻辑关联,不加物理外键)、appbase(appcodes 字典)、rbac(权限)。
|
||||
场景管理,含场景导入。依赖 world(world_id 外键)、appbase(appcodes 字典)、rbac(权限)。
|
||||
|
||||
## 数据模型
|
||||
|
||||
@ -14,9 +14,9 @@ description: 场景管理模块(W-02)——场景表 CRUD、列表查询、
|
||||
| 字段 | 类型 | 说明 |
|
||||
|------|------|------|
|
||||
| id | str(32) | 主键 |
|
||||
| world_id | str(32) | 所属世界(→world.id,逻辑关联) |
|
||||
| world_id | str(32) | 所属世界(→world.id) |
|
||||
| name | str(255) | 场景名称 |
|
||||
| code | str(64) | 场景编码(唯一,idx_scene_code) |
|
||||
| code | str(64) | 场景编码(唯一) |
|
||||
| description | text | 描述 |
|
||||
| scene_type | str(16) | 场景类型(appcodes scene_type,默认 '0') |
|
||||
| status | str(16) | 状态(appcodes scene_status,默认 '0') |
|
||||
@ -25,49 +25,48 @@ description: 场景管理模块(W-02)——场景表 CRUD、列表查询、
|
||||
| updated_at | timestamp | 更新时间 |
|
||||
|
||||
- 主键 `["id"]`;唯一索引 `idx_scene_code(code)`;索引 `idx_scene_world(world_id)`
|
||||
- codes:world_id → world(id/name)、scene_type → appcodes_kv(parentid='scene_type')、status → appcodes_kv(parentid='scene_status')
|
||||
- 字典:world_id → world(id/name)、scene_type → appcodes_kv(parentid='scene_type')、status → appcodes_kv(parentid='scene_status')
|
||||
|
||||
### 表 `scene_import`(models/scene_import.json,只读)
|
||||
### 表 `scene_import`(models/scene_import.json)
|
||||
| 字段 | 类型 | 说明 |
|
||||
|------|------|------|
|
||||
| id | str(32) | 主键 |
|
||||
| world_id | str(32) | 目标世界 |
|
||||
| file_name | str(255) | 导入文件名 |
|
||||
| total / success / fail | int | 总数 / 成功 / 失败(默认 0) |
|
||||
| status | str(16) | 导入状态(appcodes import_status:0 进行中/1 成功/2 失败) |
|
||||
| total | int | 总条数(默认 0) |
|
||||
| success | int | 成功条数(默认 0) |
|
||||
| fail | int | 失败条数(默认 0) |
|
||||
| status | str(16) | 导入状态(appcodes import_status,默认 '0') |
|
||||
| created_at | timestamp | 导入时间 |
|
||||
|
||||
- 主键 `["id"]`;索引 `idx_scene_import_world(world_id)`
|
||||
- 字典:world_id → world、status → appcodes_kv(parentid='import_status')
|
||||
|
||||
## 关键接口(load_scene() 挂载到 ServerEnv)
|
||||
- `list_scenes(params)` → `{list, total}`(sqlPaging 分页,支持 world_id/name/code/scene_type/status 过滤 + sort/order)
|
||||
- `get_scene({id})` → `{data}` 或错误
|
||||
- `create_scene(ns)` → 校验 + world 存在校验 + 自动 id/created_at/updated_at;返回 `{success, id}`
|
||||
- `update_scene(ns)` → 写 updated_at、剔除 `_text` 后缀、world 校验
|
||||
## 关键接口
|
||||
|
||||
### ServerEnv 注册函数(load_scene() 挂载)
|
||||
- `list_scenes(params)` → `[{value:id, text:name}]`
|
||||
- `get_scene({id})` → 单条
|
||||
- `create_scene(ns)` → 自动生成 id/code、写 created_at
|
||||
- `update_scene(ns)` → 写 updated_at、剔除 `_text` 后缀
|
||||
- `delete_scene({id})`
|
||||
- `import_scenes({world_id, file_name, rows})` → `{success, total, success_count, fail}`
|
||||
(**事务批量**:先全量解析+校验,再逐条插入;任一条失败整批回滚,无脏数据;success_count 表示成功条数,避免与布尔 success 冲突)
|
||||
- `parse_scene_file(content, filename)` → `{rows: [...]}`(JSON 数组 / JSON Lines / CSV)
|
||||
- `get_world_options()` / `get_scene_type_options()` / `get_scene_status_options()` / `get_import_status_options()` → `[{value, text}]`
|
||||
- `parse_scene_file(content, filename)` → `[{...}]`(JSON 数组 / JSON Lines / CSV)
|
||||
- `get_world_options()` / `get_scene_type_options()` / `get_scene_status_options()` → `[{value, text}]`
|
||||
|
||||
### wwwroot/api/*.dspy(REST 统一前缀 /api/*,宿主挂载后为 /scene/api/*.dspy)
|
||||
- `list_scenes.dspy`(分页 `{list,total}`)、`get_scene.dspy`、`create_scene.dspy`、`scene_update.dspy`、`scene_delete.dspy`
|
||||
- `get_search_world_id.dspy` / `get_search_scene_type.dspy` / `get_search_status.dspy`(字典下拉)
|
||||
- `scene_import.dspy`(world_id + file → parse_scene_file → import_scenes)
|
||||
|
||||
### 错误结构(统一)
|
||||
`{code, message, field, detail}`;code:PARAM_REQUIRED / FIELD_REQUIRED / FIELD_TOO_LONG /
|
||||
WORLD_NOT_FOUND / DUPLICATE_CODE / PARSE_ERROR / DB_ERROR / NOT_FOUND。非法输入 100% 拦截不落库。
|
||||
### wwwroot/api/*.dspy
|
||||
- `list_scenes.dspy`、`create_scene.dspy`、`scene_update.dspy`、`scene_delete.dspy`
|
||||
- `get_search_world_id.dspy` / `get_search_scene_type.dspy` / `get_search_status.dspy`:字典下拉
|
||||
- `scene_import.dspy`:场景导入(world_id + file → parse_scene_file → import_scenes)
|
||||
|
||||
## 陷阱
|
||||
- 库名禁止硬编码:.py 用 `ServerEnv().get_module_dbname("scene")`,.dspy 用 `get_module_dbname("scene")`(全局);world 表在 world 模块库(`get_module_dbname("world")`)
|
||||
- dspy 无 import(json/get_sor_context/debug/params_kw 均预加载全局);函数经 load_scene() 注册后为全局,直接调用
|
||||
- `create_scene` / `scene_import` 必须设置 `created_at = curDateString()`,否则 sor.C 静默丢记录
|
||||
- `scene_import` 记录 total/success/fail 必须为 int
|
||||
- `import_scenes` 返回用 `success_count`(避免与布尔 `success` 键冲突)
|
||||
- 库名禁止硬编码:.py 用 `ServerEnv().get_module_dbname("scene")`,.dspy 用 `get_module_dbname("scene")`(全局)
|
||||
- world 下拉用 `ServerEnv().get_module_dbname("world")`(world 表在 world 模块库)
|
||||
- dspy 无 import(json/get_sor_context/debug/params_kw 均为预加载全局)
|
||||
- `create_scene` 必须设置 `created_at = curDateString()`,否则 sor.C 静默丢记录
|
||||
- 导入时 `scene_import` 记录必须设置 `created_at`,字段 total/success/fail 为 int
|
||||
- `import_scenes` 返回用 `success_count` 表示成功条数(避免与布尔 `success` 键冲突)
|
||||
- CRUD json `new_data_url` 指向自定义 `api/create_scene.dspy`;scene_import 为只读列表,无 editable
|
||||
- 三处同步注册:scene/__init__.py 导出 ← scene/init.py 实现 ← load_scene() 注册
|
||||
- 返回 `{list,total}` 分页用 `sor.sqlPaging`,不要硬编码 LIMIT/OFFSET
|
||||
|
||||
## 依赖
|
||||
- world(world_id 逻辑关联 + 世界下拉)、appbase(appcodes 字典)、rbac(权限)
|
||||
- world(world_id 外键 + 世界下拉)、appbase(appcodes 字典)、rbac(权限)
|
||||
|
||||
@ -1,6 +1 @@
|
||||
# scene 新增(REST POST /api/create_scene.dspy)
|
||||
debug(f'scene_create.dspy: START params_kw={dict(params_kw)}')
|
||||
result = await create_scene(dict(params_kw or {}))
|
||||
if result.get('success'):
|
||||
return {'status': 'ok', 'data': {'id': result.get('id')}}
|
||||
return {'status': 'error', 'data': result}
|
||||
return await create_scene(params_kw)
|
||||
|
||||
@ -1,6 +0,0 @@
|
||||
# scene 单条查询(REST GET /api/get_scene.dspy?id=xxx)
|
||||
debug(f'scene_get.dspy: START params_kw={dict(params_kw)}')
|
||||
result = await get_scene(dict(params_kw or {}))
|
||||
if 'data' in result:
|
||||
return {'status': 'ok', 'data': result['data']}
|
||||
return {'status': 'error', 'data': result}
|
||||
@ -1,4 +1 @@
|
||||
# 场景类型下拉(REST GET /api/get_search_scene_type.dspy)
|
||||
debug(f'scene_get_scene_type_options.dspy: START')
|
||||
opts = await get_scene_type_options()
|
||||
return {'status': 'ok', 'data': opts}
|
||||
return await get_scene_type_options(params_kw)
|
||||
|
||||
@ -1,9 +1 @@
|
||||
# 场景状态下拉(REST GET /api/get_search_status.dspy)
|
||||
# scene 状态 + 导入状态共用 appcodes 下拉;按调用方 parentid 区分
|
||||
debug(f'scene_get_status_options.dspy: START params_kw={dict(params_kw)}')
|
||||
parentid = (params_kw or {}).get('parentid', 'scene_status')
|
||||
if parentid == 'import_status':
|
||||
opts = await get_import_status_options()
|
||||
else:
|
||||
opts = await get_scene_status_options()
|
||||
return {'status': 'ok', 'data': opts}
|
||||
return await get_scene_status_options(params_kw)
|
||||
|
||||
@ -1,5 +1 @@
|
||||
# 世界下拉(REST GET /api/get_search_world_id.dspy)
|
||||
# 返回 [{value, text}](world 表在 world 模块库)
|
||||
debug(f'scene_get_world_options.dspy: START')
|
||||
opts = await get_world_options()
|
||||
return {'status': 'ok', 'data': opts}
|
||||
return await get_world_options(params_kw)
|
||||
|
||||
@ -1,8 +1 @@
|
||||
# scene 列表查询(REST GET/POST /api/list_scenes.dspy)
|
||||
# 返回 {status:'ok', data:{list, total}};错误 {code,message,field,detail}
|
||||
debug(f'scene_list.dspy: START params_kw={dict(params_kw)}')
|
||||
params = dict(params_kw or {})
|
||||
result = await list_scenes(params)
|
||||
if 'list' in result:
|
||||
return {'status': 'ok', 'data': {'list': result['list'], 'total': result['total']}}
|
||||
return {'status': 'error', 'data': result}
|
||||
return await list_scenes(params_kw)
|
||||
|
||||
@ -1,6 +1 @@
|
||||
# scene 删除(REST POST /api/scene_delete.dspy)
|
||||
debug(f'scene_delete.dspy: START params_kw={dict(params_kw)}')
|
||||
result = await delete_scene(dict(params_kw or {}))
|
||||
if result.get('success'):
|
||||
return {'status': 'ok', 'data': {'id': result.get('id')}}
|
||||
return {'status': 'error', 'data': result}
|
||||
return await delete_scene(params_kw)
|
||||
|
||||
@ -1,27 +1,26 @@
|
||||
# 场景导入(REST POST /api/scene_import.dspy)
|
||||
# 参数:world_id(目标世界)+ file(上传文件,multipart)或 rows(JSON 数组)
|
||||
# 返回:{status, data:{success,total,success_count,fail}};错误 {code,message,field,detail}
|
||||
debug(f'scene_import.dspy: START params_kw keys={list((params_kw or {}).keys())}')
|
||||
params = dict(params_kw or {})
|
||||
world_id = (params.get('world_id') or '').strip()
|
||||
file_name = params.get('file_name') or ''
|
||||
file_obj = params.get('file')
|
||||
rows = params.get('rows')
|
||||
|
||||
if file_obj is not None:
|
||||
try:
|
||||
content = file_obj.read()
|
||||
except Exception:
|
||||
content = file_obj
|
||||
parsed = parse_scene_file(content, file_name)
|
||||
if 'rows' not in parsed:
|
||||
return {'status': 'error', 'data': parsed}
|
||||
result = await import_scenes({'world_id': world_id, 'file_name': file_name, 'rows': parsed['rows']})
|
||||
else:
|
||||
if not isinstance(rows, list):
|
||||
return {'status': 'error', 'data': {'code': 'PARAM_REQUIRED', 'message': '缺少导入数据(file 或 rows)', 'field': 'rows', 'detail': ''}}
|
||||
result = await import_scenes({'world_id': world_id, 'file_name': file_name, 'rows': rows})
|
||||
|
||||
if result.get('success'):
|
||||
return {'status': 'ok', 'data': {'success': True, 'total': result.get('total', 0), 'success_count': result.get('success_count', 0), 'fail': result.get('fail', 0)}}
|
||||
return {'status': 'error', 'data': result}
|
||||
# scene_import.dspy: 场景导入(文件上传 → 解析 → 批量落库 → 写导入记录)
|
||||
world_id = params_kw.get('world_id')
|
||||
file = params_kw.get('file')
|
||||
file_name = params_kw.get('file_name') or ''
|
||||
content = None
|
||||
if file is not None:
|
||||
if hasattr(file, 'file'):
|
||||
file_name = file_name or getattr(file, 'filename', '') or ''
|
||||
try:
|
||||
content = file.file.read()
|
||||
except Exception:
|
||||
content = None
|
||||
elif hasattr(file, 'read'):
|
||||
try:
|
||||
content = file.read()
|
||||
except Exception:
|
||||
content = None
|
||||
else:
|
||||
content = file
|
||||
if content is None:
|
||||
return {'success': False, 'message': '未接收到上传文件'}
|
||||
rows = parse_scene_file(content, file_name)
|
||||
if rows is None:
|
||||
return {'success': False, 'message': '文件解析失败,仅支持 JSON 数组 / JSON Lines / CSV'}
|
||||
result = await import_scenes({'world_id': world_id, 'file_name': file_name, 'rows': rows})
|
||||
return result
|
||||
|
||||
@ -1,6 +1 @@
|
||||
# scene 更新(REST POST /api/scene_update.dspy)
|
||||
debug(f'scene_update.dspy: START params_kw={dict(params_kw)}')
|
||||
result = await update_scene(dict(params_kw or {}))
|
||||
if result.get('success'):
|
||||
return {'status': 'ok', 'data': {'id': result.get('id')}}
|
||||
return {'status': 'error', 'data': result}
|
||||
return await update_scene(params_kw)
|
||||
|
||||
@ -1,17 +1,18 @@
|
||||
{
|
||||
"widgettype": "VBox",
|
||||
"options": {"width": "100%", "height": "100%", "padding": "20px"},
|
||||
"subwidgets": [
|
||||
{"widgettype": "Text", "options": {"label": "场景导入", "fontSize": "20px"}},
|
||||
{"widgettype": "Form", "options": {
|
||||
"width": "60%",
|
||||
"submit_url": "{{entire_url('/scene/api/scene_import.dspy')}}",
|
||||
"fields": [
|
||||
{"name": "world_id", "label": "目标世界", "uitype": "code", "required": true,
|
||||
"dataurl": "{{entire_url('/scene/api/get_search_world_id.dspy')}}", "valueField": "value", "textField": "text"},
|
||||
{"name": "file", "label": "导入文件", "uitype": "file", "required": true, "accept": ".json,.csv,.ndjson,.jsonl"}
|
||||
]
|
||||
}},
|
||||
{"widgettype": "Text", "id": "app.scene_import_result", "options": {"label": "", "marginTop": "16px"}}
|
||||
]
|
||||
"widgettype": "VBox",
|
||||
"options": {"width": "100%", "height": "100%", "padding": "20px"},
|
||||
"subwidgets": [
|
||||
{"widgettype": "Text", "options": {"label": "场景导入", "fontSize": "20px"}},
|
||||
{"widgettype": "Text", "options": {"label": "支持 JSON 数组 / JSON Lines / CSV(表头:name,code,description,scene_type,status,config_json)", "fontSize": "12px", "color": "#888888"}},
|
||||
{"widgettype": "Form", "options": {
|
||||
"submit_url": "{{entire_url('/scene/api/scene_import.dspy')}}",
|
||||
"method": "POST"
|
||||
}, "subwidgets": [
|
||||
{"widgettype": "Select", "options": {"name": "world_id", "label": "目标世界", "required": "true",
|
||||
"dataurl": "{{entire_url('/scene/api/get_search_world_id.dspy')}}", "valueField": "value", "textField": "text"}},
|
||||
{"widgettype": "UiFile", "options": {"name": "file", "label": "导入文件", "required": "true"}},
|
||||
{"widgettype": "Button", "options": {"label": "开始导入"}}
|
||||
]},
|
||||
{"widgettype": "VBox", "id": "app.scene_import_content", "options": {"width": "100%", "flex": "1", "marginTop": "20px"}}
|
||||
]
|
||||
}
|
||||
|
||||
@ -1,22 +1,18 @@
|
||||
{
|
||||
"widgettype": "VBox",
|
||||
"options": {"width": "100%", "height": "100%", "padding": "20px"},
|
||||
"subwidgets": [
|
||||
{"widgettype": "Text", "options": {"label": "场景管理", "fontSize": "24px"}},
|
||||
{"widgettype": "ResponsableBox", "options": {"gap": "16px", "minWidth": "250px"}, "subwidgets": [
|
||||
{"widgettype": "VBox", "options": {"backgroundColor": "#FFFFFF", "padding": "20px", "cursor": "pointer"},
|
||||
"binds": [{"wid": "self", "event": "click", "actiontype": "urlwidget", "target": "app.scene_content",
|
||||
"options": {"url": "{{entire_url('/scene/scene_list')}}"}, "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.scene_content",
|
||||
"options": {"url": "{{entire_url('/scene/import_page.ui')}}"}, "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.scene_content",
|
||||
"options": {"url": "{{entire_url('/scene/scene_import_list')}}"}, "mode": "replace"}],
|
||||
"subwidgets": [{"widgettype": "Text", "options": {"label": "导入记录"}}]}
|
||||
]},
|
||||
{"widgettype": "VBox", "id": "app.scene_content", "options": {"width": "100%", "flex": "1", "marginTop": "20px"}}
|
||||
]
|
||||
"widgettype": "VBox",
|
||||
"options": {"width": "100%", "height": "100%", "padding": "20px"},
|
||||
"subwidgets": [
|
||||
{"widgettype": "Text", "options": {"label": "场景管理", "fontSize": "24px"}},
|
||||
{"widgettype": "ResponsableBox", "options": {"gap": "16px", "minWidth": "250px"}, "subwidgets": [
|
||||
{"widgettype": "VBox", "options": {"backgroundColor": "#FFFFFF", "padding": "20px", "cursor": "pointer"},
|
||||
"binds": [{"wid": "self", "event": "click", "actiontype": "urlwidget", "target": "app.scene_content",
|
||||
"options": {"url": "{{entire_url('/scene/scene')}}"}, "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.scene_content",
|
||||
"options": {"url": "{{entire_url('/scene/import_page.ui')}}"}, "mode": "replace"}],
|
||||
"subwidgets": [{"widgettype": "Text", "options": {"label": "场景导入"}}]}
|
||||
]},
|
||||
{"widgettype": "VBox", "id": "app.scene_content", "options": {"width": "100%", "flex": "1", "marginTop": "20px"}}
|
||||
]
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user