From d049837ec78bff90cb928ba3fd6efecd0319deab Mon Sep 17 00:00:00 2001 From: "agent.develop" Date: Sat, 29 Aug 2026 15:45:36 +0800 Subject: [PATCH] =?UTF-8?q?feat(entity):=20W-03=20=E5=AE=9E=E4=BD=93?= =?UTF-8?q?=E7=AE=A1=E7=90=86=E6=A8=A1=E5=9D=97=E2=80=94=E2=80=94entity/en?= =?UTF-8?q?tity=5Fimport/entity=5Fimport=5Flog=20=E8=A1=A8=20CRUD=E3=80=81?= =?UTF-8?q?=E5=88=97=E8=A1=A8=E6=9F=A5=E8=AF=A2=E3=80=81=E6=96=87=E4=BB=B6?= =?UTF-8?q?=E5=AF=BC=E5=85=A5=EF=BC=88=E4=B8=A4=E9=98=B6=E6=AE=B5=E6=A0=A1?= =?UTF-8?q?=E9=AA=8C+=E5=A4=B1=E8=B4=A5=E5=9B=9E=E6=BB=9A=EF=BC=89?= =?UTF-8?q?=E3=80=81entity=5Ftype=20=E5=AD=97=E5=85=B8=E5=B9=82=E7=AD=89?= =?UTF-8?q?=E8=90=BD=E5=BA=93=E3=80=81i18n=20=E6=8F=90=E5=8F=96=E3=80=81RB?= =?UTF-8?q?AC=20=E6=B3=A8=E5=86=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 21 +- README.md | 56 +- __init__.py | 11 +- build.sh | 29 + docs/i18n.md | 78 +- docs/work-log-2026-07-19.md | 34 + entity/__init__.py | 29 +- entity/init.py | 914 +++++++++++----------- init/data.json | 24 +- json/entity.json | 72 +- json/entity_import.json | 43 +- models/entity.json | 71 +- models/entity_import.json | 58 +- models/entity_import_log.json | 39 +- scripts/load_path.py | 99 +-- skill/SKILL.md | 98 +-- wwwroot/api/entity_create.dspy | 13 +- wwwroot/api/entity_delete.dspy | 13 +- wwwroot/api/entity_get.dspy | 13 +- wwwroot/api/entity_import.dspy | 40 +- wwwroot/api/entity_import_guard.dspy | 5 +- wwwroot/api/entity_import_list.dspy | 35 + wwwroot/api/entity_import_reject.dspy | 4 + wwwroot/api/entity_list.dspy | 17 +- wwwroot/api/entity_update.dspy | 13 +- wwwroot/api/get_search_entity_type.dspy | 12 +- wwwroot/api/get_search_import_status.dspy | 12 +- wwwroot/api/get_search_scene_id.dspy | 12 +- wwwroot/api/get_search_status.dspy | 12 +- wwwroot/api/get_search_world_id.dspy | 12 +- wwwroot/i18n/en-US.json | 145 ++-- wwwroot/i18n/zh-CN.json | 145 ++-- wwwroot/import_page.ui | 84 +- wwwroot/index.ui | 62 +- 34 files changed, 1160 insertions(+), 1165 deletions(-) create mode 100644 build.sh create mode 100644 docs/work-log-2026-07-19.md create mode 100644 wwwroot/api/entity_import_list.dspy create mode 100644 wwwroot/api/entity_import_reject.dspy diff --git a/.gitignore b/.gitignore index 8bfc7cb..23e6cb8 100644 --- a/.gitignore +++ b/.gitignore @@ -1,21 +1,16 @@ -# Byte-compiled / optimized / DLL files -__pycache__/ +# __pycache__/ *.py[cod] *$py.class - -# Distribution / packaging build/ dist/ *.egg-info/ .eggs/ - -# CRUD-generated wwwroot subdirs (build artifacts, regenerated by build.sh) -wwwroot/entity_list/ -wwwroot/entity_import_list/ - -# Editors / OS *.swp *.swo -.DS_Store -.idea/ -.vscode/ + +# CRUD 生成目录(xls2ui 构建产物,由 json/*.json + models/*.json 重新生成,不入库) +wwwroot/entity/ +wwwroot/entity_import/ + +# DDL 生成物 +models/mysql.ddl.sql diff --git a/README.md b/README.md index 3f47bf1..8bff555 100644 --- a/README.md +++ b/README.md @@ -1,44 +1,44 @@ -# entity 模块(W-03 实体管理) +# entity 实体管理模块(W-03) -实体管理模块:实体表(entity)增删改查、分页列表查询、实体文件导入(entity_import 记录表)。依赖 world、scene(逻辑关联,不加物理外键)。 +实体管理模块:管理元景项目的实体(entity)数据,提供实体表 CRUD、分页列表查询与实体文件导入。 ## 功能 -- **实体 CRUD**:create_entity / update_entity / delete_entity / get_entity / list_entities -- **列表查询**:sqlPaging 分页 `{list, total}`,支持 world_id / scene_id / entity_type / status / name / code 过滤 + sort / order -- **文件导入**:api/entity_import.dspy 接收文件(JSON 数组 / JSON Lines / CSV 表头),事务批量 + 失败整批回滚无脏数据 -- **字典下拉**:entity_type / entity_status / import_status(appcodes)幂等落库;world / scene 下拉 -- **REST**:统一前缀 `/api/*`(宿主挂载后 `/entity/api/*.dspy`),错误结构 `{code,message,field,detail}`,分页 `{list,total}`,非法输入 100% 拦截不落库 +- **实体表 CRUD**:`entity` 表新增/编辑/删除/详情/分页列表(`{list, total}`)。 +- **列表查询**:按关键字、实体类型、状态、场景过滤,分页返回 `{list, total, page, rows}`。 +- **实体导入**:`api/entity_import.dspy` 文件导入(CSV 首行表头 / JSON 数组),两阶段处理: + 1. 全量校验(编码必填/唯一、类型/状态字典白名单、场景/世界存在性)——任一行非法整批拦截,不落库; + 2. 批量插入(同批以 `import_id` 标记),插入异常按 `import_id` 补偿回滚,保证无脏数据。 +- **导入记录**:`entity_import`(批次)+ `entity_import_log`(逐行明细)系统生成,只读,禁止手工增删改。 +- **编码字典**:`entity_type`(实体类型)、`common_status`(通用状态)、`import_status`(导入状态)经 `init/data.json` 幂等落库到 appcodes/appcodes_kv。 ## 数据表 | 表 | 说明 | |----|------| -| entity | 实体表:id / world_id / scene_id / name / code(唯一) / entity_type / status / attributes_json / created_at / updated_at | -| entity_import | 导入记录表(只读):id / world_id / scene_id / file_name / total / success / fail / status / created_at | +| `entity` | 实体表(entity_code 唯一,entity_type 字典,scene_id/world_id 逻辑关联 codes 段,不加外键) | +| `entity_import` | 实体导入批次记录(import_no 唯一,status 字典) | +| `entity_import_log` | 实体导入逐行明细日志(import_id 关联批次) | -- `entity.scene_id → scene.id`、`entity.world_id → world.id` 均为**逻辑关联**(codes 段配置,不加物理外键) -- 编码字典 entity_type / entity_status / import_status 经 init/data.json 幂等落库到 appbase +## 接口约定 + +- REST 统一前缀 `/api/*`:`/entity/api/entity_list.dspy`、`entity_create.dspy`、`entity_update.dspy`、`entity_delete.dspy`、`entity_get.dspy`、`entity_import.dspy`、`entity_import_guard.dspy`、`entity_import_list.dspy`。 +- 错误结构统一:`{code, message, field, detail}`;分页结构统一:`{list, total}`。 +- 非法输入 100% 拦截,不落库。 ## 集成 -1. `load_entity()` 挂载:在宿主应用 `init()` 中调用(函数注册到 ServerEnv,含复数别名) -2. wwwroot 符号链接到宿主 wwwroot/entity/ -3. 运行 `scripts/load_path.py` 注册 RBAC(或同步中央 load_path.py) -4. 构建:`json2ddl mysql . > mysql.ddl.sql`(models/)、`xls2ui -m ../models -o ../wwwroot entity *.json`(json/) +通过 `load_entity()` 挂载到宿主应用(`app/{应用名}.py` 的 init() 中调用并注册 `get_module_dbname('entity')`),库名禁止硬编码。前端页面:`wwwroot/index.ui`(入口导航)、`wwwroot/import_page.ui`(导入页)。 -## 关键接口(经 load_entity() 注册) +## 目录结构 -- `list_entities(params)` → `{list, total}` -- `create_entity(ns)` / `update_entity(ns)` / `delete_entity(ns)` / `get_entity(ns)` -- `import_entities({world_id, scene_id, file_name, rows})` → `{success, total, success_count, fail}` -- `parse_entity_file(content, filename)` → `{rows: [...]}` -- `get_world_options()` / `get_scene_options()` / `get_entity_type_options()` / `get_entity_status_options()` / `get_import_status_options()` → `[{value, text}]` - -## 安装 - -```bash -cd modules/entity && pip install . ``` - -详见 skill/SKILL.md。 +modules/entity/ +├── entity/ # Python 包(entity/__init__.py + entity/init.py) +├── wwwroot/ # index.ui / import_page.ui / api/*.dspy / i18n/ +├── models/ # entity / entity_import / entity_import_log 表定义 +├── json/ # entity / entity_import CRUD 定义 +├── init/data.json # 编码字典种子(appcodes) +├── scripts/load_path.py # RBAC 路径注册 +└── skill/SKILL.md # 模块技能文档 +``` diff --git a/__init__.py b/__init__.py index 4e3446f..3d9adbc 100644 --- a/__init__.py +++ b/__init__.py @@ -1,6 +1,5 @@ -"""entity 模块:实体管理,含实体导入。""" - -from entity.init import load_entity # noqa: F401 - -__all__ = ['load_entity'] -__version__ = '1.0.0' +# -*- coding: utf-8 -*- +""" +modules/entity/ 模块仓库根目录标记文件(遗留,不影响打包)。 +真实 Python 包目录为双层结构:modules/entity/entity/(见 entity/__init__.py)。 +""" diff --git a/build.sh b/build.sh new file mode 100644 index 0000000..0189858 --- /dev/null +++ b/build.sh @@ -0,0 +1,29 @@ +#!/usr/bin/env bash +# entity 模块构建脚本:① 生成 DDL;② xls2ui 生成 CRUD 前端;③ 链接 wwwroot 到宿主应用 +# 用法:build.sh <宿主应用 wwwroot 路径(可选)> +set -e +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +cd "$SCRIPT_DIR" + +# ① 表定义 -> DDL +if command -v json2ddl >/dev/null 2>&1; then + json2ddl mysql . > models/mysql.ddl.sql + echo "[entity] DDL generated: models/mysql.ddl.sql" +else + echo "[entity] json2ddl not found, skip DDL generation" +fi + +# ② CRUD 定义 -> wwwroot(生成 wwwroot/entity/ wwwroot/entity_import/ 等 CRUD 目录) +if command -v xls2ui >/dev/null 2>&1; then + xls2ui -m ../models -o ../wwwroot entity json/*.json + echo "[entity] CRUD UI generated" +else + echo "[entity] xls2ui not found, skip CRUD UI generation" +fi + +# ③ 链接 wwwroot 到宿主应用 +APP_WWWROOT="${1:-}" +if [ -n "$APP_WWWROOT" ] && [ -d "$APP_WWWROOT" ]; then + ln -sfn "$SCRIPT_DIR/wwwroot" "$APP_WWWROOT/entity" + echo "[entity] wwwroot linked -> $APP_WWWROOT/entity" +fi diff --git a/docs/i18n.md b/docs/i18n.md index b0d237b..ff2dc3d 100644 --- a/docs/i18n.md +++ b/docs/i18n.md @@ -1,68 +1,22 @@ -# entity 模块 i18n 提取说明(W-03 实体管理) +# entity 模块 i18n 说明 -本文件是 entity 模块**新文案 i18n 提取清单与引用约定**,覆盖两个新增页面 -(`wwwroot/index.ui`、`wwwroot/import_page.ui`)及后端错误码/按钮等全部新文案。 +## 提取范围 +本模块全部新增文案(页面标题、按钮、提示、错误码、字段名、过滤标签、消息)均已提取到 `wwwroot/i18n/`: -## 1. i18n 文件清单 - -| 文件 | 语言 | 内容 | +| 文件 | 语言 | 用途 | |------|------|------| -| `wwwroot/i18n/zh-CN.json` | 简体中文 | 全部 UI 文案 + 错误码文案 + 字典文案 | -| `wwwroot/i18n/en-US.json` | 英文 | 与 zh-CN 键一一对应 | +| `wwwroot/i18n/zh-CN.json` | 简体中文 | 默认语言 | +| `wwwroot/i18n/en-US.json` | 英文 | 英文界面 | -i18n 文件与页面同置于 `wwwroot/` 下,宿主应用加载时按当前语言读取对应 json, -键缺失时回退到 `zh-CN.json`,仍缺失则显示键名本身(不阻塞页面)。 +## key 规范 +- 统一前缀 `entity.`(模块名),避免跨模块冲突。 +- 分组:`entity.field.*`(字段标题)、`entity.filter.*`(搜索标签)、`entity.code.*`(错误码 message,与后端 `{code, message, field, detail}` 结构对应)、`entity.msg.*`(操作消息)、`entity.import.*`(导入页文案)、`entity.list.*`(列表页文案)。 +- 前端通过 bricks 的 `i18n('entity.xxx')` 调用(`i18n` 为 ahserver 注入全局)。 -## 2. 键命名规则(扁平点分,前缀 entity.) +## 与后端错误码的对应 +后端错误结构统一为 `{code, message, field, detail}`,其中 `code` 取值如 `ENTITY_VALIDATE_FAIL`、`IMPORT_VALIDATE_FAIL` 等。前端展示文案使用 `entity.code.` 作为 key 查找 i18n;`message` 字段为后端兜底文案(中文)。 -| 前缀 | 用途 | 示例 | -|------|------|------| -| `entity.page.*` | 页面标题/副标题 | `entity.page.index.title` | -| `entity.btn.*` | 按钮文案 | `entity.btn.add` | -| `entity.col.*` | 表格列头 | `entity.col.name` | -| `entity.filter.*` | 筛选区标签/占位 | `entity.filter.world_id` | -| `entity.form.*` | 新增/编辑表单标签 | `entity.form.code` | -| `entity.import.*` | 导入页专用文案 | `entity.import.col.total` | -| `entity.msg.*` | 操作提示/消息 | `entity.msg.confirm_delete` | -| `entity.dict.*` | 字典下拉(entity_type / entity_status / import_status) | `entity.dict.entity_type.npc` | -| `entity.err.*` | 后端错误码→文案映射 | `entity.err.DUPLICATE_CODE` | -| `entity.field.*` | 错误码文案中的字段标签占位 | `entity.field.name` | - -## 3. 页面引用方式 - -- `index.ui`(实体列表页):标题/按钮/列头/筛选/表单/提示全部使用 - `entity.page.index.title`、`entity.btn.add`、`entity.col.name` 等键,经宿主 - i18n 函数取文案,禁止硬编码中文。 -- `import_page.ui`(实体导入页):使用 `entity.page.import.*`、`entity.import.*`、 - `entity.btn.*` 键。 -- 字典下拉(世界/场景/实体类型/状态/导入状态)取值经 `get_search_*.dspy` 返回 - `[{value,text}]`,其中 `text` 优先取 i18n 文案(`entity.dict.*`),服务端字典 - 值作为兜底。 - -## 4. 错误码 → 文案映射约定 - -接口统一返回错误结构 `{code, message, field, detail}`。前端展示优先级: -1. 取 `entity.err.{code}` 对应 i18n 文案(支持 `{label}`/`{max}` 占位符替换, - 占位值来自 `field` 对应的 `entity.field.*` 与 `detail`); -2. 无映射时回退接口返回的 `message`。 - -已提取的错误码(10 个): - -| code | 中文文案 | 英文文案 | -|------|----------|----------| -| PARAM_REQUIRED | 缺少必要参数 | Missing required parameter | -| FIELD_REQUIRED | {label}不能为空 | {label} is required | -| FIELD_TOO_LONG | {label}长度不能超过{max} | {label} length cannot exceed {max} | -| WORLD_NOT_FOUND | 所属世界不存在 | World not found | -| SCENE_NOT_FOUND | 所属场景不存在 | Scene not found | -| DUPLICATE_CODE | 实体编码已存在 | Entity code already exists | -| PARSE_ERROR | 文件解析失败 | File parse error | -| INVALID_JSON | 属性JSON格式错误 | Invalid attributes JSON | -| DB_ERROR | 数据库操作失败 | Database operation failed | -| NOT_FOUND | 记录不存在 | Record not found | - -## 5. 新增文案流程(维护约定) - -新增任何页面文案/错误码/按钮时:先在 `zh-CN.json` 与 `en-US.json` 同步增加同键 -条目,再在页面中引用该键。两文件键集合必须保持一致(提交前用脚本比对 -`jq -S 'keys'` 两文件差异,差异为 0 才允许提交)。 \ No newline at end of file +## 新增文案流程 +1. 在 `wwwroot/i18n/zh-CN.json` 与 `en-US.json` 同时新增同 key。 +2. 同步更新 `docs/i18n.md` 本说明(如 key 分组有变化)。 +3. 部署时 i18n 文件随 wwwroot 一起链接到宿主应用 wwwroot。 diff --git a/docs/work-log-2026-07-19.md b/docs/work-log-2026-07-19.md new file mode 100644 index 0000000..4c7f1ce --- /dev/null +++ b/docs/work-log-2026-07-19.md @@ -0,0 +1,34 @@ +# entity 模块工作日志(W-03 实体管理) + +- 日期:2026-07-19(元景项目-初始迭代) +- 范围:W-03 实体管理功能点——entity.entity / entity_import 表 CRUD、列表查询、实体文件导入;scene_id→scene.id 逻辑关联(codes 段);entity_type 字典幂等落库;导入事务批量+失败回滚;REST 统一前缀 /api/*;错误结构 {code,message,field,detail};分页 {list,total};非法输入 100% 拦截。 + +## 交付内容 + +- `modules/entity/entity/init.py` + `entity/__init__.py`:后端全部 async 函数(create/update/delete/get/list/import/guard/reject)与 `load_entity()` 注册。 +- `modules/entity/models/`:entity / entity_import / entity_import_log 三张表定义(四段式 summary/fields/indexes/codes)。 +- `modules/entity/json/`:entity / entity_import 两个 CRUD 定义。 +- `modules/entity/wwwroot/`:index.ui(入口导航)、import_page.ui(导入页)、api/*.dspy(11 个接口)、i18n/zh-CN.json + en-US.json。 +- `modules/entity/init/data.json`:entity_type / common_status / import_status 三组字典(Format B appcodes 幂等落库)。 +- `modules/entity/scripts/load_path.py`:RBAC 显式路径注册(无通配符)。 +- `modules/entity/skill/SKILL.md`:模块技能文档。 +- `modules/entity/docs/i18n.md`:i18n 提取说明。 + +## 关键决策 + +1. **包目录双层结构**:`modules/entity/entity/`(Python 包),根 `__init__.py` 仅为仓库标记。符合「包目录=模块名」规范。 +2. **导入两阶段**:先全量校验(只读,不写任何数据)→ 再批量插入(同批 import_id 标记);插入异常按 import_id 补偿 DELETE 回滚,保证失败无脏数据。校验失败只写失败批次 + 逐行日志。 +3. **entity_import 只读**:批次/明细表系统生成,new/update/delete 指向 `entity_import_reject.dspy` 统一拒绝。 +4. **逻辑关联不加外键**:scene_id/world_id 在 models/entity.json codes 段声明(table=scene/world),不建物理外键。 +5. **非法输入 100% 拦截**:编码必填/唯一/长度、类型字典白名单、状态白名单、场景/世界存在性均在校验层拦截,不落库。 +6. **i18n**:全部新文案提取到 wwwroot/i18n/{zh-CN,en-US}.json,key 前缀 `entity.`,错误码 `entity.code.*` 对应后端 {code,...}。 + +## 验证情况 + +- 环境受限(本地无 Sage/数据库服务),无法起服务做端到端 curl。已做静态验证(见交付说明中 py_compile 与禁项审计输出)。 +- 部署后需在宿主应用:`load_entity()` 挂载、`get_module_dbname('entity')` 注册、执行 scripts/load_path.py 注册 RBAC、build.sh 链接 wwwroot 并生成 CRUD 页面、init/data.json 落库字典。 + +## 当前状态 + +- 分支/提交:见 git 本地提交记录(交付说明)。 +- 遗留:运行期集成验证由 deploy_test 在测试环境执行。 diff --git a/entity/__init__.py b/entity/__init__.py index d0e62e9..b583e09 100644 --- a/entity/__init__.py +++ b/entity/__init__.py @@ -1,26 +1,23 @@ # -*- coding: utf-8 -*- -"""entity 模块包导出——所有 async 函数必须在 __init__.py 导出,否则 dspy 调用报 NameError。""" +"""entity 模块包(W-03 实体管理)——所有 init.py 中 async 函数必须在此导出,否则 dspy 调用 NameError""" from .init import ( - EntityError, - load_entity, - list_entities, - get_entity, create_entity, update_entity, delete_entity, + get_entity, + list_entities, import_entities, - parse_entity_file, - get_world_options, - get_scene_options, - get_entity_type_options, - get_entity_status_options, - get_import_status_options, + entity_import_guard, + reject_import_write, ) __all__ = [ - 'EntityError', 'load_entity', - 'list_entities', 'get_entity', 'create_entity', 'update_entity', - 'delete_entity', 'import_entities', 'parse_entity_file', - 'get_world_options', 'get_scene_options', 'get_entity_type_options', - 'get_entity_status_options', 'get_import_status_options', + 'create_entity', + 'update_entity', + 'delete_entity', + 'get_entity', + 'list_entities', + 'import_entities', + 'entity_import_guard', + 'reject_import_write', ] diff --git a/entity/init.py b/entity/init.py index 50630c8..5b94e52 100644 --- a/entity/init.py +++ b/entity/init.py @@ -1,505 +1,485 @@ # -*- coding: utf-8 -*- -"""entity 模块后端逻辑——实体 CRUD / 列表查询 / 文件导入(W-03 实体管理)。 - -库名禁止硬编码:统一经 ServerEnv().get_module_dbname('entity') 获取; -world/scene/appbase 表分别经 get_module_dbname('world'/'scene'/'appbase') 获取。 +""" +entity 模块后端实现(W-03 实体管理) +- entity 表 CRUD / 列表查询(分页 {list, total}) +- entity_import 导入批次记录(系统生成,只读,禁止手工增删改) +- api/entity_import.dspy 文件导入:全量校验 -> 批量插入 -> 失败按 import_id 补偿回滚,保证无脏数据 +- REST 错误结构统一 {code, message, field, detail};非法输入 100% 拦截不落库 +- 取库名统一 get_module_dbname('entity')(宿主应用解析),禁止硬编码 DBNAME """ import json +from ahserver.serverenv import ServerEnv +from appPublic.uniqueID import getID +from appPublic.timeUtils import curDateString, timestampstr +from sqlor.dbpools import get_sor_context -from ahserver import ServerEnv -from appPublic.dbPools import DBPools -from appPublic.futils import getID -from appPublic.timeutils import curDateString - -__all__ = [ - 'EntityError', 'load_entity', - 'list_entities', 'get_entity', 'create_entity', 'update_entity', - 'delete_entity', 'import_entities', 'parse_entity_file', - 'get_world_options', 'get_scene_options', 'get_entity_type_options', - 'get_entity_status_options', 'get_import_status_options', -] - -_FIELDS = ('id', 'world_id', 'scene_id', 'name', 'code', 'entity_type', - 'status', 'attributes_json', 'created_at', 'updated_at') +# 与 init/data.json 中 entity_type 字典保持一致的合法取值(字典单一事实源,改动需两处同步) +ENTITY_TYPES = {'character', 'prop', 'vehicle', 'building', 'faction', 'other'} +ALLOWED_STATUS = {'0', '1'} +# entity 表可编辑字段(导入/校验使用) +ENTITY_FIELDS = { + 'entity_code', 'entity_name', 'entity_type', 'scene_id', 'world_id', + 'description', 'status', 'sort_no', +} -class EntityError(Exception): - """业务校验/处理异常,统一错误结构 {code,message,field,detail}。""" - - def __init__(self, code, message, field='', detail=''): - super().__init__(message) - self.code = code - self.message = message - self.field = field - self.detail = detail - - def to_dict(self): - return { - 'code': self.code, - 'message': self.message, - 'field': self.field, - 'detail': self.detail, - } +def _err(code, message, field='', detail=''): + """统一 REST 错误结构""" + return {'code': code, 'message': message, 'field': field, 'detail': detail} -# ---------------------------------------------------------------- 工具 - -def _module_dbname(m): - try: - return ServerEnv().get_module_dbname(m) - except Exception: - return None +def _ctx(): + """模块级数据库上下文(库名由宿主应用 get_module_dbname('entity') 解析)""" + return get_sor_context(ServerEnv(), 'entity') -def _dbname(): - return _module_dbname('entity') - - -def _world_dbname(): - return _module_dbname('world') - - -def _scene_dbname(): - return _module_dbname('scene') - - -def _appbase_dbname(): - return _module_dbname('appbase') - - -def _row_to_dict(r): - if isinstance(r, dict): - return {f: r.get(f) for f in _FIELDS} - return {f: getattr(r, f, None) for f in _FIELDS} - - -def _check_required(ns, field, label, maxlen=None): - val = (ns or {}).get(field) - if val is None or (isinstance(val, str) and not val.strip()): - raise EntityError('FIELD_REQUIRED', f'{label}不能为空', field) - val = str(val).strip() - if maxlen and len(val) > maxlen: - raise EntityError('FIELD_TOO_LONG', f'{label}长度不能超过{maxlen}', field, f'当前长度 {len(val)}') - return val - - -async def _exists(dbname, table, eid): - """逻辑关联存在性校验;关联模块未挂载/表不存在时放行(不阻断)。""" - if not eid or not dbname: - return True - try: - async with DBPools().sqlorContext(dbname) as sor: - recs = await sor.R(table, {'id': eid}) - return bool(recs) - except Exception: - return True - - -async def _world_exists(world_id): - return await _exists(_world_dbname(), 'world', world_id) - - -async def _scene_exists(scene_id): - return await _exists(_scene_dbname(), 'scene', scene_id) - - -def _check_attributes_json(attrs): - if not attrs: - return '' - if isinstance(attrs, (dict, list)): - return json.dumps(attrs, ensure_ascii=False) - text = str(attrs).strip() - if not text: - return '' - try: - json.loads(text) - except Exception as e: - raise EntityError('INVALID_JSON', '属性JSON格式错误', 'attributes_json', str(e)) - return text - - -def _norm_scalar(v, default='0', maxlen=16): - if v is None: - return default - s = str(v).strip() - if not s: - return default - return s[:maxlen] - - -# ---------------------------------------------------------------- 查询 - -async def list_entities(params): - """分页列表查询,返回 {list, total}。支持 world_id/scene_id/entity_type/status/name/code 过滤 + sort/order。""" - ns = dict(params or {}) - dbname = _dbname() - wheres, args = [], {} - for f in ('world_id', 'scene_id', 'entity_type', 'status'): - v = (ns.get(f) or '').strip() - if v: - wheres.append(f"`{f}` = ${{{f}}}$") - args[f] = v - name = (ns.get('name') or '').strip() - if name: - wheres.append('`name` LIKE ${name}$') - args['name'] = f'%{name}%' - code = (ns.get('code') or '').strip() - if code: - wheres.append('`code` LIKE ${code}$') - args['code'] = f'%{code}%' - where_sql = (' WHERE ' + ' AND '.join(wheres)) if wheres else '' - sort = (ns.get('sort') or 'created_at').strip() - if sort not in ('created_at', 'updated_at', 'name', 'code'): - sort = 'created_at' - order = (ns.get('order') or 'desc').strip().lower() - if order not in ('asc', 'desc'): - order = 'desc' - try: - page = int(ns.get('page') or 1) - rows_n = int(ns.get('rows') or 20) - except Exception: - page, rows_n = 1, 20 - if page < 1: - page = 1 - if rows_n < 1 or rows_n > 200: - rows_n = 20 - sql = f'SELECT * FROM entity{where_sql} ORDER BY `{sort}` {order}' - pns = {'page': page, 'rows': rows_n} - pns.update(args) - async with DBPools().sqlorContext(dbname) as sor: - rec = await sor.sqlPaging(sql, pns) - if isinstance(rec, dict): - total, rows = rec.get('total', 0), rec.get('rows', []) - else: - total, rows = rec.total, rec.rows - return {'list': [_row_to_dict(r) for r in rows], 'total': total} - - -async def get_entity(ns): - """按 id 查询单条,返回 {data}。""" - eid = (ns or {}).get('id') - if not eid: - raise EntityError('PARAM_REQUIRED', '缺少实体ID', 'id') - dbname = _dbname() - async with DBPools().sqlorContext(dbname) as sor: - recs = await sor.R('entity', {'id': eid}) - if not recs: - raise EntityError('NOT_FOUND', '实体不存在', 'id') - return {'data': _row_to_dict(recs[0])} - - -# ---------------------------------------------------------------- CRUD - -async def create_entity(ns): - """创建实体。非法输入 100% 拦截不落库;返回 {success, id}。""" - ns = dict(ns or {}) - name = _check_required(ns, 'name', '实体名称', 255) - code = _check_required(ns, 'code', '实体编码', 64) - world_id = (ns.get('world_id') or '').strip() - scene_id = (ns.get('scene_id') or '').strip() - entity_type = _norm_scalar(ns.get('entity_type'), '0', 16) - status = _norm_scalar(ns.get('status'), '0', 16) - attributes_json = _check_attributes_json(ns.get('attributes_json')) - if world_id and not await _world_exists(world_id): - raise EntityError('WORLD_NOT_FOUND', '所属世界不存在', 'world_id', world_id) - if scene_id and not await _scene_exists(scene_id): - raise EntityError('SCENE_NOT_FOUND', '所属场景不存在', 'scene_id', scene_id) - now = curDateString() - rec = { - 'id': getID(), - 'world_id': world_id, - 'scene_id': scene_id, - 'name': name, - 'code': code, - 'entity_type': entity_type, - 'status': status, - 'attributes_json': attributes_json, - 'created_at': now, - 'updated_at': now, - } - dbname = _dbname() - try: - async with DBPools().sqlorContext(dbname) as sor: - await sor.C('entity', rec) - except Exception as e: - msg = str(e) - if 'duplicate' in msg.lower(): - raise EntityError('DUPLICATE_CODE', '实体编码已存在', 'code', msg) - raise EntityError('DB_ERROR', '保存实体失败', '', msg) - return {'success': True, 'id': rec['id']} - - -async def update_entity(ns): - """更新实体。剔除 _text 后缀字段;返回 {success, id}。""" - ns = dict(ns or {}) - eid = ns.get('id') - if not eid: - raise EntityError('PARAM_REQUIRED', '缺少实体ID', 'id') - clean = {k: v for k, v in ns.items() if not str(k).endswith('_text')} - dbname = _dbname() - async with DBPools().sqlorContext(dbname) as sor: - recs = await sor.R('entity', {'id': eid}) - if not recs: - raise EntityError('NOT_FOUND', '实体不存在', 'id') - upd = {} - if 'name' in clean and clean['name'] is not None: - upd['name'] = _check_required(clean, 'name', '实体名称', 255) - if 'code' in clean and clean['code'] is not None: - upd['code'] = _check_required(clean, 'code', '实体编码', 64) - if 'world_id' in clean and clean['world_id'] is not None: - w = (clean['world_id'] or '').strip() - if w and not await _world_exists(w): - raise EntityError('WORLD_NOT_FOUND', '所属世界不存在', 'world_id', w) - upd['world_id'] = w - if 'scene_id' in clean and clean['scene_id'] is not None: - s = (clean['scene_id'] or '').strip() - if s and not await _scene_exists(s): - raise EntityError('SCENE_NOT_FOUND', '所属场景不存在', 'scene_id', s) - upd['scene_id'] = s - if 'entity_type' in clean and clean['entity_type'] is not None: - upd['entity_type'] = _norm_scalar(clean['entity_type'], '0', 16) - if 'status' in clean and clean['status'] is not None: - upd['status'] = _norm_scalar(clean['status'], '0', 16) - if 'attributes_json' in clean and clean['attributes_json'] is not None: - upd['attributes_json'] = _check_attributes_json(clean['attributes_json']) - if not upd: - raise EntityError('PARAM_REQUIRED', '没有可更新的字段', '') - upd['id'] = eid - upd['updated_at'] = curDateString() +async def _current_user(request): + """从请求运行上下文取当前用户/机构(ServerEnv() 是进程级单例,不能用于请求上下文)""" + rn = request._run_ns + user_id, org_id = '', '0' + if hasattr(rn, 'get_user'): try: - await sor.U('entity', upd) - except Exception as e: - msg = str(e) - if 'duplicate' in msg.lower(): - raise EntityError('DUPLICATE_CODE', '实体编码已存在', 'code', msg) - raise EntityError('DB_ERROR', '更新实体失败', '', msg) - return {'success': True, 'id': eid} + user_id = (await rn.get_user()) or '' + except Exception: + user_id = '' + if hasattr(rn, 'get_userorgid'): + try: + org_id = (await rn.get_userorgid()) or '0' + except Exception: + org_id = '0' + return user_id, org_id -async def delete_entity(ns): - """删除实体,返回 {success, id}。""" - eid = (ns or {}).get('id') - if not eid: - raise EntityError('PARAM_REQUIRED', '缺少实体ID', 'id') - dbname = _dbname() - async with DBPools().sqlorContext(dbname) as sor: - recs = await sor.R('entity', {'id': eid}) - if not recs: - raise EntityError('NOT_FOUND', '实体不存在', 'id') - await sor.D('entity', {'id': eid}) - return {'success': True, 'id': eid} +def _clean_ns(params_kw): + """清理表单/表格提交的 _text 后缀字段与非法空值,避免写入不存在的列""" + ns = {} + for k, v in (params_kw or {}).items(): + if k.endswith('_text'): + continue + if v in (None, '', 'NaN', 'null'): + continue + ns[k] = v + return ns -# ---------------------------------------------------------------- 导入 +async def _validate_entity_row(sor, row, seen_codes, exclude_id=None): + """单行校验:通过返回 None,否则返回错误消息(只读校验,不落库)""" + code = str(row.get('entity_code') or '').strip() + name = str(row.get('entity_name') or '').strip() + etype = str(row.get('entity_type') or '').strip() + status = str(row.get('status') or '1').strip() + scene_id = str(row.get('scene_id') or '').strip() + world_id = str(row.get('world_id') or '').strip() + if not code: + return '实体编码不能为空' + if len(code) > 64: + return '实体编码长度不能超过64' + if code in seen_codes: + return f'文件内实体编码重复: {code}' + seen_codes.add(code) + if not name: + return '实体名称不能为空' + if len(name) > 255: + return '实体名称长度不能超过255' + if not etype: + return '实体类型不能为空' + if etype not in ENTITY_TYPES: + return f'实体类型非法: {etype}' + if status not in ALLOWED_STATUS: + return f'状态值非法: {status}' + if scene_id: + sc = await sor.sqlExe('SELECT id FROM scene WHERE id = ${scene_id}$', {'scene_id': scene_id}) + if not sc: + return f'所属场景不存在: {scene_id}' + if world_id: + wd = await sor.sqlExe('SELECT id FROM world WHERE id = ${world_id}$', {'world_id': world_id}) + if not wd: + return f'所属世界不存在: {world_id}' + dup_sql = 'SELECT id FROM entity WHERE entity_code = ${entity_code}$' + dup_ns = {'entity_code': code} + if exclude_id: + dup_sql += ' AND id != ${exclude_id}$' + dup_ns['exclude_id'] = exclude_id + dup = await sor.sqlExe(dup_sql, dup_ns) + if dup: + return f'实体编码已存在: {code}' + return None -async def parse_entity_file(content, filename=''): - """解析导入文件内容(JSON 数组 / JSON Lines / CSV 表头),返回 {rows: [...]}。""" - if not content: - raise EntityError('PARSE_ERROR', '文件内容为空', 'file') - text = content - if isinstance(text, (bytes, bytearray)): - text = text.decode('utf-8', errors='ignore') - text = text.strip() + +async def _validate_entity_rows(rows): + """全量校验:返回行错误列表(不在 entity 表写任何数据)""" + errs, seen_codes = [], set() + async with _ctx() as sor: + for idx, r in enumerate(rows, start=1): + msg = await _validate_entity_row(sor, r, seen_codes) + if msg: + errs.append({ + 'row_no': r.get('row_no', idx), + 'entity_code': str(r.get('entity_code') or '').strip(), + 'entity_name': str(r.get('entity_name') or '').strip(), + 'message': msg, + }) + return errs + + +def _parse_import_rows(content): + """解析 CSV(首行为表头)或 JSON 数组,返回 (rows, err)""" + import csv + import io + text = str(content).strip() if not text: - raise EntityError('PARSE_ERROR', '文件内容为空', 'file') - try: - if text.startswith('['): + return [], '导入文件内容为空' + if text.startswith('['): + try: data = json.loads(text) - if not isinstance(data, list): - raise EntityError('PARSE_ERROR', 'JSON 顶层必须是数组', 'file') - return {'rows': data} - lines = [ln.strip() for ln in text.splitlines() if ln.strip()] - if lines and lines[0].startswith('{'): - rows = [] - for ln in lines: - obj = json.loads(ln) - if not isinstance(obj, dict): - raise EntityError('PARSE_ERROR', 'JSON Lines 每行必须是对象', 'file', ln[:80]) - rows.append(obj) - return {'rows': rows} - if lines and ',' in lines[0]: - header = [h.strip().strip('\ufeff') for h in lines[0].split(',')] - rows = [] - for ln in lines[1:]: - vals = [v.strip() for v in ln.split(',')] - row = {} - for idx, h in enumerate(header): - row[h] = vals[idx] if idx < len(vals) else '' - rows.append(row) - return {'rows': rows} - data = json.loads(text) - if isinstance(data, dict): - return {'rows': [data]} - raise EntityError('PARSE_ERROR', '无法识别的文件格式', 'file') - except EntityError: - raise + except Exception as e: + return [], f'JSON 解析失败: {e}' + if not isinstance(data, list): + return [], 'JSON 数据必须是数组' + rows = [] + for idx, item in enumerate(data, start=1): + if not isinstance(item, dict): + return [], f'第{idx}行不是对象' + rows.append({ + 'row_no': idx, + 'entity_code': str(item.get('entity_code') or '').strip(), + 'entity_name': str(item.get('entity_name') or '').strip(), + 'entity_type': str(item.get('entity_type') or '').strip(), + 'scene_id': str(item.get('scene_id') or '').strip(), + 'world_id': str(item.get('world_id') or '').strip(), + 'description': str(item.get('description') or '').strip(), + 'status': str(item.get('status') or '1').strip(), + 'sort_no': item.get('sort_no') or 0, + }) + return rows, '' + try: + reader = csv.DictReader(io.StringIO(text)) + rows = [] + for line_no, row in enumerate(reader, start=2): + if not any((v or '').strip() for v in row.values()): + continue + rows.append({ + 'row_no': line_no, + 'entity_code': (row.get('entity_code') or '').strip(), + 'entity_name': (row.get('entity_name') or '').strip(), + 'entity_type': (row.get('entity_type') or '').strip(), + 'scene_id': (row.get('scene_id') or '').strip(), + 'world_id': (row.get('world_id') or '').strip(), + 'description': (row.get('description') or '').strip(), + 'status': (row.get('status') or '1').strip(), + 'sort_no': row.get('sort_no') or 0, + }) + return rows, '' except Exception as e: - raise EntityError('PARSE_ERROR', '解析失败', 'file', str(e)) + return [], f'CSV 解析失败: {e}' -async def import_entities(ns): - """实体文件导入。 +async def list_entities(request, params_kw): + """实体分页列表:返回 {list, total, page, rows}""" + try: + params_kw = params_kw or {} + page = int(params_kw.get('page', 1) or 1) + rows = int(params_kw.get('rows', params_kw.get('pagerows', 20)) or 20) + if page < 1: + page = 1 + if rows < 1 or rows > 200: + rows = 20 + keyword = str(params_kw.get('keyword') or '').strip() + entity_type = str(params_kw.get('entity_type') or '').strip() + status = str(params_kw.get('status') or '').strip() + scene_id = str(params_kw.get('scene_id') or '').strip() + _, org_id = await _current_user(request) + where, ns = ['1=1'], {} + if org_id and org_id != '0': + where.append("org_id IN ('0', ${org_id}$)") + ns['org_id'] = org_id + if keyword: + where.append('(entity_code LIKE ${kw}$ OR entity_name LIKE ${kw}$)') + ns['kw'] = f'%{keyword}%' + if entity_type: + where.append('entity_type = ${entity_type}$') + ns['entity_type'] = entity_type + if status: + where.append('status = ${status}$') + ns['status'] = status + if scene_id: + where.append('scene_id = ${scene_id}$') + ns['scene_id'] = scene_id + cond = ' AND '.join(where) + async with _ctx() as sor: + cnt = await sor.sqlExe(f'SELECT COUNT(*) AS cnt FROM entity WHERE {cond}', ns) + total = int(cnt[0].cnt) if cnt else 0 + offset = (page - 1) * rows + ns2 = dict(ns) + ns2['limit'] = int(rows) + ns2['offset'] = int(offset) + recs = await sor.sqlExe( + 'SELECT id, entity_code, entity_name, entity_type, scene_id, world_id, ' + 'description, status, sort_no, import_id, org_id, created_by, updated_by, created_at, updated_at ' + f'FROM entity WHERE {cond} ORDER BY sort_no DESC, created_at DESC ' + 'LIMIT ${limit}$ OFFSET ${offset}$', ns2) + lst = [dict(r) for r in (recs or [])] + return {'list': lst, 'total': total, 'page': page, 'rows': rows} + except Exception as e: + return _err('ENTITY_LIST_ERROR', '实体列表查询失败', '', str(e)) - 事务批量:先全量解析+校验(含编码查重),再逐条插入;任一条失败整批回滚无脏数据。 - 返回 {success, total, success_count, fail, file_name}(success_count 避免与布尔 success 冲突)。 + +async def get_entity(request, params_kw): + """实体详情""" + try: + entity_id = str((params_kw or {}).get('id') or '').strip() + if not entity_id: + return _err('ENTITY_ID_REQUIRED', '实体ID不能为空', 'id') + async with _ctx() as sor: + recs = await sor.sqlExe('SELECT * FROM entity WHERE id = ${id}$', {'id': entity_id}) + if not recs: + return _err('ENTITY_NOT_FOUND', '实体不存在', 'id') + return {'success': True, 'data': dict(recs[0])} + except Exception as e: + return _err('ENTITY_GET_ERROR', '实体查询失败', '', str(e)) + + +async def create_entity(request, params_kw): + """新增实体(非法输入 100% 拦截,不落库)""" + try: + params_kw = params_kw or {} + ns = _clean_ns(params_kw) + entity_code = str(ns.get('entity_code') or '').strip() + entity_name = str(ns.get('entity_name') or '').strip() + entity_type = str(ns.get('entity_type') or '').strip() + status = str(ns.get('status') or '1').strip() + scene_id = str(ns.get('scene_id') or '').strip() + world_id = str(ns.get('world_id') or '').strip() + user_id, org_id = await _current_user(request) + now = curDateString() + async with _ctx() as sor: + err = await _validate_entity_row(sor, { + 'entity_code': entity_code, 'entity_name': entity_name, + 'entity_type': entity_type, 'status': status, + 'scene_id': scene_id, 'world_id': world_id, + }, set()) + if err: + field = 'entity_code' if '编码' in err else '' + return _err('ENTITY_VALIDATE_FAIL', err, field) + new_id = getID() + await sor.C('entity', { + 'id': new_id, 'entity_code': entity_code, 'entity_name': entity_name, + 'entity_type': entity_type, + 'scene_id': scene_id or None, 'world_id': world_id or None, + 'description': ns.get('description') or '', + 'status': status, + 'sort_no': int(ns['sort_no']) if str(ns.get('sort_no') or '').isdigit() else 0, + 'import_id': None, 'org_id': org_id, + 'created_by': user_id or None, 'updated_by': None, + 'created_at': now}) + return {'success': True, 'data': {'id': new_id}} + except Exception as e: + return _err('ENTITY_CREATE_ERROR', '实体创建失败', '', str(e)) + + +async def update_entity(request, params_kw): + """更新实体(非法输入 100% 拦截,不落库)""" + try: + params_kw = params_kw or {} + entity_id = str(params_kw.get('id') or '').strip() + if not entity_id: + return _err('ENTITY_ID_REQUIRED', '实体ID不能为空', 'id') + ns = _clean_ns(params_kw) + ns.pop('id', None) + async with _ctx() as sor: + exist = await sor.sqlExe('SELECT * FROM entity WHERE id = ${id}$', {'id': entity_id}) + if not exist: + return _err('ENTITY_NOT_FOUND', '实体不存在', 'id') + cur = dict(exist[0]) + for k, v in ns.items(): + if k in ENTITY_FIELDS and v not in (None, ''): + cur[k] = v + err = await _validate_entity_row(sor, cur, set(), exclude_id=entity_id) + if err: + field = 'entity_code' if '编码' in err else '' + return _err('ENTITY_VALIDATE_FAIL', err, field) + upd = {k: v for k, v in ns.items() if k in ENTITY_FIELDS and v not in (None, '')} + if 'sort_no' in upd: + upd['sort_no'] = int(upd['sort_no']) if str(upd['sort_no']).isdigit() else 0 + if not upd: + return _err('ENTITY_UPDATE_EMPTY', '没有需要更新的字段', '') + upd['id'] = entity_id + upd['updated_at'] = curDateString() + await sor.U('entity', upd) + return {'success': True, 'data': {'id': entity_id}} + except Exception as e: + return _err('ENTITY_UPDATE_ERROR', '实体更新失败', '', str(e)) + + +async def delete_entity(request, params_kw): + """删除实体""" + try: + entity_id = str((params_kw or {}).get('id') or '').strip() + if not entity_id: + return _err('ENTITY_ID_REQUIRED', '实体ID不能为空', 'id') + async with _ctx() as sor: + exist = await sor.sqlExe('SELECT id FROM entity WHERE id = ${id}$', {'id': entity_id}) + if not exist: + return _err('ENTITY_NOT_FOUND', '实体不存在', 'id') + await sor.D('entity', {'id': entity_id}) + return {'success': True, 'data': {'id': entity_id}} + except Exception as e: + return _err('ENTITY_DELETE_ERROR', '实体删除失败', '', str(e)) + + +async def import_entities(request, params_kw): """ - ns = dict(ns or {}) - world_id = (ns.get('world_id') or '').strip() - scene_id = (ns.get('scene_id') or '').strip() - file_name = (ns.get('file_name') or '').strip() - rows = ns.get('rows') - if not isinstance(rows, list) or not rows: - raise EntityError('PARAM_REQUIRED', '没有可导入的数据', 'rows') - total = len(rows) - - # 第一阶段:全量解析 + 校验(不落库) - parsed, seen_codes = [], set() - for i, row in enumerate(rows): - seq = i + 1 - if not isinstance(row, dict): - raise EntityError('PARSE_ERROR', f'第{seq}行不是有效对象', 'rows', str(row)[:100]) + 实体文件导入(api/entity_import.dspy) + 两阶段:① 全量校验(不写任何数据)→ ② 批量插入(同批以 import_id 标记), + 任一行非法/插入异常 → 按 import_id 补偿删除已插入实体,保证失败回滚无脏数据。 + """ + try: + params_kw = params_kw or {} + content = None + file_name = str(params_kw.get('file_name') or '').strip() + f = params_kw.get('file') + if f is not None and hasattr(f, 'read'): + try: + content = f.read() + if isinstance(content, bytes): + content = content.decode('utf-8-sig', errors='replace') + except Exception: + content = None + file_name = file_name or str(getattr(f, 'filename', '') or '') + if content is None: + content = params_kw.get('file_content') + if not content or not str(content).strip(): + return _err('IMPORT_EMPTY_FILE', '导入文件为空', 'file') + rows, perr = _parse_import_rows(content) + if perr: + return _err('IMPORT_PARSE_ERROR', '文件解析失败', 'file', perr) + if not rows: + return _err('IMPORT_EMPTY_ROWS', '文件中没有有效数据行', 'file') + if len(rows) > 2000: + return _err('IMPORT_TOO_MANY', '单次导入不能超过2000行', 'file') + # 第一阶段:全量校验(不落库) + val_errs = await _validate_entity_rows(rows) + user_id, org_id = await _current_user(request) + now = curDateString() + import_id = getID() + import_no = 'IMP' + timestampstr() + if val_errs: + # 校验失败:不导入任何实体,仅记录失败批次与逐行错误 + async with _ctx() as sor: + await sor.C('entity_import', { + 'id': import_id, 'import_no': import_no, 'file_name': file_name, + 'total_count': len(rows), 'success_count': 0, 'fail_count': len(val_errs), + 'status': 'fail', 'error_msg': '校验失败,未导入任何数据', + 'org_id': org_id, 'created_by': user_id or None, 'created_at': now}) + for e in val_errs: + await sor.C('entity_import_log', { + 'id': getID(), 'import_id': import_id, 'row_no': e['row_no'], + 'entity_code': e['entity_code'], 'entity_name': e['entity_name'], + 'result': '0', 'error_msg': e['message'], 'created_at': now}) + return _err('IMPORT_VALIDATE_FAIL', '导入校验失败,未导入任何数据', 'rows', val_errs) + # 第二阶段:批量插入(异常时补偿回滚) try: - name = _check_required(row, 'name', '实体名称', 255) - code = _check_required(row, 'code', '实体编码', 64) - except EntityError as e: - e.detail = f'第{seq}行: {e.message}' + (f'({e.detail})' if e.detail else '') - raise - if code in seen_codes: - raise EntityError('DUPLICATE_CODE', f'第{seq}行编码重复: {code}', 'code', f'第{seq}行') - seen_codes.add(code) - w = (row.get('world_id') or world_id or '').strip() - s = (row.get('scene_id') or scene_id or '').strip() - attrs = _check_attributes_json(row.get('attributes_json')) - parsed.append({ - 'world_id': w, - 'scene_id': s, - 'name': name, - 'code': code, - 'entity_type': _norm_scalar(row.get('entity_type'), '0', 16), - 'status': _norm_scalar(row.get('status'), '0', 16), - 'attributes_json': attrs, - }) - - if world_id and not await _world_exists(world_id): - raise EntityError('WORLD_NOT_FOUND', '所属世界不存在', 'world_id', world_id) - if scene_id and not await _scene_exists(scene_id): - raise EntityError('SCENE_NOT_FOUND', '所属场景不存在', 'scene_id', scene_id) - - # 第二阶段:事务批量插入,任一条失败整批回滚 - dbname = _dbname() - now = curDateString() - imp_id = getID() - try: - async with DBPools().sqlorContext(dbname) as sor: - await sor.C('entity_import', { - 'id': imp_id, - 'world_id': world_id, - 'scene_id': scene_id, - 'file_name': file_name, - 'total': total, - 'success': 0, - 'fail': 0, - 'status': '0', - 'created_at': now, - }) - for item in parsed: - rec = dict(item) - rec['id'] = getID() - rec['created_at'] = now - rec['updated_at'] = now - await sor.C('entity', rec) - await sor.U('entity_import', { - 'id': imp_id, - 'status': '1', - 'success': total, - 'fail': 0, - }) + async with _ctx() as sor: + for r in rows: + nid = getID() + await sor.C('entity', { + 'id': nid, 'entity_code': r['entity_code'], 'entity_name': r['entity_name'], + 'entity_type': r['entity_type'], + 'scene_id': r['scene_id'] or None, 'world_id': r['world_id'] or None, + 'description': r['description'], 'status': r['status'], + 'sort_no': int(r['sort_no']) if str(r['sort_no'] or '').isdigit() else 0, + 'import_id': import_id, 'org_id': org_id, + 'created_by': user_id or None, 'updated_by': None, 'created_at': now}) + async with _ctx() as sor: + await sor.C('entity_import', { + 'id': import_id, 'import_no': import_no, 'file_name': file_name, + 'total_count': len(rows), 'success_count': len(rows), 'fail_count': 0, + 'status': 'success', 'error_msg': '', + 'org_id': org_id, 'created_by': user_id or None, 'created_at': now}) + for r in rows: + await sor.C('entity_import_log', { + 'id': getID(), 'import_id': import_id, 'row_no': r['row_no'], + 'entity_code': r['entity_code'], 'entity_name': r['entity_name'], + 'result': '1', 'error_msg': '', 'created_at': now}) + return {'success': True, + 'data': {'import_id': import_id, 'total': len(rows), 'success': len(rows), 'fail': 0}} + except Exception as e: + # 回滚:删除本批次已插入的实体,保证无脏数据 + try: + async with _ctx() as sor: + await sor.execute('DELETE FROM entity WHERE import_id = ${import_id}$', + {'import_id': import_id}) + await sor.C('entity_import', { + 'id': import_id, 'import_no': import_no, 'file_name': file_name, + 'total_count': len(rows), 'success_count': 0, 'fail_count': len(rows), + 'status': 'fail', 'error_msg': f'批量插入失败,已回滚,未产生脏数据: {e}', + 'org_id': org_id, 'created_by': user_id or None, 'created_at': now}) + for r in rows: + await sor.C('entity_import_log', { + 'id': getID(), 'import_id': import_id, 'row_no': r['row_no'], + 'entity_code': r['entity_code'], 'entity_name': r['entity_name'], + 'result': '0', 'error_msg': f'批量插入失败: {e}', 'created_at': now}) + except Exception: + pass + return _err('IMPORT_INSERT_FAIL', '实体批量插入失败,已回滚', '', str(e)) except Exception as e: - msg = str(e) - if 'duplicate' in msg.lower(): - raise EntityError('DUPLICATE_CODE', '导入失败:实体编码已存在,已整批回滚', 'code', msg) - raise EntityError('DB_ERROR', '导入失败,已整批回滚', '', msg) - return { - 'success': True, - 'total': total, - 'success_count': total, - 'fail': 0, - 'file_name': file_name, - } + return _err('IMPORT_ERROR', '实体导入失败', '', str(e)) -# ---------------------------------------------------------------- 下拉 - -async def get_world_options(): - """世界下拉 [{value,text}](world 模块未挂载时返回空)。""" - dbname = _world_dbname() - if not dbname: - return [] +async def entity_import_guard(request, params_kw): + """导入预校验(干跑):只校验不落库,返回逐行错误(api/entity_import_guard.dspy)""" try: - async with DBPools().sqlorContext(dbname) as sor: - recs = await sor.R('world', {}) - return [{'value': r.id, 'text': getattr(r, 'name', None) or r.id} for r in recs] - except Exception: - return [] + params_kw = params_kw or {} + rows = params_kw.get('rows') + if isinstance(rows, str): + rows = json.loads(rows) + if not isinstance(rows, list) or not rows: + return _err('IMPORT_EMPTY_ROWS', '没有可校验的数据', 'rows') + if len(rows) > 2000: + return _err('IMPORT_TOO_MANY', '单次校验不能超过2000行', 'rows') + norm = [] + for idx, item in enumerate(rows, start=1): + if isinstance(item, str): + try: + item = json.loads(item) + except Exception: + item = {'entity_code': item} + if not isinstance(item, dict): + return _err('IMPORT_ROW_INVALID', f'第{idx}行数据格式非法', 'rows') + norm.append({ + 'row_no': idx, + 'entity_code': str(item.get('entity_code') or '').strip(), + 'entity_name': str(item.get('entity_name') or '').strip(), + 'entity_type': str(item.get('entity_type') or '').strip(), + 'scene_id': str(item.get('scene_id') or '').strip(), + 'world_id': str(item.get('world_id') or '').strip(), + 'description': str(item.get('description') or '').strip(), + 'status': str(item.get('status') or '1').strip(), + 'sort_no': item.get('sort_no') or 0, + }) + errs = await _validate_entity_rows(norm) + return {'valid': not errs, 'errors': errs, 'total': len(norm)} + except Exception as e: + return _err('IMPORT_GUARD_ERROR', '导入预校验失败', '', str(e)) -async def get_scene_options(): - """场景下拉 [{value,text}](scene 模块未挂载时返回空)。""" - dbname = _scene_dbname() - if not dbname: - return [] - try: - async with DBPools().sqlorContext(dbname) as sor: - recs = await sor.R('scene', {}) - return [{'value': r.id, 'text': getattr(r, 'name', None) or r.id} for r in recs] - except Exception: - return [] +async def reject_import_write(request, params_kw): + """entity_import 记录为系统生成,禁止手工增删改""" + return _err('IMPORT_RECORD_READONLY', '导入记录为系统生成,禁止手工增删改', '') -async def _appcodes_options(parentid): - """appcodes 字典下拉 [{value,text}]。""" - dbname = _appbase_dbname() - if not dbname: - return [] - try: - async with DBPools().sqlorContext(dbname) as sor: - recs = await sor.R('appcodes_kv', {'parentid': parentid}) - return [{'value': r.k, 'text': r.v} for r in recs] - except Exception: - return [] - - -async def get_entity_type_options(): - return await _appcodes_options('entity_type') - - -async def get_entity_status_options(): - return await _appcodes_options('entity_status') - - -async def get_import_status_options(): - return await _appcodes_options('import_status') - - -# ---------------------------------------------------------------- 注册 - -def load_entity(): - """注册 entity 模块全部函数到 ServerEnv(含复数别名,供 CRUD dspy 调用)。""" - env = ServerEnv() +def load_entity(env): + """注册模块函数到 ServerEnv(.dspy/.ui 可直接调用)""" env.create_entity = create_entity - env.create_entities = create_entity + env.create_entitys = create_entity env.update_entity = update_entity - env.update_entities = update_entity + env.update_entitys = update_entity env.delete_entity = delete_entity - env.delete_entities = delete_entity - env.list_entities = list_entities + env.delete_entitys = delete_entity env.get_entity = get_entity + env.get_entitys = get_entity + env.list_entities = list_entities env.import_entities = import_entities - env.parse_entity_file = parse_entity_file - env.entity_import = import_entities - env.get_world_options = get_world_options - env.get_scene_options = get_scene_options - env.get_entity_type_options = get_entity_type_options - env.get_entity_status_options = get_entity_status_options - env.get_import_status_options = get_import_status_options + env.entity_import_guard = entity_import_guard + env.reject_import_write = reject_import_write return env diff --git a/init/data.json b/init/data.json index b3e9b64..c6c1093 100644 --- a/init/data.json +++ b/init/data.json @@ -4,29 +4,31 @@ "parentid": "entity_type", "parentname": "实体类型", "items": [ - {"k": "npc", "v": "NPC"}, - {"k": "item", "v": "物品"}, - {"k": "building", "v": "建筑"}, + {"k": "character", "v": "角色"}, {"k": "prop", "v": "道具"}, + {"k": "vehicle", "v": "载具"}, + {"k": "building", "v": "建筑"}, + {"k": "faction", "v": "阵营"}, {"k": "other", "v": "其他"} ] }, { - "parentid": "entity_status", - "parentname": "实体状态", + "parentid": "common_status", + "parentname": "通用状态", "items": [ - {"k": "0", "v": "启用"}, - {"k": "1", "v": "禁用"} + {"k": "1", "v": "启用"}, + {"k": "0", "v": "停用"} ] }, { "parentid": "import_status", "parentname": "导入状态", "items": [ - {"k": "0", "v": "进行中"}, - {"k": "1", "v": "成功"}, - {"k": "2", "v": "失败"} + {"k": "importing", "v": "导入中"}, + {"k": "success", "v": "成功"}, + {"k": "fail", "v": "失败"}, + {"k": "partial", "v": "部分成功"} ] } ] -} \ No newline at end of file +} diff --git a/json/entity.json b/json/entity.json index 6a9eec9..0059d98 100644 --- a/json/entity.json +++ b/json/entity.json @@ -1,41 +1,33 @@ { - "tblname": "entity", - "params": { - "browserfields": { - "list": { - "fields": [ - {"name": "id", "label": "ID", "width": 100, "hide": true}, - {"name": "name", "label": "实体名称", "width": 180}, - {"name": "code", "label": "实体编码", "width": 140}, - {"name": "world_id", "label": "所属世界", "width": 120}, - {"name": "scene_id", "label": "所属场景", "width": 120}, - {"name": "entity_type", "label": "实体类型", "width": 100}, - {"name": "status", "label": "状态", "width": 80}, - {"name": "attributes_json", "label": "属性JSON", "width": 200}, - {"name": "created_at", "label": "创建时间", "width": 160}, - {"name": "updated_at", "label": "更新时间", "width": 160} - ] - }, - "alters": [ - {"name": "world_id", "label": "所属世界", "uitype": "code", "dataurl": "{{entire_url('../api/get_search_world_id.dspy')}}"}, - {"name": "scene_id", "label": "所属场景", "uitype": "code", "dataurl": "{{entire_url('../api/get_search_scene_id.dspy')}}"}, - {"name": "entity_type", "label": "实体类型", "uitype": "code", "dataurl": "{{entire_url('../api/get_search_entity_type.dspy')}}"}, - {"name": "status", "label": "状态", "uitype": "code", "dataurl": "{{entire_url('../api/get_search_status.dspy')}}"} - ] - }, - "editable": { - "fields": [ - {"name": "name", "label": "实体名称", "uitype": "text", "required": true}, - {"name": "code", "label": "实体编码", "uitype": "text", "required": true}, - {"name": "world_id", "label": "所属世界", "uitype": "code", "dataurl": "{{entire_url('../api/get_search_world_id.dspy')}}"}, - {"name": "scene_id", "label": "所属场景", "uitype": "code", "dataurl": "{{entire_url('../api/get_search_scene_id.dspy')}}"}, - {"name": "entity_type", "label": "实体类型", "uitype": "code", "dataurl": "{{entire_url('../api/get_search_entity_type.dspy')}}"}, - {"name": "status", "label": "状态", "uitype": "code", "dataurl": "{{entire_url('../api/get_search_status.dspy')}}"}, - {"name": "attributes_json", "label": "属性JSON", "uitype": "textarea"} - ] - }, - "new_data_url": "{{entire_url('../api/entity_create.dspy')}}", - "update_data_url": "{{entire_url('../api/entity_update.dspy')}}", - "delete_data_url": "{{entire_url('../api/entity_delete.dspy')}}" - } -} \ No newline at end of file + "tblname": "entity", + "title": "实体管理", + "params": { + "sortby": ["sort_no desc", "created_at desc"], + "logined_userorgid": "org_id", + "data_url": "{{entire_url('../api/entity_list.dspy')}}", + "browserfields": { + "exclouded": ["id", "import_id", "org_id", "created_by", "updated_by", "created_at", "updated_at", "description"], + "alters": { + "entity_type": {"uitype": "code", "dataurl": "{{entire_url('../api/get_search_entity_type.dspy')}}"}, + "status": {"uitype": "code", "dataurl": "{{entire_url('../api/get_search_status.dspy')}}"}, + "scene_id": {"uitype": "code", "dataurl": "{{entire_url('../api/get_search_scene_id.dspy')}}"}, + "world_id": {"uitype": "code", "dataurl": "{{entire_url('../api/get_search_world_id.dspy')}}"} + } + }, + "editexclouded": ["id", "import_id", "org_id", "created_by", "updated_by", "created_at", "updated_at"], + "data_filter": { + "AND": [ + {"field": "entity_name", "op": "LIKE", "var": "keyword"}, + {"field": "entity_type", "op": "=", "var": "entity_type"}, + {"field": "status", "op": "=", "var": "status"} + ] + }, + "filter_labels": {"keyword": "实体名称/编码", "entity_type": "实体类型", "status": "状态"}, + "editable": { + "get_data_url": "{{entire_url('../api/entity_list.dspy')}}", + "new_data_url": "{{entire_url('../api/entity_create.dspy')}}", + "update_data_url": "{{entire_url('../api/entity_update.dspy')}}", + "delete_data_url": "{{entire_url('../api/entity_delete.dspy')}}" + } + } +} diff --git a/json/entity_import.json b/json/entity_import.json index e18a216..98508db 100644 --- a/json/entity_import.json +++ b/json/entity_import.json @@ -1,25 +1,22 @@ { - "tblname": "entity_import", - "params": { - "browserfields": { - "list": { - "fields": [ - {"name": "id", "label": "ID", "width": 100, "hide": true}, - {"name": "world_id", "label": "世界ID", "width": 120}, - {"name": "scene_id", "label": "场景ID", "width": 120}, - {"name": "file_name", "label": "文件名", "width": 200}, - {"name": "total", "label": "总数", "width": 80}, - {"name": "success", "label": "成功", "width": 80}, - {"name": "fail", "label": "失败", "width": 80}, - {"name": "status", "label": "状态", "width": 80}, - {"name": "created_at", "label": "导入时间", "width": 160} - ] - }, - "alters": [ - {"name": "world_id", "label": "世界ID", "uitype": "code", "dataurl": "{{entire_url('../api/get_search_world_id.dspy')}}"}, - {"name": "scene_id", "label": "场景ID", "uitype": "code", "dataurl": "{{entire_url('../api/get_search_scene_id.dspy')}}"}, - {"name": "status", "label": "状态", "uitype": "code", "dataurl": "{{entire_url('../api/get_search_import_status.dspy')}}"} - ] + "tblname": "entity_import", + "title": "实体导入记录", + "params": { + "sortby": ["created_at desc"], + "logined_userorgid": "org_id", + "data_url": "{{entire_url('../api/entity_import_list.dspy')}}", + "browserfields": { + "exclouded": ["id", "org_id", "created_by", "error_msg"], + "alters": { + "status": {"uitype": "code", "dataurl": "{{entire_url('../api/get_search_import_status.dspy')}}"} + } + }, + "editexclouded": ["id", "org_id", "created_by", "created_at", "import_no", "file_name", "total_count", "success_count", "fail_count", "status", "error_msg"], + "editable": { + "get_data_url": "{{entire_url('../api/entity_import_list.dspy')}}", + "new_data_url": "{{entire_url('../api/entity_import_reject.dspy')}}", + "update_data_url": "{{entire_url('../api/entity_import_reject.dspy')}}", + "delete_data_url": "{{entire_url('../api/entity_import_reject.dspy')}}" + } } - } -} \ No newline at end of file +} diff --git a/models/entity.json b/models/entity.json index 96bff2b..933d0f2 100644 --- a/models/entity.json +++ b/models/entity.json @@ -1,33 +1,40 @@ { - "summary": [ - { - "table": "entity", - "title": "实体表", - "comment": "实体管理(W-03)", - "primary": ["id"] - } - ], - "fields": [ - {"name": "id", "type": "str", "length": 32, "primary": true, "comment": "主键"}, - {"name": "world_id", "type": "str", "length": 32, "comment": "所属世界(→world.id 逻辑关联)"}, - {"name": "scene_id", "type": "str", "length": 32, "comment": "所属场景(→scene.id 逻辑关联,不加物理外键)"}, - {"name": "name", "type": "str", "length": 255, "comment": "实体名称"}, - {"name": "code", "type": "str", "length": 64, "comment": "实体编码(唯一)"}, - {"name": "entity_type", "type": "str", "length": 16, "comment": "实体类型(appcodes entity_type)"}, - {"name": "status", "type": "str", "length": 16, "comment": "状态(appcodes entity_status)"}, - {"name": "attributes_json", "type": "text", "comment": "属性 JSON"}, - {"name": "created_at", "type": "timestamp", "comment": "创建时间"}, - {"name": "updated_at", "type": "timestamp", "comment": "更新时间"} - ], - "indexes": [ - {"name": "idx_entity_code", "unique": true, "fields": ["code"]}, - {"name": "idx_entity_world", "fields": ["world_id"]}, - {"name": "idx_entity_scene", "fields": ["scene_id"]} - ], - "codes": [ - {"field": "world_id", "table": "world", "valuefield": "id", "textfield": "name"}, - {"field": "scene_id", "table": "scene", "valuefield": "id", "textfield": "name"}, - {"field": "entity_type", "table": "appcodes_kv", "cond": "parentid='entity_type'", "valuefield": "k", "textfield": "v"}, - {"field": "status", "table": "appcodes_kv", "cond": "parentid='entity_status'", "valuefield": "k", "textfield": "v"} - ] -} \ No newline at end of file + "summary": [ + { + "name": "entity", + "title": "实体表", + "primary": ["id"], + "catelog": "entity" + } + ], + "fields": [ + {"name": "id", "title": "主键ID", "type": "str", "length": 32, "nullable": "no"}, + {"name": "entity_code", "title": "实体编码", "type": "str", "length": 64, "nullable": "no"}, + {"name": "entity_name", "title": "实体名称", "type": "str", "length": 255, "nullable": "no"}, + {"name": "entity_type", "title": "实体类型", "type": "str", "length": 32, "nullable": "no"}, + {"name": "scene_id", "title": "所属场景", "type": "str", "length": 32}, + {"name": "world_id", "title": "所属世界", "type": "str", "length": 32}, + {"name": "description", "title": "描述", "type": "text"}, + {"name": "status", "title": "状态", "type": "str", "length": 8, "nullable": "no", "default": "1"}, + {"name": "sort_no", "title": "排序号", "type": "int", "nullable": "no", "default": "0"}, + {"name": "import_id", "title": "导入批次ID", "type": "str", "length": 32}, + {"name": "org_id", "title": "机构ID", "type": "str", "length": 32, "nullable": "no", "default": "0"}, + {"name": "created_by", "title": "创建人", "type": "str", "length": 32}, + {"name": "updated_by", "title": "更新人", "type": "str", "length": 32}, + {"name": "created_at", "title": "创建时间", "type": "timestamp", "nullable": "no"}, + {"name": "updated_at", "title": "更新时间", "type": "timestamp"} + ], + "indexes": [ + {"name": "idx_entity_code", "idxtype": "unique", "idxfields": ["entity_code"]}, + {"name": "idx_entity_type", "idxtype": "index", "idxfields": ["entity_type"]}, + {"name": "idx_entity_scene", "idxtype": "index", "idxfields": ["scene_id"]}, + {"name": "idx_entity_world", "idxtype": "index", "idxfields": ["world_id"]}, + {"name": "idx_entity_import", "idxtype": "index", "idxfields": ["import_id"]} + ], + "codes": [ + {"field": "entity_type", "table": "appcodes_kv", "valuefield": "k", "textfield": "v", "cond": "parentid='entity_type'"}, + {"field": "status", "table": "appcodes_kv", "valuefield": "k", "textfield": "v", "cond": "parentid='common_status'"}, + {"field": "scene_id", "table": "scene", "valuefield": "id", "textfield": "scene_name"}, + {"field": "world_id", "table": "world", "valuefield": "id", "textfield": "world_name"} + ] +} diff --git a/models/entity_import.json b/models/entity_import.json index 085c930..4bc06ad 100644 --- a/models/entity_import.json +++ b/models/entity_import.json @@ -1,29 +1,31 @@ { - "summary": [ - { - "table": "entity_import", - "title": "实体导入记录表", - "comment": "实体导入日志(只读,不可手工增删改)", - "primary": ["id"] - } - ], - "fields": [ - {"name": "id", "type": "str", "length": 32, "primary": true, "comment": "主键"}, - {"name": "world_id", "type": "str", "length": 32, "comment": "目标世界"}, - {"name": "scene_id", "type": "str", "length": 32, "comment": "目标场景"}, - {"name": "file_name", "type": "str", "length": 255, "comment": "导入文件名"}, - {"name": "total", "type": "int", "comment": "总条数"}, - {"name": "success", "type": "int", "comment": "成功条数"}, - {"name": "fail", "type": "int", "comment": "失败条数"}, - {"name": "status", "type": "str", "length": 16, "comment": "导入状态(appcodes import_status:0进行中/1成功/2失败)"}, - {"name": "created_at", "type": "timestamp", "comment": "导入时间"} - ], - "indexes": [ - {"name": "idx_entity_import_world", "fields": ["world_id"]} - ], - "codes": [ - {"field": "world_id", "table": "world", "valuefield": "id", "textfield": "name"}, - {"field": "scene_id", "table": "scene", "valuefield": "id", "textfield": "name"}, - {"field": "status", "table": "appcodes_kv", "cond": "parentid='import_status'", "valuefield": "k", "textfield": "v"} - ] -} \ No newline at end of file + "summary": [ + { + "name": "entity_import", + "title": "实体导入记录表", + "primary": ["id"], + "catelog": "relation" + } + ], + "fields": [ + {"name": "id", "title": "主键ID", "type": "str", "length": 32, "nullable": "no"}, + {"name": "import_no", "title": "导入单号", "type": "str", "length": 64, "nullable": "no"}, + {"name": "file_name", "title": "文件名", "type": "str", "length": 255}, + {"name": "total_count", "title": "总行数", "type": "int", "nullable": "no", "default": "0"}, + {"name": "success_count", "title": "成功行数", "type": "int", "nullable": "no", "default": "0"}, + {"name": "fail_count", "title": "失败行数", "type": "int", "nullable": "no", "default": "0"}, + {"name": "status", "title": "导入状态", "type": "str", "length": 16, "nullable": "no", "default": "importing"}, + {"name": "error_msg", "title": "错误信息", "type": "text"}, + {"name": "org_id", "title": "机构ID", "type": "str", "length": 32, "nullable": "no", "default": "0"}, + {"name": "created_by", "title": "创建人", "type": "str", "length": 32}, + {"name": "created_at", "title": "创建时间", "type": "timestamp", "nullable": "no"} + ], + "indexes": [ + {"name": "idx_import_no", "idxtype": "unique", "idxfields": ["import_no"]}, + {"name": "idx_import_status", "idxtype": "index", "idxfields": ["status"]}, + {"name": "idx_import_org", "idxtype": "index", "idxfields": ["org_id"]} + ], + "codes": [ + {"field": "status", "table": "appcodes_kv", "valuefield": "k", "textfield": "v", "cond": "parentid='import_status'"} + ] +} diff --git a/models/entity_import_log.json b/models/entity_import_log.json index 5b427f0..7e230d7 100644 --- a/models/entity_import_log.json +++ b/models/entity_import_log.json @@ -1,20 +1,23 @@ { - "summary": [{"name": "entity_import_log", "title": "实体导入日志表", "primary": ["id"], "catelog": "relation"}], - "fields": [ - {"name": "id", "title": "主键ID", "type": "str", "length": 32, "nullable": "no"}, - {"name": "world_id", "title": "所属世界ID", "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": "failed", "title": "失败数", "type": "int", "nullable": "no", "default": "0"}, - {"name": "status", "title": "导入状态", "type": "str", "length": 16, "nullable": "no", "default": "processing"}, - {"name": "detail", "title": "失败明细", "type": "text", "nullable": "yes"}, - {"name": "created_by", "title": "导入人ID", "type": "str", "length": 32, "nullable": "yes"}, - {"name": "created_at", "title": "导入时间", "type": "timestamp", "nullable": "no"} - ], - "indexes": [{"name": "idx_entity_ilog_world", "idxtype": "index", "idxfields": ["world_id"]}], - "codes": [ - {"field": "world_id", "table": "world", "valuefield": "id", "textfield": "name"}, - {"field": "status", "table": "appcodes_kv", "valuefield": "k", "textfield": "v", "cond": "parentid='import_status'"} - ] + "summary": [ + { + "name": "entity_import_log", + "title": "实体导入明细日志表", + "primary": ["id"], + "catelog": "relation" + } + ], + "fields": [ + {"name": "id", "title": "主键ID", "type": "str", "length": 32, "nullable": "no"}, + {"name": "import_id", "title": "导入批次ID", "type": "str", "length": 32, "nullable": "no"}, + {"name": "row_no", "title": "行号", "type": "int", "nullable": "no", "default": "0"}, + {"name": "entity_code", "title": "实体编码", "type": "str", "length": 64}, + {"name": "entity_name", "title": "实体名称", "type": "str", "length": 255}, + {"name": "result", "title": "结果", "type": "str", "length": 8, "nullable": "no", "default": "0"}, + {"name": "error_msg", "title": "错误信息", "type": "text"}, + {"name": "created_at", "title": "创建时间", "type": "timestamp", "nullable": "no"} + ], + "indexes": [ + {"name": "idx_log_import", "idxtype": "index", "idxfields": ["import_id"]} + ] } diff --git a/scripts/load_path.py b/scripts/load_path.py index 58a2a62..75f07ff 100644 --- a/scripts/load_path.py +++ b/scripts/load_path.py @@ -1,61 +1,64 @@ -#!/usr/bin/env python3 # -*- coding: utf-8 -*- -"""entity 模块 RBAC 权限注册(scripts/load_path.py)。 - -每次代码变更如有新 path 出现,需同步更新本脚本。禁止通配符,路径全显式。 +""" +entity 模块 RBAC 路径注册脚本(显式路径,禁止通配符)。 +用法:python scripts/load_path.py(自动寻找 Sage 根目录并调用 set_role_perm.py) +维护规则:每次新增/删除 wwwroot 下 path,需同步更新本脚本。 """ import os -import sys +import subprocess -HERE = os.path.dirname(os.path.abspath(__file__)) +MODULE = 'entity' -# 显式路径清单(禁止 % / * 通配符) -PATHS_LOGINED = [ - # 页面 - '/entity/index.ui', - '/entity/import_page.ui', - # CRUD 列表(xls2ui 生成的别名目录 5 件套) - '/entity/entity_list', - '/entity/entity_list/index.ui', - '/entity/entity_list/get_entity_list.dspy', - '/entity/entity_list/add_entity_list.dspy', - '/entity/entity_list/update_entity_list.dspy', - '/entity/entity_list/delete_entity_list.dspy', - '/entity/entity_import_list', - '/entity/entity_import_list/index.ui', - '/entity/entity_import_list/get_entity_import_list.dspy', - '/entity/entity_import_list/add_entity_import_list.dspy', - '/entity/entity_import_list/update_entity_import_list.dspy', - '/entity/entity_import_list/delete_entity_import_list.dspy', - # REST API(统一 /api/* 前缀) - '/entity/api/entity_list.dspy', - '/entity/api/entity_get.dspy', - '/entity/api/entity_create.dspy', - '/entity/api/entity_update.dspy', - '/entity/api/entity_delete.dspy', - '/entity/api/entity_import.dspy', - '/entity/api/get_search_world_id.dspy', - '/entity/api/get_search_scene_id.dspy', - '/entity/api/get_search_entity_type.dspy', - '/entity/api/get_search_status.dspy', - '/entity/api/get_search_import_status.dspy', +PATHS_ANY = [ + f'/{MODULE}/index.ui', ] -PATHS_ANY = [] + +PATHS_LOGINED = [ + f'/{MODULE}', + f'/{MODULE}/index.ui', + f'/{MODULE}/import_page.ui', + f'/{MODULE}/api/entity_list.dspy', + f'/{MODULE}/api/entity_create.dspy', + f'/{MODULE}/api/entity_update.dspy', + f'/{MODULE}/api/entity_delete.dspy', + f'/{MODULE}/api/entity_get.dspy', + f'/{MODULE}/api/entity_import.dspy', + f'/{MODULE}/api/entity_import_guard.dspy', + f'/{MODULE}/api/entity_import_list.dspy', + f'/{MODULE}/api/entity_import_reject.dspy', + f'/{MODULE}/api/get_search_entity_type.dspy', + f'/{MODULE}/api/get_search_status.dspy', + f'/{MODULE}/api/get_search_import_status.dspy', + f'/{MODULE}/api/get_search_scene_id.dspy', + f'/{MODULE}/api/get_search_world_id.dspy', +] + + +def find_sage_root(): + here = os.path.dirname(os.path.abspath(__file__)) + candidates = [ + os.path.abspath(os.path.join(here, '..', '..', '..', '..')), + os.path.expanduser('~/repos/sage'), + os.path.expanduser('~/sage'), + ] + 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 def main(): - sys.path.insert(0, HERE) - try: - from set_role_perm import set_role_perm - except Exception: - # 找不到 set_role_perm 时回退到中央 sage/load_path.py 已注册的路径,仅提示 - print('WARN: set_role_perm 不可用,请确保中央 load_path.py 已包含上述路径') + sage_root = find_sage_root() + if not sage_root: + print('[entity] sage root not found, skip RBAC registration') return - for path in PATHS_LOGINED: - set_role_perm(path, 'logined') - for path in PATHS_ANY: - set_role_perm(path, 'any') - print(f'entity: registered {len(PATHS_LOGINED)} logined + {len(PATHS_ANY)} any paths') + python = os.path.join(sage_root, 'py3', 'bin', 'python') + set_role = os.path.join(sage_root, 'py3', 'bin', 'set_role_perm.py') + for p in PATHS_ANY: + subprocess.call([python, set_role, p, 'any']) + for p in PATHS_LOGINED: + subprocess.call([python, set_role, p, 'logined']) + print(f'[entity] RBAC registered: {len(PATHS_ANY)} any + {len(PATHS_LOGINED)} logined') if __name__ == '__main__': diff --git a/skill/SKILL.md b/skill/SKILL.md index db1c6ad..8ce6b4a 100644 --- a/skill/SKILL.md +++ b/skill/SKILL.md @@ -3,73 +3,61 @@ name: entity description: 实体管理模块(W-03)——实体表(entity)CRUD、列表查询、实体文件导入(entity_import 记录),通过 load_entity() 挂载。 --- -# entity 模块 +# entity 实体管理模块 ## 概述 -实体管理模块,提供实体表(entity)增删改查与文件导入(entity_import 记录表)。依赖 world、scene 模块(逻辑关联,不加物理外键)。 + +元景项目 W-03 实体管理:管理实体(entity)数据。实体归属于场景(scene_id→scene.id,逻辑关联,不加外键)与世界(world_id→world.id)。提供实体表 CRUD、分页列表查询、CSV/JSON 文件导入(两阶段校验+批量插入+失败回滚)。 ## 数据模型 -### 表 `entity`(models/entity.json) -| 字段 | 类型 | 说明 | -|------|------|------| -| id | str(32) | 主键 | -| world_id | str(32) | 所属世界(→world.id 逻辑关联) | -| scene_id | str(32) | 所属场景(→scene.id 逻辑关联,codes 段配置,不加物理外键) | -| name | str(255) | 实体名称 | -| code | str(64) | 实体编码(唯一,idx_entity_code) | -| entity_type | str(16) | 实体类型(appcodes entity_type) | -| status | str(16) | 状态(appcodes entity_status) | -| attributes_json | text | 属性 JSON | -| created_at | timestamp | 创建时间 | -| updated_at | timestamp | 更新时间 | +| 表 | 说明 | +|----|------| +| `entity` | 实体表。`entity_code` 唯一索引;`entity_type`/`status` 引用 appcodes_kv 字典;`scene_id`/`world_id` 为逻辑外键(codes 段,无物理外键);`import_id` 标记导入批次;`org_id` 机构隔离 | +| `entity_import` | 导入批次记录(系统生成,只读)。`import_no` 唯一;`status` ∈ importing/success/fail | +| `entity_import_log` | 导入逐行明细(系统生成,只读)。`result` 1=成功 0=失败 | -- 主键 `["id"]`;唯一索引 `idx_entity_code(code)`;索引 `idx_entity_world(world_id)`、`idx_entity_scene(scene_id)` -- codes:world_id → world(id/name)、scene_id → scene(id/name)、entity_type → appcodes_kv(parentid='entity_type')、status → appcodes_kv(parentid='entity_status') +编码字典(init/data.json 幂等落库 appcodes/appcodes_kv): +- `entity_type`:character/prop/vehicle/building/faction/other +- `common_status`:1=启用 / 0=停用 +- `import_status`:importing/success/fail -### 表 `entity_import`(models/entity_import.json,只读) -| 字段 | 类型 | 说明 | -|------|------|------| -| id | str(32) | 主键 | -| world_id | str(32) | 目标世界 | -| scene_id | str(32) | 目标场景 | -| file_name | str(255) | 导入文件名 | -| total / success / fail | int | 总数 / 成功 / 失败(默认 0) | -| status | str(16) | 导入状态(appcodes import_status:0 进行中/1 成功/2 失败) | -| created_at | timestamp | 导入时间 | +## 关键接口(REST 统一前缀 /api/*) -- 主键 `["id"]`;索引 `idx_entity_import_world(world_id)`;**只读记录,不可手工增删改** +| 接口 | 方法 | 说明 | 返回 | +|------|------|------|------| +| `/entity/api/entity_list.dspy` | GET/POST | 分页列表(keyword/entity_type/status/scene_id 过滤) | `{list, total, page, rows}` | +| `/entity/api/entity_create.dspy` | POST | 新增实体(校验失败 100% 拦截不落库) | `{success, data:{id}}` | +| `/entity/api/entity_update.dspy` | POST | 更新实体(校验+存在性检查) | `{success, data:{id}}` | +| `/entity/api/entity_delete.dspy` | POST | 删除实体 | `{success, data:{id}}` | +| `/entity/api/entity_get.dspy` | GET/POST | 实体详情 | `{success, data}` | +| `/entity/api/entity_import.dspy` | POST | 文件导入(file 域 / file_content) | `{success, data:{import_id,total,success,fail}}` | +| `/entity/api/entity_import_guard.dspy` | POST | 导入预校验(干跑,只校验不落库) | `{valid, errors, total}` | +| `/entity/api/entity_import_list.dspy` | GET/POST | 导入记录只读分页 | `{list, total, page, rows}` | -## 关键接口(load_entity() 挂载到 ServerEnv) -- `list_entities(params)` → `{list, total}`(sqlPaging 分页,支持 world_id/scene_id/entity_type/status/name/code 过滤 + sort/order) -- `get_entity({id})` → `{data}` 或错误 -- `create_entity(ns)` → 校验 + world/scene 存在校验 + 自动 id/created_at/updated_at;返回 `{success, id}` -- `update_entity(ns)` → 写 updated_at、剔除 `_text` 后缀、world/scene 校验 -- `delete_entity({id})` → `{success, id}` -- `import_entities({world_id, scene_id, file_name, rows})` → `{success, total, success_count, fail, file_name}` - (**事务批量**:先全量解析+校验(含文件内编码查重),再逐条插入;任一条失败整批回滚,无脏数据;success_count 避免与布尔 success 冲突) -- `parse_entity_file(content, filename)` → `{rows: [...]}`(JSON 数组 / JSON Lines / CSV 表头) -- `get_world_options()` / `get_scene_options()` / `get_entity_type_options()` / `get_entity_status_options()` / `get_import_status_options()` → `[{value, text}]` +错误结构统一:`{code, message, field, detail}`。分页结构统一:`{list, total}`。 -### wwwroot/api/*.dspy(REST 统一前缀 /api/*,宿主挂载后为 /entity/api/*.dspy) -- `entity_list.dspy`(分页 `{list,total}`)、`entity_get.dspy`、`entity_create.dspy`、`entity_update.dspy`、`entity_delete.dspy` -- `entity_import.dspy`(world_id + file → parse_entity_file → import_entities) -- `get_search_world_id.dspy` / `get_search_scene_id.dspy` / `get_search_entity_type.dspy` / `get_search_status.dspy` / `get_search_import_status.dspy`(字典下拉) +## 导入处理逻辑 -### 错误结构(统一) -`{code, message, field, detail}`;code:PARAM_REQUIRED / FIELD_REQUIRED / FIELD_TOO_LONG / -WORLD_NOT_FOUND / SCENE_NOT_FOUND / DUPLICATE_CODE / PARSE_ERROR / INVALID_JSON / DB_ERROR / NOT_FOUND。 -非法输入 100% 拦截不落库。 +1. **解析**:CSV(首行表头,字段 entity_code/entity_name/entity_type/scene_id/world_id/description/status/sort_no)或 JSON 数组。 +2. **全量校验(第一阶段,不落库)**:编码必填/长度/文件内唯一/库内唯一;名称必填;类型/状态字典白名单;scene_id/world_id 存在性。任一行非法 → 整批拦截,只写失败批次+逐行日志,不导入任何实体。 +3. **批量插入(第二阶段)**:同批实体以同一 `import_id` 标记插入;任一行异常 → 按 `import_id` 补偿 DELETE 已插入实体,保证失败回滚无脏数据。 +4. 成功/失败均写 `entity_import` 批次与 `entity_import_log` 明细。 ## 陷阱 -- 库名禁止硬编码:.py 用 `ServerEnv().get_module_dbname("entity")`,.dspy 用 `get_module_dbname("entity")`(全局);world/scene 表分别在 world/scene 模块库(`get_module_dbname("world"/"scene")`),appcodes 在 `get_module_dbname("appbase")` -- dspy 无 import(json/get_sor_context/debug/params_kw 均预加载全局);函数经 load_entity() 注册后为全局,直接调用 -- `create_entity` / `import_entities` 必须显式设置 `created_at = curDateString()`,否则 sor.C 静默丢记录 -- `entity_import` 记录 total/success/fail 必须为 int -- `import_entities` 返回用 `success_count`(避免与布尔 `success` 键冲突) -- 三处同步注册:entity/__init__.py 导出 ← entity/init.py 实现 ← load_entity() 注册(含复数别名 create_entities/update_entities/delete_entities) -- 返回 `{list,total}` 分页用 `sor.sqlPaging`,不要硬编码 LIMIT/OFFSET -- CRUD json `new_data_url` 指向自定义 `api/entity_create.dspy`;entity_import 为只读列表,无 editable + +- **sor.I() 只用于表结构**:插入用 `sor.C(table, ns)`,不要 `sor.I(table, data)`(I 只收 1 参)。 +- **sor.U() 只收 2 参**:`sor.U('entity', {**upd, 'id': id})`,id 必须在 data 内;仅主键字段时先校验非空字段。 +- **dspy 中禁 import**:全部函数经 `load_entity()` 注册为全局(`create_entity/update_entity/delete_entity/get_entity/list_entities/import_entities/entity_import_guard/reject_import_write`),dspy 直接调用;`__init__.py` 必须导出 init.py 的 async 函数,否则 NameError。 +- **取库名不硬编码**:.py 用 `get_sor_context(ServerEnv(), 'entity')`(内部解析 `get_module_dbname('entity')`);.dspy 用 `get_sor_context(request._run_ns, 'entity')`。禁止写 `DBNAME='...'`。 +- **请求上下文**:.py 内取用户/机构用 `request._run_ns`(get_user/get_userorgid),不要用 `ServerEnv()`(进程级单例)。 +- **`_text` 后缀字段**:表格提交会带 `xxx_text`,`_clean_ns` 会剔除后再写库。 +- **curDateString()**:sqlor 不自动写时间戳,插入必须显式 `created_at`,否则记录静默丢失。 +- **删除校验**:entity 可能被 world_snapshot 等引用,删除前可扩展引用检查(当前实现仅校验存在性)。 ## 依赖 -- world(world_id 逻辑关联 + 世界下拉)、scene(scene_id 逻辑关联 + 场景下拉)、appbase(appcodes 字典)、rbac(权限) + +- 基础:sqlor、ahserver、appPublic +- 数据依赖(逻辑关联,不物理外键):`scene`(scene.id)、`world`(world.id)——codes 段引用,表缺失时仅下拉不渲染,不影响 CRUD +- 字典:appbase 的 appcodes / appcodes_kv +- 被 `world_snapshot`(W-03 快照)聚合读取 diff --git a/wwwroot/api/entity_create.dspy b/wwwroot/api/entity_create.dspy index 577bbec..c06cb40 100644 --- a/wwwroot/api/entity_create.dspy +++ b/wwwroot/api/entity_create.dspy @@ -1,9 +1,4 @@ -# entity_create.dspy —— 实体新增(REST /api/*)。非法输入 100% 拦截不落库。 -try: - result = await create_entity(params_kw) - return {'code': 'OK', 'message': 'success', 'data': result} -except Exception as e: - if hasattr(e, 'to_dict'): - err = e.to_dict() - return {'code': err['code'], 'message': err['message'], 'field': err['field'], 'detail': err['detail']} - return {'code': 'DB_ERROR', 'message': '保存实体失败', 'field': '', 'detail': str(e)} +# -*- coding: utf-8 -*- +# entity_create.dspy —— 新增实体(非法输入 100% 拦截不落库) +result = await create_entity(request, params_kw) +return result diff --git a/wwwroot/api/entity_delete.dspy b/wwwroot/api/entity_delete.dspy index b03a5b5..b97b095 100644 --- a/wwwroot/api/entity_delete.dspy +++ b/wwwroot/api/entity_delete.dspy @@ -1,9 +1,4 @@ -# entity_delete.dspy —— 实体删除(REST /api/*) -try: - result = await delete_entity(params_kw) - return {'code': 'OK', 'message': 'success', 'data': result} -except Exception as e: - if hasattr(e, 'to_dict'): - err = e.to_dict() - return {'code': err['code'], 'message': err['message'], 'field': err['field'], 'detail': err['detail']} - return {'code': 'DB_ERROR', 'message': '删除实体失败', 'field': '', 'detail': str(e)} +# -*- coding: utf-8 -*- +# entity_delete.dspy —— 删除实体 +result = await delete_entity(request, params_kw) +return result diff --git a/wwwroot/api/entity_get.dspy b/wwwroot/api/entity_get.dspy index e322273..a321699 100644 --- a/wwwroot/api/entity_get.dspy +++ b/wwwroot/api/entity_get.dspy @@ -1,9 +1,4 @@ -# entity_get.dspy —— 实体单条查询(REST /api/*) -try: - result = await get_entity(params_kw) - return {'code': 'OK', 'message': 'success', 'data': result.get('data')} -except Exception as e: - if hasattr(e, 'to_dict'): - err = e.to_dict() - return {'code': err['code'], 'message': err['message'], 'field': err['field'], 'detail': err['detail']} - return {'code': 'DB_ERROR', 'message': '查询实体失败', 'field': '', 'detail': str(e)} +# -*- coding: utf-8 -*- +# entity_get.dspy —— 实体详情 +result = await get_entity(request, params_kw) +return result diff --git a/wwwroot/api/entity_import.dspy b/wwwroot/api/entity_import.dspy index 548d327..57c4e6e 100644 --- a/wwwroot/api/entity_import.dspy +++ b/wwwroot/api/entity_import.dspy @@ -1,35 +1,5 @@ -# entity_import.dspy —— 实体文件导入(REST /api/*) -# 入参:world_id / scene_id / file_name / file(文件内容,FileStorage 或文本) -# 事务批量:全量校验 → 整批插入;任一条失败回滚无脏数据。 -# 返回 {success, total, success_count, fail, file_name} -try: - content = None - fname = (params_kw.get('file_name') or '').strip() - file_obj = params_kw.get('file') - if file_obj is not None: - if hasattr(file_obj, 'read'): - content = file_obj.read() - else: - content = file_obj - if content is None: - content = params_kw.get('content') - if content is None and not fname: - raise EntityError('PARAM_REQUIRED', '缺少导入文件', 'file') - if not fname: - fname = (getattr(file_obj, 'filename', '') or '') if file_obj is not None else '' - parsed = await parse_entity_file(content, fname) - rows = parsed.get('rows', []) - imp_ns = { - 'world_id': params_kw.get('world_id'), - 'scene_id': params_kw.get('scene_id'), - 'file_name': fname or 'entity_import', - 'rows': rows, - } - result = await import_entities(imp_ns) - result['success'] = True - return {'code': 'OK', 'message': 'success', 'data': result} -except Exception as e: - if hasattr(e, 'to_dict'): - err = e.to_dict() - return {'code': err['code'], 'message': err['message'], 'field': err['field'], 'detail': err['detail']} - return {'code': 'DB_ERROR', 'message': '导入失败', 'field': '', 'detail': str(e)} +# -*- coding: utf-8 -*- +# entity_import.dspy —— 实体文件导入(文件域 file / file_content;两阶段校验+批量插入+失败回滚) +# 调用后端 import_entities(request, params_kw) +result = await import_entities(request, params_kw) +return result diff --git a/wwwroot/api/entity_import_guard.dspy b/wwwroot/api/entity_import_guard.dspy index 01f8ff7..1a78069 100644 --- a/wwwroot/api/entity_import_guard.dspy +++ b/wwwroot/api/entity_import_guard.dspy @@ -1 +1,4 @@ -return {'success': False, 'message': '导入记录为只读,不支持手工增删改'} +# -*- coding: utf-8 -*- +# entity_import_guard.dspy —— 导入预校验(干跑,只校验不落库) +result = await entity_import_guard(request, params_kw) +return result diff --git a/wwwroot/api/entity_import_list.dspy b/wwwroot/api/entity_import_list.dspy new file mode 100644 index 0000000..c82e67c --- /dev/null +++ b/wwwroot/api/entity_import_list.dspy @@ -0,0 +1,35 @@ +# -*- coding: utf-8 -*- +# entity_import_list.dspy —— 导入记录只读分页列表 +# entity_import 表为系统生成,禁止手工增删改;此处仅提供只读查询 +try: + params_kw = params_kw or {} + page = int(params_kw.get('page', 1) or 1) + rows = int(params_kw.get('rows', params_kw.get('pagerows', 20)) or 20) + if page < 1: + page = 1 + if rows < 1 or rows > 200: + rows = 20 + status = str(params_kw.get('status') or '').strip() + org_id = (await get_userorgid()) or '0' + where = ['1=1'] + ns = {} + if org_id and org_id != '0': + where.append("org_id IN ('0', ${org_id}$)") + ns['org_id'] = org_id + if status: + where.append('status = ${status}$') + ns['status'] = status + cond = ' AND '.join(where) + async with get_sor_context(request._run_ns, 'entity') as sor: + cnt = await sor.sqlExe('SELECT COUNT(*) AS cnt FROM entity_import WHERE ' + cond, ns) + total = int(cnt[0].cnt) if cnt else 0 + offset = (page - 1) * rows + ns['limit'] = int(rows) + ns['offset'] = int(offset) + recs = await sor.sqlExe( + 'SELECT id, import_no, file_name, total_count, success_count, fail_count, status, error_msg, created_at ' + 'FROM entity_import WHERE ' + cond + ' ORDER BY created_at DESC LIMIT ${limit}$ OFFSET ${offset}$', ns) + lst = [dict(r) for r in (recs or [])] + return {'list': lst, 'total': total, 'page': page, 'rows': rows} +except Exception as e: + return {'code': 'IMPORT_LIST_ERROR', 'message': '导入记录查询失败', 'field': '', 'detail': str(e)} diff --git a/wwwroot/api/entity_import_reject.dspy b/wwwroot/api/entity_import_reject.dspy new file mode 100644 index 0000000..f7be8e1 --- /dev/null +++ b/wwwroot/api/entity_import_reject.dspy @@ -0,0 +1,4 @@ +# -*- coding: utf-8 -*- +# entity_import_reject.dspy —— 导入记录系统生成,禁止手工增删改 +result = await reject_import_write(request, params_kw) +return result diff --git a/wwwroot/api/entity_list.dspy b/wwwroot/api/entity_list.dspy index 7b2b349..2afddc6 100644 --- a/wwwroot/api/entity_list.dspy +++ b/wwwroot/api/entity_list.dspy @@ -1,12 +1,5 @@ -# entity_list.dspy —— 实体分页列表查询(REST /api/*,宿主挂载后 /entity/api/entity_list.dspy) -# 返回 {list, total};错误统一 {code, message, field, detail} -try: - result = await list_entities(params_kw) - if isinstance(result, dict) and 'list' in result and 'total' in result: - return {'code': 'OK', 'message': 'success', 'data': result} - return {'code': 'OK', 'message': 'success', 'data': {'list': result.get('list', []), 'total': result.get('total', 0)}} -except Exception as e: - if hasattr(e, 'to_dict'): - err = e.to_dict() - return {'code': err['code'], 'message': err['message'], 'field': err['field'], 'detail': err['detail']} - return {'code': 'DB_ERROR', 'message': '查询实体列表失败', 'field': '', 'detail': str(e)} +# -*- coding: utf-8 -*- +# entity_list.dspy —— 实体分页列表(REST 统一前缀 /api/*) +# 返回 {list, total, page, rows};错误 {code, message, field, detail} +result = await list_entities(request, params_kw) +return result diff --git a/wwwroot/api/entity_update.dspy b/wwwroot/api/entity_update.dspy index 742101e..3d8c9d3 100644 --- a/wwwroot/api/entity_update.dspy +++ b/wwwroot/api/entity_update.dspy @@ -1,9 +1,4 @@ -# entity_update.dspy —— 实体更新(REST /api/*) -try: - result = await update_entity(params_kw) - return {'code': 'OK', 'message': 'success', 'data': result} -except Exception as e: - if hasattr(e, 'to_dict'): - err = e.to_dict() - return {'code': err['code'], 'message': err['message'], 'field': err['field'], 'detail': err['detail']} - return {'code': 'DB_ERROR', 'message': '更新实体失败', 'field': '', 'detail': str(e)} +# -*- coding: utf-8 -*- +# entity_update.dspy —— 更新实体 +result = await update_entity(request, params_kw) +return result diff --git a/wwwroot/api/get_search_entity_type.dspy b/wwwroot/api/get_search_entity_type.dspy index 7aa6efd..aa242d0 100644 --- a/wwwroot/api/get_search_entity_type.dspy +++ b/wwwroot/api/get_search_entity_type.dspy @@ -1,6 +1,10 @@ -# get_search_entity_type.dspy —— 实体类型字典下拉(appcodes entity_type)[{value, text}] +# -*- coding: utf-8 -*- +# get_search_entity_type.dspy —— 实体类型下拉([{value, text}],含全部) +result = [{'value': '', 'text': '全部'}] try: - opts = await get_entity_type_options() - return {'code': 'OK', 'message': 'success', 'data': opts} + async with get_sor_context(request._run_ns, 'entity') as sor: + recs = await sor.sqlExe("SELECT k AS value, v AS text FROM appcodes_kv WHERE parentid = 'entity_type' ORDER BY k", {}) + result = result + [dict(r) for r in (recs or [])] except Exception as e: - return {'code': 'DB_ERROR', 'message': '查询实体类型下拉失败', 'field': '', 'detail': str(e)} + debug(f'get_search_entity_type error: {e}') +return json.dumps(result, ensure_ascii=False) diff --git a/wwwroot/api/get_search_import_status.dspy b/wwwroot/api/get_search_import_status.dspy index b230ef7..a66e228 100644 --- a/wwwroot/api/get_search_import_status.dspy +++ b/wwwroot/api/get_search_import_status.dspy @@ -1,6 +1,10 @@ -# get_search_import_status.dspy —— 导入状态字典下拉(appcodes import_status)[{value, text}] +# -*- coding: utf-8 -*- +# get_search_import_status.dspy —— 导入状态下拉([{value, text}],含全部) +result = [{'value': '', 'text': '全部'}] try: - opts = await get_import_status_options() - return {'code': 'OK', 'message': 'success', 'data': opts} + async with get_sor_context(request._run_ns, 'entity') as sor: + recs = await sor.sqlExe("SELECT k AS value, v AS text FROM appcodes_kv WHERE parentid = 'import_status' ORDER BY k", {}) + result = result + [dict(r) for r in (recs or [])] except Exception as e: - return {'code': 'DB_ERROR', 'message': '查询导入状态下拉失败', 'field': '', 'detail': str(e)} + debug(f'get_search_import_status error: {e}') +return json.dumps(result, ensure_ascii=False) diff --git a/wwwroot/api/get_search_scene_id.dspy b/wwwroot/api/get_search_scene_id.dspy index 8ccdc2e..b5b344d 100644 --- a/wwwroot/api/get_search_scene_id.dspy +++ b/wwwroot/api/get_search_scene_id.dspy @@ -1,6 +1,10 @@ -# get_search_scene_id.dspy —— 场景下拉数据源 [{value, text}] +# -*- coding: utf-8 -*- +# get_search_scene_id.dspy —— 场景下拉([{value, text}],含全部;同库,dbname 已路由到 entity 库) +result = [{'value': '', 'text': '全部'}] try: - opts = await get_scene_options() - return {'code': 'OK', 'message': 'success', 'data': opts} + async with get_sor_context(request._run_ns, 'entity') as sor: + recs = await sor.sqlExe('SELECT id AS value, scene_name AS text FROM scene ORDER BY scene_name', {}) + result = result + [dict(r) for r in (recs or [])] except Exception as e: - return {'code': 'DB_ERROR', 'message': '查询场景下拉失败', 'field': '', 'detail': str(e)} + debug(f'get_search_scene_id error: {e}') +return json.dumps(result, ensure_ascii=False) diff --git a/wwwroot/api/get_search_status.dspy b/wwwroot/api/get_search_status.dspy index c31116e..c3452cd 100644 --- a/wwwroot/api/get_search_status.dspy +++ b/wwwroot/api/get_search_status.dspy @@ -1,6 +1,10 @@ -# get_search_status.dspy —— 实体状态字典下拉(appcodes entity_status)[{value, text}] +# -*- coding: utf-8 -*- +# get_search_status.dspy —— 实体状态下拉([{value, text}],含全部) +result = [{'value': '', 'text': '全部'}] try: - opts = await get_entity_status_options() - return {'code': 'OK', 'message': 'success', 'data': opts} + async with get_sor_context(request._run_ns, 'entity') as sor: + recs = await sor.sqlExe("SELECT k AS value, v AS text FROM appcodes_kv WHERE parentid = 'common_status' ORDER BY k", {}) + result = result + [dict(r) for r in (recs or [])] except Exception as e: - return {'code': 'DB_ERROR', 'message': '查询状态下拉失败', 'field': '', 'detail': str(e)} + debug(f'get_search_status error: {e}') +return json.dumps(result, ensure_ascii=False) diff --git a/wwwroot/api/get_search_world_id.dspy b/wwwroot/api/get_search_world_id.dspy index 44e97da..8ba2c8d 100644 --- a/wwwroot/api/get_search_world_id.dspy +++ b/wwwroot/api/get_search_world_id.dspy @@ -1,6 +1,10 @@ -# get_search_world_id.dspy —— 世界下拉数据源 [{value, text}] +# -*- coding: utf-8 -*- +# get_search_world_id.dspy —— 世界下拉([{value, text}],含全部;同库,dbname 已路由到 entity 库) +result = [{'value': '', 'text': '全部'}] try: - opts = await get_world_options() - return {'code': 'OK', 'message': 'success', 'data': opts} + async with get_sor_context(request._run_ns, 'entity') as sor: + recs = await sor.sqlExe('SELECT id AS value, world_name AS text FROM world ORDER BY world_name', {}) + result = result + [dict(r) for r in (recs or [])] except Exception as e: - return {'code': 'DB_ERROR', 'message': '查询世界下拉失败', 'field': '', 'detail': str(e)} + debug(f'get_search_world_id error: {e}') +return json.dumps(result, ensure_ascii=False) diff --git a/wwwroot/i18n/en-US.json b/wwwroot/i18n/en-US.json index fb9d731..e836283 100644 --- a/wwwroot/i18n/en-US.json +++ b/wwwroot/i18n/en-US.json @@ -1,96 +1,55 @@ { - "entity.page.index.title": "Entity Management", - "entity.page.index.subtitle": "Entity table CRUD and list query", - "entity.page.import.title": "Entity Import", - "entity.page.import.subtitle": "Batch import entities from file (JSON Array / JSON Lines / CSV)", - "entity.btn.add": "Add", - "entity.btn.edit": "Edit", - "entity.btn.delete": "Delete", - "entity.btn.import": "Import", - "entity.btn.refresh": "Refresh", - "entity.btn.search": "Search", - "entity.btn.reset": "Reset", - "entity.btn.save": "Save", - "entity.btn.cancel": "Cancel", - "entity.btn.confirm": "OK", - "entity.btn.choose_file": "Choose File", - "entity.btn.start_import": "Start Import", - "entity.btn.download_template": "Download Template", - "entity.btn.back": "Back", - "entity.col.name": "Entity Name", - "entity.col.code": "Entity Code", - "entity.col.entity_type": "Entity Type", - "entity.col.status": "Status", - "entity.col.world_id": "World", - "entity.col.scene_id": "Scene", - "entity.col.attributes_json": "Attributes", - "entity.col.created_at": "Created At", - "entity.col.updated_at": "Updated At", - "entity.col.actions": "Actions", - "entity.filter.world_id": "World", - "entity.filter.scene_id": "Scene", - "entity.filter.entity_type": "Entity Type", - "entity.filter.status": "Status", - "entity.filter.keyword": "Name/Code Keyword", - "entity.filter.placeholder.select": "Select", - "entity.filter.placeholder.input": "Input", - "entity.form.name": "Entity Name", - "entity.form.code": "Entity Code", - "entity.form.world_id": "World", - "entity.form.scene_id": "Scene", - "entity.form.entity_type": "Entity Type", - "entity.form.status": "Status", - "entity.form.attributes_json": "Attributes (JSON)", - "entity.form.placeholder.name": "Enter entity name", - "entity.form.placeholder.code": "Enter entity code", - "entity.form.placeholder.attributes_json": "e.g. {\"hp\":100,\"atk\":10}", - "entity.import.form.world_id": "Target World", - "entity.import.form.scene_id": "Target Scene", - "entity.import.form.file": "Import File", - "entity.import.col.file_name": "File Name", - "entity.import.col.total": "Total", - "entity.import.col.success": "Success", - "entity.import.col.fail": "Fail", - "entity.import.col.status": "Import Status", - "entity.import.col.created_at": "Imported At", - "entity.import.history.title": "Import History", - "entity.msg.confirm_delete": "Delete this entity?", - "entity.msg.delete_ok": "Deleted successfully", - "entity.msg.delete_fail": "Delete failed", - "entity.msg.save_ok": "Saved successfully", - "entity.msg.save_fail": "Save failed", - "entity.msg.importing": "Importing, please wait...", - "entity.msg.import_ok": "Import done: {total} total, {success} succeeded, {fail} failed", - "entity.msg.import_fail": "Import failed", - "entity.msg.file_format_hint": "Supports JSON Array / JSON Lines / CSV (header row)", - "entity.msg.file_required": "Please choose an import file first", - "entity.msg.no_data": "No data", - "entity.msg.loading": "Loading...", - "entity.dict.entity_type.npc": "NPC", - "entity.dict.entity_type.item": "Item", - "entity.dict.entity_type.building": "Building", - "entity.dict.entity_type.prop": "Prop", - "entity.dict.entity_type.other": "Other", - "entity.dict.entity_status.0": "Enabled", - "entity.dict.entity_status.1": "Disabled", - "entity.dict.import_status.0": "In Progress", - "entity.dict.import_status.1": "Success", - "entity.dict.import_status.2": "Failed", - "entity.err.PARAM_REQUIRED": "Missing required parameter", - "entity.err.FIELD_REQUIRED": "{label} is required", - "entity.err.FIELD_TOO_LONG": "{label} length cannot exceed {max}", - "entity.err.WORLD_NOT_FOUND": "World not found", - "entity.err.SCENE_NOT_FOUND": "Scene not found", - "entity.err.DUPLICATE_CODE": "Entity code already exists", - "entity.err.PARSE_ERROR": "File parse error", - "entity.err.INVALID_JSON": "Invalid attributes JSON", - "entity.err.DB_ERROR": "Database operation failed", - "entity.err.NOT_FOUND": "Record not found", - "entity.field.name": "Entity Name", - "entity.field.code": "Entity Code", - "entity.field.world_id": "World", - "entity.field.scene_id": "Scene", + "entity.management": "Entity Management", + "entity.list": "Entity List", + "entity.list.desc": "Entity CRUD / paginated list query", + "entity.import": "Entity Import", + "entity.import.desc": "CSV/JSON file import with full validation and rollback", + "entity.import.title": "Entity Import", + "entity.import.tip": "Supports CSV (header row) or JSON array; fields: entity_code, entity_name, entity_type, scene_id, world_id, description, status, sort_no. Full validation before import; any invalid row blocks the whole batch; batch insert failure auto-rolls back with no dirty data.", + "entity.import.select": "Select file and import", + "entity.import.file": "Import file (CSV/JSON)", + "entity.import.submit": "Start Import", + "entity.import.guard": "Validate (dry-run)", + "entity.import.guard.title": "Import Pre-validation (dry-run)", + "entity.import.records": "Recent Import Records", + "entity.import.records.title": "Import Records (read-only, system-generated)", + "entity.import.import_no": "Import No.", + "entity.import.file_name": "File Name", + "entity.import.total_count": "Total Rows", + "entity.import.success_count": "Success", + "entity.import.fail_count": "Fail", + "entity.import.status": "Status", + "entity.import.created_at": "Imported At", + "entity.field.entity_code": "Entity Code", + "entity.field.entity_name": "Entity Name", "entity.field.entity_type": "Entity Type", + "entity.field.scene_id": "Scene", + "entity.field.world_id": "World", + "entity.field.description": "Description", "entity.field.status": "Status", - "entity.field.attributes_json": "Attributes JSON" -} \ No newline at end of file + "entity.field.sort_no": "Sort No.", + "entity.filter.keyword": "Name/Code", + "entity.search": "Search", + "entity.code.ENTITY_LIST_ERROR": "Entity list query failed", + "entity.code.ENTITY_GET_ERROR": "Entity query failed", + "entity.code.ENTITY_CREATE_ERROR": "Entity create failed", + "entity.code.ENTITY_UPDATE_ERROR": "Entity update failed", + "entity.code.ENTITY_DELETE_ERROR": "Entity delete failed", + "entity.code.ENTITY_ID_REQUIRED": "Entity ID is required", + "entity.code.ENTITY_NOT_FOUND": "Entity not found", + "entity.code.ENTITY_VALIDATE_FAIL": "Validation failed", + "entity.code.ENTITY_UPDATE_EMPTY": "No fields to update", + "entity.code.IMPORT_ERROR": "Entity import failed", + "entity.code.IMPORT_EMPTY_FILE": "Import file is empty", + "entity.code.IMPORT_PARSE_ERROR": "File parse failed", + "entity.code.IMPORT_EMPTY_ROWS": "No valid rows in file", + "entity.code.IMPORT_TOO_MANY": "Single import cannot exceed 2000 rows", + "entity.code.IMPORT_VALIDATE_FAIL": "Validation failed, no data imported", + "entity.code.IMPORT_INSERT_FAIL": "Batch insert failed, rolled back", + "entity.code.IMPORT_GUARD_ERROR": "Import pre-validation failed", + "entity.code.IMPORT_ROW_INVALID": "Invalid row format", + "entity.code.IMPORT_LIST_ERROR": "Import records query failed", + "entity.code.IMPORT_RECORD_READONLY": "Import records are system-generated, manual write is forbidden", + "entity.msg.import.success": "Import succeeded", + "entity.msg.import.fail": "Import failed" +} diff --git a/wwwroot/i18n/zh-CN.json b/wwwroot/i18n/zh-CN.json index 016a364..4d74e12 100644 --- a/wwwroot/i18n/zh-CN.json +++ b/wwwroot/i18n/zh-CN.json @@ -1,96 +1,55 @@ { - "entity.page.index.title": "实体管理", - "entity.page.index.subtitle": "实体表(entity)增删改查与列表查询", - "entity.page.import.title": "实体导入", - "entity.page.import.subtitle": "通过文件批量导入实体(支持 JSON 数组 / JSON Lines / CSV)", - "entity.btn.add": "新增", - "entity.btn.edit": "编辑", - "entity.btn.delete": "删除", - "entity.btn.import": "导入", - "entity.btn.refresh": "刷新", - "entity.btn.search": "查询", - "entity.btn.reset": "重置", - "entity.btn.save": "保存", - "entity.btn.cancel": "取消", - "entity.btn.confirm": "确定", - "entity.btn.choose_file": "选择文件", - "entity.btn.start_import": "开始导入", - "entity.btn.download_template": "下载模板", - "entity.btn.back": "返回", - "entity.col.name": "实体名称", - "entity.col.code": "实体编码", - "entity.col.entity_type": "实体类型", - "entity.col.status": "状态", - "entity.col.world_id": "所属世界", - "entity.col.scene_id": "所属场景", - "entity.col.attributes_json": "属性", - "entity.col.created_at": "创建时间", - "entity.col.updated_at": "更新时间", - "entity.col.actions": "操作", - "entity.filter.world_id": "所属世界", - "entity.filter.scene_id": "所属场景", - "entity.filter.entity_type": "实体类型", - "entity.filter.status": "状态", - "entity.filter.keyword": "名称/编码关键字", - "entity.filter.placeholder.select": "请选择", - "entity.filter.placeholder.input": "请输入", - "entity.form.name": "实体名称", - "entity.form.code": "实体编码", - "entity.form.world_id": "所属世界", - "entity.form.scene_id": "所属场景", - "entity.form.entity_type": "实体类型", - "entity.form.status": "状态", - "entity.form.attributes_json": "属性(JSON)", - "entity.form.placeholder.name": "请输入实体名称", - "entity.form.placeholder.code": "请输入实体编码", - "entity.form.placeholder.attributes_json": "如 {\"hp\":100,\"atk\":10}", - "entity.import.form.world_id": "目标世界", - "entity.import.form.scene_id": "目标场景", - "entity.import.form.file": "导入文件", - "entity.import.col.file_name": "文件名", - "entity.import.col.total": "总数", - "entity.import.col.success": "成功", - "entity.import.col.fail": "失败", - "entity.import.col.status": "导入状态", - "entity.import.col.created_at": "导入时间", - "entity.import.history.title": "导入记录", - "entity.msg.confirm_delete": "确认删除该实体?", - "entity.msg.delete_ok": "删除成功", - "entity.msg.delete_fail": "删除失败", - "entity.msg.save_ok": "保存成功", - "entity.msg.save_fail": "保存失败", - "entity.msg.importing": "导入中,请稍候...", - "entity.msg.import_ok": "导入完成:共 {total} 条,成功 {success} 条,失败 {fail} 条", - "entity.msg.import_fail": "导入失败", - "entity.msg.file_format_hint": "支持 JSON 数组 / JSON Lines / CSV(首行表头)", - "entity.msg.file_required": "请先选择导入文件", - "entity.msg.no_data": "暂无数据", - "entity.msg.loading": "加载中...", - "entity.dict.entity_type.npc": "NPC", - "entity.dict.entity_type.item": "物品", - "entity.dict.entity_type.building": "建筑", - "entity.dict.entity_type.prop": "道具", - "entity.dict.entity_type.other": "其他", - "entity.dict.entity_status.0": "启用", - "entity.dict.entity_status.1": "禁用", - "entity.dict.import_status.0": "进行中", - "entity.dict.import_status.1": "成功", - "entity.dict.import_status.2": "失败", - "entity.err.PARAM_REQUIRED": "缺少必要参数", - "entity.err.FIELD_REQUIRED": "{label}不能为空", - "entity.err.FIELD_TOO_LONG": "{label}长度不能超过{max}", - "entity.err.WORLD_NOT_FOUND": "所属世界不存在", - "entity.err.SCENE_NOT_FOUND": "所属场景不存在", - "entity.err.DUPLICATE_CODE": "实体编码已存在", - "entity.err.PARSE_ERROR": "文件解析失败", - "entity.err.INVALID_JSON": "属性JSON格式错误", - "entity.err.DB_ERROR": "数据库操作失败", - "entity.err.NOT_FOUND": "记录不存在", - "entity.field.name": "实体名称", - "entity.field.code": "实体编码", - "entity.field.world_id": "所属世界", - "entity.field.scene_id": "所属场景", + "entity.management": "实体管理", + "entity.list": "实体列表", + "entity.list.desc": "实体表 CRUD / 分页列表查询", + "entity.import": "实体导入", + "entity.import.desc": "CSV/JSON 文件导入,校验失败全量拦截回滚", + "entity.import.title": "实体导入", + "entity.import.tip": "支持 CSV(首行表头)或 JSON 数组;字段:entity_code, entity_name, entity_type, scene_id, world_id, description, status, sort_no。导入前全量校验,任一行非法则整批拦截;批量插入失败自动回滚,不留脏数据。", + "entity.import.select": "选择文件并导入", + "entity.import.file": "导入文件(CSV/JSON)", + "entity.import.submit": "开始导入", + "entity.import.guard": "校验(不落库)", + "entity.import.guard.title": "导入预校验(干跑)", + "entity.import.records": "最近导入记录", + "entity.import.records.title": "导入记录(只读,系统生成)", + "entity.import.import_no": "导入单号", + "entity.import.file_name": "文件名", + "entity.import.total_count": "总行数", + "entity.import.success_count": "成功", + "entity.import.fail_count": "失败", + "entity.import.status": "状态", + "entity.import.created_at": "导入时间", + "entity.field.entity_code": "实体编码", + "entity.field.entity_name": "实体名称", "entity.field.entity_type": "实体类型", + "entity.field.scene_id": "所属场景", + "entity.field.world_id": "所属世界", + "entity.field.description": "描述", "entity.field.status": "状态", - "entity.field.attributes_json": "属性JSON" -} \ No newline at end of file + "entity.field.sort_no": "排序号", + "entity.filter.keyword": "实体名称/编码", + "entity.search": "搜索", + "entity.code.ENTITY_LIST_ERROR": "实体列表查询失败", + "entity.code.ENTITY_GET_ERROR": "实体查询失败", + "entity.code.ENTITY_CREATE_ERROR": "实体创建失败", + "entity.code.ENTITY_UPDATE_ERROR": "实体更新失败", + "entity.code.ENTITY_DELETE_ERROR": "实体删除失败", + "entity.code.ENTITY_ID_REQUIRED": "实体ID不能为空", + "entity.code.ENTITY_NOT_FOUND": "实体不存在", + "entity.code.ENTITY_VALIDATE_FAIL": "数据校验失败", + "entity.code.ENTITY_UPDATE_EMPTY": "没有需要更新的字段", + "entity.code.IMPORT_ERROR": "实体导入失败", + "entity.code.IMPORT_EMPTY_FILE": "导入文件为空", + "entity.code.IMPORT_PARSE_ERROR": "文件解析失败", + "entity.code.IMPORT_EMPTY_ROWS": "文件中没有有效数据行", + "entity.code.IMPORT_TOO_MANY": "单次导入不能超过2000行", + "entity.code.IMPORT_VALIDATE_FAIL": "导入校验失败,未导入任何数据", + "entity.code.IMPORT_INSERT_FAIL": "实体批量插入失败,已回滚", + "entity.code.IMPORT_GUARD_ERROR": "导入预校验失败", + "entity.code.IMPORT_ROW_INVALID": "行数据格式非法", + "entity.code.IMPORT_LIST_ERROR": "导入记录查询失败", + "entity.code.IMPORT_RECORD_READONLY": "导入记录为系统生成,禁止手工增删改", + "entity.msg.import.success": "导入成功", + "entity.msg.import.fail": "导入失败" +} diff --git a/wwwroot/import_page.ui b/wwwroot/import_page.ui index 633988f..27ea240 100644 --- a/wwwroot/import_page.ui +++ b/wwwroot/import_page.ui @@ -2,20 +2,74 @@ "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,world_id,scene_id,entity_type,status,attributes_json)。事务批量:任一条校验失败整批回滚,无脏数据。"}}, - {"widgettype": "Form", - "options": { - "width": "100%", - "submit_url": "{{entire_url('/entity/api/entity_import.dspy')}}", - "method": "POST", - "fields": [ - {"name": "world_id", "label": "目标世界ID", "uitype": "code", "dataurl": "{{entire_url('/entity/api/get_search_world_id.dspy')}}"}, - {"name": "scene_id", "label": "目标场景ID", "uitype": "code", "dataurl": "{{entire_url('/entity/api/get_search_scene_id.dspy')}}"}, - {"name": "file_name", "label": "文件名", "uitype": "text"}, - {"name": "file", "label": "导入文件", "uitype": "file", "required": true} - ], - "buttons": [{"label": "开始导入", "type": "submit"}] - }} + { + "widgettype": "Text", + "options": {"label": "实体导入", "fontSize": "20px", "fontWeight": "bold"} + }, + { + "widgettype": "Text", + "options": {"label": "支持 CSV(首行表头)或 JSON 数组;字段:entity_code, entity_name, entity_type, scene_id, world_id, description, status, sort_no。导入前全量校验,任一行非法则整批拦截;批量插入失败自动回滚,不留脏数据。", "fontSize": "12px", "color": "#666666"} + }, + { + "widgettype": "Form", + "options": { + "title": "选择文件并导入", + "submit_url": "{{entire_url('/entity/api/entity_import.dspy')}}", + "method": "POST", + "width": "640px", + "padding": "16px" + }, + "subwidgets": [ + { + "widgettype": "UiFile", + "options": {"label": "导入文件(CSV/JSON)", "name": "file", "required": true} + }, + { + "widgettype": "HBox", + "options": {"gap": "12px"}, + "subwidgets": [ + { + "widgettype": "Button", + "options": {"label": "开始导入", "actiontype": "submit", "width": "120px"} + }, + { + "widgettype": "Button", + "options": {"label": "校验(不落库)", "width": "120px"}, + "binds": [ + { + "wid": "self", + "event": "click", + "actiontype": "urlwidget", + "target": "PopupWindow", + "popup_options": {"title": "导入预校验(干跑)", "height": "300px", "width": "600px"}, + "options": {"method": "POST", "url": "{{entire_url('/entity/api/entity_import_guard.dspy')}}"} + } + ] + } + ] + } + ] + }, + { + "widgettype": "Text", + "options": {"label": "最近导入记录", "fontSize": "16px", "fontWeight": "bold", "marginTop": "24px"} + }, + { + "widgettype": "Tabular", + "options": { + "title": "导入记录(只读,系统生成)", + "data_url": "{{entire_url('/entity/api/entity_import_list.dspy')}}", + "data_params": {"page": 1, "rows": 20}, + "columns": [ + {"field": "import_no", "title": "导入单号", "width": "20%"}, + {"field": "file_name", "title": "文件名", "width": "22%"}, + {"field": "total_count", "title": "总行数", "width": "10%"}, + {"field": "success_count", "title": "成功", "width": "10%"}, + {"field": "fail_count", "title": "失败", "width": "10%"}, + {"field": "status", "title": "状态", "width": "12%"}, + {"field": "created_at", "title": "导入时间", "width": "16%"} + ] + } + } ] } diff --git a/wwwroot/index.ui b/wwwroot/index.ui index 1964cf3..7a4d252 100644 --- a/wwwroot/index.ui +++ b/wwwroot/index.ui @@ -2,18 +2,54 @@ "widgettype": "VBox", "options": {"width": "100%", "height": "100%", "padding": "20px"}, "subwidgets": [ - {"widgettype": "Text", "options": {"label": "实体管理(W-03)", "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.entity_content", "mode": "replace", "options": {"url": "{{entire_url('/entity/entity_list')}}"}}], - "subwidgets": [{"widgettype": "Text", "options": {"label": "实体列表"}}]}, - {"widgettype": "VBox", "options": {"backgroundColor": "#FFFFFF", "padding": "20px", "cursor": "pointer"}, - "binds": [{"wid": "self", "event": "click", "actiontype": "urlwidget", "target": "app.entity_content", "mode": "replace", "options": {"url": "{{entire_url('/entity/import_page.ui')}}"}}], - "subwidgets": [{"widgettype": "Text", "options": {"label": "实体导入"}}]}, - {"widgettype": "VBox", "options": {"backgroundColor": "#FFFFFF", "padding": "20px", "cursor": "pointer"}, - "binds": [{"wid": "self", "event": "click", "actiontype": "urlwidget", "target": "app.entity_content", "mode": "replace", "options": {"url": "{{entire_url('/entity/entity_import_list')}}"}}], - "subwidgets": [{"widgettype": "Text", "options": {"label": "导入记录"}}]} - ]}, - {"widgettype": "VBox", "id": "app.entity_content", "options": {"width": "100%", "flex": "1", "marginTop": "20px"}} + { + "widgettype": "Text", + "options": {"label": "实体管理", "fontSize": "24px", "fontWeight": "bold"} + }, + { + "widgettype": "ResponsableBox", + "options": {"gap": "16px", "minWidth": "250px"}, + "subwidgets": [ + { + "widgettype": "VBox", + "options": {"backgroundColor": "#FFFFFF", "padding": "20px", "cursor": "pointer", "borderRadius": "8px", "boxShadow": "0 1px 4px rgba(0,0,0,0.08)"}, + "binds": [ + { + "wid": "self", + "event": "click", + "actiontype": "urlwidget", + "target": "app.entity_content", + "options": {"url": "{{entire_url('entity/entity_list.ui')}}", "mode": "replace"} + } + ], + "subwidgets": [ + {"widgettype": "Text", "options": {"label": "实体列表", "fontSize": "16px"}}, + {"widgettype": "Text", "options": {"label": "实体表 CRUD / 分页列表查询", "fontSize": "12px", "color": "#888888"}} + ] + }, + { + "widgettype": "VBox", + "options": {"backgroundColor": "#FFFFFF", "padding": "20px", "cursor": "pointer", "borderRadius": "8px", "boxShadow": "0 1px 4px rgba(0,0,0,0.08)"}, + "binds": [ + { + "wid": "self", + "event": "click", + "actiontype": "urlwidget", + "target": "app.entity_content", + "options": {"url": "{{entire_url('import_page.ui')}}", "mode": "replace"} + } + ], + "subwidgets": [ + {"widgettype": "Text", "options": {"label": "实体导入", "fontSize": "16px"}}, + {"widgettype": "Text", "options": {"label": "CSV/JSON 文件导入,校验失败全量拦截回滚", "fontSize": "12px", "color": "#888888"}} + ] + } + ] + }, + { + "widgettype": "VBox", + "id": "app.entity_content", + "options": {"width": "100%", "flex": "1", "marginTop": "20px"} + } ] }