deliver: 交付收口(引擎代为提交)
This commit is contained in:
parent
4051e9d17f
commit
c276af5d9d
@ -1,11 +1,12 @@
|
||||
{
|
||||
"tblname": "pbl_game_definition",
|
||||
"alias": "pbl_compiler",
|
||||
"_comment": "M3b 整改(QC #13):主键/外键类字段在 models/pbl_game_definition.json 中为 str(32),CRUD 渲染类型必须为 text(原 number 会按数字输入框渲染 32 位字符串主键,排序/校验错位);同时补齐 M3b 规则导出四字段 rules_json/rules_hash/rule_count/rules_manifest 与 compile_status/error_message,移除模型中不存在的幽灵字段 entity_count/event_count。",
|
||||
"browserfields": {
|
||||
"id": {
|
||||
"label": "ID",
|
||||
"list": true,
|
||||
"type": "number"
|
||||
"type": "text"
|
||||
},
|
||||
"tenant_id": {
|
||||
"label": "租户ID",
|
||||
@ -14,7 +15,7 @@
|
||||
},
|
||||
"blueprint_id": {
|
||||
"label": "来源蓝图",
|
||||
"type": "number",
|
||||
"type": "text",
|
||||
"list": true
|
||||
},
|
||||
"blueprint_version_no": {
|
||||
@ -24,7 +25,7 @@
|
||||
},
|
||||
"compiler_version_id": {
|
||||
"label": "编译版本",
|
||||
"type": "number",
|
||||
"type": "text",
|
||||
"list": true
|
||||
},
|
||||
"content_fingerprint": {
|
||||
@ -32,29 +33,19 @@
|
||||
"type": "text",
|
||||
"list": true
|
||||
},
|
||||
"definition_json": {
|
||||
"label": "Game Definition 正文",
|
||||
"rules_hash": {
|
||||
"label": "规则集合指纹",
|
||||
"type": "text",
|
||||
"list": true
|
||||
},
|
||||
"world_id": {
|
||||
"label": "落库 world",
|
||||
"rule_count": {
|
||||
"label": "规则条数",
|
||||
"type": "number",
|
||||
"list": true
|
||||
},
|
||||
"scene_id": {
|
||||
"label": "落库 scene",
|
||||
"type": "number",
|
||||
"list": true
|
||||
},
|
||||
"entity_count": {
|
||||
"label": "实体数",
|
||||
"type": "number",
|
||||
"list": true
|
||||
},
|
||||
"event_count": {
|
||||
"label": "事件数",
|
||||
"type": "number",
|
||||
"compile_status": {
|
||||
"label": "编译结果状态",
|
||||
"type": "select",
|
||||
"list": true
|
||||
},
|
||||
"quality_state": {
|
||||
@ -62,10 +53,45 @@
|
||||
"type": "select",
|
||||
"list": true
|
||||
},
|
||||
"world_id": {
|
||||
"label": "落库 world",
|
||||
"type": "text",
|
||||
"list": true
|
||||
},
|
||||
"scene_id": {
|
||||
"label": "落库 scene",
|
||||
"type": "text",
|
||||
"list": true
|
||||
},
|
||||
"duration_ms": {
|
||||
"label": "耗时",
|
||||
"type": "number",
|
||||
"list": true
|
||||
},
|
||||
"definition_json": {
|
||||
"label": "Game Definition 正文",
|
||||
"type": "text",
|
||||
"list": false
|
||||
},
|
||||
"rules_json": {
|
||||
"label": "规则映射产物(script_type=1)",
|
||||
"type": "text",
|
||||
"list": false
|
||||
},
|
||||
"rules_manifest": {
|
||||
"label": "规则溯源清单",
|
||||
"type": "text",
|
||||
"list": false
|
||||
},
|
||||
"error_message": {
|
||||
"label": "失败原因",
|
||||
"type": "text",
|
||||
"list": false
|
||||
},
|
||||
"created_at": {
|
||||
"label": "创建时间",
|
||||
"type": "text",
|
||||
"list": true
|
||||
}
|
||||
},
|
||||
"params": {
|
||||
@ -73,7 +99,7 @@
|
||||
"id": {
|
||||
"label": "ID",
|
||||
"list": true,
|
||||
"type": "number"
|
||||
"type": "text"
|
||||
},
|
||||
"tenant_id": {
|
||||
"label": "租户ID",
|
||||
@ -82,7 +108,7 @@
|
||||
},
|
||||
"blueprint_id": {
|
||||
"label": "来源蓝图",
|
||||
"type": "number",
|
||||
"type": "text",
|
||||
"list": true
|
||||
},
|
||||
"blueprint_version_no": {
|
||||
@ -92,7 +118,7 @@
|
||||
},
|
||||
"compiler_version_id": {
|
||||
"label": "编译版本",
|
||||
"type": "number",
|
||||
"type": "text",
|
||||
"list": true
|
||||
},
|
||||
"content_fingerprint": {
|
||||
@ -100,29 +126,19 @@
|
||||
"type": "text",
|
||||
"list": true
|
||||
},
|
||||
"definition_json": {
|
||||
"label": "Game Definition 正文",
|
||||
"rules_hash": {
|
||||
"label": "规则集合指纹",
|
||||
"type": "text",
|
||||
"list": true
|
||||
},
|
||||
"world_id": {
|
||||
"label": "落库 world",
|
||||
"rule_count": {
|
||||
"label": "规则条数",
|
||||
"type": "number",
|
||||
"list": true
|
||||
},
|
||||
"scene_id": {
|
||||
"label": "落库 scene",
|
||||
"type": "number",
|
||||
"list": true
|
||||
},
|
||||
"entity_count": {
|
||||
"label": "实体数",
|
||||
"type": "number",
|
||||
"list": true
|
||||
},
|
||||
"event_count": {
|
||||
"label": "事件数",
|
||||
"type": "number",
|
||||
"compile_status": {
|
||||
"label": "编译结果状态",
|
||||
"type": "select",
|
||||
"list": true
|
||||
},
|
||||
"quality_state": {
|
||||
@ -130,16 +146,51 @@
|
||||
"type": "select",
|
||||
"list": true
|
||||
},
|
||||
"world_id": {
|
||||
"label": "落库 world",
|
||||
"type": "text",
|
||||
"list": true
|
||||
},
|
||||
"scene_id": {
|
||||
"label": "落库 scene",
|
||||
"type": "text",
|
||||
"list": true
|
||||
},
|
||||
"duration_ms": {
|
||||
"label": "耗时",
|
||||
"type": "number",
|
||||
"list": true
|
||||
},
|
||||
"definition_json": {
|
||||
"label": "Game Definition 正文",
|
||||
"type": "text",
|
||||
"list": false
|
||||
},
|
||||
"rules_json": {
|
||||
"label": "规则映射产物(script_type=1)",
|
||||
"type": "text",
|
||||
"list": false
|
||||
},
|
||||
"rules_manifest": {
|
||||
"label": "规则溯源清单",
|
||||
"type": "text",
|
||||
"list": false
|
||||
},
|
||||
"error_message": {
|
||||
"label": "失败原因",
|
||||
"type": "text",
|
||||
"list": false
|
||||
},
|
||||
"created_at": {
|
||||
"label": "创建时间",
|
||||
"type": "text",
|
||||
"list": true
|
||||
}
|
||||
},
|
||||
"editable": {
|
||||
"blueprint_id": {
|
||||
"label": "来源蓝图",
|
||||
"type": "number"
|
||||
"type": "text"
|
||||
},
|
||||
"blueprint_version_no": {
|
||||
"label": "蓝图版本",
|
||||
@ -147,7 +198,7 @@
|
||||
},
|
||||
"compiler_version_id": {
|
||||
"label": "编译版本",
|
||||
"type": "number"
|
||||
"type": "text"
|
||||
},
|
||||
"content_fingerprint": {
|
||||
"label": "SHA-256 指纹",
|
||||
@ -157,21 +208,33 @@
|
||||
"label": "Game Definition 正文",
|
||||
"type": "text"
|
||||
},
|
||||
"rules_json": {
|
||||
"label": "规则映射产物(script_type=1)",
|
||||
"type": "text"
|
||||
},
|
||||
"rules_hash": {
|
||||
"label": "规则集合指纹",
|
||||
"type": "text"
|
||||
},
|
||||
"rule_count": {
|
||||
"label": "规则条数",
|
||||
"type": "number"
|
||||
},
|
||||
"rules_manifest": {
|
||||
"label": "规则溯源清单",
|
||||
"type": "text"
|
||||
},
|
||||
"world_id": {
|
||||
"label": "落库 world",
|
||||
"type": "number"
|
||||
"type": "text"
|
||||
},
|
||||
"scene_id": {
|
||||
"label": "落库 scene",
|
||||
"type": "number"
|
||||
"type": "text"
|
||||
},
|
||||
"entity_count": {
|
||||
"label": "实体数",
|
||||
"type": "number"
|
||||
},
|
||||
"event_count": {
|
||||
"label": "事件数",
|
||||
"type": "number"
|
||||
"compile_status": {
|
||||
"label": "编译结果状态",
|
||||
"type": "select"
|
||||
},
|
||||
"quality_state": {
|
||||
"label": "编译时质量状态",
|
||||
@ -180,9 +243,14 @@
|
||||
"duration_ms": {
|
||||
"label": "耗时",
|
||||
"type": "number"
|
||||
},
|
||||
"error_message": {
|
||||
"label": "失败原因",
|
||||
"type": "text"
|
||||
}
|
||||
},
|
||||
"_crud_note": "pbl_game_definition 是编译产物表(只读,写入由 pbl_compiler_compile 主流程完成),故本定义刻意不提供 new_data_url/update_data_url/delete_data_url —— 不开放手工增删改,避免绕过 29.6 确定性指纹与 rules_hash 收口逻辑。",
|
||||
"order_by": "id DESC",
|
||||
"page_size": 20
|
||||
}
|
||||
}
|
||||
}
|
||||
269
pbl_compiler/gd_rules_writer.py
Normal file
269
pbl_compiler/gd_rules_writer.py
Normal file
@ -0,0 +1,269 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""pbl_compiler.gd_rules_writer —— M3b 第12章收口:Game Definition 规则产物落库。
|
||||
|
||||
背景(本轮 M3b 整改的真实缺口):
|
||||
exporter.write_game_definition() 用「候选列名探测」写 GD,候选集是
|
||||
('game_definition','gd_json','definition','content'),而 models/pbl_game_definition.json
|
||||
的真实列名是 `definition_json` / `content_fingerprint` / `rules_json` / `rules_hash` /
|
||||
`rule_count` / `rules_manifest` / `compile_status` / `quality_state` / `world_id` /
|
||||
`scene_id` / `duration_ms` / `error_message`。探测不命中 → M3b 的规则映射产物
|
||||
(rules_json / rules_hash / rule_count / rules_manifest)根本没写进 game_definition,
|
||||
「规则导出与 game_definition 写入」这条需求等于没落地。
|
||||
|
||||
本文件是 M3b 的**权威 GD 写入器**:列名直接对齐 models/pbl_game_definition.json,
|
||||
显式列名 INSERT/UPDATE(不靠位置、不靠猜),并把规则四件套一并落库。
|
||||
|
||||
铁律:
|
||||
- 所有 SELECT/INSERT/UPDATE 一律 tenant_id 打头,缺失即抛 CompileError(fail-closed)。
|
||||
- 幂等:同 (tenant_id, blueprint_id, blueprint_version_no) 重复写只更新不新增;
|
||||
同输入必同 content_fingerprint / rules_hash(29.6 确定性,无时间戳无 uuid 进正文)。
|
||||
- created_at/updated_at 属审计字段,只写审计列,绝不参与指纹计算。
|
||||
"""
|
||||
|
||||
import json
|
||||
|
||||
from .script_mapping import (
|
||||
CompileError,
|
||||
Issue,
|
||||
canonical_rules_json,
|
||||
build_rules_manifest,
|
||||
canonical_dumps,
|
||||
sha256_text,
|
||||
strip_volatile,
|
||||
to_int,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
'GD_TABLE',
|
||||
'GD_COLUMNS',
|
||||
'gd_primary_id',
|
||||
'build_gd_row',
|
||||
'write_gd_rules',
|
||||
'read_gd_rules',
|
||||
'read_gd_rules_by_id',
|
||||
]
|
||||
|
||||
#: 表名与列集合,唯一事实源 = models/pbl_game_definition.json(勿在此表外造列)
|
||||
GD_TABLE = 'pbl_game_definition'
|
||||
|
||||
GD_COLUMNS = (
|
||||
'id', 'tenant_id', 'blueprint_id', 'blueprint_version_no', 'compiler_version_id',
|
||||
'content_fingerprint', 'definition_json',
|
||||
'rules_json', 'rules_hash', 'rule_count', 'rules_manifest',
|
||||
'world_id', 'scene_id', 'compile_status', 'quality_state',
|
||||
'duration_ms', 'error_message',
|
||||
'created_by', 'created_at', 'updated_by', 'updated_at', 'is_deleted',
|
||||
)
|
||||
|
||||
#: 参与内容指纹的列(29.6:排除审计/耗时/状态等易变列)
|
||||
_FINGERPRINT_COLUMNS = ('blueprint_id', 'blueprint_version_no', 'definition_json',
|
||||
'rules_json')
|
||||
|
||||
|
||||
def _text(value):
|
||||
"""任意对象 → 稳定文本(None → 空串,dict/list → canonical JSON)。"""
|
||||
if value is None:
|
||||
return ''
|
||||
if isinstance(value, str):
|
||||
return value
|
||||
if isinstance(value, (dict, list)):
|
||||
return canonical_dumps(strip_volatile(value))
|
||||
return str(value)
|
||||
|
||||
|
||||
def gd_primary_id(tenant_id, blueprint_id, blueprint_version_no, content_fingerprint):
|
||||
"""确定性主键:sha256(tenant|bp|ver|fp) 前 32 位十六进制。
|
||||
|
||||
不用 uuid/不自增 —— 保证「同输入重编译 → 同一行」,幂等 upsert 才成立。
|
||||
"""
|
||||
key = '|'.join([str(tenant_id or ''), str(blueprint_id or ''),
|
||||
str(blueprint_version_no or ''), str(content_fingerprint or '')])
|
||||
return sha256_text(key)[:32]
|
||||
|
||||
|
||||
def content_fingerprint_of(row):
|
||||
"""按参与指纹的列计算 GD 内容指纹(列序固定,与 sort_keys 无关)。"""
|
||||
parts = []
|
||||
for col in _FINGERPRINT_COLUMNS:
|
||||
parts.append('%s=%s' % (col, _text(row.get(col))))
|
||||
return sha256_text('\n'.join(parts))
|
||||
|
||||
|
||||
def build_gd_row(gd, ctx, rules=None, manifest=None, base_definition_json=None,
|
||||
issues=None):
|
||||
"""组装一行 pbl_game_definition 记录(列名对齐 models,不猜列)。
|
||||
|
||||
gd: M3a gd_builder + M3b rules 顶层键合成后的完整 GD dict(10 顶层键 + rules)
|
||||
ctx: tenant_id* / blueprint_id* / blueprint_version* / compiler_version_id /
|
||||
world_id / scene_id / quality_state / compile_status / duration_ms /
|
||||
created_by / error_message
|
||||
rules: build_rules() 产出的 script_type=1 规则列表
|
||||
返回 (row_dict, issues)
|
||||
"""
|
||||
issues = list(issues or [])
|
||||
ctx = dict(ctx or {})
|
||||
tenant_id = str(ctx.get('tenant_id') or '').strip()
|
||||
if not tenant_id:
|
||||
raise CompileError('E_TENANT_REQUIRED', 'tenant_id 缺失,拒绝写入 game_definition')
|
||||
blueprint_id = str(ctx.get('blueprint_id') or ctx.get('bp_id') or '').strip()
|
||||
blueprint_version_no = to_int(ctx.get('blueprint_version')
|
||||
or ctx.get('blueprint_version_no'), 0)
|
||||
if not blueprint_id or blueprint_version_no <= 0:
|
||||
raise CompileError('E_GD_KEY_MISSING',
|
||||
'blueprint_id / blueprint_version_no 缺失或非法,无法定位 game_definition')
|
||||
|
||||
rules = list(rules or [])
|
||||
manifest = manifest or build_rules_manifest(rules)
|
||||
gd = dict(gd or {})
|
||||
gd_text = canonical_dumps(strip_volatile(gd))
|
||||
rules_text = canonical_rules_json(rules)
|
||||
|
||||
row = {
|
||||
'tenant_id': tenant_id,
|
||||
'blueprint_id': blueprint_id,
|
||||
'blueprint_version_no': blueprint_version_no,
|
||||
'compiler_version_id': _text(ctx.get('compiler_version_id')
|
||||
or ctx.get('compiler_version') or '')[:32],
|
||||
'definition_json': base_definition_json if base_definition_json is not None else gd_text,
|
||||
'rules_json': rules_text,
|
||||
'rules_hash': _text(manifest.get('rules_hash'))[:64],
|
||||
'rule_count': to_int(manifest.get('rule_count'), len(rules)),
|
||||
'rules_manifest': canonical_dumps(strip_volatile(manifest)),
|
||||
'world_id': _text(ctx.get('world_id'))[:32],
|
||||
'scene_id': _text(ctx.get('scene_id'))[:32],
|
||||
'compile_status': _text(ctx.get('compile_status') or ctx.get('status')
|
||||
or 'compiled')[:32],
|
||||
'quality_state': _text(ctx.get('quality_state'))[:64],
|
||||
'duration_ms': to_int(ctx.get('duration_ms'), 0),
|
||||
'error_message': _text(ctx.get('error_message'))[:60000],
|
||||
'created_by': _text(ctx.get('created_by') or ctx.get('actor_id'))[:32],
|
||||
'is_deleted': '0',
|
||||
}
|
||||
row['content_fingerprint'] = content_fingerprint_of(row)
|
||||
row['id'] = gd_primary_id(row['tenant_id'], row['blueprint_id'],
|
||||
row['blueprint_version_no'], row['content_fingerprint'])
|
||||
|
||||
if not row['rules_hash']:
|
||||
issues.append(Issue('error', 'E_RULES_HASH_EMPTY',
|
||||
'rules_manifest 未给出 rules_hash,规则产物不可信',
|
||||
'', 'rules_hash'))
|
||||
if row['rule_count'] != len(rules):
|
||||
issues.append(Issue('warning', 'W_RULE_COUNT_MISMATCH',
|
||||
'rule_count=%s 与实际规则数=%s 不一致'
|
||||
% (row['rule_count'], len(rules)), '', 'rule_count'))
|
||||
return row, issues
|
||||
|
||||
|
||||
async def _available_columns(sor, table):
|
||||
"""探测实际库中该表已有列(跨宿主/未跑 DDL 时降级,不猜列名写 SQL)。"""
|
||||
try:
|
||||
recs = await sor.sqlExe(
|
||||
'SELECT COLUMN_NAME AS c FROM INFORMATION_SCHEMA.COLUMNS '
|
||||
'WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME=${tbl}$', {'tbl': table})
|
||||
cols = set()
|
||||
for r in recs or []:
|
||||
v = getattr(r, 'c', None) or getattr(r, 'COLUMN_NAME', None) \
|
||||
or getattr(r, 'column_name', None)
|
||||
if v:
|
||||
cols.add(str(v))
|
||||
if cols:
|
||||
return cols
|
||||
except Exception:
|
||||
pass
|
||||
return set(GD_COLUMNS)
|
||||
|
||||
|
||||
async def write_gd_rules(sor, row, ctx=None):
|
||||
"""幂等 upsert 一行 game_definition(含规则四件套)。
|
||||
|
||||
唯一键:tenant_id + blueprint_id + blueprint_version_no(idx_gd_bp)。
|
||||
返回 {'written','mode','gd_id','content_fingerprint','rules_hash','rule_count'}
|
||||
"""
|
||||
ctx = dict(ctx or {})
|
||||
tenant_id = str(row.get('tenant_id') or '').strip()
|
||||
if not tenant_id:
|
||||
raise CompileError('E_TENANT_REQUIRED', 'tenant_id 缺失,拒绝写入 game_definition')
|
||||
|
||||
cols = await _available_columns(sor, GD_TABLE)
|
||||
payload = {k: v for k, v in row.items() if k in cols and k != 'id'}
|
||||
missing = [c for c in ('definition_json', 'rules_json', 'rules_hash', 'rule_count')
|
||||
if c not in cols]
|
||||
if missing:
|
||||
raise CompileError('E_GD_COLUMN_MISSING',
|
||||
'pbl_game_definition 缺少 M3b 规则产物列 %s,请先执行 models/'
|
||||
'pbl_game_definition.json 生成的 DDL(json2ddl)' % missing)
|
||||
|
||||
where = {'tenant_id': tenant_id,
|
||||
'blueprint_id': str(row.get('blueprint_id') or ''),
|
||||
'blueprint_version_no': int(row.get('blueprint_version_no') or 0)}
|
||||
try:
|
||||
exist = await sor.R(GD_TABLE, dict(where, is_deleted='0'))
|
||||
except Exception as e:
|
||||
raise CompileError('E_GD_QUERY', '查询 game_definition 失败: %s' % e)
|
||||
|
||||
now = ctx.get('now_str') or ''
|
||||
try:
|
||||
if exist:
|
||||
old_id = str(getattr(exist[0], 'id', '') or '')
|
||||
upd = dict(payload)
|
||||
if 'updated_at' in cols and now:
|
||||
upd['updated_at'] = now
|
||||
await sor.U(GD_TABLE, dict(upd, **{'id': old_id}))
|
||||
mode, gd_id = 'update', old_id
|
||||
else:
|
||||
ins = dict(payload)
|
||||
if 'id' in cols:
|
||||
ins['id'] = row['id']
|
||||
if 'created_at' in cols and now:
|
||||
ins['created_at'] = now
|
||||
await sor.C(GD_TABLE, ins)
|
||||
mode, gd_id = 'insert', str(row.get('id') or '')
|
||||
except Exception as e:
|
||||
raise CompileError('E_GD_WRITE', '写入 game_definition 失败: %s' % e)
|
||||
|
||||
return {
|
||||
'written': True,
|
||||
'mode': mode,
|
||||
'gd_id': gd_id,
|
||||
'content_fingerprint': row.get('content_fingerprint'),
|
||||
'rules_hash': row.get('rules_hash'),
|
||||
'rule_count': row.get('rule_count'),
|
||||
}
|
||||
|
||||
|
||||
async def read_gd_rules(sor, tenant_id, blueprint_id, blueprint_version_no=None):
|
||||
"""读回 GD(含规则四件套),rules_json 解析成 list。"""
|
||||
if not tenant_id:
|
||||
raise CompileError('E_TENANT_REQUIRED', 'tenant_id 缺失')
|
||||
where = {'tenant_id': str(tenant_id), 'blueprint_id': str(blueprint_id or '')}
|
||||
if blueprint_version_no not in (None, ''):
|
||||
where['blueprint_version_no'] = int(blueprint_version_no)
|
||||
recs = await sor.R(GD_TABLE, where)
|
||||
out = []
|
||||
for r in recs or []:
|
||||
d = {c: getattr(r, c, None) for c in GD_COLUMNS}
|
||||
if d.get('rules_json'):
|
||||
try:
|
||||
d['rules'] = json.loads(d['rules_json'])
|
||||
except Exception:
|
||||
d['rules'] = []
|
||||
out.append(d)
|
||||
return out
|
||||
|
||||
|
||||
async def read_gd_rules_by_id(sor, tenant_id, gd_id):
|
||||
"""按主键读单行(租户打头,跨租户查不到即 None)。"""
|
||||
if not tenant_id or not gd_id:
|
||||
raise CompileError('E_PARAM_MISSING', 'tenant_id / id 均为必填')
|
||||
recs = await sor.R(GD_TABLE, {'tenant_id': str(tenant_id), 'id': str(gd_id)})
|
||||
if not recs:
|
||||
return None
|
||||
r = recs[0]
|
||||
d = {c: getattr(r, c, None) for c in GD_COLUMNS}
|
||||
for key in ('rules_json', 'rules_manifest', 'definition_json'):
|
||||
if d.get(key):
|
||||
try:
|
||||
d[key + '_parsed'] = json.loads(d[key])
|
||||
except Exception:
|
||||
d[key + '_parsed'] = None
|
||||
return d
|
||||
392
pbl_compiler/rules_export_api.py
Normal file
392
pbl_compiler/rules_export_api.py
Normal file
@ -0,0 +1,392 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""pbl_compiler.rules_export_api —— M3b Web 契约层:规则映射 / 导出 / GD 写入。
|
||||
|
||||
本文件是 M3b「第12章 event→condition→response → script_type=1 规则 JSON 的映射、
|
||||
产物导出与 game_definition 写入」这条需求的**契约入口**。三层分工:
|
||||
|
||||
① pbl_compiler/script_mapping.py 纯函数映射层:ECR → 归一规则(无 IO、确定性)
|
||||
② pbl_compiler/exporter.py 导出层:规则 → script_engine 脚本表 + 版本回写
|
||||
③ pbl_compiler/gd_rules_writer.py GD 落库层:规则四件套 → pbl_game_definition 真实列
|
||||
④ 本文件 契约层:参数校验 / 门禁 / 组装返回(.dspy 直接调用)
|
||||
|
||||
导出的 5 个契约(与 init.py CONTRACTS、__init__.py、scripts/load_path.py 三处同步):
|
||||
pbl_compiler_export_rules 编译并导出规则(写脚本表 + 写 GD),支持 dry_run
|
||||
pbl_compiler_rules_preview 只映射不落库(预览 rules_json / rules_hash / issues)
|
||||
pbl_compiler_rules_diff 两个蓝图版本之间的规则集差异(diff_rules)
|
||||
pbl_game_definition_rules_get 读回 GD 的规则产物列(rules_json/rules_hash/rule_count/manifest)
|
||||
pbl_script_rule_list 分页查询已导出的 pbl_script_rule 行
|
||||
|
||||
铁律(与 api.py 一致):
|
||||
- tenant_id 打头,缺失即 fail-closed(不猜租户、不落跨租户数据)。
|
||||
- error 级 issue 存在时绝不落库(拒绝产出半成品规则)。
|
||||
- 返回值统一 {'status','data',...},异常统一转 PblError 语义 dict(不抛裸异常给前端)。
|
||||
"""
|
||||
|
||||
import time
|
||||
|
||||
from pbl_common.api import PblError, tenant_id as _ctx_tenant_id
|
||||
|
||||
from pbl_compiler.script_mapping import (
|
||||
CompileError,
|
||||
Issue,
|
||||
build_rules,
|
||||
build_rules_manifest,
|
||||
canonical_rules_json,
|
||||
diff_rules,
|
||||
has_errors,
|
||||
validate_rules,
|
||||
)
|
||||
from pbl_compiler import exporter
|
||||
from pbl_compiler.gd_rules_writer import (
|
||||
GD_TABLE,
|
||||
build_gd_row,
|
||||
content_fingerprint_of,
|
||||
read_gd_rules,
|
||||
read_gd_rules_by_id,
|
||||
write_gd_rules,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
'SCRIPT_TYPE_RULE',
|
||||
'export_rules_contract',
|
||||
'preview_rules_contract',
|
||||
'rules_diff_contract',
|
||||
'gd_rules_get_contract',
|
||||
'script_rule_list_contract',
|
||||
'pbl_compiler_export_rules',
|
||||
'pbl_compiler_rules_preview',
|
||||
'pbl_compiler_rules_diff',
|
||||
'pbl_game_definition_rules_get',
|
||||
'pbl_script_rule_list',
|
||||
]
|
||||
|
||||
SCRIPT_TYPE_RULE = 1
|
||||
|
||||
#: 脚本表候选(与 exporter._SCRIPT_TABLE_CANDIDATES 同源,只用于列表查询降级)
|
||||
_RULE_TABLE_CANDIDATES = ('pbl_script_rule',)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 内部工具
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _ok(data, **extra):
|
||||
out = {'status': 'ok', 'data': data}
|
||||
out.update(extra)
|
||||
return out
|
||||
|
||||
|
||||
def _fail(code, message, detail=None):
|
||||
out = {'status': 'error', 'code': code, 'message': message, 'data': None}
|
||||
if detail is not None:
|
||||
out['detail'] = detail
|
||||
return out
|
||||
|
||||
|
||||
def _require_tenant():
|
||||
tid = str(_ctx_tenant_id() or '').strip()
|
||||
if not tid:
|
||||
raise CompileError('E_TENANT_REQUIRED', '缺少租户上下文(tenant_id),拒绝执行规则导出')
|
||||
return tid
|
||||
|
||||
|
||||
def _issue_dicts(issues, limit=200):
|
||||
out = []
|
||||
for it in list(issues or [])[:limit]:
|
||||
out.append({k: it.get(k) for k in ('severity', 'code', 'message', 'rule_id', 'path')})
|
||||
return out
|
||||
|
||||
|
||||
async def _load_snapshot(blueprint_id, version_no):
|
||||
"""取蓝图版本快照(复用 api._snapshot,失败降级为直接查库)。"""
|
||||
tid = _require_tenant()
|
||||
try:
|
||||
from pbl_compiler.api import _snapshot
|
||||
snap, vrow = await _snapshot(tid, blueprint_id, version_no)
|
||||
if snap:
|
||||
return snap, vrow
|
||||
except Exception:
|
||||
pass
|
||||
from pbl_compiler.api import _one
|
||||
vrow = await _one(
|
||||
'SELECT * FROM `pbl_blueprint_version` WHERE `tenant_id` = ${t}$'
|
||||
' AND `blueprint_id` = ${b}$ AND `version_no` = ${v}$ LIMIT 1',
|
||||
{'t': tid, 'b': blueprint_id, 'v': version_no})
|
||||
if not vrow:
|
||||
raise CompileError('E_SNAPSHOT_NOT_FOUND',
|
||||
'未找到蓝图版本快照: blueprint_id=%s version_no=%s'
|
||||
% (blueprint_id, version_no))
|
||||
for key in ('snapshot_json', 'content_json', 'snapshot'):
|
||||
if vrow.get(key):
|
||||
import json as _json
|
||||
raw = vrow[key]
|
||||
return (_json.loads(raw) if isinstance(raw, str) else raw), vrow
|
||||
raise CompileError('E_SNAPSHOT_EMPTY', '蓝图版本快照字段为空,无法映射规则')
|
||||
|
||||
|
||||
async def _map_rules(blueprint_id, version_no, kw):
|
||||
"""快照 → 规则(含校验),返回 (rules, issues, manifest, snapshot, vrow)。"""
|
||||
snapshot, vrow = await _load_snapshot(blueprint_id, version_no)
|
||||
ctx = {
|
||||
'tenant_id': _require_tenant(),
|
||||
'blueprint_id': str(blueprint_id),
|
||||
'blueprint_version': str(version_no or (vrow or {}).get('version_no') or ''),
|
||||
'strict': bool(kw.get('strict', True)),
|
||||
'known_actions': kw.get('known_actions') or None,
|
||||
'event_types': kw.get('event_types') or None,
|
||||
'compiler_version': str(kw.get('compiler_version') or ''),
|
||||
}
|
||||
issues = []
|
||||
rules, map_issues = build_rules(snapshot, ctx)
|
||||
issues.extend(map_issues)
|
||||
issues.extend(validate_rules(rules))
|
||||
manifest = build_rules_manifest(rules)
|
||||
return rules, issues, manifest, snapshot, ctx
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 契约 1:导出规则(写 script_engine 脚本表 + 写 game_definition)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
async def export_rules_contract(**kw):
|
||||
"""M3b 主导出契约:ECR → 规则 → 脚本表 → pbl_game_definition 规则列。
|
||||
|
||||
入参:blueprint_id*, version_no*, compiler_version_id, dry_run, skip_gd,
|
||||
skip_scripts, base_gd(可选 M3a GD), status, duration_ms
|
||||
fail-closed:任一 error 级 issue → 不落库,返回 status=error + issues。
|
||||
"""
|
||||
t0 = time.time()
|
||||
kw = dict(kw or {})
|
||||
blueprint_id = str(kw.get('blueprint_id') or '').strip()
|
||||
if not blueprint_id:
|
||||
return _fail('PBL_E_PARAM', 'blueprint_id 必填')
|
||||
version_no = kw.get('version_no') or kw.get('blueprint_version_no') or 1
|
||||
dry_run = bool(kw.get('dry_run'))
|
||||
|
||||
try:
|
||||
tid = _require_tenant()
|
||||
rules, issues, manifest, snapshot, ctx = await _map_rules(blueprint_id, version_no, kw)
|
||||
if has_errors(issues):
|
||||
errs = [i for i in issues if i['severity'] == 'error']
|
||||
return _fail('E_RULE_INVALID',
|
||||
'规则映射校验未通过(%d 个错误),拒绝导出' % len(errs),
|
||||
{'issues': _issue_dicts(errs), 'rule_count': len(rules)})
|
||||
|
||||
if dry_run:
|
||||
return _ok({
|
||||
'dry_run': True,
|
||||
'rule_count': len(rules),
|
||||
'rules_hash': manifest.get('rules_hash'),
|
||||
'rules_json': canonical_rules_json(rules),
|
||||
'rules_manifest': manifest,
|
||||
'issues': _issue_dicts(issues),
|
||||
})
|
||||
|
||||
result = {
|
||||
'dry_run': False,
|
||||
'rule_count': len(rules),
|
||||
'rules_hash': manifest.get('rules_hash'),
|
||||
'rules_manifest': manifest,
|
||||
'issues': _issue_dicts(issues),
|
||||
}
|
||||
|
||||
async with exporter.open_sor(None, 'pbl_compiler') as sor:
|
||||
# ② 导出到 script_engine 脚本表(幂等 UPSERT)
|
||||
if not kw.get('skip_scripts'):
|
||||
export_result = await exporter.export_rules(sor, rules, ctx, issues)
|
||||
result['export'] = {k: v for k, v in (export_result or {}).items()
|
||||
if k != 'issues'}
|
||||
# ③ 写 game_definition(规则四件套 + GD 正文,列名对齐 models)
|
||||
if not kw.get('skip_gd'):
|
||||
base_gd = kw.get('base_gd')
|
||||
if isinstance(base_gd, str):
|
||||
import json as _json
|
||||
base_gd = _json.loads(base_gd)
|
||||
gd, gd_text, gd_hash = exporter.build_game_definition(
|
||||
rules, ctx, base_gd, result.get('export'))
|
||||
ctx['compiler_version_id'] = str(kw.get('compiler_version_id') or '')
|
||||
ctx['compile_status'] = str(kw.get('status') or 'compiled')
|
||||
ctx['quality_state'] = str(kw.get('quality_state') or '')
|
||||
ctx['world_id'] = str(kw.get('world_id') or '')
|
||||
ctx['scene_id'] = str(kw.get('scene_id') or '')
|
||||
ctx['duration_ms'] = int((time.time() - t0) * 1000)
|
||||
row, row_issues = build_gd_row(gd, ctx, rules, manifest,
|
||||
base_definition_json=gd_text, issues=issues)
|
||||
result['content_fingerprint'] = row['content_fingerprint']
|
||||
result['gd_row'] = {k: row[k] for k in
|
||||
('id', 'blueprint_id', 'blueprint_version_no',
|
||||
'rule_count', 'rules_hash', 'compile_status')}
|
||||
if has_errors(row_issues):
|
||||
return _fail('E_GD_ROW_INVALID', 'GD 行组装校验未通过,拒绝写入',
|
||||
{'issues': _issue_dicts(
|
||||
[i for i in row_issues if i['severity'] == 'error'])})
|
||||
result['game_definition'] = await write_gd_rules(sor, row, ctx)
|
||||
result['duration_ms'] = int((time.time() - t0) * 1000)
|
||||
result['tenant_id'] = tid
|
||||
result['blueprint_id'] = blueprint_id
|
||||
result['blueprint_version'] = str(version_no)
|
||||
return _ok(result)
|
||||
except CompileError as e:
|
||||
return _fail(getattr(e, 'code', 'E_RULE_EXPORT'), str(e),
|
||||
getattr(e, 'detail', None))
|
||||
except PblError as e:
|
||||
return _fail(getattr(e, 'code', 'PBL_E_COMPILE'), str(e))
|
||||
except Exception as e: # pragma: no cover - 兜底,绝不裸异常给前端
|
||||
return _fail('E_RULE_EXPORT_UNEXPECTED', '%s: %s' % (type(e).__name__, e))
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 契约 2:规则映射预览(零写入)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
async def preview_rules_contract(**kw):
|
||||
"""只跑映射 + 校验,不碰任何表(第12章映射逻辑的单步验收入口)。"""
|
||||
kw = dict(kw or {})
|
||||
kw['dry_run'] = True
|
||||
return await export_rules_contract(**kw)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 契约 3:两个蓝图版本之间的规则差异
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
async def rules_diff_contract(**kw):
|
||||
"""from_blueprint_id/from_version_no → to_*:diff_rules 产出新增/删除/变更。"""
|
||||
kw = dict(kw or {})
|
||||
bp_a = str(kw.get('blueprint_id') or kw.get('from_blueprint_id') or '').strip()
|
||||
bp_b = str(kw.get('to_blueprint_id') or bp_a).strip()
|
||||
ver_a = kw.get('from_version_no') or kw.get('version_no') or 1
|
||||
ver_b = kw.get('to_version_no') or 1
|
||||
if not bp_a or not bp_b:
|
||||
return _fail('PBL_E_PARAM', 'blueprint_id / to_blueprint_id 必填')
|
||||
try:
|
||||
_require_tenant()
|
||||
rules_a, issues_a, man_a, _, _ = await _map_rules(bp_a, ver_a, kw)
|
||||
rules_b, issues_b, man_b, _, _ = await _map_rules(bp_b, ver_b, kw)
|
||||
diff = diff_rules(rules_a, rules_b)
|
||||
return _ok({
|
||||
'from': {'blueprint_id': bp_a, 'version_no': str(ver_a),
|
||||
'rule_count': man_a.get('rule_count'), 'rules_hash': man_a.get('rules_hash')},
|
||||
'to': {'blueprint_id': bp_b, 'version_no': str(ver_b),
|
||||
'rule_count': man_b.get('rule_count'), 'rules_hash': man_b.get('rules_hash')},
|
||||
'changed': bool(man_a.get('rules_hash') != man_b.get('rules_hash')),
|
||||
'diff': diff,
|
||||
'issues': {'from': _issue_dicts(issues_a), 'to': _issue_dicts(issues_b)},
|
||||
})
|
||||
except CompileError as e:
|
||||
return _fail(getattr(e, 'code', 'E_RULES_DIFF'), str(e))
|
||||
except Exception as e: # pragma: no cover
|
||||
return _fail('E_RULES_DIFF_UNEXPECTED', '%s: %s' % (type(e).__name__, e))
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 契约 4:读回 GD 的规则产物
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
async def gd_rules_get_contract(**kw):
|
||||
"""按 id 或 (blueprint_id[, version_no]) 读 pbl_game_definition 的规则列。"""
|
||||
kw = dict(kw or {})
|
||||
try:
|
||||
tid = _require_tenant()
|
||||
gd_id = str(kw.get('id') or kw.get('game_def_id') or '').strip()
|
||||
async with exporter.open_sor(None, 'pbl_compiler') as sor:
|
||||
if gd_id:
|
||||
row = await read_gd_rules_by_id(sor, tid, gd_id)
|
||||
rows = [row] if row else []
|
||||
else:
|
||||
bp = str(kw.get('blueprint_id') or '').strip()
|
||||
if not bp:
|
||||
return _fail('PBL_E_PARAM', 'id 或 blueprint_id 至少一个必填')
|
||||
rows = await read_gd_rules(sor, tid, bp, kw.get('version_no')
|
||||
or kw.get('blueprint_version_no'))
|
||||
slim = []
|
||||
for r in rows:
|
||||
if not r:
|
||||
continue
|
||||
slim.append({
|
||||
'id': r.get('id'),
|
||||
'blueprint_id': r.get('blueprint_id'),
|
||||
'blueprint_version_no': r.get('blueprint_version_no'),
|
||||
'content_fingerprint': r.get('content_fingerprint'),
|
||||
'rules_hash': r.get('rules_hash'),
|
||||
'rule_count': r.get('rule_count'),
|
||||
'compile_status': r.get('compile_status'),
|
||||
'quality_state': r.get('quality_state'),
|
||||
'rules_manifest': r.get('rules_manifest_parsed'),
|
||||
'rules': r.get('rules') or [],
|
||||
})
|
||||
return _ok({'table': GD_TABLE, 'total': len(slim), 'rows': slim})
|
||||
except CompileError as e:
|
||||
return _fail(getattr(e, 'code', 'E_GD_READ'), str(e))
|
||||
except Exception as e: # pragma: no cover
|
||||
return _fail('E_GD_READ_UNEXPECTED', '%s: %s' % (type(e).__name__, e))
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 契约 5:已导出规则行分页查询
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
async def script_rule_list_contract(**kw):
|
||||
"""分页查 pbl_script_rule(M3b 导出产物行),tenant_id 强制打头。"""
|
||||
kw = dict(kw or {})
|
||||
try:
|
||||
tid = _require_tenant()
|
||||
page = max(1, int(kw.get('page') or 1))
|
||||
rows_n = min(200, max(1, int(kw.get('rows') or kw.get('page_size') or 20)))
|
||||
offset = (page - 1) * rows_n
|
||||
where = ['`tenant_id` = ${t}$']
|
||||
params = {'t': tid, 'lim': rows_n, 'off': offset}
|
||||
if kw.get('blueprint_id'):
|
||||
where.append('`blueprint_id` = ${b}$')
|
||||
params['b'] = str(kw.get('blueprint_id'))
|
||||
if kw.get('game_def_id'):
|
||||
where.append('`game_def_id` = ${g}$')
|
||||
params['g'] = str(kw.get('game_def_id'))
|
||||
if kw.get('rules_hash'):
|
||||
where.append('`rules_hash` = ${h}$')
|
||||
params['h'] = str(kw.get('rules_hash'))
|
||||
if kw.get('event_type'):
|
||||
where.append('`event_type` = ${e}$')
|
||||
params['e'] = str(kw.get('event_type'))
|
||||
wsql = ' AND '.join(where)
|
||||
from pbl_compiler.api import _rows
|
||||
total_rows = await _rows('SELECT COUNT(*) AS c FROM `%s` WHERE %s'
|
||||
% (_RULE_TABLE_CANDIDATES[0], wsql), params)
|
||||
total = int((total_rows[0].get('c') if total_rows else 0) or 0)
|
||||
data = await _rows(
|
||||
'SELECT `id`,`rule_id`,`script_code`,`name`,`script_type`,`event_type`,'
|
||||
'`priority`,`enabled`,`condition_count`,`response_count`,`content_hash`,'
|
||||
'`rules_hash`,`sync_status`,`blueprint_id`,`blueprint_version`,'
|
||||
'`game_def_id`,`compile_task_no`,`created_at` '
|
||||
'FROM `%s` WHERE %s ORDER BY `priority` DESC, `rule_id` ASC'
|
||||
' LIMIT ${lim}$ OFFSET ${off}$' % (_RULE_TABLE_CANDIDATES[0], wsql), params)
|
||||
return _ok({'table': _RULE_TABLE_CANDIDATES[0], 'page': page, 'rows': rows_n,
|
||||
'total': total, 'rows_data': list(data or [])})
|
||||
except CompileError as e:
|
||||
return _fail(getattr(e, 'code', 'E_RULE_LIST'), str(e))
|
||||
except Exception as e: # pragma: no cover
|
||||
return _fail('E_RULE_LIST_UNEXPECTED', '%s: %s' % (type(e).__name__, e))
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 契约名(.dspy 直接调用的全局名)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
async def pbl_compiler_export_rules(**kw):
|
||||
return await export_rules_contract(**kw)
|
||||
|
||||
|
||||
async def pbl_compiler_rules_preview(**kw):
|
||||
return await preview_rules_contract(**kw)
|
||||
|
||||
|
||||
async def pbl_compiler_rules_diff(**kw):
|
||||
return await rules_diff_contract(**kw)
|
||||
|
||||
|
||||
async def pbl_game_definition_rules_get(**kw):
|
||||
return await gd_rules_get_contract(**kw)
|
||||
|
||||
|
||||
async def pbl_script_rule_list(**kw):
|
||||
return await script_rule_list_contract(**kw)
|
||||
Loading…
x
Reference in New Issue
Block a user