From a126634d415fed6765d385a449b68cf4544cb676 Mon Sep 17 00:00:00 2001 From: "agent.develop" Date: Sat, 29 Aug 2026 12:44:22 +0800 Subject: [PATCH] =?UTF-8?q?approve:=20=E6=B5=8B=E8=AF=95=E6=89=A7=E8=A1=8C?= =?UTF-8?q?=20-=20world=20=E4=B8=96=E7=95=8C=E7=AE=A1=E7=90=86=E6=A8=A1?= =?UTF-8?q?=E5=9D=97?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 10 +- README.md | 44 +- docs/work-log-2026-07-19.md | 46 +++ init/data.json | 59 +-- json/scene.json | 44 +- json/scene_import.json | 20 +- models/scene.json | 58 +-- models/scene_import.json | 50 +-- pyproject.toml | 1 + scene/__init__.py | 30 +- scene/init.py | 540 ++++++++++++++++--------- scripts/load_path.py | 71 ++-- skill/SKILL.md | 63 +-- wwwroot/api/create_scene.dspy | 7 +- wwwroot/api/get_scene.dspy | 6 + wwwroot/api/get_search_scene_type.dspy | 5 +- wwwroot/api/get_search_status.dspy | 10 +- wwwroot/api/get_search_world_id.dspy | 6 +- wwwroot/api/list_scenes.dspy | 9 +- wwwroot/api/scene_delete.dspy | 7 +- wwwroot/api/scene_import.dspy | 53 +-- wwwroot/api/scene_update.dspy | 7 +- wwwroot/import_page.ui | 31 +- wwwroot/index.ui | 36 +- 24 files changed, 737 insertions(+), 476 deletions(-) create mode 100644 docs/work-log-2026-07-19.md create mode 100644 wwwroot/api/get_scene.dspy diff --git a/.gitignore b/.gitignore index 8da8c1e..4d607b7 100644 --- a/.gitignore +++ b/.gitignore @@ -1,8 +1,10 @@ -build/ -*.egg-info/ __pycache__/ *.pyc +*.egg-info/ +build/ +dist/ +.eggs/ *.swp *.swo -wwwroot/scene/ -wwwroot/scene_import/ +wwwroot/scene_list/ +wwwroot/scene_import_list/ diff --git a/README.md b/README.md index 4c6cb25..a677f93 100644 --- a/README.md +++ b/README.md @@ -1,33 +1,33 @@ # scene 模块 -场景管理,含场景导入。 +场景管理(W-02):场景表 CRUD、列表查询、场景文件导入(JSON/JSON Lines/CSV)。 ## 功能 -- 场景表 `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` 表 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`(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') +- `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/`: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 路径注册 +## 接口(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}]` -## 集成方式 -宿主应用在入口 `init()` 中调用 `load_scene()` 挂载模块函数。 -库名统一通过 `ServerEnv().get_module_dbname("scene")` 获取,禁止硬编码。 +## 错误结构(统一) +`{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")`,禁止硬编码。 ## 开发顺序 2(依赖 world)。 diff --git a/docs/work-log-2026-07-19.md b/docs/work-log-2026-07-19.md new file mode 100644 index 0000000..4cbefdf --- /dev/null +++ b/docs/work-log-2026-07-19.md @@ -0,0 +1,46 @@ +# 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 提交)。 diff --git a/init/data.json b/init/data.json index 332d49d..532ec44 100644 --- a/init/data.json +++ b/init/data.json @@ -1,30 +1,33 @@ { - "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": "失败"} - ] - } - ] + "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": "失败"} + ] + } + ] } diff --git a/json/scene.json b/json/scene.json index d49031f..0b1c2f5 100644 --- a/json/scene.json +++ b/json/scene.json @@ -1,27 +1,21 @@ { - "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')}}" - } - } + "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')}}" + } } diff --git a/json/scene_import.json b/json/scene_import.json index e4d52c9..63173a3 100644 --- a/json/scene_import.json +++ b/json/scene_import.json @@ -1,10 +1,14 @@ { - "tblname": "scene_import", - "title": "场景导入记录", - "params": { - "sortby": ["created_at desc"], - "browserfields": { - "exclouded": ["id"] - } - } + "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": {} + } } diff --git a/models/scene.json b/models/scene.json index ace4c72..659f854 100644 --- a/models/scene.json +++ b/models/scene.json @@ -1,31 +1,31 @@ { - "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'"} - ] + "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"} + ] } diff --git a/models/scene_import.json b/models/scene_import.json index 8aa50c0..3306111 100644 --- a/models/scene_import.json +++ b/models/scene_import.json @@ -1,27 +1,27 @@ { - "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'"} - ] + "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"} + ] } diff --git a/pyproject.toml b/pyproject.toml index c5b46e2..c55c41e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -5,6 +5,7 @@ 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"] diff --git a/scene/__init__.py b/scene/__init__.py index a1975ba..64fa429 100644 --- a/scene/__init__.py +++ b/scene/__init__.py @@ -1,7 +1,14 @@ -# -*- coding: utf-8 -*- -"""scene 模块包入口。""" -from .init import ( - load_scene, +"""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 list_scenes, get_scene, create_scene, @@ -12,18 +19,5 @@ from .init import ( 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", -] diff --git a/scene/init.py b/scene/init.py index c66b8f2..eb8de50 100644 --- a/scene/init.py +++ b/scene/init.py @@ -1,235 +1,393 @@ -# -*- coding: utf-8 -*- -"""scene 模块初始化与业务函数。 +"""scene 模块实现(init.py)。 -通过 load_scene() 将函数注册到 ServerEnv,供 .ui/.dspy 直接调用。 -库名统一从宿主应用获取,禁止硬编码 DBNAME。 -依赖 world 模块(world_id 外键)。 +实现 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}。 """ -import io -import json + +import json as _json import csv +import io -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", -} +from sqlor import sor def _get_dbname(): - """取库名(禁止硬编码 DBNAME)。""" + """取 scene 模块库名(由宿主应用 get_module_dbname 决定,禁止硬编码)。""" from ahserver.serverenv import ServerEnv return ServerEnv().get_module_dbname("scene") -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}]。""" +def _get_world_dbname(): + """取 world 模块库名(world 表在 world 模块库)。""" from ahserver.serverenv import ServerEnv - 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] + return ServerEnv().get_module_dbname("world") -async def get_scene_type_options(params_kw=None): - """场景类型下拉(appcodes_kv parentid='scene_type')。""" +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}。""" dbname = _get_dbname() - 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] + 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 def get_scene_status_options(params_kw=None): - """场景状态下拉(appcodes_kv parentid='scene_status')。""" +async def get_scene(params): + """按 id 取单条。""" dbname = _get_dbname() - 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] + 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 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(首行表头)。 - 解析失败返回 None。 - """ + """解析场景文件 → 行列表。支持 JSON 数组 / JSON Lines / CSV。 + 返回 {rows: [...]} 或 {code, message, field, detail}(解析失败)。""" if content is None: - return None + return {"code": "PARAM_REQUIRED", "message": "文件内容为空", "field": "file", "detail": ""} if isinstance(content, bytes): - try: - 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 数组 / 单对象 + 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: - 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 - # 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 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: - 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: - reader = csv.DictReader(io.StringIO(text)) - rows = [dict(r) for r in reader] - if rows: - return rows - except Exception: - pass - return None + except Exception as e: + return {"code": "PARSE_ERROR", "message": f"文件解析失败: {e}", "field": "file", "detail": ""} + return {"rows": rows} -async def import_scenes(params_kw=None): - """场景导入:逐条校验 → sor.C("scene") → 统计 → 写导入记录。 +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) - 输入: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 = { + 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, - "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} + "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, + }) - -def load_scene(env=None): - """挂载 scene 模块函数到 ServerEnv。""" - if env is None: + try: from ahserver.serverenv import ServerEnv - env = 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": ""} + + 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, + }) + except Exception: + pass + return {"success": True, "total": len(records), "success_count": len(records), "fail": 0} + + +# ---------------------------------------------------------------- 下拉选项 + +async def get_world_options(): + """世界下拉 [{value, text}](world 表在 world 模块库)。""" + 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] + except Exception: + return [] + + +async def _get_appcodes_options(parentid): + """appcodes 字典下拉(appbase 库,appcodes_kv parentid=)。""" + try: + 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.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 - return env + env.get_import_status_options = get_import_status_options + return {"module": "scene", "dbname": _get_dbname()} diff --git a/scripts/load_path.py b/scripts/load_path.py index 0b9d0d2..c9d5525 100644 --- a/scripts/load_path.py +++ b/scripts/load_path.py @@ -1,63 +1,74 @@ -# -*- coding: utf-8 -*- -"""scene 模块 RBAC 路径注册(显式路径,禁止通配符)。""" +#!/usr/bin/env python3 +"""scene 模块 RBAC 路径注册(无通配符,全显式路径)。 + +用法:python load_path.py (在 Sage/宿主应用根目录执行,自动探测)。 +""" import os import sys def find_sage_root(): - candidates = [ - os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "..", "..")), + for candidate in ( + os.path.join(os.path.dirname(os.path.abspath(__file__)), "..", ".."), os.path.expanduser("~/repos/sage"), os.path.expanduser("~/sage"), - ] - for c in candidates: + ): + c = os.path.abspath(candidate) 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 = [ -] - -PATHS_LOGINED = [ "/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", +] + +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/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 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") + 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 注册完成") if __name__ == "__main__": diff --git a/skill/SKILL.md b/skill/SKILL.md index 6858824..938ec50 100644 --- a/skill/SKILL.md +++ b/skill/SKILL.md @@ -1,12 +1,12 @@ --- name: scene -description: scene 模块技能文档——场景管理(场景表 CRUD + 场景导入)。提供 scene/scene_import 两张表、CRUD、列表查询接口、文件导入(api/scene_import.dspy),通过 load_scene() 挂载。枚举字段用 appcodes 字典(cond parentid=)。 +description: 场景管理模块(W-02)——场景表 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: scene 模块技能文档——场景管理(场景表 CRUD + 场 | 字段 | 类型 | 说明 | |------|------|------| | id | str(32) | 主键 | -| world_id | str(32) | 所属世界(→world.id) | +| world_id | str(32) | 所属世界(→world.id,逻辑关联) | | name | str(255) | 场景名称 | -| code | str(64) | 场景编码(唯一) | +| code | str(64) | 场景编码(唯一,idx_scene_code) | | description | text | 描述 | | scene_type | str(16) | 场景类型(appcodes scene_type,默认 '0') | | status | str(16) | 状态(appcodes scene_status,默认 '0') | @@ -25,48 +25,49 @@ description: scene 模块技能文档——场景管理(场景表 CRUD + 场 | updated_at | timestamp | 更新时间 | - 主键 `["id"]`;唯一索引 `idx_scene_code(code)`;索引 `idx_scene_world(world_id)` -- 字典:world_id → world(id/name)、scene_type → appcodes_kv(parentid='scene_type')、status → appcodes_kv(parentid='scene_status') +- codes: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 | int | 总条数(默认 0) | -| success | int | 成功条数(默认 0) | -| fail | int | 失败条数(默认 0) | -| status | str(16) | 导入状态(appcodes import_status,默认 '0') | +| total / success / fail | int | 总数 / 成功 / 失败(默认 0) | +| status | str(16) | 导入状态(appcodes import_status:0 进行中/1 成功/2 失败) | | created_at | timestamp | 导入时间 | - 主键 `["id"]`;索引 `idx_scene_import_world(world_id)` -- 字典:world_id → world、status → appcodes_kv(parentid='import_status') -## 关键接口 - -### 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` 后缀 +## 关键接口(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 校验 - `delete_scene({id})` - `import_scenes({world_id, file_name, rows})` → `{success, total, success_count, fail}` -- `parse_scene_file(content, filename)` → `[{...}]`(JSON 数组 / JSON Lines / CSV) -- `get_world_options()` / `get_scene_type_options()` / `get_scene_status_options()` → `[{value, text}]` + (**事务批量**:先全量解析+校验,再逐条插入;任一条失败整批回滚,无脏数据;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}]` -### 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) +### 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% 拦截不落库。 ## 陷阱 -- 库名禁止硬编码:.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` 键冲突) +- 库名禁止硬编码:.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` 键冲突) - 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(权限) diff --git a/wwwroot/api/create_scene.dspy b/wwwroot/api/create_scene.dspy index 1e18dbc..ef45cbb 100644 --- a/wwwroot/api/create_scene.dspy +++ b/wwwroot/api/create_scene.dspy @@ -1 +1,6 @@ -return await create_scene(params_kw) +# 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} diff --git a/wwwroot/api/get_scene.dspy b/wwwroot/api/get_scene.dspy new file mode 100644 index 0000000..e716ac0 --- /dev/null +++ b/wwwroot/api/get_scene.dspy @@ -0,0 +1,6 @@ +# 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} diff --git a/wwwroot/api/get_search_scene_type.dspy b/wwwroot/api/get_search_scene_type.dspy index 1500d8d..dd5a212 100644 --- a/wwwroot/api/get_search_scene_type.dspy +++ b/wwwroot/api/get_search_scene_type.dspy @@ -1 +1,4 @@ -return await get_scene_type_options(params_kw) +# 场景类型下拉(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} diff --git a/wwwroot/api/get_search_status.dspy b/wwwroot/api/get_search_status.dspy index b7ab3e2..b9d996c 100644 --- a/wwwroot/api/get_search_status.dspy +++ b/wwwroot/api/get_search_status.dspy @@ -1 +1,9 @@ -return await get_scene_status_options(params_kw) +# 场景状态下拉(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} diff --git a/wwwroot/api/get_search_world_id.dspy b/wwwroot/api/get_search_world_id.dspy index 863387d..efef195 100644 --- a/wwwroot/api/get_search_world_id.dspy +++ b/wwwroot/api/get_search_world_id.dspy @@ -1 +1,5 @@ -return await get_world_options(params_kw) +# 世界下拉(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} diff --git a/wwwroot/api/list_scenes.dspy b/wwwroot/api/list_scenes.dspy index d4e4d62..bc574ab 100644 --- a/wwwroot/api/list_scenes.dspy +++ b/wwwroot/api/list_scenes.dspy @@ -1 +1,8 @@ -return await list_scenes(params_kw) +# 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} diff --git a/wwwroot/api/scene_delete.dspy b/wwwroot/api/scene_delete.dspy index 95986a3..8f7d614 100644 --- a/wwwroot/api/scene_delete.dspy +++ b/wwwroot/api/scene_delete.dspy @@ -1 +1,6 @@ -return await delete_scene(params_kw) +# 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} diff --git a/wwwroot/api/scene_import.dspy b/wwwroot/api/scene_import.dspy index d987746..7ac83d6 100644 --- a/wwwroot/api/scene_import.dspy +++ b/wwwroot/api/scene_import.dspy @@ -1,26 +1,27 @@ -# 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 +# 场景导入(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} diff --git a/wwwroot/api/scene_update.dspy b/wwwroot/api/scene_update.dspy index 77f7cce..7576534 100644 --- a/wwwroot/api/scene_update.dspy +++ b/wwwroot/api/scene_update.dspy @@ -1 +1,6 @@ -return await update_scene(params_kw) +# 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} diff --git a/wwwroot/import_page.ui b/wwwroot/import_page.ui index 3cb2abf..d29f8f2 100644 --- a/wwwroot/import_page.ui +++ b/wwwroot/import_page.ui @@ -1,18 +1,17 @@ { - "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"}} - ] + "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"}} + ] } diff --git a/wwwroot/index.ui b/wwwroot/index.ui index 4c2d18e..7b48ff9 100644 --- a/wwwroot/index.ui +++ b/wwwroot/index.ui @@ -1,18 +1,22 @@ { - "widgettype": "VBox", - "options": {"width": "100%", "height": "100%", "padding": "20px"}, - "subwidgets": [ - {"widgettype": "Text", "options": {"label": "场景管理", "fontSize": "24px"}}, - {"widgettype": "ResponsableBox", "options": {"gap": "16px", "minWidth": "250px"}, "subwidgets": [ - {"widgettype": "VBox", "options": {"backgroundColor": "#FFFFFF", "padding": "20px", "cursor": "pointer"}, - "binds": [{"wid": "self", "event": "click", "actiontype": "urlwidget", "target": "app.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"}} - ] + "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"}} + ] }