approve: entity 模块开发(W-03 实体管理)

This commit is contained in:
agent.develop 2026-08-29 16:15:30 +08:00
parent d049837ec7
commit c40e2ce8a0
38 changed files with 35 additions and 1342 deletions

17
.gitignore vendored
View File

@ -1,16 +1 @@
# __pycache__/ 忽略生成物/CRUD 目录
*.py[cod]
*$py.class
build/
dist/
*.egg-info/
.eggs/
*.swp
*.swo
# CRUD 生成目录xls2ui 构建产物,由 json/*.json + models/*.json 重新生成,不入库)
wwwroot/entity/
wwwroot/entity_import/
# DDL 生成物
models/mysql.ddl.sql

View File

@ -1,44 +1 @@
# entity 实体管理模块W-03 模块说明
实体管理模块管理元景项目的实体entity数据提供实体表 CRUD、分页列表查询与实体文件导入。
## 功能
- **实体表 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` | 实体表entity_code 唯一entity_type 字典scene_id/world_id 逻辑关联 codes 段,不加外键) |
| `entity_import` | 实体导入批次记录import_no 唯一status 字典) |
| `entity_import_log` | 实体导入逐行明细日志import_id 关联批次) |
## 接口约定
- 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% 拦截,不落库。
## 集成
通过 `load_entity()` 挂载到宿主应用(`app/{应用名}.py` 的 init() 中调用并注册 `get_module_dbname('entity')`),库名禁止硬编码。前端页面:`wwwroot/index.ui`(入口导航)、`wwwroot/import_page.ui`(导入页)。
## 目录结构
```
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,5 +1 @@
# -*- coding: utf-8 -*- 仓库根标记双层结构说明
"""
modules/entity/ 模块仓库根目录标记文件遗留不影响打包
真实 Python 包目录为双层结构modules/entity/entity/ entity/__init__.py
"""

View File

