deliver: 交付收口(引擎代为提交)

This commit is contained in:
agent.develop 2026-09-16 15:32:49 +08:00
parent a28264bd72
commit 970a6256fb
4 changed files with 494 additions and 550 deletions

View File

@ -4,150 +4,149 @@
"dialect": "mariadb",
"groups": 8,
"items": 74,
"target_tables": ["appcodes", "appcodes_kv"],
"idempotent": true,
"zero_table": true,
"code_type_column": "varchar(32)",
"source_of_truth": "projects/pbls/docs/01-design/appcodes.md",
"contract": "8 组 / 74 项 / mariadb 方言组编码唯一、项编码组内唯一、sort 自 1 连续",
"self_check": "python -m pbl_appcodes.self_check → SELF_CHECK pbl_appcodes: PASS 8/8"
"spec_ref": "projects/pbls/docs/01-design/appcodes.md",
"note": "6 组业务枚举 + 2 组运行时枚举 = 8 组 74 项;注入幂等(重复执行零差异);本模块零自有表。"
},
"groups": [
{
"code": "pbl_blueprint_type",
"name": "蓝图类型",
"module": "pbl_blueprint",
"sort": 1,
"group_code": "pbl_tenant_status",
"group_name": "PBL 租户状态",
"module": "pbl_appcodes",
"items_count": 3,
"items": [
{ "code": "course", "name": "课程型蓝图", "sort": 1 },
{ "code": "project", "name": "项目型蓝图", "sort": 2 },
{ "code": "game", "name": "游戏型蓝图", "sort": 3 },
{ "code": "sandbox", "name": "沙盒型蓝图", "sort": 4 },
{ "code": "assessment", "name": "测评型蓝图", "sort": 5 },
{ "code": "hybrid", "name": "混合型蓝图", "sort": 6 }
{"code": "active", "name": "启用", "sort": 1, "remark": "正常可用"},
{"code": "suspended", "name": "停用", "sort": 2, "remark": "暂停服务,数据保留"},
{"code": "closed", "name": "注销", "sort": 3, "remark": "终止合作,只读归档"}
]
},
{
"code": "pbl_blueprint_status",
"name": "蓝图状态",
"module": "pbl_blueprint",
"sort": 2,
"group_code": "pbl_role",
"group_name": "PBL 租户内角色",
"module": "pbl_appcodes",
"items_count": 5,
"items": [
{ "code": "draft", "name": "草稿", "sort": 1 },
{ "code": "validating", "name": "校验中", "sort": 2 },
{ "code": "validated", "name": "校验通过", "sort": 3 },
{ "code": "rejected", "name": "校验驳回", "sort": 4 },
{ "code": "compiled", "name": "已编译", "sort": 5 },
{ "code": "published", "name": "已发布", "sort": 6 },
{ "code": "archived", "name": "已归档", "sort": 7 }
{"code": "admin", "name": "项目管理员", "sort": 1, "remark": "租户内全权,复用 rbac 不改其基表"},
{"code": "teacher", "name": "教师", "sort": 2, "remark": "蓝图设计/校验/编译/发布"},
{"code": "assistant", "name": "助教", "sort": 3, "remark": "协助运行与证据采集"},
{"code": "student", "name": "学生", "sort": 4, "remark": "世界内运行与产出"},
{"code": "observer", "name": "观察者", "sort": 5, "remark": "只读看板与分析"}
]
},
{
"code": "pbl_quality_level",
"name": "质量等级5 级)",
"module": "pbl_validation",
"sort": 3,
"group_code": "pbl_bp_status",
"group_name": "PBL 蓝图状态",
"module": "pbl_appcodes",
"items_count": 6,
"items": [
{ "code": "L1_broken", "name": "L1 不可用", "sort": 1 },
{ "code": "L2_incomplete", "name": "L2 不完整", "sort": 2 },
{ "code": "L3_consistent", "name": "L3 一致可编译", "sort": 3 },
{ "code": "L4_coherent", "name": "L4 教学连贯", "sort": 4 },
{ "code": "L5_exemplary", "name": "L5 示范级", "sort": 5 }
{"code": "draft", "name": "草稿", "sort": 1, "remark": "可编辑"},
{"code": "validating", "name": "校验中", "sort": 2, "remark": "14 维校验执行中"},
{"code": "validated", "name": "已校验", "sort": 3, "remark": "无 blocker可编译"},
{"code": "compiling", "name": "编译中", "sort": 4, "remark": "确定性编译进行中"},
{"code": "published", "name": "已发布", "sort": 5, "remark": "产出 Game Definition 并发布到班级"},
{"code": "archived", "name": "已归档", "sort": 6, "remark": "只读留存"}
]
},
{
"code": "pbl_validation_dim",
"name": "校验维度14 维",
"module": "pbl_validation",
"sort": 4,
"group_code": "pbl_quality_level",
"group_name": "PBL 蓝图质量等级5 级",
"module": "pbl_appcodes",
"items_count": 5,
"items": [
{ "code": "D01_schema", "name": "D01 结构完整性", "sort": 1 },
{ "code": "D02_reference", "name": "D02 引用完整性", "sort": 2 },
{ "code": "D03_type", "name": "D03 类型一致性", "sort": 3 },
{ "code": "D04_cycle", "name": "D04 循环依赖", "sort": 4 },
{ "code": "D05_naming", "name": "D05 命名规范", "sort": 5 },
{ "code": "D06_cardinality", "name": "D06 基数约束", "sort": 6 },
{ "code": "D07_goal_alignment", "name": "D07 目标对齐", "sort": 7 },
{ "code": "D08_assessment_coverage", "name": "D08 评估覆盖", "sort": 8 },
{ "code": "D09_evidence_traceability", "name": "D09 证据可追溯", "sort": 9 },
{ "code": "D10_tool_safety", "name": "D10 工具安全", "sort": 10 },
{ "code": "D11_tenant_isolation", "name": "D11 租户隔离", "sort": 11 },
{ "code": "D12_version_compat", "name": "D12 版本兼容", "sort": 12 },
{ "code": "D13_resource_bound", "name": "D13 资源边界", "sort": 13 },
{ "code": "D14_i18n_complete", "name": "D14 多语言完整", "sort": 14 }
{"code": "L0_draft", "name": "L0 草稿级", "sort": 1, "remark": "未过校验"},
{"code": "L1_incomplete", "name": "L1 不完整", "sort": 2, "remark": "存在 blocker"},
{"code": "L2_basic", "name": "L2 基础可用", "sort": 3, "remark": "无 blocker有 error"},
{"code": "L3_qualified", "name": "L3 合格", "sort": 4, "remark": "无 error允许 warning"},
{"code": "L4_excellent", "name": "L4 优秀", "sort": 5, "remark": "全维通过且加权得分达标"}
]
},
{
"code": "pbl_subobject_type",
"name": "子对象类型7 类泛化契约)",
"module": "pbl_blueprint",
"sort": 5,
"group_code": "pbl_subobj_type",
"group_name": "PBL 蓝图 7 类子对象类型",
"module": "pbl_appcodes",
"items_count": 7,
"items": [
{ "code": "goal", "name": "学习目标", "sort": 1 },
{ "code": "stage", "name": "阶段", "sort": 2 },
{ "code": "task", "name": "任务", "sort": 3 },
{ "code": "role", "name": "角色", "sort": 4 },
{ "code": "rule", "name": "规则", "sort": 5 },
{ "code": "rubric", "name": "评分量规", "sort": 6 },
{ "code": "resource", "name": "资源", "sort": 7 }
{"code": "objective", "name": "学习目标", "sort": 1, "remark": "泛化载荷 payload.objective"},
{"code": "stage", "name": "阶段", "sort": 2, "remark": "泛化载荷 payload.stage"},
{"code": "task", "name": "任务", "sort": 3, "remark": "泛化载荷 payload.task"},
{"code": "artifact_spec", "name": "产出物规格", "sort": 4, "remark": "泛化载荷 payload.artifact_spec"},
{"code": "rubric_ref", "name": "量规引用", "sort": 5, "remark": "泛化载荷 payload.rubric_ref"},
{"code": "resource_ref", "name": "资源引用", "sort": 6, "remark": "泛化载荷 payload.resource_ref"},
{"code": "assessment_plan", "name": "评估方案", "sort": 7, "remark": "泛化载荷 payload.assessment_plan"}
]
},
{
"code": "pbl_agent_tool",
"name": "Agent 工具22 个13 启用 / 9 禁用fail-closed",
"module": "pbl_agent_runtime",
"sort": 6,
"group_code": "pbl_valid_dim",
"group_name": "PBL 校验 14 维",
"module": "pbl_appcodes",
"items_count": 14,
"items": [
{ "code": "blueprint_read", "name": "蓝图读取", "sort": 1, "enabled": 1 },
{ "code": "blueprint_write", "name": "蓝图写入", "sort": 2, "enabled": 1 },
{ "code": "subobject_read", "name": "子对象读取", "sort": 3, "enabled": 1 },
{ "code": "subobject_write", "name": "子对象写入", "sort": 4, "enabled": 1 },
{ "code": "validate_run", "name": "执行校验", "sort": 5, "enabled": 1 },
{ "code": "validate_report", "name": "校验报告", "sort": 6, "enabled": 1 },
{ "code": "compile_run", "name": "执行编译", "sort": 7, "enabled": 1 },
{ "code": "evidence_collect", "name": "证据采集", "sort": 8, "enabled": 1 },
{ "code": "evidence_query", "name": "证据查询", "sort": 9, "enabled": 1 },
{ "code": "assess_run", "name": "执行评估", "sort": 10, "enabled": 1 },
{ "code": "assess_report", "name": "评估报告", "sort": 11, "enabled": 1 },
{ "code": "kdb_query", "name": "知识库只读查询", "sort": 12, "enabled": 1 },
{ "code": "runtime_event", "name": "运行时事件写入", "sort": 13, "enabled": 1 },
{ "code": "shell_exec", "name": "Shell 执行(禁用)", "sort": 14, "enabled": 0 },
{ "code": "sql_raw", "name": "裸 SQL 执行(禁用)", "sort": 15, "enabled": 0 },
{ "code": "file_write_abs", "name": "绝对路径写文件(禁用)", "sort": 16, "enabled": 0 },
{ "code": "net_request", "name": "外网请求(禁用)", "sort": 17, "enabled": 0 },
{ "code": "rbac_modify", "name": "权限修改(禁用)", "sort": 18, "enabled": 0 },
{ "code": "tenant_switch", "name": "租户切换(禁用)", "sort": 19, "enabled": 0 },
{ "code": "kdb_write", "name": "知识库写入(禁用)", "sort": 20, "enabled": 0 },
{ "code": "base_table_write", "name": "引用基表写入(禁用)", "sort": 21, "enabled": 0 },
{ "code": "model_invoke_paid", "name": "付费模型调用(禁用)", "sort": 22, "enabled": 0 }
{"code": "D01_driving_question", "name": "驱动性问题清晰度", "sort": 1, "remark": "blocker 级"},
{"code": "D02_objective_alignment", "name": "学习目标对齐", "sort": 2, "remark": "目标-任务-评估三向对齐"},
{"code": "D03_stage_completeness", "name": "阶段完整性", "sort": 3, "remark": "阶段链无断裂"},
{"code": "D04_task_feasibility", "name": "任务可行性", "sort": 4, "remark": "课时与团队规模匹配"},
{"code": "D05_artifact_spec", "name": "产出物规格明确性", "sort": 5, "remark": "每任务至少一个产出物规格"},
{"code": "D06_rubric_coverage", "name": "量规覆盖度", "sort": 6, "remark": "目标被量规条目全覆盖"},
{"code": "D07_resource_availability", "name": "资源可得性", "sort": 7, "remark": "资产引用可解析"},
{"code": "D08_assessment_plan", "name": "评估方案闭环", "sort": 8, "remark": "证据->量规->报告链路完整"},
{"code": "D09_duration_reasonableness", "name": "课时合理性", "sort": 9, "remark": "总课时与阶段和一致"},
{"code": "D10_team_collaboration", "name": "团队协作设计", "sort": 10, "remark": "角色分工与团队规模"},
{"code": "D11_world_scene_binding", "name": "世界/场景绑定有效性", "sort": 11, "remark": "只读引用存在且可访问"},
{"code": "D12_script_executability", "name": "脚本可执行性", "sort": 12, "remark": "script_engine 校验通过"},
{"code": "D13_evidence_traceability", "name": "证据可追溯性", "sort": 13, "remark": "幂等键规则可生成"},
{"code": "D14_compliance_safety", "name": "合规与安全", "sort": 14, "remark": "内容合规与写保护合规"}
]
},
{
"code": "pbl_evidence_type",
"name": "证据类型",
"module": "pbl_evidence",
"sort": 7,
"group_code": "pbl_agent_tool",
"group_name": "PBL Agent 工具白名单13 启用 / 9 禁用fail-closed",
"module": "pbl_appcodes",
"items_count": 22,
"items": [
{ "code": "artifact", "name": "产出物", "sort": 1 },
{ "code": "dialogue", "name": "对话记录", "sort": 2 },
{ "code": "action_log", "name": "操作日志", "sort": 3 },
{ "code": "code_snippet", "name": "代码片段", "sort": 4 },
{ "code": "document", "name": "文档", "sort": 5 },
{ "code": "media", "name": "多媒体", "sort": 6 },
{ "code": "peer_review", "name": "同伴互评", "sort": 7 },
{ "code": "teacher_note", "name": "教师批注", "sort": 8 }
{"code": "blueprint.read", "name": "读取蓝图", "sort": 1, "remark": "enabled"},
{"code": "blueprint.tree", "name": "读取蓝图子对象树", "sort": 2, "remark": "enabled"},
{"code": "subobject.read", "name": "读取子对象", "sort": 3, "remark": "enabled"},
{"code": "subobject.upsert", "name": "写入子对象", "sort": 4, "remark": "enabled需编辑锁 + tenant_id"},
{"code": "validation.run", "name": "触发校验", "sort": 5, "remark": "enabled"},
{"code": "validation.findings", "name": "读取校验发现", "sort": 6, "remark": "enabled"},
{"code": "compile.submit", "name": "提交编译", "sort": 7, "remark": "enabled前置校验必须 passed"},
{"code": "compile.status", "name": "查询编译状态", "sort": 8, "remark": "enabled"},
{"code": "evidence.collect", "name": "采集证据", "sort": 9, "remark": "enabled幂等键强制"},
{"code": "evidence.query", "name": "查询证据", "sort": 10, "remark": "enabled"},
{"code": "assessment.score", "name": "Rubric 加权评分", "sort": 11, "remark": "enabled"},
{"code": "assessment.report", "name": "生成评估报告", "sort": 12, "remark": "enabled"},
{"code": "analytics.snapshot_read", "name": "读取分析快照", "sort": 13, "remark": "enabled只读匿名聚合"},
{"code": "blueprint.delete", "name": "删除蓝图", "sort": 14, "remark": "disabledfail-closed 拒绝"},
{"code": "blueprint.publish", "name": "发布蓝图", "sort": 15, "remark": "disabled需人工拍板"},
{"code": "tenant.create", "name": "创建租户", "sort": 16, "remark": "disabled治理域人工操作"},
{"code": "rbac.role_grant", "name": "授予角色", "sort": 17, "remark": "disabledrbac 写保护"},
{"code": "world.write", "name": "写入世界", "sort": 18, "remark": "disabled复用域零改表"},
{"code": "scene.write", "name": "写入场景", "sort": 19, "remark": "disabled复用域零改表"},
{"code": "entity.write", "name": "写入实体", "sort": 20, "remark": "disabled复用域零改表"},
{"code": "kdb.write", "name": "写入知识库", "sort": 21, "remark": "disabledM7 零写入"},
{"code": "runtime.broadcast_force", "name": "强制广播", "sort": 22, "remark": "disabled广播仅由服务端权威时序触发"}
]
},
{
"code": "pbl_assessment_rubric_level",
"name": "量规评分等级",
"module": "pbl_assessment",
"sort": 8,
"group_code": "pbl_rel_artifact_kind",
"group_name": "PBL 关系类型与产出物种类",
"module": "pbl_appcodes",
"items_count": 12,
"items": [
{ "code": "R1_beginning", "name": "R1 起步", "sort": 1 },
{ "code": "R2_developing", "name": "R2 发展中", "sort": 2 },
{ "code": "R3_proficient", "name": "R3 熟练", "sort": 3 },
{ "code": "R4_advanced", "name": "R4 进阶", "sort": 4 },
{ "code": "R5_exemplary", "name": "R5 卓越", "sort": 5 }
{"code": "rel.depends_on", "name": "依赖", "sort": 1, "remark": "子对象前置依赖"},
{"code": "rel.produces", "name": "产出", "sort": 2, "remark": "任务产出产出物规格"},
{"code": "rel.assesses", "name": "评估", "sort": 3, "remark": "量规引用评估学习目标"},
{"code": "rel.references", "name": "引用", "sort": 4, "remark": "只读引用复用域对象"},
{"code": "rel.contains", "name": "包含", "sort": 5, "remark": "树形父子包含"},
{"code": "artifact.doc", "name": "文档", "sort": 6, "remark": "产出物种类"},
{"code": "artifact.image", "name": "图片", "sort": 7, "remark": "产出物种类"},
{"code": "artifact.model", "name": "三维模型", "sort": 8, "remark": "产出物种类glb/gltf"},
{"code": "artifact.audio", "name": "音频", "sort": 9, "remark": "产出物种类"},
{"code": "artifact.video", "name": "视频", "sort": 10, "remark": "产出物种类"},
{"code": "artifact.code", "name": "代码/脚本", "sort": 11, "remark": "产出物种类"},
{"code": "artifact.log", "name": "运行日志", "sort": 12, "remark": "产出物种类,运行时自动采集"}
]
}
]

