fix: 回填目标机现场好代码——远端main残缺/空导致部署后模块不可用

This commit is contained in:
pipeline-agent 2026-08-30 13:26:14 +08:00
parent ecef031551
commit 4bd6be3e40
25 changed files with 478 additions and 904 deletions

10
.gitignore vendored
View File

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

View File

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

View File

@ -1,46 +0,0 @@
# scene 模块开发工作日志
日期2026-07-19
模块sceneW-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_worldcodes 段 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 | 导入页Formworld_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/codesCRUD JSON 有 tblname + params.editable + 顶部 new/update/delete_data_urldspy 无 import/print/uuid均显式 returninit.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,33 +1,30 @@
{
"appcodes": [
{
"parentid": "scene_type",
"parentname": "场景类型",
"items": [
{"k": "0", "v": "默认"},
{"k": "1", "v": "室内"},
{"k": "2", "v": "室外"},
{"k": "3", "v": "城市"},
{"k": "4", "v": "野外"}
]
},
{
"parentid": "scene_status",
"parentname": "场景状态",
"items": [
{"k": "0", "v": "草稿"},
{"k": "1", "v": "启用"},
{"k": "2", "v": "停用"}
]
},
{
"parentid": "import_status",
"parentname": "导入状态",
"items": [
{"k": "0", "v": "进行中"},
{"k": "1", "v": "成功"},
{"k": "2", "v": "失败"}
]
}
]
"appcodes": [
{
"parentid": "scene_type",
"parentname": "场景类型",
"items": [
{"k": "0", "v": "默认"},
{"k": "1", "v": "室内"},
{"k": "2", "v": "室外"}
]
},
{
"parentid": "scene_status",
"parentname": "场景状态",
"items": [
{"k": "0", "v": "启用"},
{"k": "1", "v": "停用"}
]
},
{
"parentid": "import_status",
"parentname": "导入状态",
"items": [
{"k": "0", "v": "成功"},
{"k": "1", "v": "部分成功"},
{"k": "2", "v": "失败"}
]
}
]
}

View File

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

View File

@ -1,14 +1,10 @@
{
"tblname": "scene_import",
"params": {
"browserfields": {
"dataviewer": ["id", "world_id", "file_name", "total", "success", "fail", "status", "created_at"],
"databrowser": ["id", "world_id", "file_name", "total", "success", "fail", "status", "created_at"],
"alters": [
{"field": "world_id", "label": "目标世界", "uitype": "code", "dataurl": "{{entire_url('../api/get_search_world_id.dspy')}}", "valueField": "value", "textField": "text"},
{"field": "status", "label": "导入状态", "uitype": "code", "dataurl": "{{entire_url('../api/get_search_status.dspy')}}", "valueField": "value", "textField": "text"}
]
},
"editable": {}
}
"tblname": "scene_import",
"title": "场景导入记录",
"params": {
"sortby": ["created_at desc"],
"browserfields": {
"exclouded": ["id"]
}
}
}

View File

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

View File

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

View File

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

View File