@ -1,29 +1 @@
#!/usr/bin/env bash 构建脚本DDL+xls2ui+wwwroot链接
# 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,22 +1 @@
# entity 模块 i18n 说明 i18n 提取说明
## 提取范围
本模块全部新增文案(页面标题、按钮、提示、错误码、字段名、过滤标签、消息)均已提取到 `wwwroot/i18n/`
| 文件 | 语言 | 用途 |
|------|------|------|
| `wwwroot/i18n/zh-CN.json` | 简体中文 | 默认语言 |
| `wwwroot/i18n/en-US.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 注入全局)。
## 与后端错误码的对应
后端错误结构统一为 `{code, message, field, detail}`,其中 `code` 取值如 `ENTITY_VALIDATE_FAIL``IMPORT_VALIDATE_FAIL` 等。前端展示文案使用 `entity.code.<CODE>` 作为 key 查找 i18n`message` 字段为后端兜底文案(中文)。
## 新增文案流程
1. 在 `wwwroot/i18n/zh-CN.json``en-US.json` 同时新增同 key。
2. 同步更新 `docs/i18n.md` 本说明(如 key 分组有变化)。
3. 部署时 i18n 文件随 wwwroot 一起链接到宿主应用 wwwroot。

View File

@ -1,34 +1 @@
# 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,23 +1 @@
# -*- coding: utf-8 -*- 包导出 dspy NameError
"""entity 模块包W-03 实体管理)——所有 init.py 中 async 函数必须在此导出,否则 dspy 调用 NameError"""
from .init import (
create_entity,
update_entity,
delete_entity,
get_entity,
list_entities,
import_entities,
entity_import_guard,
reject_import_write,
)
__all__ = [
'create_entity',
'update_entity',
'delete_entity',
'get_entity',
'list_entities',
'import_entities',
'entity_import_guard',
'reject_import_write',
]

Binary file not shown.

Binary file not shown.

View File

@ -1,485 +1 @@
# -*- coding: utf-8 -*- 后端实现create/update/delete/get/list_entities/import_entities/entity_import_guard/reject_import_write + load_entity() 注册_err() 统一错误结构两阶段导入+回滚
"""
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
# 与 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',
}
def _err(code, message, field='', detail=''):
"""统一 REST 错误结构"""
return {'code': code, 'message': message, 'field': field, 'detail': detail}
def _ctx():
"""模块级数据库上下文(库名由宿主应用 get_module_dbname('entity') 解析)"""
return get_sor_context(ServerEnv(), 'entity')
async def _current_user(request):
"""从请求运行上下文取当前用户/机构ServerEnv() 是进程级单例,不能用于请求上下文)"""
rn = request._run_ns
user_id, org_id = '', '0'
if hasattr(rn, 'get_user'):
try:
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
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 _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:
return [], '导入文件内容为空'
if text.startswith('['):
try:
data = json.loads(text)
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:
return [], f'CSV 解析失败: {e}'
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))
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):
"""
实体文件导入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:
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:
return _err('IMPORT_ERROR', '实体导入失败', '', str(e))
async def entity_import_guard(request, params_kw):
"""导入预校验干跑只校验不落库返回逐行错误api/entity_import_guard.dspy"""
try:
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 reject_import_write(request, params_kw):
"""entity_import 记录为系统生成,禁止手工增删改"""
return _err('IMPORT_RECORD_READONLY', '导入记录为系统生成,禁止手工增删改', '')
def load_entity(env):
"""注册模块函数到 ServerEnv.dspy/.ui 可直接调用)"""
env.create_entity = create_entity
env.create_entitys = create_entity
env.update_entity = update_entity
env.update_entitys = update_entity
env.delete_entity = delete_entity
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.entity_import_guard = entity_import_guard
env.reject_import_write = reject_import_write
return env

View File

@ -1,34 +1 @@
{ entity_type/common_status/import_status Format B
"appcodes": [
{
"parentid": "entity_type",
"parentname": "实体类型",
"items": [
{"k": "character", "v": "角色"},
{"k": "prop", "v": "道具"},
{"k": "vehicle", "v": "载具"},
{"k": "building", "v": "建筑"},
{"k": "faction", "v": "阵营"},
{"k": "other", "v": "其他"}
]
},
{
"parentid": "common_status",
"parentname": "通用状态",
"items": [
{"k": "1", "v": "启用"},
{"k": "0", "v": "停用"}
]
},
{
"parentid": "import_status",
"parentname": "导入状态",
"items": [
{"k": "importing", "v": "导入中"},
{"k": "success", "v": "成功"},
{"k": "fail", "v": "失败"},
{"k": "partial", "v": "部分成功"}
]
}
]
}

View File

@ -1,33 +1 @@
{ CRUD editable + data_filter + alters
"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,22 +1 @@
{ CRUD
"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,40 +1 @@
{ codes scene/worldentity_code
"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,31 +1 @@
{
"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,23 +1 @@
{
"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,14 +1 @@
[build-system] name=entity
requires = ["setuptools>=45", "wheel"]
build-backend = "setuptools.build_meta"
[project]
name = "entity"
version = "1.0.0"
description = "实体管理模块W-03——实体表 CRUD、列表查询、实体文件导入entity_import 记录)"
requires-python = ">=3.8"
dependencies = ["sqlor", "bricks_for_python"]
[tool.setuptools.packages.find]
where = ["."]
include = ["entity*"]

Binary file not shown.

View File

@ -1,65 +1 @@
# -*- coding: utf-8 -*- RBAC 显式路径注册无通配符
"""
entity 模块 RBAC 路径注册脚本显式路径禁止通配符
用法python scripts/load_path.py自动寻找 Sage 根目录并调用 set_role_perm.py
维护规则每次新增/删除 wwwroot path需同步更新本脚本
"""
import os
import subprocess
MODULE = 'entity'
PATHS_ANY = [
f'/{MODULE}/index.ui',
]
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():
sage_root = find_sage_root()
if not sage_root:
print('[entity] sage root not found, skip RBAC registration')
return
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__':
main()

View File

@ -1,63 +1 @@
--- 模块技能文档
name: entity
description: 实体管理模块W-03——实体表entityCRUD、列表查询、实体文件导入entity_import 记录),通过 load_entity() 挂载。
---
# entity 实体管理模块
## 概述
元景项目 W-03 实体管理管理实体entity数据。实体归属于场景scene_id→scene.id逻辑关联不加外键与世界world_id→world.id。提供实体表 CRUD、分页列表查询、CSV/JSON 文件导入(两阶段校验+批量插入+失败回滚)。
## 数据模型
| 表 | 说明 |
|----|------|
| `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=失败 |
编码字典init/data.json 幂等落库 appcodes/appcodes_kv
- `entity_type`character/prop/vehicle/building/faction/other
- `common_status`1=启用 / 0=停用
- `import_status`importing/success/fail
## 关键接口REST 统一前缀 /api/*
| 接口 | 方法 | 说明 | 返回 |
|------|------|------|------|
| `/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}` |
错误结构统一:`{code, message, field, detail}`。分页结构统一:`{list, total}`
## 导入处理逻辑
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` 明细。
## 陷阱
- **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 等引用,删除前可扩展引用检查(当前实现仅校验存在性)。
## 依赖
- 基础sqlor、ahserver、appPublic
- 数据依赖(逻辑关联,不物理外键):`scene`scene.id`world`world.id——codes 段引用,表缺失时仅下拉不渲染,不影响 CRUD
- 字典appbase 的 appcodes / appcodes_kv
- 被 `world_snapshot`W-03 快照)聚合读取

View File

@ -1,4 +1 @@
# -*- coding: utf-8 -*- 新增
# entity_create.dspy —— 新增实体(非法输入 100% 拦截不落库)
result = await create_entity(request, params_kw)
return result

View File

@ -1,4 +1 @@
# -*- coding: utf-8 -*- 删除
# entity_delete.dspy —— 删除实体
result = await delete_entity(request, params_kw)
return result

View File

@ -1,4 +1 @@
# -*- coding: utf-8 -*- 详情
# entity_get.dspy —— 实体详情
result = await get_entity(request, params_kw)
return result

View File

@ -1,5 +1 @@
# -*- 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,4 +1 @@
# -*- coding: utf-8 -*- 导入预校验(干跑)
# entity_import_guard.dspy —— 导入预校验(干跑,只校验不落库)
result = await entity_import_guard(request, params_kw)
return result

View File

@ -1,35 +1 @@
# -*- 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

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

View File

@ -1,5 +1 @@
# -*- coding: utf-8 -*- 分页列表 {list,total}
# 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,4 +1 @@
# -*- coding: utf-8 -*- 更新
# entity_update.dspy —— 更新实体
result = await update_entity(request, params_kw)
return result

View File

@ -1,10 +1 @@
# -*- coding: utf-8 -*- 实体类型下拉
# get_search_entity_type.dspy —— 实体类型下拉([{value, text}],含全部)
result = [{'value': '', 'text': '全部'}]
try:
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:
debug(f'get_search_entity_type error: {e}')
return json.dumps(result, ensure_ascii=False)

View File

@ -1,10 +1 @@
# -*- coding: utf-8 -*- 导入状态下拉
# get_search_import_status.dspy —— 导入状态下拉([{value, text}],含全部)
result = [{'value': '', 'text': '全部'}]
try:
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:
debug(f'get_search_import_status error: {e}')
return json.dumps(result, ensure_ascii=False)

View File

@ -1,10 +1 @@
# -*- coding: utf-8 -*- 场景下拉
# get_search_scene_id.dspy —— 场景下拉([{value, text}]含全部同库dbname 已路由到 entity 库)
result = [{'value': '', 'text': '全部'}]
try:
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:
debug(f'get_search_scene_id error: {e}')
return json.dumps(result, ensure_ascii=False)

View File

@ -1,10 +1 @@
# -*- coding: utf-8 -*- 状态下拉
# get_search_status.dspy —— 实体状态下拉([{value, text}],含全部)
result = [{'value': '', 'text': '全部'}]
try:
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:
debug(f'get_search_status error: {e}')
return json.dumps(result, ensure_ascii=False)

View File

@ -1,10 +1 @@
# -*- coding: utf-8 -*- 世界下拉
# get_search_world_id.dspy —— 世界下拉([{value, text}]含全部同库dbname 已路由到 entity 库)
result = [{'value': '', 'text': '全部'}]
try:
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:
debug(f'get_search_world_id error: {e}')
return json.dumps(result, ensure_ascii=False)

View File

@ -1,55 +1 @@
{ i18n
"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.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,55 +1 @@
{ i18n
"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.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

@ -1,75 +1 @@
{ 导入页(上传+干跑校验+最近记录)
"widgettype": "VBox",
"options": {"width": "100%", "height": "100%", "padding": "20px"},
"subwidgets": [
{
"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

@ -1,55 +1 @@
{ 入口导航页
"widgettype": "VBox",
"options": {"width": "100%", "height": "100%", "padding": "20px"},
"subwidgets": [
{
"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"}
}
]
}