approve: 测试执行 - world 世界管理模块

This commit is contained in:
agent.develop 2026-08-29 12:44:22 +08:00
parent 3ddd8a7d94
commit a126634d41
24 changed files with 737 additions and 476 deletions

10
.gitignore vendored
View File

@ -1,8 +1,10 @@
build/
*.egg-info/
__pycache__/ __pycache__/
*.pyc *.pyc
*.egg-info/
build/
dist/
.eggs/
*.swp *.swp
*.swo *.swo
wwwroot/scene/ wwwroot/scene_list/
wwwroot/scene_import/ wwwroot/scene_import_list/

View File

@ -1,33 +1,33 @@
# scene 模块 # scene 模块
场景管理,含场景导入。 场景管理(W-02):场景表 CRUD、列表查询、场景文件导入(JSON/JSON Lines/CSV)。
## 功能 ## 功能
- 场景表 `scene` 的 CRUD(list/get/create/update/delete) - `scene` 表 CRUD(list/get/create/update/delete),分页 `{list, total}`
- 场景导入:`api/scene_import.dspy`(文件上传 → 解析 JSON/JSON Lines/CSV → 批量落库 → 写导入记录) - `scene_import` 表只读列表(导入记录)
- 枚举字段 appcodes 字典(scene_type / scene_status / import_status,cond parentid=) - 场景导入 `api/scene_import.dspy`:world_id + file → 解析 → 事务批量落库 → 写导入记录
- 世界下拉(依赖 world 模块) - 枚举字段 appcodes 字典(scene_type / scene_status / import_status)
- 世界下拉(依赖 world 模块,world_id 逻辑关联,不加物理外键)
## 数据表 ## 数据表
- `scene`(models/scene.json):场景表 - `scene`:id(str32 PK)、world_id(str32→world.id 逻辑关联)、name(255)、code(64 唯一)、
- 主键 `id`(str32) description(text)、scene_type(16)、status(16)、config_json(text)、created_at、updated_at
- 唯一索引 `idx_scene_code`(code)、索引 `idx_scene_world`(world_id) - `scene_import`:id(str32 PK)、world_id、file_name(255)、total/success/fail(int)、status(16)、created_at
- 字典: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)
- `scene/`:Python 包(init.py + __init__.py) - `GET/POST list_scenes.dspy` → `{status, data:{list, total}}`(支持 page/rows/sort/order/world_id/name/code/scene_type/status 过滤)
- `models/`:表定义 scene.json、scene_import.json - `GET get_scene.dspy?id=` → 单条
- `json/`:CRUD 定义 scene.json、scene_import.json(只读) - `POST create_scene.dspy` / `scene_update.dspy` / `scene_delete.dspy`
- `wwwroot/`:index.ui + import_page.ui + api/*.dspy - `POST scene_import.dspy`(multipart: world_id + file)→ `{status, data:{success,total,success_count,fail}}`
- `init/data.json`:字典种子数据(scene_type / scene_status / import_status) - `GET get_search_world_id.dspy` / `get_search_scene_type.dspy` / `get_search_status.dspy` → `[{value,text}]`
- `scripts/load_path.py`:RBAC 路径注册
## 集成方式 ## 错误结构(统一)
宿主应用在入口 `init()` 中调用 `load_scene()` 挂载模块函数。 `{code, message, field, detail}`,code 取值:PARAM_REQUIRED / FIELD_REQUIRED / FIELD_TOO_LONG /
库名统一通过 `ServerEnv().get_module_dbname("scene")` 获取,禁止硬编码。 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)。 2(依赖 world)。

View File

@ -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 提交)。

View File

@ -1,30 +1,33 @@
{ {
"appcodes": [ "appcodes": [
{ {
"parentid": "scene_type", "parentid": "scene_type",
"parentname": "场景类型", "parentname": "场景类型",
"items": [ "items": [
{"k": "0", "v": "默认"}, {"k": "0", "v": "默认"},
{"k": "1", "v": "室内"}, {"k": "1", "v": "室内"},
{"k": "2", "v": "室外"} {"k": "2", "v": "室外"},
] {"k": "3", "v": "城市"},
}, {"k": "4", "v": "野外"}
{ ]
"parentid": "scene_status", },
"parentname": "场景状态", {
"items": [ "parentid": "scene_status",
{"k": "0", "v": "启用"}, "parentname": "场景状态",
{"k": "1", "v": "停用"} "items": [
] {"k": "0", "v": "草稿"},
}, {"k": "1", "v": "启用"},
{ {"k": "2", "v": "停用"}
"parentid": "import_status", ]
"parentname": "导入状态", },
"items": [ {
{"k": "0", "v": "成功"}, "parentid": "import_status",
{"k": "1", "v": "部分成功"}, "parentname": "导入状态",
{"k": "2", "v": "失败"} "items": [
] {"k": "0", "v": "进行中"},
} {"k": "1", "v": "成功"},
] {"k": "2", "v": "失败"}
]
}
]
} }

View File

@ -1,27 +1,21 @@
{ {
"tblname": "scene", "tblname": "scene",
"title": "场景管理", "params": {
"params": { "browserfields": {
"sortby": ["created_at desc"], "dataviewer": ["id", "world_id", "name", "code", "description", "scene_type", "status", "config_json", "created_at", "updated_at"],
"data_filter": { "databrowser": ["id", "world_id", "name", "code", "scene_type", "status", "created_at"],
"AND": [ "alters": [
{"field": "name", "op": "LIKE", "var": "name"}, {"field": "world_id", "label": "所属世界", "uitype": "code", "dataurl": "{{entire_url('../api/get_search_world_id.dspy')}}", "valueField": "value", "textField": "text", "required": true},
{"field": "world_id", "op": "=", "var": "world_id"} {"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"}
}, ]
"browserfields": { },
"exclouded": ["id", "description", "config_json"], "editable": {
"alters": { "editablefields": ["world_id", "name", "code", "description", "scene_type", "status", "config_json"],
"world_id": {"uitype": "code", "dataurl": "{{entire_url('../api/get_search_world_id.dspy')}}"}, "editexclouded": ["id", "created_at", "updated_at"]
"scene_type": {"uitype": "code", "dataurl": "{{entire_url('../api/get_search_scene_type.dspy')}}"}, },
"status": {"uitype": "code", "dataurl": "{{entire_url('../api/get_search_status.dspy')}}"} "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')}}"
"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')}}"
}
}
} }

View File

@ -1,10 +1,14 @@
{ {
"tblname": "scene_import", "tblname": "scene_import",
"title": "场景导入记录", "params": {
"params": { "browserfields": {
"sortby": ["created_at desc"], "dataviewer": ["id", "world_id", "file_name", "total", "success", "fail", "status", "created_at"],
"browserfields": { "databrowser": ["id", "world_id", "file_name", "total", "success", "fail", "status", "created_at"],
"exclouded": ["id"] "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": {}
}
} }

View File

@ -1,31 +1,31 @@
{ {
"summary": [ "summary": [
{ {
"name": "scene", "table": "scene",
"title": "场景表", "primary": ["id"],
"primary": ["id"], "engine": "InnoDB",
"catelog": "entity" "comment": "场景表"
} }
], ],
"fields": [ "fields": [
{"name": "id", "title": "主键ID", "type": "str", "length": 32, "nullable": "no"}, {"name": "id", "type": "str(32)", "nullable": false, "default": "", "comment": "主键"},
{"name": "world_id", "title": "所属世界", "type": "str", "length": 32, "nullable": "no"}, {"name": "world_id", "type": "str(32)", "nullable": false, "default": "", "comment": "所属世界(逻辑关联 world.id,不加外键)"},
{"name": "name", "title": "场景名称", "type": "str", "length": 255, "nullable": "no"}, {"name": "name", "type": "str(255)", "nullable": false, "default": "", "comment": "场景名称"},
{"name": "code", "title": "场景编码", "type": "str", "length": 64, "nullable": "no"}, {"name": "code", "type": "str(64)", "nullable": false, "default": "", "comment": "场景编码(唯一)"},
{"name": "description", "title": "描述", "type": "text", "nullable": "yes"}, {"name": "description", "type": "text", "nullable": true, "comment": "描述"},
{"name": "scene_type", "title": "场景类型", "type": "str", "length": 16, "nullable": "no", "default": "0"}, {"name": "scene_type", "type": "str(16)", "nullable": false, "default": "0", "comment": "场景类型(appcodes scene_type,默认 0)"},
{"name": "status", "title": "状态", "type": "str", "length": 16, "nullable": "no", "default": "0"}, {"name": "status", "type": "str(16)", "nullable": false, "default": "0", "comment": "状态(appcodes scene_status,默认 0)"},
{"name": "config_json", "title": "场景配置JSON", "type": "text", "nullable": "yes"}, {"name": "config_json", "type": "text", "nullable": true, "comment": "场景配置 JSON"},
{"name": "created_at", "title": "创建时间", "type": "timestamp", "nullable": "no"}, {"name": "created_at", "type": "timestamp", "nullable": false, "comment": "创建时间"},
{"name": "updated_at", "title": "更新时间", "type": "timestamp", "nullable": "yes"} {"name": "updated_at", "type": "timestamp", "nullable": false, "comment": "更新时间"}
], ],
"indexes": [ "indexes": [
{"name": "idx_scene_code", "idxtype": "unique", "idxfields": ["code"]}, {"name": "idx_scene_code", "fields": ["code"], "unique": true},
{"name": "idx_scene_world", "idxtype": "index", "idxfields": ["world_id"]} {"name": "idx_scene_world", "fields": ["world_id"], "unique": false}
], ],
"codes": [ "codes": [
{"field": "world_id", "table": "world", "valuefield": "id", "textfield": "name"}, {"field": "world_id", "table": "world", "valuefield": "id", "textfield": "name"},
{"field": "scene_type", "table": "appcodes_kv", "valuefield": "k", "textfield": "v", "cond": "parentid='scene_type'"}, {"field": "scene_type", "table": "appcodes_kv", "cond": "parentid='scene_type'", "valuefield": "k", "textfield": "v"},
{"field": "status", "table": "appcodes_kv", "valuefield": "k", "textfield": "v", "cond": "parentid='scene_status'"} {"field": "status", "table": "appcodes_kv", "cond": "parentid='scene_status'", "valuefield": "k", "textfield": "v"}
] ]
} }

View File

@ -1,27 +1,27 @@
{ {
"summary": [ "summary": [
{ {
"name": "scene_import", "table": "scene_import",
"title": "场景导入记录表", "primary": ["id"],
"primary": ["id"], "engine": "InnoDB",
"catelog": "entity" "comment": "场景导入记录表(只读)"
} }
], ],
"fields": [ "fields": [
{"name": "id", "title": "主键ID", "type": "str", "length": 32, "nullable": "no"}, {"name": "id", "type": "str(32)", "nullable": false, "default": "", "comment": "主键"},
{"name": "world_id", "title": "目标世界", "type": "str", "length": 32, "nullable": "no"}, {"name": "world_id", "type": "str(32)", "nullable": false, "default": "", "comment": "目标世界(逻辑关联 world.id)"},
{"name": "file_name", "title": "导入文件名", "type": "str", "length": 255, "nullable": "no"}, {"name": "file_name", "type": "str(255)", "nullable": false, "default": "", "comment": "导入文件名"},
{"name": "total", "title": "总条数", "type": "int", "nullable": "no", "default": "0"}, {"name": "total", "type": "int", "nullable": false, "default": 0, "comment": "总条数"},
{"name": "success", "title": "成功条数", "type": "int", "nullable": "no", "default": "0"}, {"name": "success", "type": "int", "nullable": false, "default": 0, "comment": "成功条数"},
{"name": "fail", "title": "失败条数", "type": "int", "nullable": "no", "default": "0"}, {"name": "fail", "type": "int", "nullable": false, "default": 0, "comment": "失败条数"},
{"name": "status", "title": "导入状态", "type": "str", "length": 16, "nullable": "no", "default": "0"}, {"name": "status", "type": "str(16)", "nullable": false, "default": "0", "comment": "导入状态(appcodes import_status,0 进行中/1 成功/2 失败)"},
{"name": "created_at", "title": "导入时间", "type": "timestamp", "nullable": "no"} {"name": "created_at", "type": "timestamp", "nullable": false, "comment": "导入时间"}
], ],
"indexes": [ "indexes": [
{"name": "idx_scene_import_world", "idxtype": "index", "idxfields": ["world_id"]} {"name": "idx_scene_import_world", "fields": ["world_id"], "unique": false}
], ],
"codes": [ "codes": [
{"field": "world_id", "table": "world", "valuefield": "id", "textfield": "name"}, {"field": "world_id", "table": "world", "valuefield": "id", "textfield": "name"},
{"field": "status", "table": "appcodes_kv", "valuefield": "k", "textfield": "v", "cond": "parentid='import_status'"} {"field": "status", "table": "appcodes_kv", "cond": "parentid='import_status'", "valuefield": "k", "textfield": "v"}
] ]
} }

View File

@ -5,6 +5,7 @@ build-backend = "setuptools.build_meta"
[project] [project]
name = "scene" name = "scene"
version = "1.0.0" version = "1.0.0"
description = "场景管理模块(W-02):scene/scene_import CRUD、列表查询、场景导入"
requires-python = ">=3.8" requires-python = ">=3.8"
dependencies = ["sqlor", "bricks_for_python"] dependencies = ["sqlor", "bricks_for_python"]

View File

@ -1,7 +1,14 @@
# -*- coding: utf-8 -*- """scene 模块 Python 包。
"""scene 模块包入口。"""
from .init import ( 场景管理(W-02):scene / scene_import 表 CRUD、列表查询、场景导入。
load_scene, 依赖 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, list_scenes,
get_scene, get_scene,
create_scene, create_scene,
@ -12,18 +19,5 @@ from .init import (
get_world_options, get_world_options,
get_scene_type_options, get_scene_type_options,
get_scene_status_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",
]

View File

@ -1,235 +1,393 @@
# -*- coding: utf-8 -*- """scene 模块实现(init.py)。
"""scene 模块初始化与业务函数。
通过 load_scene() 将函数注册到 ServerEnv,供 .ui/.dspy 直接调用。 实现 scene / scene_import 两张表的 CRUD、列表查询(sqlPaging 分页)、
库名统一从宿主应用获取,禁止硬编码 DBNAME。 场景文件导入(JSON 数组 / JSON Lines / CSV),以及字典下拉数据。
依赖 world 模块(world_id 外键)。
关键约定:
- 库名禁止硬编码: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 csv
import io
from appPublic.uniqueID import getID from sqlor import sor
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(): def _get_dbname():
"""取库名(禁止硬编码 DBNAME)。""" """取 scene 模块库名(由宿主应用 get_module_dbname 决定,禁止硬编码)。"""
from ahserver.serverenv import ServerEnv from ahserver.serverenv import ServerEnv
return ServerEnv().get_module_dbname("scene") return ServerEnv().get_module_dbname("scene")
def _gen_code(): def _get_world_dbname():
"""自动生成场景编码。""" """取 world 模块库名(world 表在 world 模块库)。"""
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 from ahserver.serverenv import ServerEnv
dbname = ServerEnv().get_module_dbname("world") return 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]
async def get_scene_type_options(params_kw=None): def _clean_ns(ns):
"""场景类型下拉(appcodes_kv parentid='scene_type')。""" """剔除 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() dbname = _get_dbname()
async with DBPools().sqlorContext(dbname) as sor: params = params or {}
recs = await sor.R("appcodes_kv", {"parentid": "scene_type", "page": 1, "rows": 1000}) page = int(params.get("page") or 1)
return [{"value": r.k, "text": r.v} for r in recs] 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): async def get_scene(params):
"""场景状态下拉(appcodes_kv parentid='scene_status')。""" """按 id 取单条。"""
dbname = _get_dbname() dbname = _get_dbname()
async with DBPools().sqlorContext(dbname) as sor: scene_id = (params or {}).get("id")
recs = await sor.R("appcodes_kv", {"parentid": "scene_status", "page": 1, "rows": 1000}) if not scene_id:
return [{"value": r.k, "text": r.v} for r in recs] 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=""): def parse_scene_file(content, filename=""):
"""解析上传文件内容为 [{...}] 场景记录列表。 """解析场景文件 → 行列表。支持 JSON 数组 / JSON Lines / CSV。
返回 {rows: [...]} 或 {code, message, field, detail}(解析失败)。"""
支持:JSON 数组 / JSON Lines / CSV(首行表头)。
解析失败返回 None。
"""
if content is None: if content is None:
return None return {"code": "PARAM_REQUIRED", "message": "文件内容为空", "field": "file", "detail": ""}
if isinstance(content, bytes): if isinstance(content, bytes):
try: content = content.decode("utf-8", errors="replace")
content = content.decode("utf-8") content = content.strip()
except UnicodeDecodeError: if not content:
try: return {"code": "PARAM_REQUIRED", "message": "文件内容为空", "field": "file", "detail": ""}
content = content.decode("gbk", errors="ignore") fname = (filename or "").lower()
except Exception: rows = []
return None
if not isinstance(content, str):
return None
text = content.strip()
if not text:
return []
# JSON 数组 / 单对象
try: try:
data = json.loads(text) if fname.endswith(".csv"):
if isinstance(data, list): reader = csv.DictReader(io.StringIO(content))
return [d for d in data if isinstance(d, dict)] for r in reader:
if isinstance(data, dict): rows.append({k.strip(): (v or "").strip() for k, v in r.items() if k and k.strip()})
return [data] elif content.startswith("["):
except Exception: data = _json.loads(content)
pass if not isinstance(data, list):
# JSON Lines return {"code": "PARSE_ERROR", "message": "JSON 顶层必须是数组", "field": "file", "detail": ""}
lines = [ln for ln in text.splitlines() if ln.strip()] rows = data
if lines and lines[0].strip().startswith("{"): else:
rows = [] for line in content.splitlines():
for ln in lines: line = line.strip()
try: if not line:
obj = json.loads(ln) continue
obj = _json.loads(line)
if isinstance(obj, dict): if isinstance(obj, dict):
rows.append(obj) rows.append(obj)
except Exception: except Exception as e:
rows.append(None) return {"code": "PARSE_ERROR", "message": f"文件解析失败: {e}", "field": "file", "detail": ""}
if any(r is not None for r in rows): return {"rows": 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
async def import_scenes(params_kw=None): async def import_scenes(import_params):
"""场景导入:逐条校验 → sor.C("scene") → 统计 → 写导入记录。 """事务批量导入。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() dbname = _get_dbname()
async with DBPools().sqlorContext(dbname) as sor: from appPublic.utils import getID
for row in rows: now = _now()
try: records = []
if not isinstance(row, dict): seen_codes = set()
raise ValueError("非法记录") for row in validated:
name = str(row.get("name") or "").strip() code = row.get("code", "").strip()
code = str(row.get("code") or "").strip() if code in seen_codes:
if not name or not code: return {"success": False, "code": "DUPLICATE_CODE", "message": f"文件内重复场景编码: {code}", "field": "code", "detail": code}
raise ValueError("name/code 不能为空") seen_codes.add(code)
ns = {k: v for k, v in row.items() if k in _SCENE_FIELDS} records.append({
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(), "id": getID(),
"world_id": world_id, "world_id": world_id,
"file_name": file_name, "name": row.get("name", "").strip(),
"total": total, "code": code,
"success": n_success, "description": (row.get("description") or "").strip(),
"fail": n_fail, "scene_type": (row.get("scene_type") or "0").strip() or "0",
"status": "0", "status": (row.get("status") or "0").strip() or "0",
"created_at": curDateString(), "config_json": row.get("config_json"),
} "created_at": now,
await sor.C("scene_import", import_ns) "updated_at": now,
return {"success": True, "total": total, "success_count": n_success, "fail": n_fail} })
try:
def load_scene(env=None):
"""挂载 scene 模块函数到 ServerEnv。"""
if env is None:
from ahserver.serverenv import ServerEnv 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.list_scenes = list_scenes
env.get_scene = get_scene env.get_scene = get_scene
env.create_scene = create_scene env.create_scene = create_scene
env.create_scenes = create_scene
env.update_scene = update_scene env.update_scene = update_scene
env.update_scenes = update_scene
env.delete_scene = delete_scene env.delete_scene = delete_scene
env.delete_scenes = delete_scene
env.import_scenes = import_scenes env.import_scenes = import_scenes
env.parse_scene_file = parse_scene_file env.parse_scene_file = parse_scene_file
env.get_world_options = get_world_options env.get_world_options = get_world_options
env.get_scene_type_options = get_scene_type_options env.get_scene_type_options = get_scene_type_options
env.get_scene_status_options = get_scene_status_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()}

View File

@ -1,63 +1,74 @@
# -*- coding: utf-8 -*- #!/usr/bin/env python3
"""scene 模块 RBAC 路径注册(显式路径,禁止通配符)。""" """scene 模块 RBAC 路径注册(无通配符,全显式路径)。
用法:python load_path.py (在 Sage/宿主应用根目录执行,自动探测)。
"""
import os import os
import sys import sys
def find_sage_root(): def find_sage_root():
candidates = [ for candidate in (
os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "..", "..")), os.path.join(os.path.dirname(os.path.abspath(__file__)), "..", ".."),
os.path.expanduser("~/repos/sage"), os.path.expanduser("~/repos/sage"),
os.path.expanduser("~/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")): if os.path.isdir(os.path.join(c, "wwwroot")) and os.path.isdir(os.path.join(c, "py3", "bin")):
return c return c
return None return None
SAGE_ROOT = find_sage_root() 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_ANY = [
]
PATHS_LOGINED = [
"/scene", "/scene",
"/scene/index.ui", "/scene/index.ui",
"/scene/import_page.ui", "/scene/import_page.ui",
"/scene/scene", ]
"/scene/scene/index.ui",
"/scene/scene/get_scene.dspy", PATHS_LOGINED = [
"/scene/scene/add_scene.dspy", "/scene/scene_list",
"/scene/scene/update_scene.dspy", "/scene/scene_list/index.ui",
"/scene/scene/delete_scene.dspy", "/scene/scene_list/get_scene_list.dspy",
"/scene/scene_import", "/scene/scene_list/add_scene_list.dspy",
"/scene/scene_import/index.ui", "/scene/scene_list/update_scene_list.dspy",
"/scene/scene_import/get_scene_import.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/list_scenes.dspy",
"/scene/api/get_scene.dspy",
"/scene/api/create_scene.dspy", "/scene/api/create_scene.dspy",
"/scene/api/scene_update.dspy", "/scene/api/scene_update.dspy",
"/scene/api/scene_delete.dspy", "/scene/api/scene_delete.dspy",
"/scene/api/scene_import.dspy",
"/scene/api/get_search_world_id.dspy", "/scene/api/get_search_world_id.dspy",
"/scene/api/get_search_scene_type.dspy", "/scene/api/get_search_scene_type.dspy",
"/scene/api/get_search_status.dspy", "/scene/api/get_search_status.dspy",
"/scene/api/scene_import.dspy",
] ]
def main(): def main():
if SAGE_ROOT is None:
print("Sage root not found, skip RBAC registration.")
return
sys.path.insert(0, SAGE_ROOT)
try: try:
from set_role_perm import set_role_perm from set_role_perm import set_role_perm
except ImportError: except Exception as e:
print("set_role_perm not found, skip RBAC registration.") print(f"ERROR: 无法导入 set_role_perm: {e}")
return sys.exit(1)
for p in PATHS_ANY:
set_role_perm(p, "any") for path in PATHS_ANY:
for p in PATHS_LOGINED: set_role_perm(path, "any")
set_role_perm(p, "logined") print(f"any {path}")
for path in PATHS_LOGINED:
set_role_perm(path, "logined")
print(f"logined {path}")
print("scene RBAC 注册完成")
if __name__ == "__main__": if __name__ == "__main__":

View File

@ -1,12 +1,12 @@
--- ---
name: scene 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 模块 # scene 模块
## 概述 ## 概述
场景管理,含场景导入。依赖 world(world_id 外键)、appbase(appcodes 字典)、rbac(权限)。 场景管理,含场景导入。依赖 world(world_id 逻辑关联,不加物理外键)、appbase(appcodes 字典)、rbac(权限)。
## 数据模型 ## 数据模型
@ -14,9 +14,9 @@ description: scene 模块技能文档——场景管理(场景表 CRUD + 场
| 字段 | 类型 | 说明 | | 字段 | 类型 | 说明 |
|------|------|------| |------|------|------|
| id | str(32) | 主键 | | id | str(32) | 主键 |
| world_id | str(32) | 所属世界(→world.id) | | world_id | str(32) | 所属世界(→world.id,逻辑关联) |
| name | str(255) | 场景名称 | | name | str(255) | 场景名称 |
| code | str(64) | 场景编码(唯一) | | code | str(64) | 场景编码(唯一,idx_scene_code) |
| description | text | 描述 | | description | text | 描述 |
| scene_type | str(16) | 场景类型(appcodes scene_type,默认 '0') | | scene_type | str(16) | 场景类型(appcodes scene_type,默认 '0') |
| status | str(16) | 状态(appcodes scene_status,默认 '0') | | status | str(16) | 状态(appcodes scene_status,默认 '0') |
@ -25,48 +25,49 @@ description: scene 模块技能文档——场景管理(场景表 CRUD + 场
| updated_at | timestamp | 更新时间 | | updated_at | timestamp | 更新时间 |
- 主键 `["id"]`;唯一索引 `idx_scene_code(code)`;索引 `idx_scene_world(world_id)` - 主键 `["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) | 主键 | | id | str(32) | 主键 |
| world_id | str(32) | 目标世界 | | world_id | str(32) | 目标世界 |
| file_name | str(255) | 导入文件名 | | file_name | str(255) | 导入文件名 |
| total | int | 总条数(默认 0) | | total / success / fail | int | 总数 / 成功 / 失败(默认 0) |
| success | int | 成功条数(默认 0) | | status | str(16) | 导入状态(appcodes import_status:0 进行中/1 成功/2 失败) |
| fail | int | 失败条数(默认 0) |
| status | str(16) | 导入状态(appcodes import_status,默认 '0') |
| created_at | timestamp | 导入时间 | | created_at | timestamp | 导入时间 |
- 主键 `["id"]`;索引 `idx_scene_import_world(world_id)` - 主键 `["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)
### ServerEnv 注册函数(load_scene() 挂载) - `get_scene({id})` → `{data}` 或错误
- `list_scenes(params)` → `[{value:id, text:name}]` - `create_scene(ns)` → 校验 + world 存在校验 + 自动 id/created_at/updated_at;返回 `{success, id}`
- `get_scene({id})` → 单条 - `update_scene(ns)` → 写 updated_at、剔除 `_text` 后缀、world 校验
- `create_scene(ns)` → 自动生成 id/code、写 created_at
- `update_scene(ns)` → 写 updated_at、剔除 `_text` 后缀
- `delete_scene({id})` - `delete_scene({id})`
- `import_scenes({world_id, file_name, rows})` → `{success, total, success_count, fail}` - `import_scenes({world_id, file_name, rows})` → `{success, total, success_count, fail}`
- `parse_scene_file(content, filename)` → `[{...}]`(JSON 数组 / JSON Lines / CSV) (**事务批量**:先全量解析+校验,再逐条插入;任一条失败整批回滚,无脏数据;success_count 表示成功条数,避免与布尔 success 冲突)
- `get_world_options()` / `get_scene_type_options()` / `get_scene_status_options()` → `[{value, text}]` - `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 ### wwwroot/api/*.dspy(REST 统一前缀 /api/*,宿主挂载后为 /scene/api/*.dspy)
- `list_scenes.dspy`、`create_scene.dspy`、`scene_update.dspy`、`scene_delete.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`:字典下拉 - `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) - `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")`(全局) - 库名禁止硬编码:.py 用 `ServerEnv().get_module_dbname("scene")`,.dspy 用 `get_module_dbname("scene")`(全局);world 表在 world 模块库(`get_module_dbname("world")`)
- world 下拉用 `ServerEnv().get_module_dbname("world")`(world 表在 world 模块库) - dspy 无 import(json/get_sor_context/debug/params_kw 均预加载全局);函数经 load_scene() 注册后为全局,直接调用
- dspy 无 import(json/get_sor_context/debug/params_kw 均为预加载全局) - `create_scene` / `scene_import` 必须设置 `created_at = curDateString()`,否则 sor.C 静默丢记录
- `create_scene` 必须设置 `created_at = curDateString()`,否则 sor.C 静默丢记录 - `scene_import` 记录 total/success/fail 必须为 int
- 导入时 `scene_import` 记录必须设置 `created_at`,字段 total/success/fail 为 int - `import_scenes` 返回用 `success_count`(避免与布尔 `success` 键冲突)
- `import_scenes` 返回用 `success_count` 表示成功条数(避免与布尔 `success` 键冲突)
- CRUD json `new_data_url` 指向自定义 `api/create_scene.dspy`;scene_import 为只读列表,无 editable - 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(权限)

View File

@ -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}

View File

@ -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}

View File

@ -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}

View File

@ -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}

View File

@ -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}

View File

@ -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}

View File

@ -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}

View File

@ -1,26 +1,27 @@
# scene_import.dspy: 场景导入(文件上传 → 解析 → 批量落库 → 写导入记录) # 场景导入(REST POST /api/scene_import.dspy)
world_id = params_kw.get('world_id') # 参数:world_id(目标世界)+ file(上传文件,multipart)或 rows(JSON 数组)
file = params_kw.get('file') # 返回:{status, data:{success,total,success_count,fail}};错误 {code,message,field,detail}
file_name = params_kw.get('file_name') or '' debug(f'scene_import.dspy: START params_kw keys={list((params_kw or {}).keys())}')
content = None params = dict(params_kw or {})
if file is not None: world_id = (params.get('world_id') or '').strip()
if hasattr(file, 'file'): file_name = params.get('file_name') or ''
file_name = file_name or getattr(file, 'filename', '') or '' file_obj = params.get('file')
try: rows = params.get('rows')
content = file.file.read()
except Exception: if file_obj is not None:
content = None try:
elif hasattr(file, 'read'): content = file_obj.read()
try: except Exception:
content = file.read() content = file_obj
except Exception: parsed = parse_scene_file(content, file_name)
content = None if 'rows' not in parsed:
else: return {'status': 'error', 'data': parsed}
content = file result = await import_scenes({'world_id': world_id, 'file_name': file_name, 'rows': parsed['rows']})
if content is None: else:
return {'success': False, 'message': '未接收到上传文件'} if not isinstance(rows, list):
rows = parse_scene_file(content, file_name) return {'status': 'error', 'data': {'code': 'PARAM_REQUIRED', 'message': '缺少导入数据(file 或 rows)', 'field': 'rows', 'detail': ''}}
if rows is None: result = await import_scenes({'world_id': world_id, 'file_name': file_name, 'rows': rows})
return {'success': False, 'message': '文件解析失败,仅支持 JSON 数组 / JSON Lines / CSV'}
result = await import_scenes({'world_id': world_id, 'file_name': file_name, 'rows': rows}) if result.get('success'):
return result 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}

View File

@ -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}

View File

@ -1,18 +1,17 @@
{ {
"widgettype": "VBox", "widgettype": "VBox",
"options": {"width": "100%", "height": "100%", "padding": "20px"}, "options": {"width": "100%", "height": "100%", "padding": "20px"},
"subwidgets": [ "subwidgets": [
{"widgettype": "Text", "options": {"label": "场景导入", "fontSize": "20px"}}, {"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": {
{"widgettype": "Form", "options": { "width": "60%",
"submit_url": "{{entire_url('/scene/api/scene_import.dspy')}}", "submit_url": "{{entire_url('/scene/api/scene_import.dspy')}}",
"method": "POST" "fields": [
}, "subwidgets": [ {"name": "world_id", "label": "目标世界", "uitype": "code", "required": true,
{"widgettype": "Select", "options": {"name": "world_id", "label": "目标世界", "required": "true", "dataurl": "{{entire_url('/scene/api/get_search_world_id.dspy')}}", "valueField": "value", "textField": "text"},
"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": "UiFile", "options": {"name": "file", "label": "导入文件", "required": "true"}}, ]
{"widgettype": "Button", "options": {"label": "开始导入"}} }},
]}, {"widgettype": "Text", "id": "app.scene_import_result", "options": {"label": "", "marginTop": "16px"}}
{"widgettype": "VBox", "id": "app.scene_import_content", "options": {"width": "100%", "flex": "1", "marginTop": "20px"}} ]
]
} }

View File

@ -1,18 +1,22 @@
{ {
"widgettype": "VBox", "widgettype": "VBox",
"options": {"width": "100%", "height": "100%", "padding": "20px"}, "options": {"width": "100%", "height": "100%", "padding": "20px"},
"subwidgets": [ "subwidgets": [
{"widgettype": "Text", "options": {"label": "场景管理", "fontSize": "24px"}}, {"widgettype": "Text", "options": {"label": "场景管理", "fontSize": "24px"}},
{"widgettype": "ResponsableBox", "options": {"gap": "16px", "minWidth": "250px"}, "subwidgets": [ {"widgettype": "ResponsableBox", "options": {"gap": "16px", "minWidth": "250px"}, "subwidgets": [
{"widgettype": "VBox", "options": {"backgroundColor": "#FFFFFF", "padding": "20px", "cursor": "pointer"}, {"widgettype": "VBox", "options": {"backgroundColor": "#FFFFFF", "padding": "20px", "cursor": "pointer"},
"binds": [{"wid": "self", "event": "click", "actiontype": "urlwidget", "target": "app.scene_content", "binds": [{"wid": "self", "event": "click", "actiontype": "urlwidget", "target": "app.scene_content",
"options": {"url": "{{entire_url('/scene/scene')}}"}, "mode": "replace"}], "options": {"url": "{{entire_url('/scene/scene_list')}}"}, "mode": "replace"}],
"subwidgets": [{"widgettype": "Text", "options": {"label": "场景列表"}}]}, "subwidgets": [{"widgettype": "Text", "options": {"label": "场景列表"}}]},
{"widgettype": "VBox", "options": {"backgroundColor": "#FFFFFF", "padding": "20px", "cursor": "pointer"}, {"widgettype": "VBox", "options": {"backgroundColor": "#FFFFFF", "padding": "20px", "cursor": "pointer"},
"binds": [{"wid": "self", "event": "click", "actiontype": "urlwidget", "target": "app.scene_content", "binds": [{"wid": "self", "event": "click", "actiontype": "urlwidget", "target": "app.scene_content",
"options": {"url": "{{entire_url('/scene/import_page.ui')}}"}, "mode": "replace"}], "options": {"url": "{{entire_url('/scene/import_page.ui')}}"}, "mode": "replace"}],
"subwidgets": [{"widgettype": "Text", "options": {"label": "场景导入"}}]} "subwidgets": [{"widgettype": "Text", "options": {"label": "场景导入"}}]},
]}, {"widgettype": "VBox", "options": {"backgroundColor": "#FFFFFF", "padding": "20px", "cursor": "pointer"},
{"widgettype": "VBox", "id": "app.scene_content", "options": {"width": "100%", "flex": "1", "marginTop": "20px"}} "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"}}
]
} }