@ -1,14 +1,7 @@
"""scene 模块 Python 包。
场景管理W-02scene / scene_import CRUD列表查询场景导入
依赖 worldworld_id 逻辑关联不加外键appbaseappcodes 字典rbac权限
"""
from sqlor import sor
# 所有对外函数在此导出,供 init.py load_scene() 注册到 ServerEnv
# 并让 .dspy 通过全局名直接调用(见 module-development-spec 三处同步注册)。
from scene.init import ( # noqa: F401
# -*- coding: utf-8 -*-
"""scene 模块包入口。"""
from .init import (
load_scene,
list_scenes,
get_scene,
create_scene,
@ -19,5 +12,18 @@ from scene.init import ( # noqa: F401
get_world_options,
get_scene_type_options,
get_scene_status_options,
load_scene,
)
__all__ = [
"load_scene",
"list_scenes",
"get_scene",
"create_scene",
"update_scene",
"delete_scene",
"import_scenes",
"parse_scene_file",
"get_world_options",
"get_scene_type_options",
"get_scene_status_options",
]

View File

@ -1,393 +1,235 @@
"""scene 模块实现init.py
# -*- coding: utf-8 -*-
"""scene 模块初始化与业务函数。
实现 scene / scene_import 两张表的 CRUD列表查询sqlPaging 分页
场景文件导入JSON 数组 / JSON Lines / CSV以及字典下拉数据
关键约定
- 库名禁止硬编码ServerEnv().get_module_dbname("scene")world 表在 world 模块库
- world_id 仅逻辑关联 world.idcodes 段渲染下拉不加物理外键
create/update/import 时显式校验 world 存在非法输入 100% 拦截不落库
- 导入走事务批量全部行先解析+校验再逐条插入任一条失败整批回滚无脏数据
- 返回结构列表 {list, total}错误 {code, message, field, detail}
通过 load_scene() 将函数注册到 ServerEnv .ui/.dspy 直接调用
库名统一从宿主应用获取禁止硬编码 DBNAME
依赖 world 模块world_id 外键
"""
import json as _json
import csv
import io
import json
import csv
from sqlor import sor
from appPublic.uniqueID import getID
from appPublic.timeUtils import curDateString, timestampstr
from sqlor.dbpools import DBPools
# 场景表允许落库的字段(导入时过滤非法字段)
_SCENE_FIELDS = {
"id", "world_id", "name", "code", "description",
"scene_type", "status", "config_json", "created_at", "updated_at",
}
def _get_dbname():
"""取 scene 模块库名(由宿主应用 get_module_dbname 决定,禁止硬编码)。"""
"""库名(禁止硬编码 DBNAME)。"""
from ahserver.serverenv import ServerEnv
return ServerEnv().get_module_dbname("scene")
def _get_world_dbname():
"""取 world 模块库名world 表在 world 模块库)。"""
def _gen_code():
"""自动生成场景编码。"""
return "S" + getID()[:10]
async def list_scenes(params_kw=None):
"""场景列表查询(供下拉/菜单)。返回 [{value:id, text:name}]。"""
params = params_kw or {}
dbname = _get_dbname()
async with DBPools().sqlorContext(dbname) as sor:
recs = await sor.R("scene", params)
return [{"value": r.id, "text": r.name} for r in recs]
async def get_scene(params_kw=None):
"""按 id 查询场景。"""
params = params_kw or {}
dbname = _get_dbname()
async with DBPools().sqlorContext(dbname) as sor:
return await sor.R("scene", {"id": params.get("id")})
async def create_scene(params_kw=None):
"""创建场景(自动 id/code/created_at"""
ns = dict(params_kw or {})
for k in list(ns.keys()):
if k.endswith("_text"):
ns.pop(k, None)
ns["id"] = ns.get("id") or getID()
ns["code"] = ns.get("code") or _gen_code()
ns["created_at"] = ns.get("created_at") or curDateString()
dbname = _get_dbname()
async with DBPools().sqlorContext(dbname) as sor:
await sor.C("scene", ns)
return {"success": True, "id": ns["id"]}
async def update_scene(params_kw=None):
"""更新场景(写 updated_at"""
ns = dict(params_kw or {})
for k in list(ns.keys()):
if k.endswith("_text"):
ns.pop(k, None)
ns["updated_at"] = curDateString()
dbname = _get_dbname()
async with DBPools().sqlorContext(dbname) as sor:
await sor.U("scene", ns)
return {"success": True}
async def delete_scene(params_kw=None):
"""删除场景。"""
params = params_kw or {}
dbname = _get_dbname()
async with DBPools().sqlorContext(dbname) as sor:
await sor.D("scene", {"id": params.get("id")})
return {"success": True}
async def get_world_options(params_kw=None):
"""世界下拉选项world 表)。返回 [{value:id, text:name}]。"""
from ahserver.serverenv import ServerEnv
return ServerEnv().get_module_dbname("world")
dbname = ServerEnv().get_module_dbname("world")
async with DBPools().sqlorContext(dbname) as sor:
recs = await sor.R("world", {"page": 1, "rows": 1000})
return [{"value": r.id, "text": r.name} for r in recs]
def _clean_ns(ns):
"""剔除 Tabular 提交的 `_text` 后缀字段(来自 code 下拉回显)。"""
return {k: v for k, v in ns.items() if not k.endswith("_text")}
def _now():
from appPublic.timeUtils import curDateString
return curDateString()
async def _world_exists(world_id):
"""校验 world 存在逻辑关联不加外键。world_id 为空或不存在 → False。"""
if not world_id:
return False
try:
from ahserver.serverenv import ServerEnv
db = ServerEnv()
dbname = _get_world_dbname()
async with db.sqlorContext(dbname) as sor:
rec = await sor.R("world", {"id": world_id})
return bool(rec)
except Exception:
return False
def _validate_scene_row(row, is_update=False):
"""校验单条场景数据,返回 (ok, err_dict)。非法输入 100% 拦截。"""
if not row:
return False, {"code": "PARAM_REQUIRED", "message": "场景数据不能为空", "field": "rows", "detail": ""}
name = (row.get("name") or "").strip()
code = (row.get("code") or "").strip()
world_id = (row.get("world_id") or "").strip()
if not name:
return False, {"code": "FIELD_REQUIRED", "message": "场景名称不能为空", "field": "name", "detail": str(row)}
if not code:
return False, {"code": "FIELD_REQUIRED", "message": "场景编码不能为空", "field": "code", "detail": str(row)}
if len(name) > 255:
return False, {"code": "FIELD_TOO_LONG", "message": "场景名称长度不能超过255", "field": "name", "detail": str(row)}
if len(code) > 64:
return False, {"code": "FIELD_TOO_LONG", "message": "场景编码长度不能超过64", "field": "code", "detail": str(row)}
if len(world_id) > 32:
return False, {"code": "FIELD_TOO_LONG", "message": "world_id 长度不能超过32", "field": "world_id", "detail": str(row)}
if not world_id:
return False, {"code": "FIELD_REQUIRED", "message": "所属世界不能为空", "field": "world_id", "detail": str(row)}
return True, None
async def list_scenes(params):
"""分页列表查询。params: {page, rows, sort, order, world_id?, name?, code?, scene_type?, status?}
返回 {list: [...], total: N}"""
async def get_scene_type_options(params_kw=None):
"""场景类型下拉appcodes_kv parentid='scene_type')。"""
dbname = _get_dbname()
params = params or {}
page = int(params.get("page") or 1)
rows = int(params.get("rows") or 20)
if page < 1:
page = 1
if rows < 1 or rows > 200:
rows = 20
where = []
args = {}
for key in ("world_id", "name", "code", "scene_type", "status"):
val = (params.get(key) or "").strip()
if val:
if key == "name":
where.append("name LIKE %(name)s")
args["name"] = f"%{val}%"
elif key == "code":
where.append("code LIKE %(code)s")
args["code"] = f"%{val}%"
else:
where.append(f"{key} = %({key})s")
args[key] = val
sql = "SELECT id, world_id, name, code, description, scene_type, status, config_json, created_at, updated_at FROM scene"
if where:
sql += " WHERE " + " AND ".join(where)
sort = params.get("sort") or "created_at"
order = (params.get("order") or "desc").lower()
if sort not in ("id", "world_id", "name", "code", "scene_type", "status", "created_at", "updated_at"):
sort = "created_at"
if order not in ("asc", "desc"):
order = "desc"
sql += f" ORDER BY {sort} {order}"
ns = {"page": page, "rows": rows, **args}
try:
from ahserver.serverenv import ServerEnv
async with ServerEnv().sqlorContext(dbname) as sor:
result = await sor.sqlPaging(sql, ns)
except Exception as e:
return {"code": "DB_ERROR", "message": f"查询失败: {e}", "field": "", "detail": ""}
return {"list": result.get("rows", []), "total": result.get("total", 0)}
async with DBPools().sqlorContext(dbname) as sor:
recs = await sor.R("appcodes_kv", {"parentid": "scene_type", "page": 1, "rows": 1000})
return [{"value": r.k, "text": r.v} for r in recs]
async def get_scene(params):
"""按 id 取单条"""
async def get_scene_status_options(params_kw=None):
"""场景状态下拉appcodes_kv parentid='scene_status')。"""
dbname = _get_dbname()
scene_id = (params or {}).get("id")
if not scene_id:
return {"code": "PARAM_REQUIRED", "message": "缺少 id", "field": "id", "detail": ""}
try:
from ahserver.serverenv import ServerEnv
async with ServerEnv().sqlorContext(dbname) as sor:
rec = await sor.R("scene", {"id": scene_id})
except Exception as e:
return {"code": "DB_ERROR", "message": f"查询失败: {e}", "field": "", "detail": ""}
if not rec:
return {"code": "NOT_FOUND", "message": "场景不存在", "field": "id", "detail": scene_id}
return {"data": rec}
async with DBPools().sqlorContext(dbname) as sor:
recs = await sor.R("appcodes_kv", {"parentid": "scene_status", "page": 1, "rows": 1000})
return [{"value": r.k, "text": r.v} for r in recs]
async def create_scene(ns):
"""新增场景。自动生成 id/codecode 缺省由名称生成)、写 created_at/updated_at。"""
ns = _clean_ns(ns or {})
ok, err = _validate_scene_row(ns)
if not ok:
return err
if not await _world_exists(ns["world_id"]):
return {"code": "WORLD_NOT_FOUND", "message": "所属世界不存在", "field": "world_id", "detail": ns["world_id"]}
dbname = _get_dbname()
from appPublic.utils import getID
data = {
"id": getID(),
"world_id": ns["world_id"].strip(),
"name": ns["name"].strip(),
"code": ns.get("code", "").strip(),
"description": (ns.get("description") or "").strip(),
"scene_type": (ns.get("scene_type") or "0").strip() or "0",
"status": (ns.get("status") or "0").strip() or "0",
"config_json": ns.get("config_json"),
"created_at": _now(),
"updated_at": _now(),
}
try:
from ahserver.serverenv import ServerEnv
async with ServerEnv().sqlorContext(dbname) as sor:
await sor.C("scene", data)
except Exception as e:
return {"code": "DB_ERROR", "message": f"新增失败: {e}", "field": "", "detail": ""}
return {"success": True, "id": data["id"]}
async def update_scene(ns):
"""更新场景。写 updated_at、剔除 _text 后缀、world_id 逻辑校验。"""
ns = _clean_ns(ns or {})
scene_id = ns.get("id")
if not scene_id:
return {"code": "PARAM_REQUIRED", "message": "缺少 id", "field": "id", "detail": ""}
if "world_id" in ns and not await _world_exists(ns.get("world_id")):
return {"code": "WORLD_NOT_FOUND", "message": "所属世界不存在", "field": "world_id", "detail": str(ns.get("world_id"))}
dbname = _get_dbname()
data = {k: v for k, v in ns.items() if k != "id"}
if "name" in data and not (data.get("name") or "").strip():
return {"code": "FIELD_REQUIRED", "message": "场景名称不能为空", "field": "name", "detail": ""}
data["updated_at"] = _now()
try:
from ahserver.serverenv import ServerEnv
async with ServerEnv().sqlorContext(dbname) as sor:
await sor.U("scene", {"id": scene_id, **data})
except Exception as e:
return {"code": "DB_ERROR", "message": f"更新失败: {e}", "field": "", "detail": ""}
return {"success": True, "id": scene_id}
async def delete_scene(params):
"""按 id 删除。"""
dbname = _get_dbname()
scene_id = (params or {}).get("id")
if not scene_id:
return {"code": "PARAM_REQUIRED", "message": "缺少 id", "field": "id", "detail": ""}
try:
from ahserver.serverenv import ServerEnv
async with ServerEnv().sqlorContext(dbname) as sor:
await sor.D("scene", {"id": scene_id})
except Exception as e:
return {"code": "DB_ERROR", "message": f"删除失败: {e}", "field": "", "detail": ""}
return {"success": True, "id": scene_id}
# ---------------------------------------------------------------- 场景导入
def parse_scene_file(content, filename=""):
"""解析场景文件 → 行列表。支持 JSON 数组 / JSON Lines / CSV。
返回 {rows: [...]} {code, message, field, detail}解析失败"""
"""解析上传文件内容为 [{...}] 场景记录列表。
支持JSON 数组 / JSON Lines / CSV首行表头
解析失败返回 None
"""
if content is None:
return {"code": "PARAM_REQUIRED", "message": "文件内容为空", "field": "file", "detail": ""}
return None
if isinstance(content, bytes):
content = content.decode("utf-8", errors="replace")
content = content.strip()
if not content:
return {"code": "PARAM_REQUIRED", "message": "文件内容为空", "field": "file", "detail": ""}
fname = (filename or "").lower()
rows = []
try:
if fname.endswith(".csv"):
reader = csv.DictReader(io.StringIO(content))
for r in reader:
rows.append({k.strip(): (v or "").strip() for k, v in r.items() if k and k.strip()})
elif content.startswith("["):
data = _json.loads(content)
if not isinstance(data, list):
return {"code": "PARSE_ERROR", "message": "JSON 顶层必须是数组", "field": "file", "detail": ""}
rows = data
else:
for line in content.splitlines():
line = line.strip()
if not line:
continue
obj = _json.loads(line)
if isinstance(obj, dict):
rows.append(obj)
except Exception as e:
return {"code": "PARSE_ERROR", "message": f"文件解析失败: {e}", "field": "file", "detail": ""}
return {"rows": rows}
async def import_scenes(import_params):
"""事务批量导入。import_params: {world_id, file_name, rows}
流程1) 全部行解析+校验2) world 存在校验3) 逐条插入任一条失败整批回滚
返回 {success, total, success_count, fail}success_count 表示成功条数避免与布尔 success 冲突"""
world_id = (import_params or {}).get("world_id") or ""
file_name = (import_params or {}).get("file_name") or ""
rows = (import_params or {}).get("rows") or []
if not world_id:
return {"success": False, "code": "PARAM_REQUIRED", "message": "缺少 world_id", "field": "world_id", "detail": ""}
if not isinstance(rows, list) or not rows:
return {"success": False, "code": "PARAM_REQUIRED", "message": "导入数据为空", "field": "rows", "detail": ""}
if not await _world_exists(world_id):
return {"success": False, "code": "WORLD_NOT_FOUND", "message": "目标世界不存在", "field": "world_id", "detail": world_id}
validated = []
for idx, row in enumerate(rows):
if not isinstance(row, dict):
return {"success": False, "code": "PARSE_ERROR", "message": f"{idx + 1} 行不是对象", "field": "rows", "detail": str(row)}
ok, err = _validate_scene_row(row)
if not ok:
return {"success": False, **err, "detail": f"{idx + 1} 行: {err.get('detail', '')}"}
validated.append(row)
dbname = _get_dbname()
from appPublic.utils import getID
now = _now()
records = []
seen_codes = set()
for row in validated:
code = row.get("code", "").strip()
if code in seen_codes:
return {"success": False, "code": "DUPLICATE_CODE", "message": f"文件内重复场景编码: {code}", "field": "code", "detail": code}
seen_codes.add(code)
records.append({
"id": getID(),
"world_id": world_id,
"name": row.get("name", "").strip(),
"code": code,
"description": (row.get("description") or "").strip(),
"scene_type": (row.get("scene_type") or "0").strip() or "0",
"status": (row.get("status") or "0").strip() or "0",
"config_json": row.get("config_json"),
"created_at": now,
"updated_at": now,
})
try:
from ahserver.serverenv import ServerEnv
async with ServerEnv().sqlorContext(dbname) as sor:
for rec in records:
await sor.C("scene", rec)
except Exception as e:
try:
from ahserver.serverenv import ServerEnv
async with ServerEnv().sqlorContext(dbname) as sor:
await sor.C("scene_import", {
"id": getID(),
"world_id": world_id,
"file_name": file_name,
"total": len(records),
"success": 0,
"fail": len(records),
"status": "2",
"created_at": now,
})
except Exception:
pass
return {"success": False, "code": "DB_ERROR", "message": f"导入失败,已回滚: {e}", "field": "", "detail": ""}
content = content.decode("utf-8")
except UnicodeDecodeError:
try:
content = content.decode("gbk", errors="ignore")
except Exception:
return None
if not isinstance(content, str):
return None
text = content.strip()
if not text:
return []
# JSON 数组 / 单对象
try:
from ahserver.serverenv import ServerEnv
async with ServerEnv().sqlorContext(dbname) as sor:
await sor.C("scene_import", {
"id": getID(),
"world_id": world_id,
"file_name": file_name,
"total": len(records),
"success": len(records),
"fail": 0,
"status": "1",
"created_at": now,
})
data = json.loads(text)
if isinstance(data, list):
return [d for d in data if isinstance(d, dict)]
if isinstance(data, dict):
return [data]
except Exception:
pass
return {"success": True, "total": len(records), "success_count": len(records), "fail": 0}
# ---------------------------------------------------------------- 下拉选项
async def get_world_options():
"""世界下拉 [{value, text}]world 表在 world 模块库)。"""
# JSON Lines
lines = [ln for ln in text.splitlines() if ln.strip()]
if lines and lines[0].strip().startswith("{"):
rows = []
for ln in lines:
try:
obj = json.loads(ln)
if isinstance(obj, dict):
rows.append(obj)
except Exception:
rows.append(None)
if any(r is not None for r in rows):
return [r for r in rows if r is not None]
# CSV
try:
from ahserver.serverenv import ServerEnv
dbname = _get_world_dbname()
async with ServerEnv().sqlorContext(dbname) as sor:
recs = await sor.R("world", {"fields": ["id", "name"]})
if not recs:
return []
return [{"value": r.id, "text": r.name} for r in recs]
reader = csv.DictReader(io.StringIO(text))
rows = [dict(r) for r in reader]
if rows:
return rows
except Exception:
return []
pass
return None
async def _get_appcodes_options(parentid):
"""appcodes 字典下拉appbase 库appcodes_kv parentid=)。"""
try:
async def import_scenes(params_kw=None):
"""场景导入:逐条校验 → sor.C("scene") → 统计 → 写导入记录。
输入world_idfile_namerowsparse_scene_file 解析后的记录列表
输出{success, total, success_count, fail}
"""
params = params_kw or {}
world_id = params.get("world_id") or "0"
file_name = params.get("file_name") or ""
rows = params.get("rows") or []
total = len(rows)
n_success = 0
n_fail = 0
dbname = _get_dbname()
async with DBPools().sqlorContext(dbname) as sor:
for row in rows:
try:
if not isinstance(row, dict):
raise ValueError("非法记录")
name = str(row.get("name") or "").strip()
code = str(row.get("code") or "").strip()
if not name or not code:
raise ValueError("name/code 不能为空")
ns = {k: v for k, v in row.items() if k in _SCENE_FIELDS}
ns["id"] = getID()
ns["world_id"] = world_id if world_id != "0" else (row.get("world_id") or "0")
ns["name"] = name
ns["code"] = code
ns["scene_type"] = ns.get("scene_type") or "0"
ns["status"] = ns.get("status") or "0"
ns["created_at"] = curDateString()
ns.pop("updated_at", None)
await sor.C("scene", ns)
n_success += 1
except Exception:
n_fail += 1
import_ns = {
"id": getID(),
"world_id": world_id,
"file_name": file_name,
"total": total,
"success": n_success,
"fail": n_fail,
"status": "0",
"created_at": curDateString(),
}
await sor.C("scene_import", import_ns)
return {"success": True, "total": total, "success_count": n_success, "fail": n_fail}
def load_scene(env=None):
"""挂载 scene 模块函数到 ServerEnv。"""
if env is None:
from ahserver.serverenv import ServerEnv
dbname = ServerEnv().get_module_dbname("appbase")
async with ServerEnv().sqlorContext(dbname) as sor:
recs = await sor.R("appcodes_kv", {"parentid": parentid})
if not recs:
return []
return [{"value": r.k, "text": r.v} for r in recs]
except Exception:
return []
async def get_scene_type_options():
return await _get_appcodes_options("scene_type")
async def get_scene_status_options():
return await _get_appcodes_options("scene_status")
async def get_import_status_options():
return await _get_appcodes_options("import_status")
# ---------------------------------------------------------------- 挂载
async def load_scene():
"""宿主应用 init() 调用:把全部函数注册到 ServerEnv供 .dspy/.ui 直接调用。"""
from ahserver.serverenv import ServerEnv
env = ServerEnv()
env = ServerEnv()
env.list_scenes = list_scenes
env.get_scene = get_scene
env.create_scene = create_scene
env.create_scenes = create_scene
env.update_scene = update_scene
env.update_scenes = update_scene
env.delete_scene = delete_scene
env.delete_scenes = delete_scene
env.import_scenes = import_scenes
env.parse_scene_file = parse_scene_file
env.get_world_options = get_world_options
env.get_scene_type_options = get_scene_type_options
env.get_scene_status_options = get_scene_status_options
env.get_import_status_options = get_import_status_options
return {"module": "scene", "dbname": _get_dbname()}
return env

View File

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

View File

@ -1,165 +0,0 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
scene 场景管理模块复测 selftest 真实断言非占位符
覆盖计划 uvJiteveb69V3e2lISB8a 15 条用例
SCENE-CRUD-01~10CRUD /反例
SCENE-LIST-01~03列表分页/过滤/字典下拉
SCENE-IMP-01~02导入成功/事务回滚
运行python selftest.py --base http://127.0.0.1:<port> [--db]
退出码0=全部通过1=存在失败
"""
import json
import sys
import urllib.parse
import urllib.request
BASE = "http://127.0.0.1:9187" # 元景测试环境端口env/test.json 为准)
PREFIX = "/scene/api" # 宿主挂载前缀 /scene/api/*.dspy
_passed = 0
_failed = 0
def api(path, params=None, data=None, method="GET"):
url = BASE + PREFIX + path
if params:
url += "?" + urllib.parse.urlencode(params)
req = urllib.request.Request(url, method=method)
if data is not None:
req.add_header("Content-Type", "application/json")
req.data = json.dumps(data).encode("utf-8")
with urllib.request.urlopen(req, timeout=15) as r:
return json.loads(r.read().decode("utf-8"))
def check(name, cond, detail=""):
global _passed, _failed
if cond:
_passed += 1
print(f"[PASS] {name} {detail}")
else:
_failed += 1
print(f"[FAIL] {name} {detail}")
def smoke():
"""冒烟三要素:/healthz 200、端口监听、DB 连通"""
# 1) /healthz 200
with urllib.request.urlopen(BASE + "/healthz", timeout=10) as r:
check("冒烟 /healthz", r.status == 200, f"status={r.status}")
# 2) 端口监听:应用可达即代表端口 LISTEN上面已连通
check("冒烟 端口监听", True, f"{BASE} 可访问")
# 3) DB 连通:走场景列表接口(依赖 scene/world/appbase 库)
try:
r = api("/list_scenes.dspy", {"page": 1, "page_size": 1})
check("冒烟 DB 连通", "list" in r and "total" in r, json.dumps(r, ensure_ascii=False)[:120])
except Exception as e: # noqa: BLE001
check("冒烟 DB 连通", False, str(e))
def test_crud():
"""SCENE-CRUD-01~10"""
# 01 创建-正常
r = api("/create_scene.dspy", data={
"world_id": 1, "name": "selftest-场景A", "code": f"ST_A_{_passed}",
"scene_type": "0", "status": "0", "description": "selftest"})
check("CRUD-01 创建-正常", r.get("success") is True and r.get("id"), json.dumps(r, ensure_ascii=False))
sid = r.get("id")
# 01 反查 created_at 非空sor.C 不丢记录)
d = api("/get_scene.dspy", {"id": sid}).get("data", {})
check("CRUD-01 反查 created_at 非空", bool(d.get("created_at")), f"created_at={d.get('created_at')}")
# 02 创建-world 不存在
r = api("/create_scene.dspy", data={"world_id": 999999, "name": "x", "code": "ST_B"})
check("CRUD-02 world 不存在", r.get("code") == "WORLD_NOT_FOUND", json.dumps(r, ensure_ascii=False))
# 03 创建-code 重复(唯一索引 idx_scene_code
r = api("/create_scene.dspy", data={"world_id": 1, "name": "dup", "code": d.get("code") or "ST_A_0"})
check("CRUD-03 code 重复", r.get("code") == "DUPLICATE_CODE", json.dumps(r, ensure_ascii=False))
# 04 创建-必填缺失
r = api("/create_scene.dspy", data={"world_id": 1})
check("CRUD-04 必填缺失", r.get("code") in ("PARAM_REQUIRED", "FIELD_REQUIRED"), json.dumps(r, ensure_ascii=False))
# 05 创建-字段超长name 256>255 / code 65>64 / scene_type 17>16 / status 17>16
r = api("/create_scene.dspy", data={"world_id": 1, "name": "n" * 256, "code": "c" * 65,
"scene_type": "t" * 17, "status": "s" * 17})
check("CRUD-05 字段超长", r.get("code") == "FIELD_TOO_LONG", json.dumps(r, ensure_ascii=False))
# 06 查询详情-正常codes 关联)
check("CRUD-06 查询详情-正常", bool(d.get("id")) and "codes" in d, f"codes keys={list((d.get('codes') or {}).keys())}")
# 07 查询详情-不存在
r = api("/get_scene.dspy", {"id": "no_such_id"})
check("CRUD-07 查询-不存在", r.get("code") == "NOT_FOUND", json.dumps(r, ensure_ascii=False))
# 08 更新-正常updated_at 刷新、剔除 _text 后缀)
r = api("/scene_update.dspy", data={"id": sid, "name": "selftest-场景A-改", "name_text": "应剔除", "status": "1"})
check("CRUD-08 更新-正常", r.get("success") is True, json.dumps(r, ensure_ascii=False))
d2 = api("/get_scene.dspy", {"id": sid}).get("data", {})
check("CRUD-08 updated_at 刷新", d2.get("updated_at") >= d.get("updated_at"), f"{d2.get('updated_at')} >= {d.get('updated_at')}")
# 09 更新-world 不存在
r = api("/scene_update.dspy", data={"id": sid, "world_id": 999999})
check("CRUD-09 更新-world 不存在", r.get("code") == "WORLD_NOT_FOUND", json.dumps(r, ensure_ascii=False))
# 10 删除-正常(列表不再返回)
r = api("/scene_delete.dspy", data={"id": sid})
check("CRUD-10 删除-正常", r.get("success") is True, json.dumps(r, ensure_ascii=False))
r = api("/get_scene.dspy", {"id": sid})
check("CRUD-10 删除后查不到", r.get("code") == "NOT_FOUND", json.dumps(r, ensure_ascii=False))
def test_list():
"""SCENE-LIST-01~03"""
# 01 列表分页sqlPaging 返回 {list,total}
r = api("/list_scenes.dspy", {"page": 1, "page_size": 10})
check("LIST-01 分页结构", isinstance(r.get("list"), list) and isinstance(r.get("total"), int),
f"total={r.get('total')} len={len(r.get('list', []))}")
# 02 列表过滤
r = api("/list_scenes.dspy", {"scene_type": "0", "status": "0", "page": 1, "page_size": 10})
check("LIST-02 过滤生效", isinstance(r.get("list"), list), f"filter scene_type=0 len={len(r.get('list', []))}")
r = api("/list_scenes.dspy", {"sort": "created_at", "order": "desc", "page": 1, "page_size": 10})
check("LIST-02 sort/order", isinstance(r.get("list"), list), "sort=created_at order=desc")
# 03 字典下拉 [{value,text}]
for ep in ("get_search_world_id.dspy", "get_search_scene_type.dspy", "get_search_status.dspy"):
r = api("/" + ep)
ok = isinstance(r.get("data"), list) and all("value" in x and "text" in x for x in r.get("data", []))
check(f"LIST-03 下拉 {ep}", ok, json.dumps(r, ensure_ascii=False)[:120])
def test_import():
"""SCENE-IMP-01~02"""
rows_ok = [{"world_id": 1, "name": f"导入-{i}", "code": f"IMP_OK_{i}", "scene_type": "0", "status": "0"} for i in range(3)]
# 01 导入成功:先全量解析再逐条插入,全部成功
r = api("/scene_import.dspy", data={"world_id": 1, "file_name": "scenes.json",
"rows": rows_ok})
ok = r.get("success") is True and r.get("fail") == 0 and isinstance(r.get("success_count"), int)
check("IMP-01 导入成功", ok and r.get("success_count") == 3, json.dumps(r, ensure_ascii=False))
# 02 导入事务回滚:任一条重复 code → 整批回滚无脏数据
rows_bad = rows_ok + [{"world_id": 1, "name": "dup", "code": rows_ok[0]["code"], "scene_type": "0", "status": "0"}]
r = api("/scene_import.dspy", data={"world_id": 1, "file_name": "scenes_bad.json", "rows": rows_bad})
ok = r.get("success") is False and r.get("fail") >= 1
check("IMP-02 事务回滚-接口失败", ok, json.dumps(r, ensure_ascii=False))
rl = api("/list_scenes.dspy", {"code": "IMP_OK_0", "page": 1, "page_size": 10})
dup = [x for x in rl.get("list", []) if x.get("code") == "IMP_OK_0"]
check("IMP-02 整批回滚无脏数据", len(dup) == 0, f"IMP_OK_0 残留 {len(dup)}")
def main():
for a in sys.argv[1:]:
if a.startswith("--base="):
global BASE # noqa: PLW0603
BASE = a.split("=", 1)[1]
print(f"== scene selftest == base={BASE}")
smoke()
test_crud()
test_list()
test_import()
print(f"== 结果:{_passed} passed, {_failed} failed ==")
sys.exit(1 if _failed else 0)
if __name__ == "__main__":
main()

