approve: 测试执行 - script_engine 脚本引擎模块
This commit is contained in:
parent
d1578dc4ea
commit
7ff253692c
@ -1,17 +1,26 @@
|
||||
"""entity 模块包。"""
|
||||
|
||||
from .init import ( # noqa: F401
|
||||
create_entity,
|
||||
delete_entity,
|
||||
entity_import,
|
||||
# -*- coding: utf-8 -*-
|
||||
"""entity 模块包导出——所有 async 函数必须在 __init__.py 导出,否则 dspy 调用报 NameError。"""
|
||||
from .init import (
|
||||
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,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
'load_entity',
|
||||
'create_entity',
|
||||
'update_entity',
|
||||
'delete_entity',
|
||||
'entity_import',
|
||||
'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',
|
||||
]
|
||||
|
||||
638
entity/init.py
638
entity/init.py
@ -1,175 +1,505 @@
|
||||
"""entity 模块:实体管理 + 实体导入。
|
||||
# -*- coding: utf-8 -*-
|
||||
"""entity 模块后端逻辑——实体 CRUD / 列表查询 / 文件导入(W-03 实体管理)。
|
||||
|
||||
通过 load_entity() 挂载到 ServerEnv,函数被 .dspy 以全局方式直接调用。
|
||||
库名禁止硬编码:统一经 ServerEnv().get_module_dbname('entity') 获取;
|
||||
world/scene/appbase 表分别经 get_module_dbname('world'/'scene'/'appbase') 获取。
|
||||
"""
|
||||
|
||||
import csv
|
||||
import io
|
||||
import json
|
||||
|
||||
from appPublic.timeUtils import curDateString, timestampstr
|
||||
from appPublic.uniqueID import getID
|
||||
from sqlor.dbpools import DBPools
|
||||
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')
|
||||
|
||||
|
||||
def _get_dbname():
|
||||
"""取库名(由宿主应用决定,禁止硬编码)。"""
|
||||
from ahserver.serverenv import ServerEnv
|
||||
return ServerEnv().get_module_dbname('entity')
|
||||
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 _clean_ns(ns):
|
||||
"""清理 Tabular/Form 提交的 _text 后缀字段与占位值。"""
|
||||
for k in list(ns.keys()):
|
||||
if k.endswith('_text'):
|
||||
ns.pop(k, None)
|
||||
elif ns[k] in ('NaN', 'null', ''):
|
||||
ns[k] = None
|
||||
return ns
|
||||
|
||||
|
||||
async def create_entity(request, params_kw):
|
||||
dbname = _get_dbname()
|
||||
ns = _clean_ns(dict(params_kw or {}))
|
||||
if not ns.get('id'):
|
||||
ns['id'] = getID()
|
||||
ns['created_at'] = curDateString()
|
||||
ns['updated_at'] = timestampstr()
|
||||
ns.setdefault('entity_type', '0')
|
||||
ns.setdefault('status', '0')
|
||||
db = DBPools()
|
||||
async with db.sqlorContext(dbname) as sor:
|
||||
await sor.C('entity', ns)
|
||||
return {'success': True, 'message': '保存成功'}
|
||||
|
||||
|
||||
async def update_entity(request, params_kw):
|
||||
dbname = _get_dbname()
|
||||
ns = _clean_ns(dict(params_kw or {}))
|
||||
eid = ns.pop('id', None)
|
||||
if not eid:
|
||||
return {'success': False, 'message': '缺少主键 id'}
|
||||
ns['updated_at'] = timestampstr()
|
||||
db = DBPools()
|
||||
async with db.sqlorContext(dbname) as sor:
|
||||
await sor.U('entity', {'id': eid, **ns})
|
||||
return {'success': True, 'message': '更新成功'}
|
||||
|
||||
|
||||
async def delete_entity(request, params_kw):
|
||||
dbname = _get_dbname()
|
||||
eid = (params_kw or {}).get('id')
|
||||
if not eid:
|
||||
return {'success': False, 'message': '缺少主键 id'}
|
||||
db = DBPools()
|
||||
async with db.sqlorContext(dbname) as sor:
|
||||
await sor.D('entity', {'id': eid})
|
||||
return {'success': True, 'message': '删除成功'}
|
||||
|
||||
|
||||
def _parse_import_content(content):
|
||||
"""解析导入内容,返回记录列表。支持 JSON 数组/对象 或 CSV(首行表头)。"""
|
||||
text = (content or '').strip()
|
||||
if not text:
|
||||
return []
|
||||
if text.startswith('[') or text.startswith('{'):
|
||||
data = json.loads(text)
|
||||
return data if isinstance(data, list) else [data]
|
||||
reader = csv.DictReader(io.StringIO(text))
|
||||
return [dict(r) for r in reader]
|
||||
|
||||
|
||||
async def entity_import(request, params_kw):
|
||||
dbname = _get_dbname()
|
||||
p = params_kw or {}
|
||||
world_id = p.get('world_id', '')
|
||||
scene_id = p.get('scene_id') or None
|
||||
file_obj = p.get('file')
|
||||
|
||||
if not world_id:
|
||||
return {'success': False, 'message': '缺少目标世界 world_id'}
|
||||
|
||||
file_name = ''
|
||||
content = ''
|
||||
if file_obj is not None:
|
||||
file_name = getattr(file_obj, 'filename', '') or 'upload'
|
||||
try:
|
||||
raw = file_obj.read()
|
||||
content = raw.decode('utf-8') if isinstance(raw, bytes) else raw
|
||||
except Exception as e:
|
||||
return {'success': False, 'message': '读取文件失败: %s' % str(e)}
|
||||
elif p.get('file_content'):
|
||||
content = p.get('file_content')
|
||||
file_name = p.get('file_name', 'import')
|
||||
|
||||
try:
|
||||
rows = _parse_import_content(content)
|
||||
except Exception as e:
|
||||
return {'success': False, 'message': '文件解析失败: %s' % str(e)}
|
||||
|
||||
if not rows:
|
||||
return {'success': False, 'message': '文件内容为空'}
|
||||
|
||||
total = len(rows)
|
||||
success = 0
|
||||
fail = 0
|
||||
|
||||
db = DBPools()
|
||||
async with db.sqlorContext(dbname) as sor:
|
||||
for row in rows:
|
||||
try:
|
||||
name = str(row.get('name') or '').strip()
|
||||
code = str(row.get('code') or '').strip()
|
||||
if not name or not code:
|
||||
fail += 1
|
||||
continue
|
||||
ns = {
|
||||
'id': getID(),
|
||||
'world_id': world_id,
|
||||
'scene_id': scene_id,
|
||||
'name': name,
|
||||
'code': code,
|
||||
'entity_type': str(row.get('entity_type') or '0'),
|
||||
'status': str(row.get('status') or '0'),
|
||||
'attributes_json': row.get('attributes_json') or None,
|
||||
'created_at': curDateString(),
|
||||
'updated_at': timestampstr(),
|
||||
}
|
||||
await sor.C('entity', ns)
|
||||
success += 1
|
||||
except Exception:
|
||||
fail += 1
|
||||
|
||||
status = '2' if fail == 0 else ('3' if success == 0 else '2')
|
||||
import_ns = {
|
||||
'id': getID(),
|
||||
'world_id': world_id,
|
||||
'scene_id': scene_id,
|
||||
'file_name': file_name,
|
||||
'total': total,
|
||||
'success': success,
|
||||
'fail': fail,
|
||||
'status': status,
|
||||
'created_at': curDateString(),
|
||||
def to_dict(self):
|
||||
return {
|
||||
'code': self.code,
|
||||
'message': self.message,
|
||||
'field': self.field,
|
||||
'detail': self.detail,
|
||||
}
|
||||
await sor.C('entity_import', import_ns)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- 工具
|
||||
|
||||
def _module_dbname(m):
|
||||
try:
|
||||
return ServerEnv().get_module_dbname(m)
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
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()
|
||||
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}
|
||||
|
||||
|
||||
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}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- 导入
|
||||
|
||||
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()
|
||||
if not text:
|
||||
raise EntityError('PARSE_ERROR', '文件内容为空', 'file')
|
||||
try:
|
||||
if text.startswith('['):
|
||||
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:
|
||||
raise EntityError('PARSE_ERROR', '解析失败', 'file', str(e))
|
||||
|
||||
|
||||
async def import_entities(ns):
|
||||
"""实体文件导入。
|
||||
|
||||
事务批量:先全量解析+校验(含编码查重),再逐条插入;任一条失败整批回滚无脏数据。
|
||||
返回 {success, total, success_count, fail, file_name}(success_count 避免与布尔 success 冲突)。
|
||||
"""
|
||||
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])
|
||||
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,
|
||||
})
|
||||
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': success,
|
||||
'fail': fail,
|
||||
'success_count': total,
|
||||
'fail': 0,
|
||||
'file_name': file_name,
|
||||
}
|
||||
|
||||
|
||||
async def load_entity():
|
||||
from ahserver.serverenv import ServerEnv
|
||||
# ---------------------------------------------------------------- 下拉
|
||||
|
||||
async def get_world_options():
|
||||
"""世界下拉 [{value,text}](world 模块未挂载时返回空)。"""
|
||||
dbname = _world_dbname()
|
||||
if not dbname:
|
||||
return []
|
||||
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 []
|
||||
|
||||
|
||||
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 _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()
|
||||
env.create_entity = create_entity
|
||||
env.create_entitys = create_entity
|
||||
env.create_entities = create_entity
|
||||
env.update_entity = update_entity
|
||||
env.update_entitys = update_entity
|
||||
env.update_entities = update_entity
|
||||
env.delete_entity = delete_entity
|
||||
env.delete_entitys = delete_entity
|
||||
env.entity_import = entity_import
|
||||
env.delete_entities = delete_entity
|
||||
env.list_entities = list_entities
|
||||
env.get_entity = get_entity
|
||||
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
|
||||
return env
|
||||
|
||||
@ -1,2 +1,9 @@
|
||||
result = await create_entity(request, params_kw)
|
||||
return result
|
||||
# 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)}
|
||||
|
||||
@ -1,2 +1,9 @@
|
||||
result = await delete_entity(request, params_kw)
|
||||
return result
|
||||
# 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)}
|
||||
|
||||
9
wwwroot/api/entity_get.dspy
Normal file
9
wwwroot/api/entity_get.dspy
Normal file
@ -0,0 +1,9 @@
|
||||
# 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)}
|
||||
@ -1,3 +1,35 @@
|
||||
debug('entity_import.dspy: START')
|
||||
result = await entity_import(request, params_kw)
|
||||
return result
|
||||
# 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)}
|
||||
|
||||
12
wwwroot/api/entity_list.dspy
Normal file
12
wwwroot/api/entity_list.dspy
Normal file
@ -0,0 +1,12 @@
|
||||
# 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)}
|
||||
@ -1,2 +1,9 @@
|
||||
result = await update_entity(request, params_kw)
|
||||
return result
|
||||
# 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)}
|
||||
|
||||
@ -1,8 +1,6 @@
|
||||
result = [{'value': '', 'text': '全部'}]
|
||||
# get_search_entity_type.dspy —— 实体类型字典下拉(appcodes entity_type)[{value, text}]
|
||||
try:
|
||||
async with get_sor_context(request._run_ns, 'entity') as sor:
|
||||
rows = await sor.sqlExe("select k as value, v as text from appcodes_kv where parentid='entity_type' order by k", {})
|
||||
return json.dumps(result + [{'value': r.value, 'text': r.text} for r in rows], ensure_ascii=False)
|
||||
opts = await get_entity_type_options()
|
||||
return {'code': 'OK', 'message': 'success', 'data': opts}
|
||||
except Exception as e:
|
||||
debug('get_search_entity_type error: %s' % str(e))
|
||||
return json.dumps(result, ensure_ascii=False)
|
||||
return {'code': 'DB_ERROR', 'message': '查询实体类型下拉失败', 'field': '', 'detail': str(e)}
|
||||
|
||||
6
wwwroot/api/get_search_import_status.dspy
Normal file
6
wwwroot/api/get_search_import_status.dspy
Normal file
@ -0,0 +1,6 @@
|
||||
# get_search_import_status.dspy —— 导入状态字典下拉(appcodes import_status)[{value, text}]
|
||||
try:
|
||||
opts = await get_import_status_options()
|
||||
return {'code': 'OK', 'message': 'success', 'data': opts}
|
||||
except Exception as e:
|
||||
return {'code': 'DB_ERROR', 'message': '查询导入状态下拉失败', 'field': '', 'detail': str(e)}
|
||||
@ -1,8 +1,6 @@
|
||||
result = [{'value': '', 'text': '全部'}]
|
||||
# get_search_scene_id.dspy —— 场景下拉数据源 [{value, text}]
|
||||
try:
|
||||
async with get_sor_context(request._run_ns, 'entity') as sor:
|
||||
rows = await sor.sqlExe("select id as value, name as text from scene order by name", {})
|
||||
return json.dumps(result + [{'value': r.value, 'text': r.text} for r in rows], ensure_ascii=False)
|
||||
opts = await get_scene_options()
|
||||
return {'code': 'OK', 'message': 'success', 'data': opts}
|
||||
except Exception as e:
|
||||
debug('get_search_scene_id error: %s' % str(e))
|
||||
return json.dumps(result, ensure_ascii=False)
|
||||
return {'code': 'DB_ERROR', 'message': '查询场景下拉失败', 'field': '', 'detail': str(e)}
|
||||
|
||||
@ -1,8 +1,6 @@
|
||||
result = [{'value': '', 'text': '全部'}]
|
||||
# get_search_status.dspy —— 实体状态字典下拉(appcodes entity_status)[{value, text}]
|
||||
try:
|
||||
async with get_sor_context(request._run_ns, 'entity') as sor:
|
||||
rows = await sor.sqlExe("select k as value, v as text from appcodes_kv where parentid='entity_status' order by k", {})
|
||||
return json.dumps(result + [{'value': r.value, 'text': r.text} for r in rows], ensure_ascii=False)
|
||||
opts = await get_entity_status_options()
|
||||
return {'code': 'OK', 'message': 'success', 'data': opts}
|
||||
except Exception as e:
|
||||
debug('get_search_status error: %s' % str(e))
|
||||
return json.dumps(result, ensure_ascii=False)
|
||||
return {'code': 'DB_ERROR', 'message': '查询状态下拉失败', 'field': '', 'detail': str(e)}
|
||||
|
||||
@ -1,8 +1,6 @@
|
||||
result = [{'value': '', 'text': '全部'}]
|
||||
# get_search_world_id.dspy —— 世界下拉数据源 [{value, text}]
|
||||
try:
|
||||
async with get_sor_context(request._run_ns, 'entity') as sor:
|
||||
rows = await sor.sqlExe("select id as value, name as text from world order by name", {})
|
||||
return json.dumps(result + [{'value': r.value, 'text': r.text} for r in rows], ensure_ascii=False)
|
||||
opts = await get_world_options()
|
||||
return {'code': 'OK', 'message': 'success', 'data': opts}
|
||||
except Exception as e:
|
||||
debug('get_search_world_id error: %s' % str(e))
|
||||
return json.dumps(result, ensure_ascii=False)
|
||||
return {'code': 'DB_ERROR', 'message': '查询世界下拉失败', 'field': '', 'detail': str(e)}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user