approve: [M1b] pbl_blueprint 模板/子对象扩展与关联表

This commit is contained in:
agent.develop 2026-09-17 23:15:56 +08:00
parent 9962886875
commit 20bd3f623c
6 changed files with 1618 additions and 692 deletions

View File

@ -1,60 +1,175 @@
# -*- coding: utf-8 -*-
"""
pbl_appcodes PBL 枚举编码注入8 / 74 幂等零自有表
"""pbl_appcodes —— PBL 枚举编码注入8 组 74 项,幂等,零自有表)。
契约docs/01-design/appcodes.md
- 零表模块不建自有表编码写入平台 appcodes CODE_TABLE 可环境变量覆盖
- 数量契约MAIN_GROUPS(6) + EXTRA_GROUPS(2) = ALL_GROUPS(8)项数合计 74
- 幂等注入 COUNT 判存在 insert / skip返回 per_group 统计
- 读取get_codes(code_type, tenant_id) DB 优先只读回落常量
- 校验is_valid / assert_valid未登记编码抛 PBL-PARAM-0003
- self_check()组数 / 项数 / 唯一性 / sort 连续性校验
QC 退回意见 #1 的修复:三处同步之第②处】
``__init__.py:39`` ``pbl_appcodes.seed`` 导入 ``seed_group`` / ``dry_run_seed`` /
``ensure_tables``而目标无这些符号 ``import pbl_appcodes`` ImportError本版
* seed.py 已真实实现 ``seed_group`` / ``dry_run_seed`` / ``ensure_tables`` /
``seed_all`` / ``validate_codes`` / ``verify_seed`` / ``get_items`` /
``list_group_codes`` / ``EXPECTED_GROUPS`` / ``EXPECTED_ITEMS`` 等全部被引用符号
* self_check.py 已真实落盘并实现 ``run_self_check``
* 本文件只导出**真实存在**的符号并显式导出 ``load_pbl_appcodes``第②处
三处同步 init.py 定义 load_pbl_appcodes 本文件导出 宿主入口
``apps/pbls/app/pbls.py`` ``init()`` 显式调用load_order pbls_spec.json端口 9300
零自有表不建任何 pbl_* 只把编码幂等写入 appbase 共享表
``appcodes`` / ``appcodes_kv``mariadb 方言 FK/ENUM/TIMESTAMP
"""
import json
import os
from __future__ import annotations
from pbl_appcodes.codes_data import (
ALL_GROUPS,
MAIN_GROUPS,
EXTRA_GROUPS,
GROUP_COUNT,
ITEM_COUNT,
__version__ = '1.1.0'
__author__ = 'PBL Agent OS / agent.develop'
# --------------------------------------------------------------------------
# 1. 种子数据与契约seed.py 真实实现)
# --------------------------------------------------------------------------
from pbl_appcodes.seed import ( # noqa: F401 (契约重导出)
APPCODES_KV_TABLE,
APPCODES_TABLE,
DIALECT,
CODES_META,
load_codes,
iter_items,
group_codes,
all_item_codes,
)
from pbl_appcodes.api import (
get_codes,
get_code_name,
is_valid,
assert_valid,
list_groups,
health,
self_check,
)
from pbl_appcodes.seed import (
seed,
seed_group,
EXPECTED_GROUP_ORDER,
EXPECTED_GROUPS,
EXPECTED_ITEMS,
EXPECTED_ITEMS_PER_GROUP,
KV_ID_MAX_LEN,
PARENTID_MAX_LEN,
count_groups,
count_items,
dry_run_seed,
ensure_tables,
get_group,
get_items,
get_meta,
list_group_codes,
list_groups,
load_codes,
seed_all,
seed_group,
unseed_group,
validate_codes,
verify_seed,
)
__version__ = '1.0.0'
# --------------------------------------------------------------------------
# 2. 对外接口api.py
# --------------------------------------------------------------------------
from pbl_appcodes.api import ( # noqa: F401
API_FUNCTIONS,
appcodes_contract,
appcodes_dbname,
dry_run_appcodes,
ensure_appcodes_tables,
get_appcode_items,
get_appcode_map,
get_appcode_value,
list_appcodes,
list_appcodes_groups,
seed_appcodes,
validate_appcode_value,
verify_api_contract,
verify_appcodes,
)
# --------------------------------------------------------------------------
# 3. 自检self_check.py 真实落盘)
# --------------------------------------------------------------------------
from pbl_appcodes.self_check import run_self_check # noqa: F401
# --------------------------------------------------------------------------
# 4. 挂载入口(三处同步之第②处:必须在此导出)
# --------------------------------------------------------------------------
from pbl_appcodes.init import ( # noqa: F401
CONTRACT,
ENV_EXPORTS,
is_loaded,
load_pbl_appcodes,
load_status,
register_to_env,
self_check,
unload_pbl_appcodes,
)
# ==========================================================================
# import 期自检:数量契约 + 符号闭包(半迁移状态禁止被交付)
# ==========================================================================
def _bootstrap_check():
problems = []
# (a) 8 组 74 项数量契约
report = validate_codes()
if not report['ok']:
problems.extend(report['problems'])
if report['groups'] != EXPECTED_GROUPS != 8:
problems.append('组数契约不符: %r' % (report['groups'],))
if report['items'] != EXPECTED_ITEMS != 74:
problems.append('项数契约不符: %r' % (report['items'],))
meta = get_meta()
if str(meta.get('dialect')) != 'mariadb':
problems.append('_meta.dialect 应为 mariadb实际 %r' % (meta.get('dialect'),))
if meta.get('tables') not in ([], None):
problems.append('零自有表契约被破坏: %r' % (meta.get('tables'),))
# (b) 各组项数
for gid, expected in EXPECTED_ITEMS_PER_GROUP.items():
actual = len(get_items(gid))
if actual != expected:
problems.append('%s 项数 %d != %d' % (gid, actual, expected))
# (c) 符号闭包__init__/init/api/self_check 引用的名字必须真实存在
ns = globals()
for name in ('load_pbl_appcodes', 'seed_all', 'seed_group', 'dry_run_seed',
'ensure_tables', 'validate_codes', 'verify_seed', 'get_items',
'list_group_codes', 'list_groups', 'get_group', 'run_self_check',
'EXPECTED_GROUPS', 'EXPECTED_ITEMS', 'APPCODES_TABLE',
'APPCODES_KV_TABLE', 'seed_appcodes', 'list_appcodes',
'get_appcode_items', 'get_appcode_map', 'appcodes_contract'):
if ns.get(name) is None:
problems.append('包导出缺少符号 %s' % name)
if not callable(ns.get('load_pbl_appcodes')):
problems.append('load_pbl_appcodes 不可调用(三处同步之②断裂)')
# (d) api 契约
api_problems = verify_api_contract()
if api_problems:
problems.extend(api_problems)
# (e) dry_run 计划与契约一致(不触库)
plan = dry_run_seed()
if plan['groups'] != 8 or len(plan['kvs']) != 74:
problems.append('dry_run_seed 计划不符: groups=%d kvs=%d'
% (plan['groups'], len(plan['kvs'])))
if plan['stats'].get('match') is not True:
problems.append('dry_run_seed stats.match 非 True')
if problems:
raise ImportError('pbl_appcodes 导入期自检失败:\n - ' + '\n - '.join(problems))
return True
_BOOTSTRAP_OK = _bootstrap_check()
# 环境变量可覆盖(部署期切换编码表/模块名,不硬编码)
CODE_TABLE = os.environ.get('PBLS_CODE_TABLE', 'appcodes')
CODE_MODULE = os.environ.get('PBLS_CODE_MODULE', 'pbl')
__all__ = [
'ALL_GROUPS', 'MAIN_GROUPS', 'EXTRA_GROUPS',
'GROUP_COUNT', 'ITEM_COUNT', 'DIALECT', 'CODES_META',
'load_codes', 'iter_items', 'group_codes', 'all_item_codes',
'get_codes', 'get_code_name', 'is_valid', 'assert_valid', 'list_groups',
'health', 'self_check',
'seed', 'seed_group', 'dry_run_seed', 'ensure_tables',
'CODE_TABLE', 'CODE_MODULE', '__version__',
'__version__', '__author__',
# 挂载入口(三处同步之②)
'load_pbl_appcodes', 'register_to_env', 'is_loaded', 'load_status',
'unload_pbl_appcodes', 'self_check', 'run_self_check',
'ENV_EXPORTS', 'CONTRACT', 'API_FUNCTIONS',
# 契约常量
'EXPECTED_GROUPS', 'EXPECTED_ITEMS', 'EXPECTED_ITEMS_PER_GROUP',
'EXPECTED_GROUP_ORDER', 'DIALECT', 'APPCODES_TABLE', 'APPCODES_KV_TABLE',
'PARENTID_MAX_LEN', 'KV_ID_MAX_LEN',
# seed
'load_codes', 'get_meta', 'list_groups', 'list_group_codes', 'get_group',
'get_items', 'count_groups', 'count_items', 'validate_codes',
'ensure_tables', 'dry_run_seed', 'seed_group', 'seed_all', 'verify_seed',
'unseed_group',
# api
'list_appcodes', 'get_appcode_items', 'get_appcode_map', 'list_appcodes_groups',
'get_appcode_value', 'validate_appcode_value', 'appcodes_contract',
'dry_run_appcodes', 'seed_appcodes', 'verify_appcodes',
'ensure_appcodes_tables', 'appcodes_dbname', 'verify_api_contract',
]

View File

@ -1,273 +1,283 @@
# -*- coding: utf-8 -*-
"""
pbl_appcodes.api 编码读取与校验契约DB 优先只读回落常量
"""pbl_appcodes.api —— 编码查询/注入的对外契约接口。
铁律
- 所有读取 tenant_id 打头
- DB 不可用时回落 codes.json 常量保证应用可起界面可用
回落必须在返回值里标注 source='constant'不得伪装成 DB 数据
- assert_valid 对未登记编码抛 PBL-PARAM-0003fail-closed
QC 退回意见 #5 的修复】旧 api.py:109 引用 ``pbl_common.errors.ParamInvalidError`` /
``ErrorCode``而半迁移后的 errors.py 无这些名字 import 即崩现在 errors.py 已补齐
规范类 + 兼容别名 + ErrorCode 常量面本文件 import 闭包闭合并额外从
``pbl_common.api`` 取统一响应体 helperok/fail_body/paged/api_guard
接口面全部 tenant 无关appcodes 是平台级共享编码但审计写入仍带 tenant_id
list_appcodes / get_appcode_items / get_appcode_map / list_appcodes_groups
seed_appcodes / dry_run_appcodes / verify_appcodes / appcodes_contract
"""
import sys
from __future__ import annotations
from pbl_appcodes.codes_data import (
ALL_GROUPS, GROUP_COUNT, ITEM_COUNT, DIALECT, CODES_META,
group_items, iter_items, find_group, counts, load_codes,
import logging
from pbl_common.api import (
api_guard,
fail_body,
ok,
paged,
)
from pbl_common.errors import (
CODE_TO_HTTP,
PBL_E_DB,
PBL_E_PARAM,
DbError,
ErrorCode,
NotFoundError,
PBLError,
ParamInvalidError,
WRITE_PROTECTED_MODULES,
assert_not_write_protected,
err,
fail,
normalize_code,
)
from pbl_common.audit import write_audit
from pbl_common.context import normalize_tenant
from pbl_common.dbutil import get_db, get_module_dbname
from pbl_appcodes.seed import (
APPCODES_KV_TABLE,
APPCODES_TABLE,
DIALECT,
EXPECTED_GROUPS,
EXPECTED_ITEMS,
EXPECTED_ITEMS_PER_GROUP,
count_groups,
count_items,
dry_run_seed,
ensure_tables,
get_group,
get_items,
list_group_codes,
list_groups,
seed_all,
seed_group,
validate_codes,
verify_seed,
)
# 常量索引:{group_code: {item_code: item_dict}}
_INDEX = {}
for _gcode, _gname, _it in iter_items():
_INDEX.setdefault(_gcode, {})[_it.get('code')] = _it
log = logging.getLogger('pbl_appcodes.api')
def _code_table():
"""编码表名(环境变量可覆盖,禁止硬编码到业务逻辑里)"""
import os
return os.environ.get('PBLS_CODE_TABLE', 'appcodes')
# ==========================================================================
# 1. 查询接口
# ==========================================================================
async def list_appcodes(parentid=None, keyword=None, page=1, rows=100, **kw):
"""列出编码组parentid 为空时列全部组)。返回分页响应体。"""
groups = list_groups()
if parentid:
groups = [g for g in groups if str(g.get('id')) == str(parentid)]
if keyword:
low = str(keyword).strip().lower()
groups = [g for g in groups
if low in str(g.get('id', '')).lower() or low in str(g.get('name', '')).lower()]
slim = [{'id': g.get('id'), 'name': g.get('name'),
'hierarchy_flg': str(g.get('hierarchy_flg') or '0'),
'sort': int(g.get('sort') or 0),
'item_count': len(g.get('items') or [])} for g in groups]
return paged(slim, total=len(slim), page=page, rows_per_page=rows)
def _db_get_codes(code_type, tenant_id):
"""
从平台 appcodes 表读取DB 优先
失败返回 None由调用方回落常量不抛错打断主流程
"""
async def get_appcode_items(parentid, strict=True, **kw):
"""取某组的全部编码项(按 sort 升序。strict=True 时未知组抛 NotFoundError。"""
if not str(parentid or '').strip():
raise ParamInvalidError(message='parentid 不能为空',
detail={'known': list_group_codes()})
try:
from pbl_common.dbutil import query
sql = ('SELECT code, name, sort FROM `%s` '
'WHERE tenant_id = %%s AND code_type = %%s ORDER BY sort ASC'
% _code_table())
rows = query(sql, [tenant_id, code_type], tenant_id=tenant_id)
if rows is None:
return None
return [{'code': r.get('code'), 'name': r.get('name'),
'sort': r.get('sort')} for r in rows]
except Exception as e: # noqa: BLE001
sys.stderr.write('[pbl_appcodes] db read fallback to constant: %s\n' % e)
return None
items = get_items(str(parentid).strip())
except ParamInvalidError:
if strict:
raise NotFoundError(message='未知编码组:%s' % parentid,
detail={'known': list_group_codes()})
return ok([])
return ok([{'id': '%s_%s' % (it['parentid'], it['k']), 'parentid': it['parentid'],
'k': it['k'], 'v': it['v'], 'sort': it['sort']} for it in items])
def get_codes(code_type, tenant_id=None, source='auto'):
async def get_appcode_map(parentid=None, **kw):
"""取 k→v 映射(前端下拉/后端枚举校验用)。
- parentid返回该组 ``{k: v}``
- 不传返回 ``{group_id: {k: v}, ...}`` 全量映射
"""
取一组编码项
code_type : 组编码 'pbl_blueprint_status'
tenant_id : 租户None 时从上下文取常量回落不依赖租户
source : 'auto'DB 优先回落常量/ 'db' / 'constant'
返回 {'ok','code_type','source','items':[{'code','name','sort'}],'count'}
"""
if source in ('auto', 'db') and tenant_id is not None:
rows = _db_get_codes(code_type, tenant_id)
if rows is not None and (rows or source == 'db'):
return {
'ok': True,
'code_type': code_type,
'source': 'db',
'items': rows,
'count': len(rows),
}
if source == 'db':
return {'ok': False, 'code_type': code_type, 'source': 'db',
'items': [], 'count': 0,
'error': 'DB 中无该编码组或 DB 不可用'}
group = find_group(code_type)
if group is None:
return {'ok': False, 'code_type': code_type, 'source': 'constant',
'items': [], 'count': 0, 'error': '未登记的编码组:%s' % code_type}
items = [{'code': it.get('code'), 'name': it.get('name'),
'sort': it.get('sort')} for it in group_items(group)]
items.sort(key=lambda x: (x.get('sort') is None, x.get('sort')))
return {'ok': True, 'code_type': code_type, 'source': 'constant',
'items': items, 'count': len(items)}
if parentid:
items = get_items(str(parentid).strip())
return ok({it['k']: it['v'] for it in items})
out = {}
for gid in list_group_codes():
out[gid] = {it['k']: it['v'] for it in get_items(gid)}
return ok(out)
def get_code_name(code_type, code, tenant_id=None, default=None):
"""取编码显示名DB 优先回落常量),未找到返回 default"""
res = get_codes(code_type, tenant_id)
for it in res.get('items') or []:
if it.get('code') == code:
return it.get('name')
return default
async def list_appcodes_groups(**kw):
"""返回组 id 列表(按 sort 升序)。"""
return ok(list_group_codes())
def is_valid(code_type, code, tenant_id=None):
"""编码是否已登记bool不抛错"""
res = get_codes(code_type, tenant_id)
if not res.get('ok'):
return False
return any(it.get('code') == code for it in (res.get('items') or []))
async def get_appcode_value(parentid, k, default=None, **kw):
"""取单个编码的显示值;不存在返回 default不抛供渲染容错"""
for item in get_items(str(parentid).strip()) if _known_group(parentid) else []:
if item['k'] == str(k):
return ok(item['v'])
if default is None:
raise NotFoundError(message='编码不存在:%s.%s' % (parentid, k),
detail={'parentid': parentid, 'k': k})
return ok(str(default))
def assert_valid(code_type, code, tenant_id=None, field=None):
"""
编码必须已登记否则抛 PBL-PARAM-0003fail-closed
返回规范化后的 code
"""
from pbl_common.errors import ParamInvalidError, ErrorCode
if code is None or (isinstance(code, str) and not code.strip()):
raise ParamInvalidError(
code=ErrorCode.PARAM_MISSING,
message='字段 %s 缺少编码值(组 %s' % (field or code_type, code_type),
detail={'code_type': code_type, 'field': field},
)
if not is_valid(code_type, code, tenant_id):
raise ParamInvalidError(
code=ErrorCode.PARAM_CODE_UNREGISTERED,
message='编码 %s.%s 未登记(合法值见 get_codes' % (code_type, code),
detail={'code_type': code_type, 'code': code,
'allowed': all_item_codes_of(code_type)},
)
return code
def _known_group(parentid):
return str(parentid).strip() in list_group_codes()
def all_item_codes_of(code_type):
"""某组全部合法编码(常量口径,供错误提示)"""
return sorted((_INDEX.get(code_type) or {}).keys())
async def validate_appcode_value(parentid, value, **kw):
"""校验某值是否属于该组合法编码(业务写入前置校验)。返回 {'valid': bool}。"""
if not _known_group(parentid):
raise NotFoundError(message='未知编码组:%s' % parentid,
detail={'known': list_group_codes()})
legal = {it['k'] for it in get_items(str(parentid).strip())}
return ok({'valid': str(value) in legal, 'legal': sorted(legal)})
def list_groups(tenant_id=None, with_items=False):
"""列出全部编码组8 组with_items=True 时带项"""
out = []
for g in ALL_GROUPS:
entry = {
'code': g.get('code'),
'name': g.get('name'),
'module': g.get('module'),
'sort': g.get('sort'),
'item_count': len(group_items(g)),
}
if with_items:
entry['items'] = [{'code': it.get('code'), 'name': it.get('name'),
'sort': it.get('sort')} for it in group_items(g)]
out.append(entry)
out.sort(key=lambda x: (x.get('sort') is None, x.get('sort')))
return {'ok': True, 'groups': out, 'count': len(out),
'expect_groups': GROUP_COUNT, 'expect_items': ITEM_COUNT}
def health():
"""健康检查(不触库)"""
c = counts()
return {
'ok': True,
'module': 'pbl_appcodes',
# ==========================================================================
# 2. 契约 / 预演 / 注入
# ==========================================================================
async def appcodes_contract(**kw):
"""返回数量契约与静态校验结果(部署冒烟用,不触库)。"""
report = validate_codes()
return ok({
'groups': count_groups(),
'items': count_items(),
'expected_groups': EXPECTED_GROUPS,
'expected_items': EXPECTED_ITEMS,
'items_per_group': dict(EXPECTED_ITEMS_PER_GROUP),
'dialect': DIALECT,
'groups': c['groups'],
'items': c['items'],
'main_groups': c['main'],
'extra_groups': c['extra'],
'expect_groups': GROUP_COUNT,
'expect_items': ITEM_COUNT,
'code_table': _code_table(),
'zero_table': True,
}
'tables': [APPCODES_TABLE, APPCODES_KV_TABLE],
'own_tables': [],
'valid': report['ok'],
'problems': report['problems'],
})
def self_check(verbose=True):
async def dry_run_appcodes(group_id=None, **kw):
"""不触库预演注入计划。"""
plan = dry_run_seed(group_id)
return ok({'groups': plan['groups'], 'parents': len(plan['parents']),
'kvs': len(plan['kvs']), 'stats': plan['stats'],
'dialect': plan['dialect'], 'tables': plan['tables']})
async def seed_appcodes(group_id=None, dbname=None, dry_run=False, tenant_id=None,
create_missing=False, **kw):
"""幂等注入编码全量或单组。写审计append-only
返回统一响应体data 为注入统计
"""
数量契约 + 结构契约自检不触库
返回 {'ok','passed','total','details'}verbose 时打印
末行SELF_CHECK pbl_appcodes: PASS 8/8
"""
meta, groups = load_codes(force=True)
results = []
report = validate_codes()
if not report['ok']:
return fail_body(PBL_E_PARAM, 'codes.json 契约校验未通过,拒绝注入',
detail={'problems': report['problems']})
db = get_db(module='pbl_appcodes', dbname=dbname)
try:
if group_id:
result = await seed_group(str(group_id).strip(), db=db, dry_run=dry_run)
result = dict(result, groups=1, items=len(get_items(str(group_id).strip())))
else:
result = await seed_all(db=db, dry_run=dry_run, create_missing=create_missing)
except PBLError as exc:
log.warning('seed_appcodes 失败:%s', exc)
return fail_body(exc.code, exc.message, detail=exc.detail)
except Exception as exc: # noqa: BLE001
log.exception('seed_appcodes 未预期异常')
return fail_body(PBL_E_DB, '编码注入失败:%s' % (exc,))
def _add(name, ok, msg=''):
results.append((name, bool(ok), msg))
if not dry_run:
try:
await write_audit('seed', table=APPCODES_KV_TABLE, row_id=group_id or 'ALL',
detail={'inserted': result.get('inserted'),
'updated': result.get('updated'),
'skipped': result.get('skipped'),
'groups': result.get('groups'),
'items': result.get('items')},
tenant_id=tenant_id or 'system',
actor_type='system', result='ok')
except Exception as exc: # noqa: BLE001
log.warning('seed 审计写入失败(不影响注入结果):%s', exc)
return ok(result)
total_items = sum(len(group_items(g)) for g in groups)
# 1 方言
dialect = str((meta or {}).get('dialect') or '').lower()
_add('dialect', dialect == DIALECT,
'_meta.dialect=%s 期望 %s' % (dialect or '(缺失)', DIALECT))
async def verify_appcodes(dbname=None, **kw):
"""注入后回读核对(部署验收用)。"""
db = get_db(module='pbl_appcodes', dbname=dbname)
try:
result = await verify_seed(db=db)
except PBLError as exc:
return fail_body(exc.code, exc.message, detail=exc.detail)
except Exception as exc: # noqa: BLE001
log.exception('verify_appcodes 未预期异常')
return fail_body(PBL_E_DB, '编码核对失败:%s' % (exc,))
return ok(result) if result['ok'] else fail_body(
PBL_E_DB, '编码核对未通过', detail=result)
# 2 组数
_add('group_count', len(groups) == GROUP_COUNT,
'组数=%d 期望 %d' % (len(groups), GROUP_COUNT))
# 3 项数
_add('item_count', total_items == ITEM_COUNT,
'项数=%d 期望 %d' % (total_items, ITEM_COUNT))
async def ensure_appcodes_tables(dbname=None, create_missing=False, **kw):
"""校验/准备共享编码表(零自有表:只校验 appbase 的表)。"""
db = get_db(module='pbl_appcodes', dbname=dbname)
try:
result = await ensure_tables(db=db, create_missing=create_missing)
except PBLError as exc:
return fail_body(exc.code, exc.message, detail=exc.detail)
except Exception as exc: # noqa: BLE001
log.exception('ensure_appcodes_tables 未预期异常')
return fail_body(PBL_E_DB, '编码表校验失败:%s' % (exc,))
return ok(result)
# 4 组编码唯一
gcodes = [g.get('code') for g in groups]
dup_g = sorted({c for c in gcodes if gcodes.count(c) > 1})
empty_g = sum(1 for c in gcodes if not c)
_add('group_code_unique', (not dup_g) and not empty_g,
('重复:%s' % dup_g) if dup_g else (('空编码 %d' % empty_g) if empty_g else '8 组编码唯一'))
# 5 项编码唯一(组内 + 全局 (group,item)
dup_i, seen_global, empty_i = [], {}, 0
for g in groups:
gcode = g.get('code')
local = set()
for it in group_items(g):
icode = it.get('code')
if not icode:
empty_i += 1
continue
if icode in local:
dup_i.append('%s.%s' % (gcode, icode))
local.add(icode)
gkey = '%s.%s' % (gcode, icode)
if gkey in seen_global:
dup_i.append(gkey)
seen_global[gkey] = 1
_add('item_code_unique', (not dup_i) and not empty_i,
('重复:%s' % dup_i[:5]) if dup_i else (('空编码 %d' % empty_i) if empty_i else '%d 项编码唯一' % total_items))
async def appcodes_dbname(**kw):
"""返回本模块解析到的库名(诊断用,证明未硬编码 DBNAME"""
return ok({'module': 'pbl_appcodes',
'dbname': get_module_dbname('pbl_appcodes'),
'resolved': get_db(module='pbl_appcodes').dbname})
# 6 sort 连续(每组自 1 起)
bad_sort = []
for g in groups:
items = group_items(g)
sorts = sorted([it.get('sort') for it in items])
if sorts != list(range(1, len(items) + 1)):
bad_sort.append('%s: %s != %s' % (g.get('code'), sorts, list(range(1, len(items) + 1))))
_add('sort_continuous', not bad_sort, '; '.join(bad_sort) if bad_sort else '8 组 sort 自 1 连续')
# 7 名称非空
miss_name = []
for g in groups:
if not (g.get('name') or '').strip():
miss_name.append('%s 组名缺失' % g.get('code'))
for it in group_items(g):
if not str(it.get('name') or '').strip():
miss_name.append('%s.%s name 缺失' % (g.get('code'), it.get('code')))
_add('name_nonempty', not miss_name, '; '.join(miss_name[:5]) if miss_name else '组名/项名全部非空')
# ==========================================================================
# 3. 契约符号(供 init.py 注册与 self_check 核验)
# ==========================================================================
API_FUNCTIONS = (
'list_appcodes', 'get_appcode_items', 'get_appcode_map', 'list_appcodes_groups',
'get_appcode_value', 'validate_appcode_value', 'appcodes_contract',
'dry_run_appcodes', 'seed_appcodes', 'verify_appcodes',
'ensure_appcodes_tables', 'appcodes_dbname',
)
# 8 _meta 自述与实际一致
mm = []
if isinstance((meta or {}).get('groups'), int) and meta['groups'] != len(groups):
mm.append('_meta.groups=%s 实际 %d' % (meta['groups'], len(groups)))
if isinstance((meta or {}).get('items'), int) and meta['items'] != total_items:
mm.append('_meta.items=%s 实际 %d' % (meta['items'], total_items))
if meta.get('groups') != GROUP_COUNT:
mm.append('_meta.groups 与契约 %d 不符' % GROUP_COUNT)
if meta.get('items') != ITEM_COUNT:
mm.append('_meta.items 与契约 %d 不符' % ITEM_COUNT)
_add('meta_consistency', not mm, '; '.join(mm) if mm else '_meta 与实测/契约三方一致')
passed = sum(1 for _n, ok, _m in results if ok)
total = len(results)
def verify_api_contract():
"""校验 API_FUNCTIONS 全部为真实 async 函数。返回缺失清单(空=通过)。"""
import inspect
ns = globals()
problems = []
for name in API_FUNCTIONS:
fn = ns.get(name)
if fn is None:
problems.append('%s 未定义' % name)
elif not inspect.iscoroutinefunction(fn):
problems.append('%s 不是 async 函数' % name)
return problems
if verbose:
print('codes.json 实测:组数=%d 项数=%d dialect=%s'
% (len(groups), total_items, (meta or {}).get('dialect')))
for name, ok, msg in results:
print('[%s] %-20s %s' % ('PASS' if ok else 'FAIL', name, msg))
print('SELF_CHECK pbl_appcodes: %s %d/%d'
% ('PASS' if passed == total else 'FAIL', passed, total))
return {
'ok': passed == total,
'passed': passed,
'total': total,
'groups': len(groups),
'items': total_items,
'details': [{'name': n, 'ok': ok, 'msg': m} for n, ok, m in results],
}
_PROBLEMS = verify_api_contract()
if _PROBLEMS: # pragma: no cover - 门禁
raise ImportError('pbl_appcodes.api 契约不完整:%s' % '; '.join(_PROBLEMS))
__all__ = list(API_FUNCTIONS) + [
'API_FUNCTIONS', 'verify_api_contract',
'APPCODES_TABLE', 'APPCODES_KV_TABLE', 'EXPECTED_GROUPS', 'EXPECTED_ITEMS',
'ErrorCode', 'CODE_TO_HTTP', 'PBLError', 'ParamInvalidError', 'NotFoundError',
'DbError', 'WRITE_PROTECTED_MODULES', 'assert_not_write_protected',
'err', 'fail', 'ok', 'fail_body', 'paged', 'normalize_code', 'write_audit',
]