View File

@ -1,12 +1,12 @@
---
name: scene
description: 场景管理模块W-02——场景表 CRUD、列表查询、场景导入。提供 scene/scene_import 两张表、CRUD、分页列表接口、文件导入api/scene_import.dspy通过 load_scene() 挂载。枚举字段用 appcodes 字典cond parentid=)。
description: scene 模块技能文档——场景管理(场景表 CRUD + 场景导入)。提供 scene/scene_import 两张表、CRUD、列表查询接口、文件导入api/scene_import.dspy通过 load_scene() 挂载。枚举字段用 appcodes 字典cond parentid=)。
---
# scene 模块
## 概述
场景管理,含场景导入。依赖 worldworld_id 逻辑关联,不加物理外键、appbaseappcodes 字典、rbac权限
场景管理,含场景导入。依赖 worldworld_id 外键、appbaseappcodes 字典、rbac权限
## 数据模型
@ -14,9 +14,9 @@ description: 场景管理模块W-02——场景表 CRUD、列表查询、
| 字段 | 类型 | 说明 |
|------|------|------|
| id | str(32) | 主键 |
| world_id | str(32) | 所属世界→world.id,逻辑关联 |
| world_id | str(32) | 所属世界→world.id |
| name | str(255) | 场景名称 |
| code | str(64) | 场景编码(唯一idx_scene_code |
| code | str(64) | 场景编码(唯一) |
| description | text | 描述 |
| scene_type | str(16) | 场景类型appcodes scene_type默认 '0' |
| status | str(16) | 状态appcodes scene_status默认 '0' |
@ -25,49 +25,48 @@ description: 场景管理模块W-02——场景表 CRUD、列表查询、
| updated_at | timestamp | 更新时间 |
- 主键 `["id"]`;唯一索引 `idx_scene_code(code)`;索引 `idx_scene_world(world_id)`
- codesworld_id → world(id/name)、scene_type → appcodes_kv(parentid='scene_type')、status → appcodes_kv(parentid='scene_status')
- 字典world_id → world(id/name)、scene_type → appcodes_kv(parentid='scene_type')、status → appcodes_kv(parentid='scene_status')
### 表 `scene_import`models/scene_import.json,只读
### 表 `scene_import`models/scene_import.json
| 字段 | 类型 | 说明 |
|------|------|------|
| id | str(32) | 主键 |
| world_id | str(32) | 目标世界 |
| file_name | str(255) | 导入文件名 |
| total / success / fail | int | 总数 / 成功 / 失败(默认 0 |
| status | str(16) | 导入状态appcodes import_status0 进行中/1 成功/2 失败) |
| total | int | 总条数(默认 0 |
| success | int | 成功条数(默认 0 |
| fail | int | 失败条数(默认 0 |
| status | str(16) | 导入状态appcodes import_status默认 '0' |
| created_at | timestamp | 导入时间 |
- 主键 `["id"]`;索引 `idx_scene_import_world(world_id)`
- 字典world_id → world、status → appcodes_kv(parentid='import_status')
## 关键接口load_scene() 挂载到 ServerEnv
- `list_scenes(params)``{list, total}`sqlPaging 分页,支持 world_id/name/code/scene_type/status 过滤 + sort/order
- `get_scene({id})``{data}` 或错误
- `create_scene(ns)` → 校验 + world 存在校验 + 自动 id/created_at/updated_at返回 `{success, id}`
- `update_scene(ns)` → 写 updated_at、剔除 `_text` 后缀、world 校验
## 关键接口
### ServerEnv 注册函数load_scene() 挂载)
- `list_scenes(params)``[{value:id, text:name}]`
- `get_scene({id})` → 单条
- `create_scene(ns)` → 自动生成 id/code、写 created_at
- `update_scene(ns)` → 写 updated_at、剔除 `_text` 后缀
- `delete_scene({id})`
- `import_scenes({world_id, file_name, rows})``{success, total, success_count, fail}`
**事务批量**:先全量解析+校验再逐条插入任一条失败整批回滚无脏数据success_count 表示成功条数,避免与布尔 success 冲突)
- `parse_scene_file(content, filename)``{rows: [...]}`JSON 数组 / JSON Lines / CSV
- `get_world_options()` / `get_scene_type_options()` / `get_scene_status_options()` / `get_import_status_options()``[{value, text}]`
- `parse_scene_file(content, filename)``[{...}]`JSON 数组 / JSON Lines / CSV
- `get_world_options()` / `get_scene_type_options()` / `get_scene_status_options()``[{value, text}]`
### wwwroot/api/*.dspyREST 统一前缀 /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}`codePARAM_REQUIRED / FIELD_REQUIRED / FIELD_TOO_LONG /
WORLD_NOT_FOUND / DUPLICATE_CODE / PARSE_ERROR / DB_ERROR / NOT_FOUND。非法输入 100% 拦截不落库。
### wwwroot/api/*.dspy
- `list_scenes.dspy``create_scene.dspy``scene_update.dspy``scene_delete.dspy`
- `get_search_world_id.dspy` / `get_search_scene_type.dspy` / `get_search_status.dspy`:字典下拉
- `scene_import.dspy`场景导入world_id + file → parse_scene_file → import_scenes
## 陷阱
- 库名禁止硬编码:.py 用 `ServerEnv().get_module_dbname("scene")`.dspy 用 `get_module_dbname("scene")`全局world 表在 world 模块库(`get_module_dbname("world")`
- dspy 无 importjson/get_sor_context/debug/params_kw 均预加载全局);函数经 load_scene() 注册后为全局,直接调用
- `create_scene` / `scene_import` 必须设置 `created_at = curDateString()`,否则 sor.C 静默丢记录
- `scene_import` 记录 total/success/fail 必须为 int
- `import_scenes` 返回用 `success_count`(避免与布尔 `success` 键冲突)
- 库名禁止硬编码:.py 用 `ServerEnv().get_module_dbname("scene")`.dspy 用 `get_module_dbname("scene")`(全局)
- world 下拉用 `ServerEnv().get_module_dbname("world")`world 表在 world 模块库)
- dspy 无 importjson/get_sor_context/debug/params_kw 均为预加载全局)
- `create_scene` 必须设置 `created_at = curDateString()`,否则 sor.C 静默丢记录
- 导入时 `scene_import` 记录必须设置 `created_at`,字段 total/success/fail 为 int
- `import_scenes` 返回用 `success_count` 表示成功条数(避免与布尔 `success` 键冲突)
- CRUD json `new_data_url` 指向自定义 `api/create_scene.dspy`scene_import 为只读列表,无 editable
- 三处同步注册scene/__init__.py 导出 ← scene/init.py 实现 ← load_scene() 注册
- 返回 `{list,total}` 分页用 `sor.sqlPaging`,不要硬编码 LIMIT/OFFSET
## 依赖
- worldworld_id 逻辑关联 + 世界下拉、appbaseappcodes 字典、rbac权限
- worldworld_id 外键 + 世界下拉、appbaseappcodes 字典、rbac权限

View File

@ -1,6 +1 @@
# scene 新增REST POST /api/create_scene.dspy
debug(f'scene_create.dspy: START params_kw={dict(params_kw)}')
result = await create_scene(dict(params_kw or {}))
if result.get('success'):
return {'status': 'ok', 'data': {'id': result.get('id')}}
return {'status': 'error', 'data': result}
return await create_scene(params_kw)

View File

@ -1,6 +0,0 @@
# scene 单条查询REST GET /api/get_scene.dspy?id=xxx
debug(f'scene_get.dspy: START params_kw={dict(params_kw)}')
result = await get_scene(dict(params_kw or {}))
if 'data' in result:
return {'status': 'ok', 'data': result['data']}
return {'status': 'error', 'data': result}

View File

@ -1,4 +1 @@
# 场景类型下拉REST GET /api/get_search_scene_type.dspy
debug(f'scene_get_scene_type_options.dspy: START')
opts = await get_scene_type_options()
return {'status': 'ok', 'data': opts}
return await get_scene_type_options(params_kw)

View File

@ -1,9 +1 @@
# 场景状态下拉REST GET /api/get_search_status.dspy
# scene 状态 + 导入状态共用 appcodes 下拉;按调用方 parentid 区分
debug(f'scene_get_status_options.dspy: START params_kw={dict(params_kw)}')
parentid = (params_kw or {}).get('parentid', 'scene_status')
if parentid == 'import_status':
opts = await get_import_status_options()
else:
opts = await get_scene_status_options()
return {'status': 'ok', 'data': opts}
return await get_scene_status_options(params_kw)

View File

@ -1,5 +1 @@
# 世界下拉REST GET /api/get_search_world_id.dspy
# 返回 [{value, text}]world 表在 world 模块库)
debug(f'scene_get_world_options.dspy: START')
opts = await get_world_options()
return {'status': 'ok', 'data': opts}
return await get_world_options(params_kw)

View File

@ -1,8 +1 @@
# scene 列表查询REST GET/POST /api/list_scenes.dspy
# 返回 {status:'ok', data:{list, total}};错误 {code,message,field,detail}
debug(f'scene_list.dspy: START params_kw={dict(params_kw)}')
params = dict(params_kw or {})
result = await list_scenes(params)
if 'list' in result:
return {'status': 'ok', 'data': {'list': result['list'], 'total': result['total']}}
return {'status': 'error', 'data': result}
return await list_scenes(params_kw)

View File

@ -1,6 +1 @@
# scene 删除REST POST /api/scene_delete.dspy
debug(f'scene_delete.dspy: START params_kw={dict(params_kw)}')
result = await delete_scene(dict(params_kw or {}))
if result.get('success'):
return {'status': 'ok', 'data': {'id': result.get('id')}}
return {'status': 'error', 'data': result}
return await delete_scene(params_kw)

View File

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

View File

@ -1,6 +1 @@
# scene 更新REST POST /api/scene_update.dspy
debug(f'scene_update.dspy: START params_kw={dict(params_kw)}')
result = await update_scene(dict(params_kw or {}))
if result.get('success'):
return {'status': 'ok', 'data': {'id': result.get('id')}}
return {'status': 'error', 'data': result}
return await update_scene(params_kw)

View File

@ -1,17 +1,18 @@
{
"widgettype": "VBox",
"options": {"width": "100%", "height": "100%", "padding": "20px"},
"subwidgets": [
{"widgettype": "Text", "options": {"label": "场景导入", "fontSize": "20px"}},
{"widgettype": "Form", "options": {
"width": "60%",
"submit_url": "{{entire_url('/scene/api/scene_import.dspy')}}",
"fields": [
{"name": "world_id", "label": "目标世界", "uitype": "code", "required": true,
"dataurl": "{{entire_url('/scene/api/get_search_world_id.dspy')}}", "valueField": "value", "textField": "text"},
{"name": "file", "label": "导入文件", "uitype": "file", "required": true, "accept": ".json,.csv,.ndjson,.jsonl"}
]
}},
{"widgettype": "Text", "id": "app.scene_import_result", "options": {"label": "", "marginTop": "16px"}}
]
"widgettype": "VBox",
"options": {"width": "100%", "height": "100%", "padding": "20px"},
"subwidgets": [
{"widgettype": "Text", "options": {"label": "场景导入", "fontSize": "20px"}},
{"widgettype": "Text", "options": {"label": "支持 JSON 数组 / JSON Lines / CSV表头name,code,description,scene_type,status,config_json", "fontSize": "12px", "color": "#888888"}},
{"widgettype": "Form", "options": {
"submit_url": "{{entire_url('/scene/api/scene_import.dspy')}}",
"method": "POST"
}, "subwidgets": [
{"widgettype": "Select", "options": {"name": "world_id", "label": "目标世界", "required": "true",
"dataurl": "{{entire_url('/scene/api/get_search_world_id.dspy')}}", "valueField": "value", "textField": "text"}},
{"widgettype": "UiFile", "options": {"name": "file", "label": "导入文件", "required": "true"}},
{"widgettype": "Button", "options": {"label": "开始导入"}}
]},
{"widgettype": "VBox", "id": "app.scene_import_content", "options": {"width": "100%", "flex": "1", "marginTop": "20px"}}
]
}

View File

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