View File

@ -1,131 +1,71 @@
# -*- coding: utf-8 -*-
"""
pbl_appcodes.init 模块挂载入口load_pbl_appcodes
"""pbl_appcodes 模块挂载入口load_pbl_appcodes
挂载职责
1. 零表模块ensure_tables() 返回空列表不建自有表
2. 注册 API 契约到 ServerEnvget_codes/is_valid/assert_valid/list_groups
3. 幂等注入编码种子8 74 注入失败不阻断启动降级 stderr
4. 注册 RBAC 权限路径
三处同步注册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
零自有表只向平台既有 appcodes / appcodes_kv 注入 8 74 项枚举幂等
挂载时执行数量契约自检self_check不通过直接抛错 fail-fast避免脏字典进入运行期
"""
import os
import sys
from pbl_appcodes import api as appcodes_api
from pbl_appcodes.seed import seed, ensure_tables, seed_summary
from pbl_appcodes.seed import (
EXPECTED_GROUPS,
EXPECTED_ITEMS,
get_items,
list_group_codes,
seed_all,
)
from pbl_appcodes.self_check import run_self_check
MODULE_NAME = 'pbl_appcodes'
MODULE_TABLES = () # 零自有表
MODULE_VERSION = '1.0.0'
CONTRACT_FUNCS = (
'get_codes', 'get_code_name', 'is_valid', 'assert_valid',
'list_groups', 'health', 'self_check',
)
RBAC_PATHS = (
'/api/pbl_appcodes_list.dspy',
'/api/pbl_appcodes_inject.dspy',
)
_loaded = False
def _register_contract(env):
contract = {}
for name in CONTRACT_FUNCS:
fn = getattr(appcodes_api, name, None)
if fn is not None:
contract[name] = fn
# 种子注入也作为契约暴露(部署脚本/运维接口调用)
contract['seed'] = seed
contract['ensure_tables'] = ensure_tables
try:
env.pbl_appcodes = contract
except Exception: # noqa: BLE001
try:
setattr(env, 'modules', getattr(env, 'modules', {}))
env.modules[MODULE_NAME] = contract
except Exception: # noqa: BLE001
pass
return contract
def load_pbl_appcodes(auto_seed=True):
"""挂载 pbl_appcodes先跑数量契约自检再幂等注入枚举。
def _register_rbac(env):
registered = []
try:
perm = getattr(env, 'register_perm', None) or getattr(env, 'add_perm', None)
if callable(perm):
for path in RBAC_PATHS:
try:
perm('logined', path)
registered.append(path)
except Exception: # noqa: BLE001
pass
except Exception: # noqa: BLE001
pass
return registered
def load_pbl_appcodes(env=None):
auto_seed=False 时只注册查询能力供单测/离线场景不触库
"""
挂载 pbl_appcodes
global _loaded
if _loaded:
return True
env None 时自取 ServerEnv()兼容单模块测试
返回 {'module','tables','contract','rbac','seed','ok'}
"""
if env is None:
# ---- 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:
try:
from ahserver.serverenv import ServerEnv
env = ServerEnv()
except Exception: # noqa: BLE001
env = None
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)}
result = {'module': MODULE_NAME, 'tables': [], 'contract': [],
'rbac': [], 'seed': None, 'ok': True}
_loaded = True
return True
# 1) 零表契约
try:
result['tables'] = ensure_tables(module=MODULE_NAME) or []
except Exception as e: # noqa: BLE001
sys.stderr.write('[pbl_appcodes] ensure_tables failed: %s\n' % e)
result['tables'] = []
# 2) 注册契约
if env is not None:
contract = _register_contract(env)
result['contract'] = sorted(contract.keys())
result['rbac'] = _register_rbac(env)
# 3) 自检(数量契约 8/74——不通过只告警不阻断启动
try:
sc = appcodes_api.self_check(verbose=False)
result['self_check'] = {'passed': sc['passed'], 'total': sc['total'],
'groups': sc['groups'], 'items': sc['items'],
'ok': sc['ok']}
if not sc['ok']:
sys.stderr.write('[pbl_appcodes] SELF_CHECK FAIL %d/%d\n'
% (sc['passed'], sc['total']))
except Exception as e: # noqa: BLE001
sys.stderr.write('[pbl_appcodes] self_check error: %s\n' % e)
# 4) 幂等注入种子PBLS_SKIP_SEED=1 可跳过,供离线/无库环境启动)
if os.environ.get('PBLS_SKIP_SEED', '').strip() in ('1', 'true', 'yes'):
result['seed'] = {'skipped_by_env': True}
else:
try:
tenant = os.environ.get('PBLS_SEED_TENANT', '*')
res = seed(tenant_id=tenant, dry_run=False)
result['seed'] = {'ok': res.get('ok'), 'groups': res.get('groups'),
'items': res.get('items'),
'inserted': res.get('inserted'),
'skipped': res.get('skipped'),
'failed': res.get('failed')}
sys.stdout.write('[pbl_appcodes] %s\n' % seed_summary(res))
if not res.get('ok'):
result['ok'] = False
except Exception as e: # noqa: BLE001
# 注入失败不阻断启动常量回落仍可提供编码get_codes source='constant'
sys.stderr.write('[pbl_appcodes] seed failed (fallback to constant): %s\n' % e)
result['seed'] = {'ok': False, 'error': str(e), 'fallback': 'constant'}
sys.stdout.write('[pbl_appcodes] loaded groups=%d contract=%d\n'
% (len(result['contract']), len(result['contract'])))
return result
# 别名:兼容 load_{module} 与 init 两种解析路径
load_module = load_pbl_appcodes
init = load_pbl_appcodes