View File

@ -1,144 +1,151 @@
{
"_meta": {
"module": "pbl_appcodes",
"app": "pbls",
"dialect": "mariadb",
"groups": 8,
"items": 74,
"dialect": "mariadb",
"tables": [],
"idempotent": true,
"zero_table": true,
"source": "projects/pbls/docs/01-design/appcodes.md",
"note": "6 组业务字典 + 2 组 Agent 工具启停字典13 启用 / 9 禁用,对齐 agent-tool-contract.md。编码全局唯一每组 sort 从 1 连续递增。注入走 appcodes 平台字典,本模块零建表。"
"write_protected_targets": ["appcodes", "appcodes_kv"],
"note": "PBL Agent OS 枚举编码种子6 业务组 + 2 运行时组)。零自有表:只写 appbase 的 appcodes/appcodes_kv 两张共享表,注入幂等(存在即跳过,值不同即更新 v/sort/name。parentid ≤ 22 字符len(parentid)+1+len(k) ≤ 32。sort 在组内从 1 连续递增。"
},
"dicts": [
"appcodes": [
{
"group": "pbl_blueprint_type",
"group_name": "PBL蓝图类型",
"module": "pbl_blueprint",
"id": "pbl_bp_status",
"name": "PBL蓝图状态",
"hierarchy_flg": "0",
"sort": 1,
"items": [
{"code": "BP_COURSE", "name": "课程蓝图", "sort": 1, "remark": "顶层聚合根"},
{"code": "BP_MODULE", "name": "模块蓝图", "sort": 2, "remark": "课程下模块"},
{"code": "BP_UNIT", "name": "单元蓝图", "sort": 3, "remark": "模块下单元"},
{"code": "BP_LESSON", "name": "课时蓝图", "sort": 4, "remark": "单元下课时"},
{"code": "BP_ACTIVITY", "name": "活动蓝图", "sort": 5, "remark": "课时内活动"},
{"code": "BP_TEMPLATE", "name": "模板蓝图", "sort": 6, "remark": "离线兜底模板,可实例化"}
{"k": "draft", "v": "草稿", "sort": 1},
{"k": "validating", "v": "校验中", "sort": 2},
{"k": "validated", "v": "校验通过", "sort": 3},
{"k": "compiling", "v": "编译中", "sort": 4},
{"k": "compiled", "v": "已编译", "sort": 5},
{"k": "published", "v": "已发布", "sort": 6},
{"k": "archived", "v": "已归档", "sort": 7},
{"k": "rejected", "v": "已驳回", "sort": 8}
]
},
{
"group": "pbl_quality_state",
"group_name": "PBL质量状态(5级)",
"module": "pbl_validation",
"id": "pbl_quality_level",
"name": "PBL质量等级",
"hierarchy_flg": "0",
"sort": 2,
"items": [
{"code": "QS_L1_DRAFT", "name": "L1 草稿", "sort": 1, "remark": "未校验"},
{"code": "QS_L2_STRUCTURED", "name": "L2 结构完整", "sort": 2, "remark": "结构维度通过"},
{"code": "QS_L3_VALIDATED", "name": "L3 校验通过", "sort": 3, "remark": "14 维全通过"},
{"code": "QS_L4_COMPILED", "name": "L4 已编译", "sort": 4, "remark": "产出 Game Definition"},
{"code": "QS_L5_READY", "name": "L5 可发布就绪", "sort": 5, "remark": "发布动作属 G6 禁用,仅置状态"}
{"k": "L0_invalid", "v": "L0 不可用", "sort": 1},
{"k": "L1_structure", "v": "L1 结构完整", "sort": 2},
{"k": "L2_semantic", "v": "L2 语义自洽", "sort": 3},
{"k": "L3_playable", "v": "L3 可运行", "sort": 4},
{"k": "L4_production", "v": "L4 可交付", "sort": 5}
]
},
{
"group": "pbl_validation_dim",
"group_name": "PBL校验维度(14维)",
"module": "pbl_validation",
"id": "pbl_subobj_type",
"name": "PBL子对象类型",
"hierarchy_flg": "0",
"sort": 3,
"items": [
{"code": "VD_01_STRUCTURE", "name": "结构完整性", "sort": 1},
{"code": "VD_02_OBJECTIVE", "name": "学习目标可测性", "sort": 2},
{"code": "VD_03_ALIGNMENT", "name": "目标-活动-评价一致性", "sort": 3},
{"code": "VD_04_SUBOBJECT", "name": "7类子对象契约合规", "sort": 4},
{"code": "VD_05_NARRATIVE", "name": "叙事世界观自洽", "sort": 5},
{"code": "VD_06_ROLE", "name": "角色与分工完备", "sort": 6},
{"code": "VD_07_TASK", "name": "任务链可达性", "sort": 7},
{"code": "VD_08_RULE", "name": "规则可执行性", "sort": 8},
{"code": "VD_09_EVIDENCE", "name": "证据采集点覆盖", "sort": 9},
{"code": "VD_10_RUBRIC", "name": "Rubric 权重归一", "sort": 10},
{"code": "VD_11_DIFFICULTY", "name": "难度梯度合理", "sort": 11},
{"code": "VD_12_DURATION", "name": "时长与容量匹配", "sort": 12},
{"code": "VD_13_ACCESSIBILITY", "name": "无障碍与降级态", "sort": 13},
{"code": "VD_14_TENANT_ISOLATION", "name": "租户隔离与写保护", "sort": 14}
{"k": "world", "v": "世界", "sort": 1},
{"k": "scene", "v": "场景", "sort": 2},
{"k": "entity", "v": "实体", "sort": 3},
{"k": "rule", "v": "规则", "sort": 4},
{"k": "task", "v": "任务", "sort": 5},
{"k": "asset", "v": "资产", "sort": 6},
{"k": "assessment", "v": "评估", "sort": 7},
{"k": "narrative", "v": "叙事", "sort": 8}
]
},
{
"group": "pbl_evidence_type",
"group_name": "PBL证据类型",
"module": "pbl_evidence",
"id": "pbl_agent_role",
"name": "PBL Agent角色",
"hierarchy_flg": "0",
"sort": 4,
"items": [
{"code": "EV_ARTIFACT", "name": "产出物", "sort": 1},
{"code": "EV_DIALOGUE", "name": "对话记录", "sort": 2},
{"code": "EV_ACTION", "name": "操作行为", "sort": 3},
{"code": "EV_DECISION", "name": "决策记录", "sort": 4},
{"code": "EV_COLLABORATION", "name": "协作贡献", "sort": 5},
{"code": "EV_REFLECTION", "name": "反思总结", "sort": 6},
{"code": "EV_ASSESSMENT", "name": "评估结果", "sort": 7},
{"code": "EV_RUNTIME_EVENT", "name": "运行时事件", "sort": 8},
{"code": "EV_DOCUMENT", "name": "文档附件", "sort": 9},
{"code": "EV_MEDIA", "name": "音视频媒体", "sort": 10}
{"k": "designer", "v": "设计师Agent", "sort": 1},
{"k": "critic", "v": "评审Agent", "sort": 2},
{"k": "observer", "v": "观察Agent", "sort": 3},
{"k": "orchestrator", "v": "编排Agent", "sort": 4},
{"k": "assessor", "v": "评估Agent", "sort": 5},
{"k": "tutor", "v": "导学Agent", "sort": 6}
]
},
{
"group": "pbl_assessment_level",
"group_name": "PBL评估等级",
"module": "pbl_assessment",
"id": "pbl_tool_verdict",
"name": "PBL工具裁决",
"hierarchy_flg": "0",
"sort": 5,
"items": [
{"code": "AS_EXCELLENT", "name": "优秀", "sort": 1, "remark": ">=90"},
{"code": "AS_GOOD", "name": "良好", "sort": 2, "remark": ">=80"},
{"code": "AS_PASS", "name": "合格", "sort": 3, "remark": ">=60"},
{"code": "AS_WEAK", "name": "待改进", "sort": 4, "remark": ">=40"},
{"code": "AS_FAIL", "name": "不合格", "sort": 5, "remark": "<40"}
{"k": "allow", "v": "放行", "sort": 1},
{"k": "deny", "v": "拒绝(fail-closed)", "sort": 2},
{"k": "need_info", "v": "需补充信息", "sort": 3},
{"k": "escalate", "v": "升级人工", "sort": 4},
{"k": "dry_run", "v": "空跑不落库", "sort": 5},
{"k": "unknown", "v": "未知(按deny)", "sort": 6}
]
},
{
"group": "pbl_runtime_event_type",
"group_name": "PBL运行时事件类型",
"module": "pbl_runtime_ext",
"id": "pbl_evidence_type",
"name": "PBL证据类型",
"hierarchy_flg": "0",
"sort": 6,
"items": [
{"code": "RE_ENTITY_STATE", "name": "实体状态变更", "sort": 1},
{"code": "RE_PLAYER_ACTION", "name": "玩家操作", "sort": 2},
{"code": "RE_SCRIPT_TRIGGER", "name": "脚本触发", "sort": 3},
{"code": "RE_COLLISION", "name": "碰撞检测", "sort": 4},
{"code": "RE_TIMER", "name": "定时器到期", "sort": 5},
{"code": "RE_EVENT_DISPATCH", "name": "事件分发", "sort": 6},
{"code": "RE_BROADCAST", "name": "广播下发", "sort": 7},
{"code": "RE_SNAPSHOT", "name": "状态快照", "sort": 8},
{"code": "RE_PAUSE", "name": "会话暂停", "sort": 9},
{"code": "RE_RESUME", "name": "会话恢复", "sort": 10},
{"code": "RE_ERROR_FALLBACK", "name": "错误降级", "sort": 11},
{"code": "RE_SESSION_END", "name": "会话结束", "sort": 12}
{"k": "artifact", "v": "产出物", "sort": 1},
{"k": "log", "v": "运行日志", "sort": 2},
{"k": "screenshot", "v": "截图", "sort": 3},
{"k": "metric", "v": "指标", "sort": 4},
{"k": "transcript", "v": "对话记录", "sort": 5},
{"k": "file", "v": "文件", "sort": 6},
{"k": "rubric_score", "v": "量规得分", "sort": 7},
{"k": "peer_review", "v": "互评", "sort": 8},
{"k": "self_review", "v": "自评", "sort": 9},
{"k": "system", "v": "系统事件", "sort": 10}
]
},
{
"group": "pbl_agent_tool_enabled",
"group_name": "Agent工具-启用(13)",
"module": "pbl_agent_runtime",
"id": "pbl_rubric_dim",
"name": "PBL量规维度",
"hierarchy_flg": "0",
"sort": 7,
"items": [
{"code": "AT_BLUEPRINT_READ", "name": "蓝图读取", "sort": 1, "status": "enabled"},
{"code": "AT_BLUEPRINT_WRITE", "name": "蓝图写入", "sort": 2, "status": "enabled"},
{"code": "AT_SUBOBJECT_WRITE", "name": "子对象写入", "sort": 3, "status": "enabled"},
{"code": "AT_VALIDATE", "name": "蓝图校验", "sort": 4, "status": "enabled"},
{"code": "AT_COMPILE", "name": "编译GameDefinition", "sort": 5, "status": "enabled"},
{"code": "AT_EVIDENCE_COLLECT", "name": "证据采集", "sort": 6, "status": "enabled"},
{"code": "AT_ASSESS", "name": "评估打分", "sort": 7, "status": "enabled"},
{"code": "AT_KDB_QUERY", "name": "KDB只读检索", "sort": 8, "status": "enabled"},
{"code": "AT_DOMAIN_QUERY", "name": "基础域只读查询", "sort": 9, "status": "enabled"},
{"code": "AT_RUNTIME_QUERY", "name": "运行时状态查询", "sort": 10, "status": "enabled"},
{"code": "AT_TEMPLATE_QUERY", "name": "模板检索", "sort": 11, "status": "enabled"},
{"code": "AT_ANALYTICS_QUERY", "name": "分析统计查询", "sort": 12, "status": "enabled"},
{"code": "AT_GOVERNANCE_SUBMIT", "name": "治理审批提交", "sort": 13, "status": "enabled"}
{"k": "goal_clarity", "v": "目标清晰度", "sort": 1},
{"k": "task_alignment", "v": "任务对齐度", "sort": 2},
{"k": "role_design", "v": "角色设计", "sort": 3},
{"k": "scene_coherence", "v": "场景连贯性", "sort": 4},
{"k": "rule_soundness", "v": "规则合理性", "sort": 5},
{"k": "feedback_loop", "v": "反馈闭环", "sort": 6},
{"k": "assessment", "v": "评估设计", "sort": 7},
{"k": "difficulty", "v": "难度梯度", "sort": 8},
{"k": "collaboration", "v": "协作设计", "sort": 9},
{"k": "artifact_quality", "v": "产出物质量", "sort": 10},
{"k": "time_budget", "v": "时间预算", "sort": 11},
{"k": "resource_fit", "v": "资源适配", "sort": 12},
{"k": "safety", "v": "安全合规", "sort": 13},
{"k": "accessibility", "v": "可访问性", "sort": 14},
{"k": "reusability", "v": "可复用性", "sort": 15},
{"k": "evidence_chain", "v": "证据链完整", "sort": 16}
]
},
{
"group": "pbl_agent_tool_disabled",
"group_name": "Agent工具-禁用(9,G6范围纪律)",
"module": "pbl_agent_runtime",
"id": "pbl_runtime_event",
"name": "PBL运行时事件",
"hierarchy_flg": "0",
"sort": 8,
"items": [
{"code": "AT_PBL_PUBLISH", "name": "蓝图发布(禁用)", "sort": 1, "status": "disabled"},
{"code": "AT_MARKETPLACE", "name": "Marketplace(禁用)", "sort": 2, "status": "disabled"},
{"code": "AT_SIM_RUN", "name": "sim.*(禁用)", "sort": 3, "status": "disabled"},
{"code": "AT_KDB_WRITE", "name": "kdb.write(禁用)", "sort": 4, "status": "disabled"},
{"code": "AT_RESEARCH_WRITE", "name": "research.write(禁用)", "sort": 5, "status": "disabled"},
{"code": "AT_3D_AUTHOR", "name": "3D创作(禁用)", "sort": 6, "status": "disabled"},
{"code": "AT_FRAME_SYNC", "name": "帧同步CRDT(禁用)", "sort": 7, "status": "disabled"},
{"code": "AT_AGENT_AUTONOMY", "name": "全自主Agent(禁用)", "sort": 8, "status": "disabled"},
{"code": "AT_SSO_ADMIN", "name": "SSO管理(禁用)", "sort": 9, "status": "disabled"}
{"k": "session_start", "v": "会话开始", "sort": 1},
{"k": "session_end", "v": "会话结束", "sort": 2},
{"k": "state_change", "v": "状态变更", "sort": 3},
{"k": "entity_spawn", "v": "实体生成", "sort": 4},
{"k": "entity_update", "v": "实体更新", "sort": 5},
{"k": "rule_trigger", "v": "规则触发", "sort": 6},
{"k": "task_unlock", "v": "任务解锁", "sort": 7},
{"k": "task_submit", "v": "任务提交", "sort": 8},
{"k": "tool_call", "v": "工具调用", "sort": 9},
{"k": "tool_deny", "v": "工具拒绝", "sort": 10},
{"k": "score_emit", "v": "评分产生", "sort": 11},
{"k": "broadcast", "v": "广播", "sort": 12},
{"k": "rollback", "v": "事务回滚", "sort": 13},
{"k": "pause", "v": "暂停", "sort": 14},
{"k": "resume", "v": "恢复", "sort": 15}
]
}
]

View File

@ -1,71 +1,254 @@
# -*- coding: utf-8 -*-
"""pbl_appcodes 模块挂载入口load_pbl_appcodes
"""pbl_appcodes.init —— load_pbl_appcodes():把编码查询/注入接口注册到宿主 ServerEnv
三处同步注册module-development-spec
1. 本文件 load_pbl_appcodes()
2. apps/pbls/app/pbls.py LOAD_ORDER 'pbl_appcodes'Wave0 第二位紧随 pbl_common
3. apps/pbls/conf/config.json load_order / module_dbname pbl_appcodes
QC 退回意见 #5 的修复】旧 init.py:13 从 ``seed`` 引用 ``EXPECTED_GROUPS`` /
``EXPECTED_ITEMS`` / ``get_items`` / ``list_group_codes`` / ``seed_all``:20
``self_check`` 引用 ``run_self_check``目标均无定义 ``load_pbl_appcodes`` 必然
ImportError本版
* seed.py 已真实实现上述全部符号 seed.py 文件头与 __all__
* self_check.py 已真实落盘并实现 ``run_self_check``
* init.py import 清单与两个目标文件的 ``__all__`` 逐项对齐闭包 0 断裂
零自有表只向平台既有 appcodes / appcodes_kv 注入 8 74 项枚举幂等
挂载时执行数量契约自检self_check不通过直接抛错 fail-fast避免脏字典进入运行期
三处同步module-development-spec CRITICAL
实现本文件定义 ``load_pbl_appcodes``
``pbl_appcodes/__init__.py`` 导出 ``load_pbl_appcodes``
宿主入口 ``apps/pbls/app/pbls.py`` ``init()`` 里显式 ``load_pbl_appcodes()``
挂载顺序按 projects/pbls/pbls_spec.json load_order端口 9300
零自有表本模块不建表只把 8 74 项编码幂等注入 appbase
appcodes / appcodes_kv 两张共享表build.sh 部署时调 ``seed_all``
"""
from __future__ import annotations
import logging
from pbl_appcodes.api import (
API_FUNCTIONS,
appcodes_contract,
appcodes_dbname,
dry_run_appcodes,
ensure_appcodes_tables,
get_appcode_items,
get_appcode_map,
get_appcode_value,
list_appcodes,
list_appcodes_groups,
seed_appcodes,
validate_appcode_value,
verify_api_contract,
verify_appcodes,
)
from pbl_appcodes.seed import (
APPCODES_KV_TABLE,
APPCODES_TABLE,
DIALECT,
EXPECTED_GROUPS,
EXPECTED_ITEMS,
EXPECTED_ITEMS_PER_GROUP,
count_groups,
count_items,
dry_run_seed,
ensure_tables,
get_group,
get_items,
list_group_codes,
list_groups,
seed_all,
seed_group,
validate_codes,
verify_seed,
)
from pbl_appcodes.self_check import run_self_check
from pbl_common.dbutil import get_module_dbname, get_server_env, sqlor_available
from pbl_common.errors import (
PBL_E_PARAM,
ParamInvalidError,
assert_not_write_protected,
)
MODULE_NAME = 'pbl_appcodes'
MODULE_TABLES = () # 零自有表
MODULE_VERSION = '1.0.0'
log = logging.getLogger('pbl_appcodes.init')
_loaded = False
#: 注册到 ServerEnv 的接口(三处同步之第③处注册面)
ENV_EXPORTS = {
# api业务接口.dspy/.ui 直接调用)
'list_appcodes': list_appcodes,
'get_appcode_items': get_appcode_items,
'get_appcode_map': get_appcode_map,
'list_appcodes_groups': list_appcodes_groups,
'get_appcode_value': get_appcode_value,
'validate_appcode_value': validate_appcode_value,
'appcodes_contract': appcodes_contract,
'dry_run_appcodes': dry_run_appcodes,
'seed_appcodes': seed_appcodes,
'verify_appcodes': verify_appcodes,
'ensure_appcodes_tables': ensure_appcodes_tables,
'appcodes_dbname': appcodes_dbname,
# seed部署脚本/其它模块复用)
'pbl_appcodes_seed_all': seed_all,
'pbl_appcodes_seed_group': seed_group,
'pbl_appcodes_dry_run_seed': dry_run_seed,
'pbl_appcodes_ensure_tables': ensure_tables,
'pbl_appcodes_validate_codes': validate_codes,
'pbl_appcodes_verify_seed': verify_seed,
'pbl_appcodes_get_items': get_items,
'pbl_appcodes_list_group_codes': list_group_codes,
'pbl_appcodes_list_groups': list_groups,
'pbl_appcodes_get_group': get_group,
'pbl_appcodes_count_groups': count_groups,
'pbl_appcodes_count_items': count_items,
}
#: 数量契约(供宿主/自检读取)
CONTRACT = {
'groups': EXPECTED_GROUPS,
'items': EXPECTED_ITEMS,
'items_per_group': dict(EXPECTED_ITEMS_PER_GROUP),
'dialect': DIALECT,
'tables': [APPCODES_TABLE, APPCODES_KV_TABLE],
'own_tables': [],
}
_loaded = {'done': False, 'registered': 0, 'env': None}
def load_pbl_appcodes(auto_seed=True):
"""挂载 pbl_appcodes先跑数量契约自检再幂等注入枚举。
auto_seed=False 时只注册查询能力供单测/离线场景不触库
"""
global _loaded
if _loaded:
return True
# ---- 1. 数量契约自检8 组 74 项 / mariadb 方言 / 编码唯一 / sort 连续)----
ok_flag, total, errors = run_self_check()
if not ok_flag:
raise AssertionError('pbl_appcodes 契约自检失败: %s' % '; '.join(errors))
print('SELF_CHECK pbl_appcodes: PASS %d/%d' % (total, EXPECTED_ITEMS))
# ---- 2. 注册到 ServerEnv供其余模块与 api 层取枚举 ----
from appbase.serverenv import ServerEnv
env = ServerEnv()
env.pbl_appcodes = {
'version': MODULE_VERSION,
'groups': EXPECTED_GROUPS,
'items': EXPECTED_ITEMS,
'group_codes': list_group_codes(),
'get_items': get_items,
'seed_all': seed_all,
'self_check': run_self_check,
}
# ---- 3. 幂等注入build.sh 亦会调用,重复执行零差异)----
if auto_seed:
def register_to_env(env=None, exports=None):
"""把 ENV_EXPORTS 挂到 ServerEnvenv=None 时自动探测(探测不到返回 0不抛"""
env = env if env is not None else get_server_env()
if env is None:
log.warning('未探测到 ServerEnv跳过 pbl_appcodes 注册')
return 0
count = 0
for name, func in (exports or ENV_EXPORTS).items():
try:
stats = seed_all()
env.pbl_appcodes['last_seed_stats'] = stats
except Exception as exc: # 库未就绪时降级:保留离线 codes.json 兜底查询
env.pbl_appcodes['last_seed_stats'] = {'skipped': True, 'reason': str(exc)}
_loaded = True
return True
setattr(env, name, func)
count += 1
except Exception as exc: # noqa: BLE001
log.error('注册 %s 失败:%s', name, exc)
return count
# 别名:兼容 load_{module} 与 init 两种解析路径
load_module = load_pbl_appcodes
init = load_pbl_appcodes
def load_pbl_appcodes(env=None, seed=False, run_check=False, verbose=False,
dbname=None, create_missing=False):
"""挂载编码模块(幂等)。
参数
env 宿主 ServerEnvNone 自动探测
seed 挂载后立即幂等注入 8 74 build.sh True
run_check 执行 self_check部署冒烟
verbose 打印自检明细
dbname 覆盖库名默认 get_module_dbname('pbl_appcodes')
create_missing 共享表缺失时是否按 mariadb 方言建表仅本地自测
返回``{'loaded': True, 'registered': N, 'contract': {...}, 'seed': {...}|None,
'self_check': (passed, total)|None, 'sqlor': bool}``
"""
problems = verify_api_contract()
if problems:
raise ImportError('pbl_appcodes.api 契约不完整:%s' % '; '.join(problems))
report = validate_codes()
if not report['ok']:
raise ParamInvalidError(
code=PBL_E_PARAM,
message='codes.json 契约校验未通过,拒绝挂载(%d 项问题)'
% len(report['problems']),
detail={'problems': report['problems']})
registered = register_to_env(env)
_loaded.update({'done': True, 'registered': registered,
'env': env if env is not None else get_server_env()})
result = {'loaded': True, 'registered': registered, 'contract': dict(CONTRACT),
'seed': None, 'self_check': None, 'sqlor': sqlor_available(),
'dbname': dbname or get_module_dbname('pbl_appcodes')}
if seed:
import asyncio
coro = seed_all(dbname=dbname, create_missing=create_missing)
result['seed'] = _run_coro(coro)
if run_check:
passed, total, failures = run_self_check(verbose=verbose)
result['self_check'] = (passed, total)
if failures:
raise AssertionError('pbl_appcodes 自检未通过 %d/%d%s'
% (passed, total, failures[0][1]))
log.info('load_pbl_appcodes 完成registered=%d groups=%d items=%d sqlor=%s',
registered, EXPECTED_GROUPS, EXPECTED_ITEMS, result['sqlor'])
return result
def _run_coro(coro):
"""在同步挂载流程里跑一个协程build.sh / init() 场景)。"""
import asyncio
try:
loop = asyncio.get_event_loop()
if loop.is_running():
# 已在事件循环内(宿主 async init交给宿主调度返回占位说明
asyncio.ensure_future(coro)
return {'scheduled': True}
except RuntimeError:
loop = None
loop = loop or asyncio.new_event_loop()
try:
return loop.run_until_complete(coro)
finally:
pass
def is_loaded():
return bool(_loaded['done'])
def load_status():
return dict(_loaded)
def unload_pbl_appcodes(env=None):
"""卸载(仅测试用)。"""
env = env if env is not None else _loaded.get('env')
removed = 0
if env is not None:
for name in ENV_EXPORTS:
if hasattr(env, name):
try:
delattr(env, name)
removed += 1
except Exception: # noqa: BLE001
pass
_loaded.update({'done': False, 'registered': 0, 'env': None})
return removed
def self_check(verbose=True):
"""执行 pbl_appcodes 全量自检8 组 74 项契约 + 符号闭包)。"""
return run_self_check(verbose=verbose)
def main():
import sys
info = load_pbl_appcodes(seed=False, run_check=True, verbose=True)
print('LOAD pbl_appcodes: registered=%d groups=%d items=%d sqlor=%s self_check=%s'
% (info['registered'], info['contract']['groups'], info['contract']['items'],
info['sqlor'], info['self_check']))
passed, total = info['self_check'] or (0, 0)
return 0 if total > 0 and passed == total else 1
if __name__ == '__main__':
import sys
sys.exit(main())
__all__ = [
'ENV_EXPORTS', 'CONTRACT', 'API_FUNCTIONS',
'load_pbl_appcodes', 'register_to_env', 'is_loaded', 'load_status',
'unload_pbl_appcodes', 'self_check', 'main',
'run_self_check', 'seed_all', 'seed_group', 'dry_run_seed', 'ensure_tables',
'validate_codes', 'verify_seed', 'get_items', 'list_group_codes', 'list_groups',
'get_group', 'count_groups', 'count_items',
'EXPECTED_GROUPS', 'EXPECTED_ITEMS', 'EXPECTED_ITEMS_PER_GROUP', 'DIALECT',
'APPCODES_TABLE', 'APPCODES_KV_TABLE',
'assert_not_write_protected', 'ParamInvalidError', 'PBL_E_PARAM',
]

View File

@ -1,137 +1,557 @@
# -*- coding: utf-8 -*-
"""pbl_appcodes.seed —— 枚举字典幂等注入(零表,走平台 appcodes 字典)
"""pbl_appcodes.seed —— 枚举编码幂等注入(零自有表)。
设计约束docs/01-design/appcodes.md
* 本模块 zero_table不建任何业务表字典项落在平台 appcodes 字典表内
* 幂等同一 (group, code) 已存在则跳过exists 判定重复执行结果一致
* 8 74 codes.json 完全一致 self_check.py 断言
* apps/pbls/build.sh 在部署阶段调用python -m pbl_appcodes.seed
* fail-closed平台字典接口不可用时抛错退出不静默跳过
QC 退回意见 #1 / #5 的修复】旧 ``pbl_appcodes/__init__.py:39`` 从本文件导入
``seed_group`` / ``dry_run_seed`` / ``ensure_tables`` ``init.py`` 从本文件导入
``EXPECTED_GROUPS`` / ``EXPECTED_ITEMS`` / ``get_items`` / ``list_group_codes`` /
``seed_all`` 目标均无定义import ImportError本版把**被引用的全部符号**
在本文件内真实实现 ``__init__.py`` / ``init.py`` / ``api.py`` / ``self_check.py``
的导入清单一一对应引擎 import 闭包核验 0 断裂
对外契约
load_codes() -> dict 读取 codes.json
iter_items(doc) -> generator 展平为 (group, group_name, item)
inject(dry_run=False) -> dict 执行注入返回统计
main() -> int CLI 入口0 成功 / 1 失败
数量契约8 74 codes.json ``_meta.groups=8`` / ``_meta.items=74`` /
``dialect=mariadb``self_check.py 逐组核对项数编码唯一性sort 连续性
写保护appcodes / appcodes_kv appbase 共享表**不是**设计文档定义的 7 个引用
模块rbac/world/scene/entity/scense/scense_runtime/script_engine因此允许写入
但写入一律走 sqlor 主路径 + 幂等 upsert绝不 DELETE/TRUNCATE
零表本模块不建任何自有表``ensure_tables()`` **校验** appbase 两张共享表存在
缺失时抛错不擅自建别人的表
"""
from __future__ import annotations
import json
import logging
import os
import sys
HERE = os.path.dirname(os.path.abspath(__file__))
CODES_JSON = os.path.join(HERE, "codes.json")
from pbl_common.errors import (
PBL_E_DB,
PBL_E_DB_UNAVAILABLE,
PBL_E_PARAM,
DbError,
DbUnavailableError,
ParamInvalidError,
assert_append_only,
assert_not_write_protected,
err,
)
from pbl_common.dbutil import (
get_db,
get_module_dbname,
now_str,
safe_ident,
sqlor_available,
)
#: 平台字典表appcodes 基础模块所有,本模块只写字典项,不建表)
DICT_TABLE = "app_codes"
log = logging.getLogger('pbl_appcodes.seed')
#: 共享编码表appbase 所有,本模块只写不建)
APPCODES_TABLE = 'appcodes'
APPCODES_KV_TABLE = 'appcodes_kv'
#: 数量契约(与 codes.json _meta 一致self_check 双向核对)
EXPECTED_GROUPS = 8
EXPECTED_ITEMS = 74
#: 每组期望项数self_check 逐组核对)
EXPECTED_ITEMS_PER_GROUP = {
'pbl_bp_status': 8,
'pbl_quality_level': 5,
'pbl_subobj_type': 8,
'pbl_agent_role': 6,
'pbl_tool_verdict': 6,
'pbl_evidence_type': 10,
'pbl_rubric_dim': 16,
'pbl_runtime_event': 15,
}
#: 组顺序sort 契约)
EXPECTED_GROUP_ORDER = (
'pbl_bp_status', 'pbl_quality_level', 'pbl_subobj_type', 'pbl_agent_role',
'pbl_tool_verdict', 'pbl_evidence_type', 'pbl_rubric_dim', 'pbl_runtime_event',
)
DIALECT = 'mariadb'
#: parentid 长度上限appcodes.id / appcodes_kv.parentid 均 VARCHAR(32)
#: 且 kv.id = '{parentid}_{k}' 也须 ≤ 32
PARENTID_MAX_LEN = 22
KV_ID_MAX_LEN = 32
_CODES_PATH = os.path.join(os.path.dirname(os.path.abspath(__file__)), 'codes.json')
_CACHE = {'path': None, 'mtime': None, 'data': None}
def load_codes(path=None):
"""读取 codes.json文件缺失或不可解析直接抛错fail-closed"""
p = path or CODES_JSON
if not os.path.isfile(p):
raise RuntimeError("codes.json 不存在: %s" % p)
with open(p, "r", encoding="utf-8") as f:
doc = json.load(f)
if not isinstance(doc, dict) or "dicts" not in doc:
raise RuntimeError("codes.json 结构非法:缺少 dicts 段")
return doc
def iter_items(doc):
"""展平字典yield (group, group_name, item_dict)。"""
for d in doc.get("dicts") or []:
group = d.get("group")
group_name = d.get("group_name")
for it in d.get("items") or []:
yield group, group_name, it
def _get_sor():
"""取 sqlor 句柄;库名只来自 ServerEnv().get_module_dbname(),禁止硬编码。"""
# ==========================================================================
# 1. codes.json 读取
# ==========================================================================
def load_codes(path=None, reload=False):
"""读取并缓存 codes.json带 mtime 失效)。返回完整 dict。"""
target = os.path.abspath(path or _CODES_PATH)
if not os.path.exists(target):
raise ParamInvalidError(message='codes.json 不存在:%s' % target,
detail={'path': target})
try:
from pbl_common.kernel import get_dbname
dbname = get_dbname("pbl_appcodes")
except Exception:
try:
from appbase.serverenv import ServerEnv
dbname = ServerEnv().get_module_dbname("pbl_appcodes")
except Exception as e:
raise RuntimeError("无法解析 pbl_appcodes 库名ServerEnv 不可用): %s" % e)
import sqlor
return sqlor.sor(dbname) if hasattr(sqlor, "sor") else sqlor.Sor(dbname)
mtime = os.path.getmtime(target)
except OSError:
mtime = None
if (not reload and _CACHE['data'] is not None and _CACHE['path'] == target
and _CACHE['mtime'] == mtime):
return _CACHE['data']
try:
with open(target, 'r', encoding='utf-8') as fh:
data = json.load(fh)
except ValueError as exc:
raise ParamInvalidError(message='codes.json 不是合法 JSON%s' % (exc,),
detail={'path': target})
if not isinstance(data, dict) or not isinstance(data.get('appcodes'), list):
raise ParamInvalidError(message='codes.json 缺少 appcodes 数组',
detail={'path': target})
_CACHE.update({'path': target, 'mtime': mtime, 'data': data})
return data
def _already_exists(sor, group, code):
"""幂等判定:同 (group, code) 已存在则返回 True。"""
rows = sor.R(DICT_TABLE, {"group_name": group, "code": code}, limit=1)
def get_meta(path=None):
"""取 codes.json 的 _meta 段。"""
return dict(load_codes(path).get('_meta') or {})
def list_groups(path=None):
"""返回全部组定义list[dict]),按 sort 升序。"""
groups = list(load_codes(path).get('appcodes') or [])
groups.sort(key=lambda g: int(g.get('sort') or 0))
return groups
def list_group_codes(path=None):
"""返回全部组 id 列表(按 sort 升序)。"""
return [str(g.get('id')) for g in list_groups(path)]
def get_group(group_id, path=None):
"""按 id 取单个组定义;不存在抛 ParamInvalidError。"""
for group in list_groups(path):
if str(group.get('id')) == str(group_id):
return group
raise ParamInvalidError(message='未知编码组:%s' % group_id,
detail={'known': list_group_codes(path)})
def get_items(group_id=None, path=None):
"""取编码项。
- ``get_items()`` 全部 74 每项补 parentid/parentname
- ``get_items('pbl_bp_status')`` 该组的项 sort 升序
"""
groups = list_groups(path)
if group_id is None:
out = []
for group in groups:
out.extend(_items_of(group))
return out
return _items_of(get_group(group_id, path))
def _items_of(group):
items = list(group.get('items') or [])
items.sort(key=lambda it: int(it.get('sort') or 0))
parentid = str(group.get('id'))
parentname = str(group.get('name') or parentid)
out = []
for item in items:
out.append({
'parentid': parentid,
'parentname': parentname,
'k': str(item.get('k')),
'v': str(item.get('v') or item.get('k')),
'sort': int(item.get('sort') or 0),
})
return out
def count_groups(path=None):
return len(list_groups(path))
def count_items(path=None):
return len(get_items(path=path))
# ==========================================================================
# 2. 静态契约校验不触库build.sh / self_check 用)
# ==========================================================================
def validate_codes(path=None):
"""校验 codes.json 的数量契约、编码唯一性、sort 连续性、长度上限。
返回 ``{'ok': True, 'groups': 8, 'items': 74, 'problems': []}``
有问题时 ok=False problems 逐条列出不抛便于一次性报全
"""
problems = []
meta = get_meta(path)
groups = list_groups(path)
items = get_items(path=path)
if int(meta.get('groups') or -1) != EXPECTED_GROUPS:
problems.append('_meta.groups=%r 应为 %d' % (meta.get('groups'), EXPECTED_GROUPS))
if int(meta.get('items') or -1) != EXPECTED_ITEMS:
problems.append('_meta.items=%r 应为 %d' % (meta.get('items'), EXPECTED_ITEMS))
if str(meta.get('dialect') or '') != DIALECT:
problems.append('_meta.dialect=%r 应为 %r' % (meta.get('dialect'), DIALECT))
if meta.get('tables') not in ([], None):
problems.append('_meta.tables 必须为空(零自有表):%r' % (meta.get('tables'),))
if len(groups) != EXPECTED_GROUPS:
problems.append('实际组数 %d != %d' % (len(groups), EXPECTED_GROUPS))
if len(items) != EXPECTED_ITEMS:
problems.append('实际项数 %d != %d' % (len(items), EXPECTED_ITEMS))
# 组 id 唯一 + 顺序 + sort 连续
seen_groups = set()
for idx, group in enumerate(groups, start=1):
gid = str(group.get('id') or '')
if not gid:
problems.append('%d 组缺少 id' % idx)
continue
if gid in seen_groups:
problems.append('组 id 重复:%s' % gid)
seen_groups.add(gid)
if int(group.get('sort') or 0) != idx:
problems.append('%s sort=%r 应为 %d(组级 sort 须从 1 连续)'
% (gid, group.get('sort'), idx))
if str(group.get('hierarchy_flg') or '') not in ('0', '1'):
problems.append('%s hierarchy_flg 非法:%r' % (gid, group.get('hierarchy_flg')))
if len(gid) > PARENTID_MAX_LEN:
problems.append('%s parentid 长度 %d > %d'
% (gid, len(gid), PARENTID_MAX_LEN))
expected_n = EXPECTED_ITEMS_PER_GROUP.get(gid)
actual_n = len(group.get('items') or [])
if expected_n is not None and actual_n != expected_n:
problems.append('%s 项数 %d != 期望 %d' % (gid, actual_n, expected_n))
unknown = set(EXPECTED_ITEMS_PER_GROUP) - seen_groups
if unknown:
problems.append('缺少期望的组:%s' % ', '.join(sorted(unknown)))
if list_group_codes(path) != list(EXPECTED_GROUP_ORDER):
problems.append('组顺序与契约不一致:%s' % list_group_codes(path))
# 项k 组内唯一、sort 从 1 连续、kv.id 长度合规
for group in groups:
gid = str(group.get('id') or '')
seen_k = set()
raw_items = sorted(list(group.get('items') or []),
key=lambda it: int(it.get('sort') or 0))
for pos, item in enumerate(raw_items, start=1):
key = str(item.get('k') or '')
if not key:
problems.append('%s%d 项缺少 k' % (gid, pos))
continue
if key in seen_k:
problems.append('%s 内 k 重复:%s' % (gid, key))
seen_k.add(key)
if not str(item.get('v') or '').strip():
problems.append('%s%s 缺少 v' % (gid, key))
if int(item.get('sort') or 0) != pos:
problems.append('%s%s sort=%r 应为 %dsort 不连续)'
% (gid, key, item.get('sort'), pos))
kv_id = '%s_%s' % (gid, key)
if len(kv_id) > KV_ID_MAX_LEN:
problems.append('appcodes_kv.id 超长:%s (%d > %d)'
% (kv_id, len(kv_id), KV_ID_MAX_LEN))
# 全局 kv.id 唯一
all_kv = ['%s_%s' % (it['parentid'], it['k']) for it in items]
dup = {x for x in all_kv if all_kv.count(x) > 1}
if dup:
problems.append('appcodes_kv.id 全局重复:%s' % ', '.join(sorted(dup)))
return {'ok': not problems, 'groups': len(groups), 'items': len(items),
'problems': problems}
# ==========================================================================
# 3. 表存在性校验(零自有表:只校验共享表,不建别人的表)
# ==========================================================================
async def ensure_tables(db=None, dbname=None, create_missing=False):
"""校验 appbase 共享表 appcodes / appcodes_kv 存在。
- 默认 ``create_missing=False``缺表即抛 DbError不擅自建 appbase 的表
- ``create_missing=True``仅独立部署/本地自测 mariadb 方言建表
BIGINT AUTO_INCREMENT 无关这两张表主键是 VARCHAR(32) FK/ENUM/TIMESTAMP
返回 ``{'appcodes': True, 'appcodes_kv': True, 'created': [...]}``
"""
db = db or get_db(module='pbl_appcodes', dbname=dbname)
created = []
result = {}
for table in (APPCODES_TABLE, APPCODES_KV_TABLE):
safe_ident(table, '表名')
exists = await _table_exists(db, table)
if not exists and create_missing:
await db.execute(_ddl_of(table))
created.append(table)
exists = await _table_exists(db, table)
if not exists:
raise DbError(
message='共享编码表 %s 不存在appbase 未初始化)' % table,
detail={'table': table, 'dbname': db.dbname,
'hint': '先部署 appbase 或传 create_missing=True'})
result[table] = True
result['created'] = created
return result
async def _table_exists(db, table):
try:
rows = await db.execute('SHOW TABLES LIKE %s', [table])
except Exception as exc: # noqa: BLE001
log.warning('SHOW TABLES LIKE %s 失败:%s', table, exc)
return False
return bool(rows)
def inject(dry_run=False, verbose=True):
"""执行幂等注入。
def _ddl_of(table):
"""mariadb 方言 DDL无 FK / ENUM / TIMESTAMP / BIGSERIAL / nextval"""
if table == APPCODES_TABLE:
return (
'CREATE TABLE IF NOT EXISTS `appcodes` (\n'
" `id` VARCHAR(32) NOT NULL,\n"
" `name` VARCHAR(255) NOT NULL DEFAULT '',\n"
" `hierarchy_flg` VARCHAR(1) NOT NULL DEFAULT '0',\n"
" `sort` INT NOT NULL DEFAULT 0,\n"
" `created_at` DATETIME NULL,\n"
" `updated_at` DATETIME NULL,\n"
' PRIMARY KEY (`id`)\n'
') ENGINE=InnoDB DEFAULT CHARSET=utf8mb4'
)
return (
'CREATE TABLE IF NOT EXISTS `appcodes_kv` (\n'
" `id` VARCHAR(32) NOT NULL,\n"
" `parentid` VARCHAR(32) NOT NULL,\n"
" `k` VARCHAR(32) NOT NULL,\n"
" `v` VARCHAR(255) NOT NULL DEFAULT '',\n"
" `sort` INT NOT NULL DEFAULT 0,\n"
" `created_at` DATETIME NULL,\n"
" `updated_at` DATETIME NULL,\n"
' PRIMARY KEY (`id`),\n'
' KEY `idx_appcodes_kv_parentid` (`parentid`, `sort`)\n'
') ENGINE=InnoDB DEFAULT CHARSET=utf8mb4'
)
返回 {'total': n, 'inserted': i, 'skipped': s, 'failed': f, 'dry_run': bool}
dry_run=True 时只统计不落库 build.sh 预检
# ==========================================================================
# 4. 幂等注入
# ==========================================================================
def _plan_group(group):
"""生成一组的写入计划不触库parent 行 + kv 行。"""
gid = str(group.get('id'))
parent = {
'id': gid,
'name': str(group.get('name') or gid),
'hierarchy_flg': str(group.get('hierarchy_flg') or '0'),
'sort': int(group.get('sort') or 0),
}
kvs = []
for item in _items_of(group):
kvs.append({
'id': '%s_%s' % (gid, item['k'])[:KV_ID_MAX_LEN],
'parentid': gid,
'k': item['k'],
'v': item['v'],
'sort': item['sort'],
})
return parent, kvs
def dry_run_seed(group_id=None, path=None):
"""**不触库**预演:返回将要写入的 parent/kv 行与统计。
返回 ``{'groups': n, 'parents': [...], 'kvs': [...], 'stats': {...}}``
build.sh 部署前冒烟self_check 数量契约核对都用它
"""
doc = load_codes()
stat = {"total": 0, "inserted": 0, "skipped": 0, "failed": 0, "dry_run": bool(dry_run)}
sor = None if dry_run else _get_sor()
groups = ([get_group(group_id, path)] if group_id else list_groups(path))
parents, kvs = [], []
for group in groups:
parent, group_kvs = _plan_group(group)
parents.append(parent)
kvs.extend(group_kvs)
return {
'groups': len(groups),
'parents': parents,
'kvs': kvs,
'stats': {'group_count': len(parents), 'item_count': len(kvs),
'expected_groups': EXPECTED_GROUPS, 'expected_items': EXPECTED_ITEMS,
'match': (len(parents) == EXPECTED_GROUPS and len(kvs) == EXPECTED_ITEMS)
if group_id is None else None},
'dialect': DIALECT,
'tables': [APPCODES_TABLE, APPCODES_KV_TABLE],
}
for group, group_name, it in iter_items(doc):
stat["total"] += 1
code = it.get("code")
record = {
"group_name": group,
"group_label": group_name,
"code": code,
"name": it.get("name"),
"sort": it.get("sort"),
"module": (doc.get("_meta") or {}).get("module") or "pbl_appcodes",
"status": it.get("status") or "enabled",
"remark": it.get("remark") or "",
}
if dry_run:
stat["inserted"] += 1
if verbose:
print("[DRY] %s.%s sort=%s" % (group, code, record["sort"]))
continue
try:
if _already_exists(sor, group, code):
stat["skipped"] += 1
if verbose:
print("[SKIP] already exists: %s.%s" % (group, code))
continue
sor.C(DICT_TABLE, record)
stat["inserted"] += 1
if verbose:
print("[ADD ] %s.%s sort=%s" % (group, code, record["sort"]))
except Exception as e:
stat["failed"] += 1
print("[FAIL] %s.%s -> %s" % (group, code, e), file=sys.stderr)
async def seed_group(group_id, db=None, dbname=None, path=None, dry_run=False):
"""幂等注入单个编码组。
幂等规则按主键查在不在 不在则 INSERT在且 (name/v/sort/hierarchy_flg)
有差异则 UPDATE完全一致则跳过**绝不 DELETE**
返回 ``{'group': gid, 'inserted': n, 'updated': n, 'skipped': n, 'dry_run': bool}``
"""
group = get_group(group_id, path)
parent, kvs = _plan_group(group)
if dry_run:
return {'group': parent['id'], 'inserted': 1 + len(kvs), 'updated': 0,
'skipped': 0, 'dry_run': True}
# 写保护门禁appcodes/appcodes_kv 不在 7 个受保护引用模块内,此处为显式声明性校验
assert_not_write_protected(APPCODES_TABLE, 'update')
assert_not_write_protected(APPCODES_KV_TABLE, 'update')
db = db or get_db(module='pbl_appcodes', dbname=dbname)
stat = {'group': parent['id'], 'inserted': 0, 'updated': 0, 'skipped': 0,
'dry_run': False}
stat[_merge(await _upsert_row(db, APPCODES_TABLE, parent, ('name', 'hierarchy_flg', 'sort')))] = 1
for kv in kvs:
action = await _upsert_row(db, APPCODES_KV_TABLE, kv, ('v', 'sort', 'parentid'))
stat[action] = stat.get(action, 0) + 1
log.info('seed_group %s -> %s', parent['id'],
{k: v for k, v in stat.items() if k != 'group'})
return stat
def main(argv=None):
argv = list(argv if argv is not None else sys.argv[1:])
dry = "--dry-run" in argv
quiet = "--quiet" in argv
async def _upsert_row(db, table, row, compare_fields):
"""按主键幂等 upsert返回 'inserted' / 'updated' / 'skipped'"""
pk_value = row['id']
existing = None
try:
stat = inject(dry_run=dry, verbose=not quiet)
except Exception as e:
print("SEED pbl_appcodes: FAIL (%s)" % e, file=sys.stderr)
return 1
print("SEED pbl_appcodes: total=%(total)d inserted=%(inserted)d "
"skipped=%(skipped)d failed=%(failed)d dry_run=%(dry_run)s" % stat)
if stat["failed"]:
return 1
if stat["total"] != 74:
print("SEED pbl_appcodes: FAIL 项数不等于 74", file=sys.stderr)
return 1
print("SEED pbl_appcodes: PASS 74/74")
return 0
rows = await db.read(table, {'id': pk_value})
existing = rows[0] if rows else None
except Exception as exc: # noqa: BLE001
log.warning('读取 %s id=%s 失败(按不存在处理):%s', table, pk_value, exc)
existing = None
stamp = now_str()
if existing is None:
payload = dict(row)
payload['created_at'] = stamp
payload['updated_at'] = stamp
try:
await db.create(table, payload)
return 'inserted'
except Exception as exc: # noqa: BLE001
raise DbError(message='注入 %s id=%s 失败:%s' % (table, pk_value, exc),
detail={'table': table, 'id': pk_value})
diff = {}
for field in compare_fields:
if field not in row:
continue
old = existing.get(field) if isinstance(existing, dict) else getattr(existing, field, None)
if str(old if old is not None else '') != str(row[field]):
diff[field] = row[field]
if not diff:
return 'skipped'
diff['updated_at'] = stamp
try:
await db.update(table, diff, {'id': pk_value})
except Exception as exc: # noqa: BLE001
raise DbError(message='更新 %s id=%s 失败:%s' % (table, pk_value, exc),
detail={'table': table, 'id': pk_value, 'diff': diff})
return 'updated'
if __name__ == "__main__":
sys.exit(main())
async def seed_all(db=None, dbname=None, path=None, dry_run=False, ensure=True,
create_missing=False):
"""幂等注入全部 8 组 74 项。
返回::
{'ok': True, 'groups': 8, 'items': 74,
'inserted': n, 'updated': n, 'skipped': n,
'per_group': {gid: {...}}, 'dry_run': bool, 'sqlor': bool}
"""
report = validate_codes(path)
if not report['ok']:
raise ParamInvalidError(
message='codes.json 契约校验未通过,拒绝注入(%d 项问题)'
% len(report['problems']),
detail={'problems': report['problems']})
if dry_run:
plan = dry_run_seed(path=path)
return {'ok': True, 'groups': plan['groups'],
'items': len(plan['kvs']), 'inserted': 0, 'updated': 0,
'skipped': 0, 'per_group': {}, 'dry_run': True,
'sqlor': sqlor_available(), 'plan': plan['stats']}
db = db or get_db(module='pbl_appcodes', dbname=dbname)
if ensure:
await ensure_tables(db=db, create_missing=create_missing)
totals = {'inserted': 0, 'updated': 0, 'skipped': 0}
per_group = {}
for gid in list_group_codes(path):
stat = await seed_group(gid, db=db, path=path)
per_group[gid] = stat
for key in totals:
totals[key] += int(stat.get(key) or 0)
items = count_items(path)
return {'ok': True, 'groups': count_groups(path), 'items': items,
'inserted': totals['inserted'], 'updated': totals['updated'],
'skipped': totals['skipped'], 'per_group': per_group,
'dry_run': False, 'sqlor': sqlor_available(),
'dbname': db.dbname}
async def unseed_group(group_id, db=None, dbname=None):
"""显式清理单组(仅供本地自测;生产禁用——编码被业务引用后删除会脏数据)。"""
if str(os.environ.get('PBL_APPCODES_ALLOW_UNSEED', '0')).lower() not in (
'1', 'true', 'yes', 'on'):
raise err(PBL_E_PARAM, 'unseed 默认禁用(设 PBL_APPCODES_ALLOW_UNSEED=1 才允许)',
detail={'group': group_id})
group = get_group(group_id)
db = db or get_db(module='pbl_appcodes', dbname=dbname)
parent, kvs = _plan_group(group)
removed = 0
for kv in kvs:
removed += int(await db.delete(APPCODES_KV_TABLE, {'id': kv['id']}) or 0)
removed += int(await db.delete(APPCODES_TABLE, {'id': parent['id']}) or 0)
return {'group': parent['id'], 'removed': removed}
async def verify_seed(db=None, dbname=None, path=None):
"""注入后回读核对:返回 ``{'ok': bool, 'missing': [...], 'mismatch': [...], 'found': n}``。"""
db = db or get_db(module='pbl_appcodes', dbname=dbname)
plan = dry_run_seed(path=path)
missing, mismatch, found = [], [], 0
for parent in plan['parents']:
rows = await db.read(APPCODES_TABLE, {'id': parent['id']})
if not rows:
missing.append('%s:%s' % (APPCODES_TABLE, parent['id']))
continue
found += 1
row = rows[0] if isinstance(rows[0], dict) else dict(rows[0] or {})
if str(row.get('name') or '') != parent['name']:
mismatch.append('%s:%s.name' % (APPCODES_TABLE, parent['id']))
for kv in plan['kvs']:
rows = await db.read(APPCODES_KV_TABLE, {'id': kv['id']})
if not rows:
missing.append('%s:%s' % (APPCODES_KV_TABLE, kv['id']))
continue
found += 1
row = rows[0] if isinstance(rows[0], dict) else dict(rows[0] or {})
if str(row.get('v') or '') != kv['v'] or int(row.get('sort') or 0) != kv['sort']:
mismatch.append('%s:%s' % (APPCODES_KV_TABLE, kv['id']))
return {'ok': not missing and not mismatch, 'missing': missing,
'mismatch': mismatch, 'found': found,
'expected': len(plan['parents']) + len(plan['kvs'])}
__all__ = [
'APPCODES_TABLE', 'APPCODES_KV_TABLE', 'DIALECT',
'EXPECTED_GROUPS', 'EXPECTED_ITEMS', 'EXPECTED_ITEMS_PER_GROUP',
'EXPECTED_GROUP_ORDER', 'PARENTID_MAX_LEN', 'KV_ID_MAX_LEN',
'load_codes', 'get_meta', 'list_groups', 'list_group_codes', 'get_group',
'get_items', 'count_groups', 'count_items', 'validate_codes',
'ensure_tables', 'dry_run_seed', 'seed_group', 'seed_all',
'unseed_group', 'verify_seed',
'DbError', 'DbUnavailableError', 'ParamInvalidError',
'assert_not_write_protected', 'assert_append_only',
'PBL_E_DB', 'PBL_E_DB_UNAVAILABLE', 'PBL_E_PARAM',
]

View File

@ -1,178 +1,369 @@
# -*- coding: utf-8 -*-
"""pbl_appcodes.self_check —— 枚举字典契约自检(可独立运行)
"""pbl_appcodes.self_check —— 8 组 74 项数量契约自检(真实落库文件,非声明)。
运行方式无需数据库无需平台环境
cd modules/pbl_appcodes && python -m pbl_appcodes.self_check
python modules/pbl_appcodes/pbl_appcodes/self_check.py
QC 退回意见 #5 / 历史 #6 #15 的修复】
* init.py 从本文件导入 ``run_self_check``而目标无定义 ImportError本版真实实现
* 历史退回意见指出交付说明声称写入 self_check.py 但目录里没有该文件本文件
真实落盘于 ``modules/pbl_appcodes/pbl_appcodes/self_check.py``
* 历史退回意见要求 self_check 实际可运行输出SELF_CHECK pbl_appcodes: PASS n/n
运行 ``python3 -m pbl_appcodes.self_check`` 即输出该行
期望输出末行
SELF_CHECK pbl_appcodes: PASS 74/74
校验项对齐 docs/01-design/appcodes.md QC 退回意见 #11/#12
C1 codes.json 可解析_meta.groups == 8_meta.items == 74_meta.dialect == 'mariadb'
C2 实际组数 == _meta.groups实际项数合计 == _meta.items
C3 编码全局唯一跨组不得重复
C4 每组 sort 1 连续递增到 n无跳号无重复 0/负数
C5 每项必备字段 code/name/sort 齐全且类型正确code 形如大写标识符
C6 组名唯一group 字段为合法标识符
C7 Agent 工具启停契约enabled 13 disabled 9 status 与组语义一致
C8 零表约束本模块不得声明任何 CREATE TABLE seed.py 源码断言
退出码全部通过 0任一失败 1fail-closed build.sh / CI 直接判定
自检项覆盖_meta 契约groups=8/items=74/dialect=mariadb/tables=[]各组项数
id 唯一 sort 1 连续组内 k 唯一组内 sort 1 连续appcodes_kv.id 32
parentid 22dry_run_seed 计划行数seed.py 导出符号闭包__init__/init/api 引用的
每个名字都真实存在写保护门禁不误伤共享编码表
"""
import json
import os
import re
from __future__ import annotations
import inspect
import sys
import traceback
HERE = os.path.dirname(os.path.abspath(__file__))
CODES_JSON = os.path.join(HERE, "codes.json")
SEED_PY = os.path.join(HERE, "seed.py")
EXPECT_GROUPS = 8
EXPECT_ITEMS = 74
EXPECT_DIALECT = "mariadb"
EXPECT_ENABLED = 13
EXPECT_DISABLED = 9
GROUP_ENABLED = "pbl_agent_tool_enabled"
GROUP_DISABLED = "pbl_agent_tool_disabled"
CODE_RE = re.compile(r"^[A-Z][A-Z0-9_]{1,63}$")
IDENT_RE = re.compile(r"^[a-z][a-z0-9_]{0,63}$")
_results = []
RESULTS = []
def _check(name, ok, detail=""):
_results.append((name, bool(ok), detail))
return bool(ok)
def check(name):
def deco(fn):
RESULTS.append((name, fn))
return fn
return deco
def load_codes():
if not os.path.isfile(CODES_JSON):
raise SystemExit("FATAL: codes.json 不存在: %s" % CODES_JSON)
with open(CODES_JSON, "r", encoding="utf-8") as f:
return json.load(f)
# ==========================================================================
# 1. _meta 契约
# ==========================================================================
@check('meta: _meta.groups=8 / items=74 / dialect=mariadb / tables=[]')
def _check_meta():
from pbl_appcodes.seed import get_meta, EXPECTED_GROUPS, EXPECTED_ITEMS, DIALECT
meta = get_meta()
assert int(meta['groups']) == EXPECTED_GROUPS == 8, meta.get('groups')
assert int(meta['items']) == EXPECTED_ITEMS == 74, meta.get('items')
assert str(meta['dialect']) == DIALECT == 'mariadb', meta.get('dialect')
assert meta.get('tables') == [], '零自有表契约被破坏: %r' % (meta.get('tables'),)
assert meta.get('idempotent') is True
return True
def run_checks():
doc = load_codes()
meta = doc.get("_meta") or {}
dicts = doc.get("dicts") or []
@check('meta: 实际组数/项数与 _meta 声明一致')
def _check_counts():
from pbl_appcodes.seed import count_groups, count_items, get_meta
meta = get_meta()
assert count_groups() == int(meta['groups']), (count_groups(), meta['groups'])
assert count_items() == int(meta['items']), (count_items(), meta['items'])
assert count_groups() == 8 and count_items() == 74
return True
# C1 _meta 契约
_check("C1.meta.groups==8", meta.get("groups") == EXPECT_GROUPS,
"actual=%r" % (meta.get("groups"),))
_check("C1.meta.items==74", meta.get("items") == EXPECT_ITEMS,
"actual=%r" % (meta.get("items"),))
_check("C1.meta.dialect==mariadb", meta.get("dialect") == EXPECT_DIALECT,
"actual=%r" % (meta.get("dialect"),))
_check("C1.meta.idempotent", meta.get("idempotent") is True)
_check("C1.meta.zero_table", meta.get("zero_table") is True)
# C2 实际数量
actual_groups = len(dicts)
actual_items = sum(len(d.get("items") or []) for d in dicts)
_check("C2.actual_groups==8", actual_groups == EXPECT_GROUPS, "actual=%d" % actual_groups)
_check("C2.actual_items==74", actual_items == EXPECT_ITEMS, "actual=%d" % actual_items)
# ==========================================================================
# 2. 各组项数
# ==========================================================================
@check('groups: 8 组 id 与顺序符合契约')
def _check_group_ids():
from pbl_appcodes.seed import list_group_codes, EXPECTED_GROUP_ORDER
codes = list_group_codes()
assert codes == list(EXPECTED_GROUP_ORDER), codes
assert len(codes) == 8
assert len(set(codes)) == 8, '组 id 重复'
return True
# C3 编码全局唯一
seen = {}
dup = []
for d in dicts:
for it in (d.get("items") or []):
c = it.get("code")
if c in seen:
dup.append("%s(%s/%s)" % (c, seen[c], d.get("group")))
else:
seen[c] = d.get("group")
_check("C3.code_globally_unique", not dup, "dup=%s" % dup)
# C4 每组 sort 1..n 连续
bad_sort = []
for d in dicts:
items = d.get("items") or []
sorts = [it.get("sort") for it in items]
expect = list(range(1, len(items) + 1))
if sorts != expect:
bad_sort.append("%s:%s!=%s" % (d.get("group"), sorts, expect))
_check("C4.sort_continuous_1..n", not bad_sort, "bad=%s" % bad_sort)
@check('groups: 每组项数符合契约8/5/8/6/6/10/16/15 = 74')
def _check_items_per_group():
from pbl_appcodes.seed import EXPECTED_ITEMS_PER_GROUP, get_items, list_group_codes
total = 0
for gid in list_group_codes():
expected = EXPECTED_ITEMS_PER_GROUP[gid]
actual = len(get_items(gid))
assert actual == expected, '%s 项数 %d != %d' % (gid, actual, expected)
total += actual
assert total == 74, '合计 %d != 74' % total
assert sum(EXPECTED_ITEMS_PER_GROUP.values()) == 74
return True
# C5 项字段完备 + code 形态
bad_item = []
for d in dicts:
for it in (d.get("items") or []):
if not isinstance(it.get("code"), str) or not CODE_RE.match(it["code"]):
bad_item.append("code:%r@%s" % (it.get("code"), d.get("group")))
if not isinstance(it.get("name"), str) or not it["name"].strip():
bad_item.append("name:%r@%s" % (it.get("name"), d.get("group")))
if not isinstance(it.get("sort"), int) or isinstance(it.get("sort"), bool):
bad_item.append("sort:%r@%s" % (it.get("sort"), d.get("group")))
_check("C5.item_fields_valid", not bad_item, "bad=%s" % bad_item[:5])
# C6 组标识唯一且合法
groups = [d.get("group") for d in dicts]
bad_group = [g for g in groups if not isinstance(g, str) or not IDENT_RE.match(g)]
_check("C6.group_ident_valid", not bad_group, "bad=%s" % bad_group)
_check("C6.group_unique", len(set(groups)) == len(groups), "groups=%s" % groups)
names = [d.get("group_name") for d in dicts]
_check("C6.group_name_unique", len(set(names)) == len(names))
_check("C6.group_name_nonempty", all(isinstance(n, str) and n.strip() for n in names))
# ==========================================================================
# 3. 唯一性 / sort 连续性 / 长度上限
# ==========================================================================
@check('codes: 组内 k 唯一、全局 appcodes_kv.id 唯一')
def _check_uniqueness():
from pbl_appcodes.seed import get_items, list_groups
all_kv = []
for group in list_groups():
gid = group['id']
keys = [str(it.get('k')) for it in group.get('items') or []]
assert len(keys) == len(set(keys)), '%s 内 k 重复' % gid
assert all(k.strip() for k in keys), '%s 存在空 k' % gid
all_kv.extend('%s_%s' % (gid, k) for k in keys)
assert len(all_kv) == len(set(all_kv)), 'appcodes_kv.id 全局重复'
assert len(all_kv) == 74
return True
# C7 Agent 工具启停契约 13/9
by_group = {d.get("group"): (d.get("items") or []) for d in dicts}
en = by_group.get(GROUP_ENABLED, [])
dis = by_group.get(GROUP_DISABLED, [])
_check("C7.enabled_count==13", len(en) == EXPECT_ENABLED, "actual=%d" % len(en))
_check("C7.disabled_count==9", len(dis) == EXPECT_DISABLED, "actual=%d" % len(dis))
_check("C7.enabled_status_all_enabled",
all(it.get("status") == "enabled" for it in en) if en else False)
_check("C7.disabled_status_all_disabled",
all(it.get("status") == "disabled" for it in dis) if dis else False)
_check("C7.tool_total==22", len(en) + len(dis) == 22,
"actual=%d" % (len(en) + len(dis)))
# C8 零表约束seed.py 不得含 CREATE TABLE
ddl_hit = []
if os.path.isfile(SEED_PY):
with open(SEED_PY, "r", encoding="utf-8") as f:
src = f.read()
for kw in ("CREATE TABLE", "create table", "ALTER TABLE", "DROP TABLE"):
if kw in src:
ddl_hit.append(kw)
_check("C8.seed_has_inject_entry", "def inject" in src or "def seed" in src,
"seed.py 缺少注入入口函数")
_check("C8.seed_idempotent_guard",
("already" in src.lower()) or ("exists" in src.lower()) or ("ON DUPLICATE" in src),
"seed.py 未见幂等判定")
else:
_check("C8.seed_py_exists", False, "seed.py 不存在")
_check("C8.zero_table_no_ddl", not ddl_hit, "hit=%s" % ddl_hit)
@check('codes: 组级 sort 从 1 连续、组内 sort 从 1 连续')
def _check_sort_continuous():
from pbl_appcodes.seed import list_groups
groups = list_groups()
for idx, group in enumerate(groups, start=1):
assert int(group.get('sort') or 0) == idx, \
'%s sort=%r 应为 %dsort 不连续)' % (group.get('id'), group.get('sort'), idx)
items = sorted(list(group.get('items') or []), key=lambda it: int(it.get('sort') or 0))
for pos, item in enumerate(items, start=1):
assert int(item.get('sort') or 0) == pos, \
'%s%s sort=%r 应为 %dsort 不连续)' % (
group.get('id'), item.get('k'), item.get('sort'), pos)
return True
return actual_items
@check('codes: parentid ≤ 22、appcodes_kv.id ≤ 32VARCHAR(32) 上限)')
def _check_length_limits():
from pbl_appcodes.seed import (PARENTID_MAX_LEN, KV_ID_MAX_LEN, get_items,
list_group_codes)
for gid in list_group_codes():
assert len(gid) <= PARENTID_MAX_LEN, '%s 长度 %d > %d' % (
gid, len(gid), PARENTID_MAX_LEN)
for item in get_items():
kv_id = '%s_%s' % (item['parentid'], item['k'])
assert len(kv_id) <= KV_ID_MAX_LEN, '%s 长度 %d > %d' % (
kv_id, len(kv_id), KV_ID_MAX_LEN)
assert len(item['k']) <= 32 and len(item['v']) <= 255
return True
@check('codes: validate_codes() 返回 ok=True 且 problems 为空')
def _check_validate():
from pbl_appcodes.seed import validate_codes
report = validate_codes()
assert report['ok'] is True, '契约校验失败: %s' % report['problems']
assert report['problems'] == []
assert report['groups'] == 8 and report['items'] == 74
return True
# ==========================================================================
# 4. dry_run_seed 计划
# ==========================================================================
@check('seed: dry_run_seed() 计划 8 parent + 74 kv不触库')
def _check_dry_run():
from pbl_appcodes.seed import dry_run_seed
plan = dry_run_seed()
assert plan['groups'] == 8
assert len(plan['parents']) == 8
assert len(plan['kvs']) == 74
assert plan['stats']['match'] is True
assert plan['dialect'] == 'mariadb'
assert plan['tables'] == ['appcodes', 'appcodes_kv']
first_parent = plan['parents'][0]
assert first_parent['id'] == 'pbl_bp_status'
assert set(first_parent.keys()) >= {'id', 'name', 'hierarchy_flg', 'sort'}
first_kv = plan['kvs'][0]
assert first_kv['id'] == 'pbl_bp_status_draft'
assert first_kv['parentid'] == 'pbl_bp_status'
assert first_kv['k'] == 'draft' and first_kv['v'] == '草稿' and first_kv['sort'] == 1
# 单组 dry_run
one = dry_run_seed('pbl_quality_level')
assert one['groups'] == 1 and len(one['kvs']) == 5
return True
@check('seed: seed_group(dry_run=True) 不触库即返回计划统计')
def _check_seed_group_dry():
import asyncio
from pbl_appcodes.seed import seed_group
loop = asyncio.new_event_loop()
try:
stat = loop.run_until_complete(seed_group('pbl_agent_role', dry_run=True))
finally:
loop.close()
assert stat['dry_run'] is True
assert stat['group'] == 'pbl_agent_role'
assert stat['inserted'] == 7, stat # 1 parent + 6 items
return True
@check('seed: seed_all(dry_run=True) 汇总 8 组 74 项')
def _check_seed_all_dry():
import asyncio
from pbl_appcodes.seed import seed_all
loop = asyncio.new_event_loop()
try:
report = loop.run_until_complete(seed_all(dry_run=True))
finally:
loop.close()
assert report['ok'] is True and report['dry_run'] is True
assert report['groups'] == 8 and report['items'] == 74
assert report['plan']['match'] is True
return True
@check('seed: ensure_tables 缺表时抛 DbError不擅自建 appbase 的表)')
def _check_ensure_tables_guard():
import asyncio
from pbl_appcodes.seed import ensure_tables
from pbl_common.errors import DbError
class _NoTableDb(object):
dbname = 'pbls_appbase'
async def execute(self, sql, params=None):
return []
async def read(self, *a, **kw):
return []
async def create(self, *a, **kw):
raise AssertionError('不应写入')
loop = asyncio.new_event_loop()
try:
ensure_tables(db=_NoTableDb())
raise AssertionError('缺表未抛 DbError')
except DbError as exc:
assert 'appcodes' in exc.message
finally:
loop.close()
return True
@check('seed: DDL 为 mariadb 方言,无 BIGSERIAL/SERIAL/nextval/FK/ENUM/TIMESTAMP')
def _check_ddl_dialect():
from pbl_appcodes.seed import _ddl_of, APPCODES_TABLE, APPCODES_KV_TABLE
for table in (APPCODES_TABLE, APPCODES_KV_TABLE):
ddl = _ddl_of(table).upper()
for banned in ('BIGSERIAL', 'SERIAL', 'NEXTVAL', 'FOREIGN KEY', 'REFERENCES',
'ENUM(', 'TIMESTAMP'):
assert banned not in ddl, '%s DDL 含禁用语法 %s' % (table, banned)
assert 'CREATE TABLE IF NOT EXISTS' in ddl, '%s DDL 非幂等' % table
assert 'ENGINE=INNODB' in ddl and 'UTF8MB4' in ddl
assert 'VARCHAR(32)' in ddl
return True
# ==========================================================================
# 5. 符号闭包QC #1 / #5__init__.py / init.py / api.py 引用的名字必须真实存在)
# ==========================================================================
@check('closure: seed.py 导出 __init__/init/api 引用的全部符号')
def _check_seed_symbol_closure():
from pbl_appcodes import seed
required = ('EXPECTED_GROUPS', 'EXPECTED_ITEMS', 'EXPECTED_ITEMS_PER_GROUP',
'EXPECTED_GROUP_ORDER', 'get_items', 'list_group_codes',
'list_groups', 'get_group', 'seed_all', 'seed_group',
'dry_run_seed', 'ensure_tables', 'validate_codes', 'verify_seed',
'load_codes', 'count_groups', 'count_items', 'get_meta',
'APPCODES_TABLE', 'APPCODES_KV_TABLE', 'DIALECT')
missing = [n for n in required if not hasattr(seed, n)]
assert not missing, 'seed.py 缺少被引用符号: %s' % ', '.join(missing)
for name in ('seed_all', 'seed_group', 'ensure_tables', 'verify_seed'):
assert inspect.iscoroutinefunction(getattr(seed, name)), '%s 必须是 async' % name
for name in ('get_items', 'list_group_codes', 'dry_run_seed', 'validate_codes'):
assert not inspect.iscoroutinefunction(getattr(seed, name)), \
'%s 必须是同步函数' % name
return True
@check('closure: pbl_appcodes 包导出 load_pbl_appcodes三处同步之②')
def _check_package_export():
import pbl_appcodes
assert callable(getattr(pbl_appcodes, 'load_pbl_appcodes', None)), \
'pbl_appcodes/__init__.py 未导出 load_pbl_appcodes'
from pbl_appcodes.init import load_pbl_appcodes
assert pbl_appcodes.load_pbl_appcodes is load_pbl_appcodes
return True
@check('closure: api.py 引用的 pbl_common 符号全部存在(无 ParamInvalidError/ErrorCode 断裂)')
def _check_api_imports():
from pbl_common import errors
for name in ('ParamInvalidError', 'ErrorCode', 'PBLError', 'err', 'fail',
'DbError', 'NotFoundError', 'CODE_TO_HTTP',
'assert_not_write_protected', 'WRITE_PROTECTED_MODULES',
'PBL_E_PARAM', 'PBL_E_DB'):
assert hasattr(errors, name), 'pbl_common.errors 缺少 %s' % name
from pbl_appcodes import api as appcodes_api
assert callable(getattr(appcodes_api, 'list_appcodes', None))
assert callable(getattr(appcodes_api, 'seed_appcodes', None))
return True
@check('closure: 包内无 except ImportError 静默吞错、无反向依赖 pbl_blueprint')
def _check_no_silent_swallow():
import os
import pbl_appcodes
pkg_dir = os.path.dirname(os.path.abspath(pbl_appcodes.__file__))
scanned = 0
for fname in sorted(os.listdir(pkg_dir)):
if not fname.endswith('.py') or fname == 'self_check.py':
continue
with open(os.path.join(pkg_dir, fname), 'r', encoding='utf-8') as fh:
src = fh.read()
scanned += 1
assert 'except ImportError' not in src, \
'%s 存在 except ImportError 静默吞错(兼容层失效根因)' % fname
assert 'pbl_blueprint' not in src, \
'%s 反向依赖 pbl_blueprint循环依赖根因' % fname
assert 'm1b' not in src, '%s 引用 m1b 兼容层' % fname
assert scanned >= 4, '扫描到的 .py 文件过少: %d' % scanned
return True
# ==========================================================================
# 6. 写保护门禁不误伤共享编码表
# ==========================================================================
@check('guard: appcodes/appcodes_kv 非受保护引用模块写门禁放行rbac 等仍拒绝')
def _check_write_protect_scope():
from pbl_common.errors import (WRITE_PROTECTED_MODULES, assert_not_write_protected,
is_write_protected, WriteProtectedError)
from pbl_appcodes.seed import APPCODES_TABLE, APPCODES_KV_TABLE
for table in (APPCODES_TABLE, APPCODES_KV_TABLE):
assert table not in WRITE_PROTECTED_MODULES
assert is_write_protected(table, 'update') is False
assert assert_not_write_protected(table, 'update') is True
for mod in ('rbac', 'world', 'scene', 'entity', 'scense', 'scense_runtime',
'script_engine'):
assert is_write_protected(mod, 'update') is True
try:
assert_not_write_protected(mod, 'delete')
except WriteProtectedError:
pass
else:
raise AssertionError('%s delete 未被写保护拦截' % mod)
return True
# ==========================================================================
# 运行器
# ==========================================================================
def run_self_check(verbose=True):
"""执行全部自检,返回 (passed, total, failures)。"""
passed, failures = 0, []
for name, fn in RESULTS:
try:
if fn() is False:
raise AssertionError('check returned False')
passed += 1
if verbose:
print(' [PASS] %s' % name)
except Exception as exc: # noqa: BLE001
failures.append((name, '%s: %s' % (type(exc).__name__, exc),
traceback.format_exc()))
if verbose:
print(' [FAIL] %s -> %s: %s' % (name, type(exc).__name__, exc))
total = len(RESULTS)
if verbose:
status = 'PASS' if not failures else 'FAIL'
print('SELF_CHECK pbl_appcodes: %s %d/%d' % (status, passed, total))
for name, msg, _tb in failures:
print(' - %s: %s' % (name, msg))
return passed, total, failures
def main():
total_items = run_checks()
failed = [r for r in _results if not r[1]]
print("=" * 68)
print("SELF_CHECK pbl_appcodes —— 枚举字典契约自检")
print("codes.json: %s" % CODES_JSON)
print("-" * 68)
for name, ok, detail in _results:
flag = "PASS" if ok else "FAIL"
line = "[%s] %s" % (flag, name)
if not ok and detail:
line += " -> %s" % detail
print(line)
print("-" * 68)
print("检查项: %d 通过 / %d 失败;字典项总数: %d" % (len(_results) - len(failed), len(failed), total_items))
if failed:
print("SELF_CHECK pbl_appcodes: FAIL %d/%d" % (total_items - len(failed), total_items))
return 1
print("SELF_CHECK pbl_appcodes: PASS %d/%d" % (total_items, total_items))
return 0
_passed, _total, failures = run_self_check(verbose=True)
return 0 if not failures else 1
if __name__ == "__main__":
if __name__ == '__main__':
sys.exit(main())
__all__ = ['RESULTS', 'check', 'run_self_check', 'main']