feat(entity): W-03 实体管理模块——entity/entity_import/entity_import_log 表 CRUD、列表查询、文件导入(两阶段校验+失败回滚)、entity_type 字典幂等落库、i18n 提取、RBAC 注册

This commit is contained in:
agent.develop 2026-08-29 15:45:36 +08:00
parent d15085f644
commit d049837ec7
34 changed files with 1160 additions and 1165 deletions

21
.gitignore vendored
View File

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

View File

@ -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_statusappcodes幂等落库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 # 模块技能文档
```

View File

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

29
build.sh Normal file
View File

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

View File

@ -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.<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 才允许提交)。
## 新增文案流程
1. 在 `wwwroot/i18n/zh-CN.json``en-US.json` 同时新增同 key。
2. 同步更新 `docs/i18n.md` 本说明(如 key 分组有变化)。
3. 部署时 i18n 文件随 wwwroot 一起链接到宿主应用 wwwroot。

View File

@ -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/*.dspy11 个接口、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}.jsonkey 前缀 `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 在测试环境执行。

View File

@ -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',
]

View File

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

View File

@ -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": "部分成功"}
]
}
]
}
}

View File

@ -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')}}"
}
}
"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')}}"
}
}
}

View File

@ -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')}}"
}
}
}
}
}

View File

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

View File

@ -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_status0进行中/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"}
]
}
"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'"}
]
}

View File

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

View File

@ -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__':

View File

@ -3,73 +3,61 @@ name: entity
description: 实体管理模块W-03——实体表entityCRUD、列表查询、实体文件导入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)`
- codesworld_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_status0 进行中/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/*.dspyREST 统一前缀 /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}`codePARAM_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 无 importjson/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 等引用,删除前可扩展引用检查(当前实现仅校验存在性)。
## 依赖
- worldworld_id 逻辑关联 + 世界下拉、scenescene_id 逻辑关联 + 场景下拉、appbaseappcodes 字典、rbac权限
- 基础sqlor、ahserver、appPublic
- 数据依赖(逻辑关联,不物理外键):`scene`scene.id`world`world.id——codes 段引用,表缺失时仅下拉不渲染,不影响 CRUD
- 字典appbase 的 appcodes / appcodes_kv
- 被 `world_snapshot`W-03 快照)聚合读取

View File

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

View File

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

View File

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

View File

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

View File

@ -1 +1,4 @@
return {'success': False, 'message': '导入记录为只读,不支持手工增删改'}
# -*- coding: utf-8 -*-
# entity_import_guard.dspy —— 导入预校验(干跑,只校验不落库)
result = await entity_import_guard(request, params_kw)
return result

View File

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

View File

@ -0,0 +1,4 @@
# -*- coding: utf-8 -*-
# entity_import_reject.dspy —— 导入记录系统生成,禁止手工增删改
result = await reject_import_write(request, params_kw)
return result

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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"
}
"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": "导入失败"
}

View File

@ -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%"}
]
}
}
]
}

View File

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