View File

@ -1,213 +1,203 @@
# -*- coding: utf-8 -*-
"""
pbl_appcodes.seed 幂等编码注入零自有表写平台 appcodes
"""pbl_appcodes 枚举幂等注入6+2 组 74 项,零自有表)。
幂等策略 COUNT 判存在 存在则 skip内容变更才 update 不存在则 insert
返回 per_group 统计{'inserted':n,'skipped':n,'updated':n,'failed':n}
目标表appcodes/ appcodes_kv均为平台基础模块 appbase 既有表
本模块只写入 pbl_* 前缀的组与项不改动既有组不新增表
铁律
- 零表模块ensure_tables() 返回空列表不建任何自有表
- tenant_id 打头注入必须指定租户'*' 表示全局种子
- 写保护只写 appcodes 绝不触碰 rbac/world/scene/entity/scense/
scense_runtime/script_engine 基表
- dry_run 支持只统计不落库供部署前预演
幂等策略build.sh 可重复执行零差异
1. group_code appcodes存在则比对 name/module不一致才 UPDATE一致则跳过
2. (group_code, code) appcodes_kv存在则比对 name/sort/remark一致跳过
3. 全程不 DELETE避免误删平台既有字典
4. 返回统计 {groups_inserted, groups_updated, groups_skipped, items_*}重复执行应全为 skipped
租户维度枚举字典为平台级tenant_id='__system__'业务数据租户隔离由各模块 CRUD 保证
"""
import datetime
import hashlib
import json
import os
import sys
from pbl_appcodes.codes_data import (
ALL_GROUPS, GROUP_COUNT, ITEM_COUNT, group_items, find_group, counts,
from pbl_common.kernel import (
PblError,
current_tenant_id,
db,
fail,
now_str,
write_audit,
)
CODE_TABLE = os.environ.get('PBLS_CODE_TABLE', 'appcodes')
CODE_MODULE = os.environ.get('PBLS_CODE_MODULE', 'pbl')
GLOBAL_TENANT = '*'
CODES_FILE = os.path.join(os.path.dirname(os.path.abspath(__file__)), 'codes.json')
SYSTEM_TENANT = '__system__'
GROUP_TABLE = 'appcodes'
ITEM_TABLE = 'appcodes_kv'
EXPECTED_GROUPS = 8
EXPECTED_ITEMS = 74
def ensure_tables(conn=None, module='pbl_appcodes'):
"""
零表模块契约不建任何自有表恒返回空列表
编码写入平台既有 appcodes appbase/rbac 侧建表
"""
return []
def load_codes(path=None):
"""读取 codes.json注入与自检共用同一事实源"""
target = path or CODES_FILE
if not os.path.exists(target):
fail('PBL_E_PARAM_INVALID', detail={'reason': 'codes.json 不存在: %s' % target})
with open(target, 'r', encoding='utf-8') as fp:
return json.load(fp)
def _now():
return datetime.datetime.now().strftime('%Y-%m-%d %H:%M:%S')
def iter_groups(data):
"""产出组字典序列(兼容 groups 为数组/字典两种写法)。"""
groups = data.get('groups')
if isinstance(groups, list):
for grp in groups:
if isinstance(grp, dict):
yield grp
return
if isinstance(groups, dict):
for key, grp in groups.items():
if isinstance(grp, dict):
grp.setdefault('group_code', key)
yield grp
def _checksum(text):
return hashlib.sha256((text or '').encode('utf-8')).hexdigest()[:32]
def _diff_fields(old_row, new_fields):
"""比对字段差异,返回需要更新的字段子集(无差异返回空 dict"""
changed = {}
for key, val in new_fields.items():
old_val = old_row.get(key)
if old_val is None and val is None:
continue
if str(old_val if old_val is not None else '') != str(val if val is not None else ''):
changed[key] = val
return changed
def _item_payload(group_code, item):
"""项 → appcodes 表行(不含 tenant_id由调用方注入"""
from pbl_common.serialize import dumps
extra = {k: v for k, v in item.items() if k not in ('code', 'name', 'sort')}
return {
'code_type': group_code,
'code': item.get('code'),
'name': item.get('name'),
'sort': item.get('sort'),
'module': CODE_MODULE,
'remark': dumps(extra) if extra else None,
def seed_group(adp, grp, stats):
"""注入单个枚举组到 appcodes存在即比对无差异跳过"""
group_code = grp.get('group_code')
if not group_code:
fail('PBL_E_PARAM_INVALID', detail={'reason': '组缺少 group_code'})
fields = {
'code': group_code,
'name': grp.get('group_name') or group_code,
'module': grp.get('module') or 'pbl_appcodes',
'tenant_id': SYSTEM_TENANT,
'remark': grp.get('remark') or 'PBL Agent OS 枚举(%s' % group_code,
}
existing = adp.get(GROUP_TABLE, {'code': group_code}, tenant_id=SYSTEM_TENANT)
if existing:
changed = _diff_fields(existing, fields)
if changed:
changed['update_time'] = now_str()
adp.update(GROUP_TABLE, {'code': group_code}, changed, tenant_id=SYSTEM_TENANT)
stats['groups_updated'] += 1
else:
stats['groups_skipped'] += 1
return group_code
row = dict(fields)
row['create_time'] = now_str()
row['update_time'] = row['create_time']
adp.insert(GROUP_TABLE, row, tenant_id=SYSTEM_TENANT)
stats['groups_inserted'] += 1
return group_code
def _exists(conn, tenant_id, code_type, code):
"""幂等判定COUNT 该 (tenant, code_type, code) 是否已存在"""
from pbl_common.dbutil import query_one
sql = ('SELECT COUNT(1) AS cnt FROM `%s` '
'WHERE tenant_id = %%s AND code_type = %%s AND code = %%s' % CODE_TABLE)
row = query_one(sql, [tenant_id, code_type, code], conn=None)
return int((row or {}).get('cnt', 0)) > 0
def _insert_row(conn, tenant_id, row):
from pbl_common.dbutil import execute
data = dict(row)
data['tenant_id'] = tenant_id
data.setdefault('created_at', _now())
data.setdefault('updated_at', _now())
cols = list(data.keys())
sql = 'INSERT INTO `%s` (%s) VALUES (%s)' % (
CODE_TABLE,
', '.join('`%s`' % c for c in cols),
', '.join(['%s'] * len(cols)),
)
return execute(sql, [data[c] for c in cols], conn=conn)
def _update_row(conn, tenant_id, code_type, code, row):
from pbl_common.dbutil import execute
data = dict(row)
data.pop('tenant_id', None)
data['updated_at'] = _now()
cols = list(data.keys())
sql = 'UPDATE `%s` SET %s WHERE tenant_id = %%s AND code_type = %%s AND code = %%s' % (
CODE_TABLE, ', '.join('`%s` = %%s' % c for c in cols))
args = [data[c] for c in cols] + [tenant_id, code_type, code]
return execute(sql, args, conn=conn)
def seed_group(group_code, tenant_id=GLOBAL_TENANT, conn=None, dry_run=False):
"""
注入单个编码组幂等
返回 {'group','tenant_id','inserted','skipped','updated','failed','dry_run','errors'}
"""
stat = {'group': group_code, 'tenant_id': tenant_id, 'inserted': 0,
'skipped': 0, 'updated': 0, 'failed': 0, 'dry_run': bool(dry_run),
'errors': []}
group = find_group(group_code)
if group is None:
stat['failed'] = 1
stat['errors'].append('未登记的编码组:%s' % group_code)
return stat
items = group_items(group)
for item in items:
def seed_items(adp, group_code, items, stats):
"""注入组内枚举项到 appcodes_kv按 (group_code, code) 幂等)。"""
for item in items or []:
code = item.get('code')
if not code:
stat['failed'] += 1
stat['errors'].append('%s 存在无 code 的项' % group_code)
fail('PBL_E_PARAM_INVALID', detail={'reason': '%s 存在缺 code 的项' % group_code})
fields = {
'group_code': group_code,
'code': code,
'name': item.get('name') or code,
'sort': int(item.get('sort') or 0),
'remark': item.get('remark') or '',
'enabled': 1,
'tenant_id': SYSTEM_TENANT,
}
existing = adp.get(ITEM_TABLE, {'group_code': group_code, 'code': code},
tenant_id=SYSTEM_TENANT)
if existing:
changed = _diff_fields(existing, fields)
if changed:
changed['update_time'] = now_str()
adp.update(ITEM_TABLE, {'group_code': group_code, 'code': code}, changed,
tenant_id=SYSTEM_TENANT)
stats['items_updated'] += 1
else:
stats['items_skipped'] += 1
continue
row = _item_payload(group_code, item)
try:
if dry_run:
stat['inserted'] += 1
continue
if _exists(conn, tenant_id, group_code, code):
# 已存在:内容一致则 skip不一致则 update幂等且可演进
stat['skipped'] += 1
else:
_insert_row(conn, tenant_id, row)
stat['inserted'] += 1
except Exception as e: # noqa: BLE001
stat['failed'] += 1
stat['errors'].append('%s.%s: %s' % (group_code, code, e))
return stat
row = dict(fields)
row['create_time'] = now_str()
row['update_time'] = row['create_time']
adp.insert(ITEM_TABLE, row, tenant_id=SYSTEM_TENANT)
stats['items_inserted'] += 1
def seed(tenant_id=GLOBAL_TENANT, groups=None, dry_run=False, conn=None):
"""
注入全部或指定编码组幂等
tenant_id : 目标租户'*' 表示全局种子
groups : 组编码列表None = 全部 8
dry_run : True 只统计不落库
conn : 可选外部连接多组共用一个事务
返回 {'ok','tenant_id','dry_run','groups','items','per_group':[...],
'inserted','skipped','updated','failed'}
"""
from pbl_common.tenant import normalize_tenant
from pbl_common.audit import write_audit
tid = tenant_id if tenant_id == GLOBAL_TENANT else normalize_tenant(tenant_id)
target_codes = list(groups) if groups else [g.get('code') for g in ALL_GROUPS]
per_group = []
totals = {'inserted': 0, 'skipped': 0, 'updated': 0, 'failed': 0}
own_conn = conn is None and not dry_run
if own_conn:
from pbl_common.dbutil import get_conn
conn = get_conn(module='pbl_appcodes')
try:
for gcode in target_codes:
st = seed_group(gcode, tid, conn=conn, dry_run=dry_run)
per_group.append(st)
for k in totals:
totals[k] += st.get(k, 0)
if own_conn and conn is not None:
if totals['failed']:
conn.rollback()
else:
conn.commit()
finally:
if own_conn and conn is not None:
try:
conn.close()
except Exception: # noqa: BLE001
pass
result = {
'ok': totals['failed'] == 0,
'tenant_id': tid,
'dry_run': bool(dry_run),
'groups': len(per_group),
'items': sum(len(group_items(find_group(g) or {})) for g in target_codes),
'per_group': per_group,
'code_table': CODE_TABLE,
def new_stats():
"""初始化注入统计。"""
return {
'groups_inserted': 0, 'groups_updated': 0, 'groups_skipped': 0,
'items_inserted': 0, 'items_updated': 0, 'items_skipped': 0,
'groups_total': 0, 'items_total': 0,
}
result.update(totals)
if not dry_run:
def seed_all(adapter=None, path=None, audit=True):
"""全量幂等注入 8 组 74 项,返回统计 dict。
重复执行时 inserted/updated 应全为 0skipped 等于总量零差异校验依据
"""
data = load_codes(path)
adp = adapter or db('pbl_appcodes')
stats = new_stats()
for grp in iter_groups(data):
group_code = seed_group(adp, grp, stats)
seed_items(adp, group_code, grp.get('items'), stats)
stats['groups_total'] += 1
stats['items_total'] += len(grp.get('items') or [])
if stats['groups_total'] != EXPECTED_GROUPS:
fail('PBL_E_PARAM_INVALID',
detail={'reason': '组数契约不符', 'expect': EXPECTED_GROUPS, 'got': stats['groups_total']})
if stats['items_total'] != EXPECTED_ITEMS:
fail('PBL_E_PARAM_INVALID',
detail={'reason': '项数契约不符', 'expect': EXPECTED_ITEMS, 'got': stats['items_total']})
if audit:
try:
write_audit('seed', 'appcodes', None, tenant_id=tid,
result='success' if result['ok'] else 'fail',
detail={'groups': len(per_group), 'inserted': totals['inserted'],
'skipped': totals['skipped'], 'failed': totals['failed']},
module='pbl_appcodes')
except Exception: # noqa: BLE001
pass
write_audit('seed', object_type='pbl_appcodes', object_code='codes.json',
result='success', detail=stats, tenant_id=SYSTEM_TENANT, adapter=adp)
except PblError:
pass # 审计降级不阻断种子注入
return result
stats['idempotent'] = (stats['groups_inserted'] == 0 and stats['groups_updated'] == 0
and stats['items_inserted'] == 0 and stats['items_updated'] == 0)
return stats
def dry_run_seed(tenant_id=GLOBAL_TENANT, groups=None):
"""预演注入(不落库),返回与 seed 同结构统计"""
return seed(tenant_id=tenant_id, groups=groups, dry_run=True)
def list_group_codes(adapter=None):
"""返回 codes.json 中全部组编码(供 build.sh 校验与 api 下拉)。"""
data = load_codes()
return [grp.get('group_code') for grp in iter_groups(data)]
def seed_summary(result):
"""把 seed() 结果压成一行文本build.sh 日志用)"""
return ('appcodes seed tenant=%s groups=%s items=%s '
'inserted=%s skipped=%s updated=%s failed=%s dry_run=%s'
% (result.get('tenant_id'), result.get('groups'), result.get('items'),
result.get('inserted'), result.get('skipped'), result.get('updated'),
result.get('failed'), result.get('dry_run')))
def get_items(group_code, adapter=None):
"""按组编码取枚举项(优先读库,库不可用时回落 codes.json 离线兜底)。"""
adp = adapter or db('pbl_appcodes')
try:
rows = adp.query(ITEM_TABLE, where={'group_code': group_code},
order_by='sort', limit=200, tenant_id=SYSTEM_TENANT)
if rows:
return rows
except PblError:
pass
data = load_codes()
for grp in iter_groups(data):
if grp.get('group_code') == group_code:
return list(grp.get('items') or [])
return []

View File

@ -1,166 +1,181 @@
# -*- coding: utf-8 -*-
"""pbl_appcodes 自检脚本QC 硬门禁 #15 证据件,真实落库可运行)
"""pbl_appcodes 数量契约自检(可独立运行,不依赖数据库
输出机器可判读结论行SELF_CHECK pbl_appcodes: PASS 6/6 / FAIL n/6 (failed: ...)
契约来源pbls_spec.json appcodes{groups:8,items:74,idempotent:true} + docs/01-design/appcodes.md + data-model.md(dialect=mariadb, code varchar(32))
运行python3 modules/pbl_appcodes/pbl_appcodes/self_check.py [-q] | python3 -m pbl_appcodes.self_check
退出码 0=通过 1=有失败项零表零写入零网络只读 codes.json幂等可重复
核验项对齐 docs/01-design/appcodes.md QC 退回意见 #15
1. _meta.groups == 8_meta.items == 74_meta.dialect == 'mariadb'
2. 实际组数 == _meta.groups实际项数合计 == _meta.items
3. 每组声明 items == 该组实际项数
4. 全部编码group_code item code全局唯一
5. 每组内 sort 1 开始连续递增无跳号无重复
6. 每项必含 code/name/sort 三字段且类型合法
运行
python -m pbl_appcodes.self_check
python modules/pbl_appcodes/pbl_appcodes/self_check.py
输出成功
SELF_CHECK pbl_appcodes: PASS 74/74
退出码0=PASS1=FAILbuild.sh 据此 fail-fast
"""
from __future__ import print_function
import io, json, os, sys
HERE = os.path.dirname(os.path.abspath(__file__))
CODES_JSON = os.path.join(HERE, 'codes.json')
EXPECT_GROUPS = 8
EXPECT_ITEMS = 74
EXPECT_DIALECT = 'mariadb'
EXPECT_CODE_TYPE_MAXLEN = 32
EXPECT_ITEM_FIELDS = ('code', 'name', 'sort')
import json
import os
import sys
def _load_codes(path=CODES_JSON):
with io.open(path, 'r', encoding='utf-8') as f:
return json.load(f)
CODES_FILE = os.path.join(os.path.dirname(os.path.abspath(__file__)), 'codes.json')
def _extract_groups(doc):
"""兼容三种落盘形态,统一返回 [(group_name, code_type, [item,...]), ...]"""
out = []
if not isinstance(doc, dict):
return out
gs = doc.get('groups')
if isinstance(gs, list):
for g in gs:
if not isinstance(g, dict):
continue
name = g.get('group') or g.get('name') or g.get('code_type') or ''
ctype = g.get('code_type') or name
items = g.get('items')
if not isinstance(items, list):
items = []
out.append((name, ctype, items))
return out
if isinstance(gs, dict):
for k, v in gs.items():
if isinstance(v, list):
items, ctype = v, k
elif isinstance(v, dict):
items = v.get('items') if isinstance(v.get('items'), list) else []
ctype = v.get('code_type') or k
else:
items, ctype = [], k
out.append((k, ctype, items))
return out
for k, v in doc.items():
if k.startswith('_'):
EXPECTED_GROUPS = 8
EXPECTED_ITEMS = 74
EXPECTED_DIALECT = 'mariadb'
REQUIRED_ITEM_FIELDS = ('code', 'name', 'sort')
def load_codes(path=None):
"""读取 codes.json文件缺失或非法 JSON 直接抛错fail-fast不静默"""
target = path or CODES_FILE
if not os.path.exists(target):
raise IOError('codes.json 不存在: %s' % target)
with open(target, 'r', encoding='utf-8') as fp:
return json.load(fp)
def check_meta(data, errors):
"""校验 _meta 段声明值。"""
meta = data.get('_meta') or {}
if meta.get('dialect') != EXPECTED_DIALECT:
errors.append('_meta.dialect 应为 %s,实际 %r' % (EXPECTED_DIALECT, meta.get('dialect')))
if meta.get('groups') != EXPECTED_GROUPS:
errors.append('_meta.groups 应为 %d,实际 %r' % (EXPECTED_GROUPS, meta.get('groups')))
if meta.get('items') != EXPECTED_ITEMS:
errors.append('_meta.items 应为 %d,实际 %r' % (EXPECTED_ITEMS, meta.get('items')))
return meta
def iter_groups(data):
"""产出 (group_code, group_dict) 序列,跳过 _meta 段。"""
groups = data.get('groups')
if isinstance(groups, list):
for grp in groups:
if isinstance(grp, dict):
yield grp.get('group_code') or grp.get('code') or '', grp
return
if isinstance(groups, dict):
for key, grp in groups.items():
if isinstance(grp, dict):
yield grp.get('group_code') or key, grp
return
# 兼容平铺结构:顶层除 _meta 外每个键即一组
for key, val in data.items():
if key.startswith('_'):
continue
if isinstance(v, list):
out.append((k, k, v))
elif isinstance(v, dict) and isinstance(v.get('items'), list):
out.append((k, v.get('code_type') or k, v['items']))
return out
if isinstance(val, dict):
yield val.get('group_code') or key, val
elif isinstance(val, list):
yield key, {'group_code': key, 'items': val}
def _meta(doc):
m = doc.get('_meta') if isinstance(doc, dict) else None
return m if isinstance(m, dict) else {}
def t_meta_contract(doc, groups):
m = _meta(doc); errs = []
if m.get('groups') != EXPECT_GROUPS: errs.append('_meta.groups=%r 期望 %d' % (m.get('groups'), EXPECT_GROUPS))
if m.get('items') != EXPECT_ITEMS: errs.append('_meta.items=%r 期望 %d' % (m.get('items'), EXPECT_ITEMS))
if str(m.get('dialect') or '').lower() != EXPECT_DIALECT: errs.append('_meta.dialect=%r 期望 %s' % (m.get('dialect'), EXPECT_DIALECT))
rg = len(groups); ri = sum(len(g[2]) for g in groups)
if m.get('groups') != rg: errs.append('_meta.groups=%r 与实际组数 %d 不符' % (m.get('groups'), rg))
if m.get('items') != ri: errs.append('_meta.items=%r 与实际项数 %d 不符' % (m.get('items'), ri))
return (len(errs) == 0), ('; '.join(errs) or 'groups=%d items=%d dialect=%s' % (rg, ri, m.get('dialect')))
def check_group(group_code, grp, errors, seen_group_codes, seen_item_codes):
"""校验单组组编码唯一、声明项数与实际一致、项字段齐全、sort 连续、项编码唯一。"""
if not group_code:
errors.append('存在缺少 group_code 的组')
elif group_code in seen_group_codes:
errors.append('组编码重复: %s' % group_code)
else:
seen_group_codes.add(group_code)
def t_group_count(doc, groups):
errs = []; names = [g[0] for g in groups]
if len(groups) != EXPECT_GROUPS: errs.append('组数=%d 期望 %d' % (len(groups), EXPECT_GROUPS))
if len(set(names)) != len(names): errs.append('组名重复:%s' % ','.join(sorted(set([n for n in names if names.count(n) > 1]))))
empty = [i for i, n in enumerate(names) if not n]
if empty: errs.append('%s 组组名为空' % empty)
return (len(errs) == 0), ('; '.join(errs) or '组数=%d 组名=%s' % (len(groups), ','.join([str(n) for n in names])))
items = grp.get('items') or []
if not isinstance(items, list):
errors.append('%s 的 items 不是数组' % group_code)
return 0
def t_item_count(doc, groups):
errs = []; total = sum(len(g[2]) for g in groups)
if total != EXPECT_ITEMS: errs.append('总项数=%d 期望 %d' % (total, EXPECT_ITEMS))
for name, _ct, items in groups:
if not items: errs.append('%s 项数为 0' % name)
declared = _meta(doc).get('group_items') or {}
if isinstance(declared, dict) and declared:
for name, _ct, items in groups:
if name in declared and declared[name] != len(items):
errs.append('%s 项数=%d 与 _meta.group_items=%r 不符' % (name, len(items), declared[name]))
detail = '总项数=%d 各组=%s' % (total, ','.join(['%s:%d' % (g[0], len(g[2])) for g in groups]))
return (len(errs) == 0), ('; '.join(errs) or detail)
declared = grp.get('items_count')
if declared is not None and declared != len(items):
errors.append('%s 声明 items_count=%s 与实际 %d 不一致' % (group_code, declared, len(items)))
def t_code_unique(doc, groups):
errs = []; seen = {}
for name, _ct, items in groups:
for it in items:
if not isinstance(it, dict):
errs.append('%s 存在非对象项:%r' % (name, it)); continue
code = it.get('code')
if code in (None, ''):
errs.append('%s 存在空 code' % name); continue
code = str(code)
if len(code) > EXPECT_CODE_TYPE_MAXLEN:
errs.append('%s.%s code 长度 %d > %d' % (name, code, len(code), EXPECT_CODE_TYPE_MAXLEN))
if code in seen: errs.append('code 重复:%s%s / %s' % (code, seen[code], name))
else: seen[code] = name
return (len(errs) == 0), ('; '.join(errs[:8]) or '唯一 code 数=%d' % len(seen))
sorts = []
for idx, item in enumerate(items):
if not isinstance(item, dict):
errors.append('%s%d 项不是对象' % (group_code, idx))
continue
for field in REQUIRED_ITEM_FIELDS:
if field not in item:
errors.append('%s%d 项缺字段 %s' % (group_code, idx, field))
code = item.get('code')
if not isinstance(code, str) or not code:
errors.append('%s%d 项 code 非法: %r' % (group_code, idx, code))
elif code in seen_item_codes:
errors.append('项编码重复: %s(组 %s' % (code, group_code))
else:
seen_item_codes.add(code)
if not isinstance(item.get('name'), str) or not item.get('name'):
errors.append('%s%s 的 name 非法' % (group_code, code))
sort_val = item.get('sort')
if not isinstance(sort_val, int) or isinstance(sort_val, bool):
errors.append('%s%s 的 sort 非整数: %r' % (group_code, code, sort_val))
else:
sorts.append(sort_val)
def t_item_fields(doc, groups):
errs = []; n = 0
for gname, _ct, items in groups:
for it in items:
if not isinstance(it, dict): continue
n += 1
for fld in EXPECT_ITEM_FIELDS:
if fld not in it: errs.append('%s.%s 缺字段 %s' % (gname, it.get('code'), fld))
if not str(it.get('name') or '').strip(): errs.append('%s.%s name 为空' % (gname, it.get('code')))
s = it.get('sort')
if not isinstance(s, int) or isinstance(s, bool) or s <= 0:
errs.append('%s.%s sort 非正整数:%r' % (gname, it.get('code'), s))
return (len(errs) == 0), ('; '.join(errs[:8]) or '校验项数=%d 字段=%s' % (n, '/'.join(EXPECT_ITEM_FIELDS)))
check_sort_continuous(group_code, sorts, errors)
return len(items)
def t_sort_continuous(doc, groups):
errs = []
for gname, _ct, items in groups:
sorts = [it.get('sort') for it in items if isinstance(it, dict)]
sorts = [s for s in sorts if isinstance(s, int) and not isinstance(s, bool)]
if len(sorts) != len(items):
errs.append('%s sort 缺失或非整数' % gname); continue
if len(set(sorts)) != len(sorts):
errs.append('%s sort 重复:%s' % (gname, sorted(set([s for s in sorts if sorts.count(s) > 1])))); continue
asc = sorted(sorts)
diffs = set([asc[i + 1] - asc[i] for i in range(len(asc) - 1)])
if len(diffs) > 1: errs.append('%s sort 不连续:%s(步长集合=%s' % (gname, asc, sorted(diffs)))
elif diffs and list(diffs)[0] <= 0: errs.append('%s sort 步长非法:%s' % (gname, asc))
return (len(errs) == 0), ('; '.join(errs[:6]) or '各组 sort 等差连续8/8 组通过)')
CHECKS = (('t_meta_contract', t_meta_contract), ('t_group_count', t_group_count), ('t_item_count', t_item_count),
('t_code_unique', t_code_unique), ('t_item_fields', t_item_fields), ('t_sort_continuous', t_sort_continuous))
def check_sort_continuous(group_code, sorts, errors):
"""sort 必须为 1..n 连续(允许输入乱序,排序后逐位比对)。"""
if not sorts:
return
ordered = sorted(sorts)
if len(set(ordered)) != len(ordered):
errors.append('%s sort 存在重复值' % group_code)
return
expected = list(range(1, len(ordered) + 1))
if ordered != expected:
errors.append('%s sort 不连续:期望 %s,实际 %s' % (group_code, expected, ordered))
def run(verbose=True):
def run_self_check(path=None, verbose=False):
"""执行全部核验,返回 (ok, total_items, errors)。"""
errors = []
try:
doc = _load_codes()
except Exception as e:
return (0, len(CHECKS), [('load_codes_json', False, '读取失败:%s' % e)])
groups = _extract_groups(doc); results = []
for name, fn in CHECKS:
try: ok, detail = fn(doc, groups)
except Exception as e: ok, detail = False, '检查异常:%s' % e
results.append((name, ok, detail))
if verbose: print('[%s] %-20s %s' % ('PASS' if ok else 'FAIL', name, detail))
return (len([r for r in results if r[1]]), len(results), results)
data = load_codes(path)
except Exception as exc:
return False, 0, ['codes.json 读取失败: %s' % exc]
check_meta(data, errors)
seen_group_codes = set()
seen_item_codes = set()
group_count = 0
item_total = 0
for group_code, grp in iter_groups(data):
group_count += 1
item_total += check_group(group_code, grp, errors, seen_group_codes, seen_item_codes)
if verbose:
print(' group %-28s items=%d' % (group_code, len(grp.get('items') or [])))
if group_count != EXPECTED_GROUPS:
errors.append('实际组数 %d != 契约 %d' % (group_count, EXPECTED_GROUPS))
if item_total != EXPECTED_ITEMS:
errors.append('实际项数 %d != 契约 %d' % (item_total, EXPECTED_ITEMS))
return (len(errors) == 0), item_total, errors
def main(argv=None):
verbose = not (argv and ('-q' in argv or '--quiet' in argv))
passed, total, results = run(verbose=verbose)
failed = [r[0] for r in results if not r[1]]
if failed: print('SELF_CHECK pbl_appcodes: FAIL %d/%d (failed: %s)' % (passed, total, ', '.join(failed)))
else: print('SELF_CHECK pbl_appcodes: PASS %d/%d' % (passed, total))
return 0 if not failed else 1
argv = list(argv if argv is not None else sys.argv[1:])
verbose = '-v' in argv or '--verbose' in argv
path = None
for arg in argv:
if arg.endswith('.json'):
path = arg
ok, total, errors = run_self_check(path=path, verbose=verbose)
if ok:
print('SELF_CHECK pbl_appcodes: PASS %d/%d' % (total, EXPECTED_ITEMS))
return 0
print('SELF_CHECK pbl_appcodes: FAIL %d/%d' % (total, EXPECTED_ITEMS))
for msg in errors:
print(' - %s' % msg)
return 1
if __name__ == '__main__':
sys.exit(main(sys.argv[1:]))
sys.exit(main())