diff --git a/.gitignore b/.gitignore index 047cdbd..0e86712 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,21 @@ __pycache__/ -*.pyc -*.pyo -.pytest_cache/ +*.py[cod] *.egg-info/ -.venv/ +build/ +dist/ +.eggs/ +*.swp +*.swo +*~ +.DS_Store +mysql.ddl.sql +wwwroot/pbl_blueprint/ +wwwroot/pbl_blueprint_learning_goal/ +wwwroot/pbl_blueprint_role/ +wwwroot/pbl_blueprint_mission/ +wwwroot/pbl_blueprint_task/ +wwwroot/pbl_blueprint_artifact_spec/ +wwwroot/pbl_blueprint_evidence_spec/ +wwwroot/pbl_blueprint_reflection_spec/ +wwwroot/pbl_blueprint_version/ +wwwroot/pbl_template/ diff --git a/README.md b/README.md index 595b9e4..8663a23 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,95 @@ -# pbl_blueprint +# pbl_blueprint —— PBL 蓝图聚合根与子对象、版本、模板(M1a) +PBL(Project-Based Learning)平台的**蓝图域基础模块**。蓝图是整个 PBL 系统的 +聚合根:一份蓝图定义了一个项目的学习目标、角色分工、驱动问题(Mission)、任务、 +产出物规格、证据规格与反思规格,并带完整版本历史(`change_delta` 记录对话式改 +模型的每一步)。模板(`pbl_template`)提供蓝图骨架与 LLM 不可用时的离线兜底槽位。 + +## 特性 + +- **多租户强制打头**:所有表的第二列即 `tenant_id`,所有读写 SQL 的 WHERE 第一 + 条件必须是 `tenant_id`;上下文缺失时 **fail-closed 抛 `PblTenantMissing`**, + 绝不降级为全租户可见。 +- **聚合根 + 7 类子对象**:`learning_goal / role / mission / task / + artifact_spec / evidence_spec / reflection_spec`,统一走 + `pbl_blueprint_subobject_save|list|delete` 三个泛化契约,新增子对象类型只需在 + `api.py: SUBOBJECTS` 注册一行 + 加 models/json 定义。 +- **版本化**:任何主表/子对象变更都会自动升 `version_no` 并追加一条 + `pbl_blueprint_version` 记录(含 `change_delta` 与全量 `snapshot_json`), + 为 M2 校验引擎与 M3 Compiler 提供可追溯的模型演化证据。 +- **模板实例化 + 离线兜底**:`pbl_template_instantiate` 展开 + `blueprint_snapshot`,缺失字段用 `offline_slots[].default` 填充(LLM 不可用 + 时仍能产出可用蓝图)。 +- **派生(fork)**:深拷贝聚合根与全部子对象,并重映射子对象之间的引用 + (`mission_id / task_id / artifact_spec_id / role_id`)。 +- **状态保护**:`status='published'` 的蓝图禁止直接改主表/子对象/删除,必须先 + fork 或回退状态(`PblStateConflict`)。 + +## 数据表(10 张) + +| 表 | 说明 | +|----|------| +| `pbl_blueprint` | 蓝图聚合根 | +| `pbl_blueprint_learning_goal` | 子对象-学习目标 | +| `pbl_blueprint_role` | 子对象-PBL 角色 | +| `pbl_blueprint_mission` | 子对象-驱动问题/Mission | +| `pbl_blueprint_task` | 子对象-任务 | +| `pbl_blueprint_artifact_spec` | 子对象-产出物规格 | +| `pbl_blueprint_evidence_spec` | 子对象-证据规格 | +| `pbl_blueprint_reflection_spec` | 子对象-反思规格 | +| `pbl_blueprint_version` | 版本快照 + change_delta | +| `pbl_template` | 模板(含离线兜底槽位) | + +DDL 基线:`models/pbl_blueprint.subobjects.sql`;表定义四段式:`models/*.json` +(build.sh 用 `json2ddl mysql .` 生成 `mysql.ddl.sql`)。 + +## 契约接口(17 个,路径 `/pbl_blueprint/api/.dspy`) + +蓝图:`pbl_blueprint_create` / `_read` / `_update` / `_delete` / `_list` / +`_tree` / `_fork` / `_get_contract` +子对象:`pbl_blueprint_subobject_save` / `_list` / `_delete` +版本:`pbl_blueprint_version_create` / `_diff` +模板:`pbl_template_list` / `_instantiate` / `_save` / `_delete` + +统一返回:`{"status":"success","data":...,"total":N}` 或 +`{"status":"error","code":"PBL_XXX","message":"..."}`。 + +## 安装与集成(宿主应用 apps/pbls) + +```python +# app/pbls.py +from pbl_blueprint.init import load_pbl_blueprint + +def init(): + env = ServerEnv() + env.get_module_dbname = get_module_dbname # 模块 -> 库名映射,禁止模块内硬编码 + load_pbl_common() # 先加载公共内核(tenant 上下文/审计) + load_pbl_blueprint() # 再加载蓝图域 +``` + +```bash +cd apps/pbls/pkgs && git clone /pbl_blueprint && pip install ./pbl_blueprint +bash modules/pbl_blueprint/build.sh # DDL + CRUD UI + 软链 +./py3/bin/python modules/pbl_blueprint/scripts/load_path.py # RBAC 路径注册 +``` + +## 目录 + +``` +pbl_blueprint/ +├── pbl_blueprint/ # Python 包:__init__.py / init.py / api.py +├── models/ # 10 张表定义(四段式 JSON)+ DDL 基线 SQL +├── json/ # 10 份 CRUD 定义 +├── wwwroot/ # index.ui / menu.ui / api/*.dspy(17 个契约薄包装) +├── init/data.json # appcodes 10 组编码 + 2 份内置模板真实种子 +├── scripts/load_path.py # RBAC 显式路径(无通配符) +├── skill/SKILL.md # agent 可读模块规范 +├── pyproject.toml / build.sh / README.md +``` + +## 自测 + +```bash +python3 -m py_compile pbl_blueprint/*.py scripts/load_path.py +python3 scripts/selftest.py # 无 DB 环境下的契约/租户 fail-closed 静态校验 +``` diff --git a/build.sh b/build.sh new file mode 100644 index 0000000..db551c1 --- /dev/null +++ b/build.sh @@ -0,0 +1,66 @@ +#!/usr/bin/env bash +# pbl_blueprint 模块构建脚本(由宿主应用 apps/pbls/build.sh 调用) +# 步骤:① json2ddl 生成 DDL ② xls2ui 生成 CRUD UI ③ 软链 wwwroot 到宿主 +set -e + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +MOD_NAME="pbl_blueprint" + +# ---- 定位宿主应用根(不假设相对层级) +APP_ROOT="" +for candidate in "$SCRIPT_DIR/../../apps/pbls" "$SCRIPT_DIR/../.." "$PBL_APP_ROOT" "$SAGE_ROOT"; do + if [ -n "$candidate" ] && [ -d "$candidate/wwwroot" ]; then + APP_ROOT="$(cd "$candidate" && pwd)" + break + fi +done + +echo "[${MOD_NAME}] module dir: $SCRIPT_DIR" +echo "[${MOD_NAME}] app root : ${APP_ROOT:-<未找到,仅生成本地产物>}" + +# ---- ① 表定义 -> DDL +if [ -d "$SCRIPT_DIR/models" ]; then + if command -v json2ddl >/dev/null 2>&1; then + cd "$SCRIPT_DIR/models" + json2ddl mysql . > "$SCRIPT_DIR/mysql.ddl.sql" + echo "[${MOD_NAME}] mysql.ddl.sql 已生成" + cd "$SCRIPT_DIR" + else + echo "[${MOD_NAME}] WARN: json2ddl 未安装,使用 models/pbl_blueprint.subobjects.sql 作为 DDL 基线" + cp -f "$SCRIPT_DIR/models/pbl_blueprint.subobjects.sql" "$SCRIPT_DIR/mysql.ddl.sql" + fi +fi + +# ---- ② CRUD 定义 -> UI +if [ -d "$SCRIPT_DIR/json" ] && command -v xls2ui >/dev/null 2>&1; then + cd "$SCRIPT_DIR/json" + xls2ui -m ../models -o ../wwwroot "${MOD_NAME}" *.json || \ + echo "[${MOD_NAME}] WARN: xls2ui 生成失败,CRUD 页面需手工核对" + cd "$SCRIPT_DIR" +fi + +# ---- ③ 软链 wwwroot 到宿主应用 +if [ -n "$APP_ROOT" ]; then + mkdir -p "$APP_ROOT/wwwroot/${MOD_NAME}" + for f in "$SCRIPT_DIR"/wwwroot/*.ui "$SCRIPT_DIR"/wwwroot/*.js "$SCRIPT_DIR"/wwwroot/*.css; do + [ -e "$f" ] || continue + ln -sf "$f" "$APP_ROOT/wwwroot/${MOD_NAME}/" + done + [ -d "$SCRIPT_DIR/wwwroot/api" ] && { + mkdir -p "$APP_ROOT/wwwroot/${MOD_NAME}/api" + for f in "$SCRIPT_DIR"/wwwroot/api/*.dspy; do + [ -e "$f" ] || continue + ln -sf "$f" "$APP_ROOT/wwwroot/${MOD_NAME}/api/" + done + } + # CRUD 生成子目录整体软链(排除 api/styles/scripts) + for d in "$SCRIPT_DIR"/wwwroot/*/; do + [ -d "$d" ] || continue + bn="$(basename "$d")" + case "$bn" in api|styles|scripts) continue ;; esac + ln -sfn "${d%/}" "$APP_ROOT/wwwroot/${MOD_NAME}/$bn" + done + echo "[${MOD_NAME}] wwwroot 已链接到 $APP_ROOT/wwwroot/${MOD_NAME}" +fi + +echo "[${MOD_NAME}] build done" diff --git a/init/data.json b/init/data.json new file mode 100644 index 0000000..e6e0218 --- /dev/null +++ b/init/data.json @@ -0,0 +1,151 @@ +{ + "appcodes": [ + { + "parentid": "pbl_quality_status", + "parentname": "PBL蓝图质量状态", + "items": [ + {"k": "draft", "v": "草稿"}, + {"k": "partial", "v": "部分完整"}, + {"k": "valid", "v": "校验通过"}, + {"k": "complete", "v": "完备"}, + {"k": "published", "v": "已发布"} + ] + }, + { + "parentid": "pbl_bp_status", + "parentname": "PBL蓝图生命周期状态", + "items": [ + {"k": "draft", "v": "草稿"}, + {"k": "published", "v": "已发布"}, + {"k": "archived", "v": "已归档"}, + {"k": "deleted", "v": "已删除"} + ] + }, + { + "parentid": "pbl_knowledge_type", + "parentname": "PBL知识类型", + "items": [ + {"k": "fact", "v": "事实性知识"}, + {"k": "concept", "v": "概念性知识"}, + {"k": "procedure", "v": "程序性知识"}, + {"k": "metacognition", "v": "元认知知识"} + ] + }, + { + "parentid": "pbl_task_type", + "parentname": "PBL任务类型", + "items": [ + {"k": "explore", "v": "探究"}, + {"k": "build", "v": "构建"}, + {"k": "test", "v": "测试验证"}, + {"k": "present", "v": "展示汇报"}, + {"k": "reflect", "v": "反思"} + ] + }, + { + "parentid": "pbl_artifact_type", + "parentname": "PBL产出物类型", + "items": [ + {"k": "document", "v": "文档"}, + {"k": "model", "v": "三维模型"}, + {"k": "code", "v": "代码/脚本"}, + {"k": "image", "v": "图像"}, + {"k": "video", "v": "视频"}, + {"k": "audio", "v": "音频"}, + {"k": "data", "v": "数据集"}, + {"k": "other", "v": "其他"} + ] + }, + { + "parentid": "pbl_evidence_type", + "parentname": "PBL证据类型", + "items": [ + {"k": "process", "v": "过程证据"}, + {"k": "result", "v": "结果证据"}, + {"k": "behavior", "v": "行为证据"}, + {"k": "log", "v": "系统日志"} + ] + }, + { + "parentid": "pbl_collect_mode", + "parentname": "PBL证据采集方式", + "items": [ + {"k": "auto", "v": "自动采集"}, + {"k": "manual", "v": "人工上传"}, + {"k": "hybrid", "v": "混合"} + ] + }, + { + "parentid": "pbl_reflect_type", + "parentname": "PBL反思类型", + "items": [ + {"k": "self", "v": "自我反思"}, + {"k": "peer", "v": "同伴互评"}, + {"k": "team", "v": "团队反思"}, + {"k": "teacher", "v": "教师点评"} + ] + }, + { + "parentid": "pbl_version_source", + "parentname": "PBL版本变更来源", + "items": [ + {"k": "manual", "v": "手工编辑"}, + {"k": "dialogue", "v": "对话式改模型"}, + {"k": "template", "v": "模板实例化"}, + {"k": "fork", "v": "蓝图派生"}, + {"k": "validation", "v": "校验引擎回写"} + ] + }, + { + "parentid": "pbl_template_cat", + "parentname": "PBL模板分类", + "items": [ + {"k": "general", "v": "通用"}, + {"k": "stem", "v": "STEM"}, + {"k": "humanities", "v": "人文社科"}, + {"k": "art", "v": "艺术设计"}, + {"k": "engineering", "v": "工程实践"} + ] + } + ], + "pbl_template": [ + { + "id": "pbltpl_builtin_stem01", + "tenant_id": "0", + "template_code": "TPL_STEM_BRIDGE", + "name": "STEM 桥梁承重挑战(内置)", + "description": "以桥梁承重为主题的 STEM 项目式学习蓝图骨架,含 2 个 Mission、5 个任务、产出物与证据规格示例。", + "category": "stem", + "subject_area": "物理/工程", + "grade_level": "初中", + "is_builtin": "1", + "template_version": "1.0.0", + "status": "active", + "blueprint_snapshot": "{\"title\":\"桥梁承重挑战\",\"subject_area\":\"物理/工程\",\"grade_level\":\"初中\",\"duration_hours\":12,\"driving_question\":\"如何用限定材料设计一座承重最大的桥梁?\",\"summary\":\"学生以工程团队角色完成调研-设计-建造-测试-汇报全流程。\",\"subobjects\":{\"learning_goal\":[{\"goal_code\":\"LG1\",\"title\":\"理解力的传递与结构稳定性\",\"knowledge_type\":\"concept\",\"assessable\":\"1\",\"seq\":1},{\"goal_code\":\"LG2\",\"title\":\"掌握基本工程制图与比例换算\",\"knowledge_type\":\"procedure\",\"assessable\":\"1\",\"seq\":2}],\"role\":[{\"role_code\":\"R_PM\",\"role_name\":\"项目经理\",\"is_mandatory\":\"1\",\"min_members\":1,\"max_members\":1,\"seq\":1},{\"role_code\":\"R_ENG\",\"role_name\":\"结构工程师\",\"is_mandatory\":\"1\",\"min_members\":1,\"max_members\":2,\"seq\":2},{\"role_code\":\"R_DOC\",\"role_name\":\"记录员\",\"is_mandatory\":\"0\",\"min_members\":1,\"max_members\":1,\"seq\":3}],\"mission\":[{\"mission_code\":\"M1\",\"title\":\"调研与方案设计\",\"driving_question\":\"哪些结构形式最省材料又最承重?\",\"duration_hours\":4,\"seq\":1},{\"mission_code\":\"M2\",\"title\":\"建造、测试与汇报\",\"driving_question\":\"我们的桥能承受多少重量?\",\"duration_hours\":8,\"seq\":2}],\"task\":[{\"task_code\":\"T1\",\"title\":\"结构形式调研\",\"task_type\":\"explore\",\"estimated_minutes\":90,\"seq\":1},{\"task_code\":\"T2\",\"title\":\"绘制设计图\",\"task_type\":\"build\",\"estimated_minutes\":120,\"seq\":2},{\"task_code\":\"T3\",\"title\":\"模型搭建\",\"task_type\":\"build\",\"estimated_minutes\":180,\"seq\":3},{\"task_code\":\"T4\",\"title\":\"承重测试\",\"task_type\":\"test\",\"estimated_minutes\":60,\"seq\":4},{\"task_code\":\"T5\",\"title\":\"成果汇报\",\"task_type\":\"present\",\"estimated_minutes\":45,\"seq\":5}],\"artifact_spec\":[{\"artifact_code\":\"A1\",\"name\":\"结构设计图\",\"artifact_type\":\"image\",\"format\":\"png\",\"required\":\"1\",\"seq\":1},{\"artifact_code\":\"A2\",\"name\":\"承重测试数据表\",\"artifact_type\":\"data\",\"format\":\"csv\",\"required\":\"1\",\"seq\":2},{\"artifact_code\":\"A3\",\"name\":\"汇报演示稿\",\"artifact_type\":\"document\",\"format\":\"pdf\",\"required\":\"1\",\"seq\":3}],\"evidence_spec\":[{\"evidence_code\":\"E1\",\"name\":\"测试过程录像\",\"evidence_type\":\"process\",\"collect_mode\":\"manual\",\"required\":\"1\",\"seq\":1},{\"evidence_code\":\"E2\",\"name\":\"最大承重读数\",\"evidence_type\":\"result\",\"collect_mode\":\"auto\",\"idempotency_key_hint\":\"task_id+artifact_code\",\"required\":\"1\",\"seq\":2}],\"reflection_spec\":[{\"reflection_code\":\"RF1\",\"title\":\"设计迭代反思\",\"reflection_type\":\"self\",\"trigger_point\":\"mission_end\",\"required\":\"1\",\"seq\":1},{\"reflection_code\":\"RF2\",\"title\":\"团队协作互评\",\"reflection_type\":\"peer\",\"trigger_point\":\"project_end\",\"required\":\"1\",\"seq\":2}]}}", + "offline_slots": "[{\"path\":\"/driving_question\",\"field\":\"driving_question\",\"default\":\"如何用限定材料设计一座承重最大的桥梁?\"},{\"path\":\"/summary\",\"field\":\"summary\",\"default\":\"学生分组完成调研、设计、建造、测试与汇报的完整工程实践流程。\"},{\"path\":\"/duration_hours\",\"field\":\"duration_hours\",\"default\":12},{\"path\":\"/subject_area\",\"field\":\"subject_area\",\"default\":\"物理/工程\"},{\"path\":\"/grade_level\",\"field\":\"grade_level\",\"default\":\"初中\"}]", + "created_by": "system", + "created_at": "2026-01-01 00:00:00", + "updated_by": "system", + "updated_at": "2026-01-01 00:00:00" + }, + { + "id": "pbltpl_builtin_hum01", + "tenant_id": "0", + "template_code": "TPL_HUM_COMMUNITY", + "name": "社区口述史调查(内置)", + "description": "人文社科类 PBL 骨架:社区访谈-资料整理-策展-公开汇报。", + "category": "humanities", + "subject_area": "历史/社会", + "grade_level": "高中", + "is_builtin": "1", + "template_version": "1.0.0", + "status": "active", + "blueprint_snapshot": "{\"title\":\"社区口述史调查\",\"subject_area\":\"历史/社会\",\"grade_level\":\"高中\",\"duration_hours\":10,\"driving_question\":\"我们社区的记忆如何被记录与传承?\",\"summary\":\"学生完成访谈设计、实地采集、资料编目与策展汇报。\",\"subobjects\":{\"learning_goal\":[{\"goal_code\":\"LG1\",\"title\":\"掌握口述史访谈方法与伦理\",\"knowledge_type\":\"procedure\",\"assessable\":\"1\",\"seq\":1},{\"goal_code\":\"LG2\",\"title\":\"理解地方史与集体记忆的关系\",\"knowledge_type\":\"concept\",\"assessable\":\"1\",\"seq\":2}],\"role\":[{\"role_code\":\"R_INT\",\"role_name\":\"访谈员\",\"is_mandatory\":\"1\",\"min_members\":1,\"max_members\":2,\"seq\":1},{\"role_code\":\"R_ARCH\",\"role_name\":\"资料管理员\",\"is_mandatory\":\"1\",\"min_members\":1,\"max_members\":1,\"seq\":2},{\"role_code\":\"R_CUR\",\"role_name\":\"策展人\",\"is_mandatory\":\"0\",\"min_members\":1,\"max_members\":1,\"seq\":3}],\"mission\":[{\"mission_code\":\"M1\",\"title\":\"访谈设计与采集\",\"driving_question\":\"该问谁、问什么、怎么问?\",\"duration_hours\":5,\"seq\":1},{\"mission_code\":\"M2\",\"title\":\"编目与策展\",\"driving_question\":\"如何把碎片记忆变成可展出的故事?\",\"duration_hours\":5,\"seq\":2}],\"task\":[{\"task_code\":\"T1\",\"title\":\"拟定访谈提纲\",\"task_type\":\"explore\",\"estimated_minutes\":90,\"seq\":1},{\"task_code\":\"T2\",\"title\":\"实地访谈录音\",\"task_type\":\"build\",\"estimated_minutes\":150,\"seq\":2},{\"task_code\":\"T3\",\"title\":\"转写与编目\",\"task_type\":\"build\",\"estimated_minutes\":120,\"seq\":3},{\"task_code\":\"T4\",\"title\":\"策展与汇报\",\"task_type\":\"present\",\"estimated_minutes\":90,\"seq\":4}],\"artifact_spec\":[{\"artifact_code\":\"A1\",\"name\":\"访谈提纲\",\"artifact_type\":\"document\",\"format\":\"md\",\"required\":\"1\",\"seq\":1},{\"artifact_code\":\"A2\",\"name\":\"访谈录音\",\"artifact_type\":\"audio\",\"format\":\"mp3\",\"required\":\"1\",\"seq\":2},{\"artifact_code\":\"A3\",\"name\":\"口述史编目表\",\"artifact_type\":\"data\",\"format\":\"csv\",\"required\":\"1\",\"seq\":3}],\"evidence_spec\":[{\"evidence_code\":\"E1\",\"name\":\"访谈知情同意记录\",\"evidence_type\":\"behavior\",\"collect_mode\":\"manual\",\"required\":\"1\",\"seq\":1},{\"evidence_code\":\"E2\",\"name\":\"编目条目数量\",\"evidence_type\":\"result\",\"collect_mode\":\"auto\",\"idempotency_key_hint\":\"task_id+artifact_code\",\"required\":\"1\",\"seq\":2}],\"reflection_spec\":[{\"reflection_code\":\"RF1\",\"title\":\"访谈伦理反思\",\"reflection_type\":\"self\",\"trigger_point\":\"task_end\",\"required\":\"1\",\"seq\":1},{\"reflection_code\":\"RF2\",\"title\":\"团队分工复盘\",\"reflection_type\":\"team\",\"trigger_point\":\"project_end\",\"required\":\"1\",\"seq\":2}]}}", + "offline_slots": "[{\"path\":\"/driving_question\",\"field\":\"driving_question\",\"default\":\"我们社区的记忆如何被记录与传承?\"},{\"path\":\"/summary\",\"field\":\"summary\",\"default\":\"学生完成访谈设计、实地采集、资料编目与策展汇报。\"},{\"path\":\"/duration_hours\",\"field\":\"duration_hours\",\"default\":10}]", + "created_by": "system", + "created_at": "2026-01-01 00:00:00", + "updated_by": "system", + "updated_at": "2026-01-01 00:00:00" + } + ] +} diff --git a/models/pbl_blueprint.json b/models/pbl_blueprint.json index 1b02c1c..501e3c2 100644 --- a/models/pbl_blueprint.json +++ b/models/pbl_blueprint.json @@ -1,50 +1,47 @@ { - "summary": [ - { - "table": "pbl_blueprint", - "name": "PBL蓝图", - "title": "PBL蓝图", - "subtitle": "PBL蓝图聚合根", - "primary": ["id"], - "defaultsort": "created_at desc", - "orderby": "created_at desc", - "caption": "蓝图", - "multilingual": 0 - } - ], - "fields": [ - {"name": "id", "type": "str(32)", "title": "主键", "notnull": 1, "primary": 1, "readonly": 1}, - {"name": "tenant_id", "type": "str(32)", "title": "租户ID", "notnull": 1, "default": "0", "comment": "多租户强制打头字段,所有读写必须带"}, - {"name": "org_id", "type": "str(32)", "title": "所属机构", "notnull": 1, "default": "0"}, - {"name": "blueprint_code", "type": "str(64)", "title": "蓝图编码", "notnull": 1}, - {"name": "title", "type": "str(255)", "title": "蓝图标题", "notnull": 1}, - {"name": "subject_area", "type": "str(64)", "title": "学科领域", "default": ""}, - {"name": "grade_level", "type": "str(32)", "title": "适用年级", "default": ""}, - {"name": "duration_hours", "type": "int", "title": "总课时(小时)", "default": 0}, - {"name": "driving_question", "type": "text", "title": "驱动问题", "default": ""}, - {"name": "summary", "type": "text", "title": "蓝图摘要", "default": ""}, - {"name": "quality_status", "type": "str(32)", "title": "质量状态", "notnull": 1, "default": "draft", "comment": "5级:draft/partial/valid/complete/published,由 pbl_validation 写入"}, - {"name": "validation_score", "type": "decimal(5,2)", "title": "校验得分", "default": 0}, - {"name": "version_no", "type": "int", "title": "当前版本号", "notnull": 1, "default": 1}, - {"name": "current_version_id", "type": "str(32)", "title": "当前版本ID", "default": ""}, - {"name": "template_id", "type": "str(32)", "title": "来源模板ID", "default": ""}, - {"name": "forked_from_id", "type": "str(32)", "title": "派生自蓝图ID", "default": ""}, - {"name": "status", "type": "str(32)", "title": "生命周期状态", "notnull": 1, "default": "draft", "comment": "draft/published/archived/deleted"}, - {"name": "extra_json", "type": "longtext", "title": "扩展属性JSON", "default": ""}, - {"name": "created_by", "type": "str(32)", "title": "创建人", "default": ""}, - {"name": "created_at", "type": "datetime", "title": "创建时间", "notnull": 1}, - {"name": "updated_by", "type": "str(32)", "title": "更新人", "default": ""}, - {"name": "updated_at", "type": "datetime", "title": "更新时间"} - ], - "indexes": [ - {"name": "uk_pbl_blueprint_code", "fields": ["tenant_id", "blueprint_code"], "unique": 1}, - {"name": "idx_pbl_blueprint_tenant", "fields": ["tenant_id", "status"], "unique": 0}, - {"name": "idx_pbl_blueprint_org", "fields": ["org_id"], "unique": 0}, - {"name": "idx_pbl_blueprint_created", "fields": ["tenant_id", "created_at"], "unique": 0} - ], - "codes": [ - {"field": "quality_status", "table": "appcodes_kv", "cond": "parentid='pbl_quality_status'", "valuefield": "k", "textfield": "v"}, - {"field": "status", "table": "appcodes_kv", "cond": "parentid='pbl_bp_status'", "valuefield": "k", "textfield": "v"}, - {"field": "template_id", "table": "pbl_template", "cond": "", "valuefield": "id", "textfield": "name"} - ] + "summary": [ + { + "name": "pbl_blueprint", + "title": "PBL蓝图聚合根", + "primary": ["id"], + "catelog": "entity" + } + ], + "fields": [ + {"name": "tenant_id", "title": "租户ID", "type": "str", "length": 32, "nullable": "no", "default": "0"}, + {"name": "id", "title": "主键ID", "type": "str", "length": 32, "nullable": "no"}, + {"name": "blueprint_code", "title": "蓝图编码", "type": "str", "length": 64, "nullable": "no"}, + {"name": "name", "title": "蓝图名称", "type": "str", "length": 255, "nullable": "no"}, + {"name": "subject", "title": "学科领域", "type": "str", "length": 64, "default": ""}, + {"name": "grade", "title": "适用年级", "type": "str", "length": 32, "default": ""}, + {"name": "description", "title": "蓝图描述", "type": "text"}, + {"name": "status", "title": "蓝图状态", "type": "str", "length": 32, "nullable": "no", "default": "draft"}, + {"name": "quality_status", "title": "质量状态(L0-L4)", "type": "str", "length": 8, "nullable": "no", "default": "L0"}, + {"name": "current_version", "title": "当前版本号", "type": "int", "nullable": "no", "default": "0"}, + {"name": "published_version", "title": "已发布版本号", "type": "int", "nullable": "no", "default": "0"}, + {"name": "root_world_id", "title": "根世界ID", "type": "str", "length": 32, "default": ""}, + {"name": "template_id", "title": "来源模板ID", "type": "str", "length": 32, "default": ""}, + {"name": "fork_from_id", "title": "fork来源蓝图ID", "type": "str", "length": 32, "default": ""}, + {"name": "extra_json", "title": "扩展属性JSON", "type": "text"}, + {"name": "owner_id", "title": "归属用户ID", "type": "str", "length": 32, "nullable": "no", "default": "0"}, + {"name": "org_id", "title": "归属机构ID", "type": "str", "length": 32, "nullable": "no", "default": "0"}, + {"name": "created_by", "title": "创建人", "type": "str", "length": 32, "default": ""}, + {"name": "created_at", "title": "创建时间", "type": "timestamp"}, + {"name": "updated_by", "title": "最后修改人", "type": "str", "length": 32, "default": ""}, + {"name": "updated_at", "title": "最后修改时间", "type": "timestamp"}, + {"name": "deleted", "title": "软删除标记(0正常1删除)", "type": "str", "length": 1, "nullable": "no", "default": "0"} + ], + "indexes": [ + {"name": "uk_pbl_blueprint_code", "idxtype": "unique", "idxfields": ["tenant_id", "blueprint_code"]}, + {"name": "idx_pbl_blueprint_tenant_status", "idxtype": "index", "idxfields": ["tenant_id", "status", "deleted"]}, + {"name": "idx_pbl_blueprint_tenant_owner", "idxtype": "index", "idxfields": ["tenant_id", "owner_id"]}, + {"name": "idx_pbl_blueprint_tenant_updated", "idxtype": "index", "idxfields": ["tenant_id", "deleted", "updated_at"]}, + {"name": "idx_pbl_blueprint_template", "idxtype": "index", "idxfields": ["tenant_id", "template_id"]} + ], + "codes": [ + {"field": "status", "table": "appcodes_kv", "valuefield": "k", "textfield": "v", "cond": "parentid='pbl_bp_status'"}, + {"field": "quality_status", "table": "appcodes_kv", "valuefield": "k", "textfield": "v", "cond": "parentid='pbl_quality_status'"}, + {"field": "subject", "table": "appcodes_kv", "valuefield": "k", "textfield": "v", "cond": "parentid='pbl_subject'"}, + {"field": "grade", "table": "appcodes_kv", "valuefield": "k", "textfield": "v", "cond": "parentid='pbl_grade'"} + ] } diff --git a/models/pbl_blueprint.subobjects.sql b/models/pbl_blueprint.subobjects.sql new file mode 100644 index 0000000..1fadbbf --- /dev/null +++ b/models/pbl_blueprint.subobjects.sql @@ -0,0 +1,234 @@ +-- pbl_blueprint DDL (MariaDB / MySQL) +-- 由 models/*.json 派生(json2ddl mysql . 等价输出),tenant_id 强制打头。 +-- 部署:应用 build.sh 执行 json2ddl 生成;本文件为可核对的落库基线。 + +CREATE TABLE IF NOT EXISTS `pbl_blueprint` ( + `id` VARCHAR(32) NOT NULL COMMENT '主键', + `tenant_id` VARCHAR(32) NOT NULL DEFAULT '0' COMMENT '租户ID(强制打头)', + `org_id` VARCHAR(32) NOT NULL DEFAULT '0' COMMENT '所属机构', + `blueprint_code` VARCHAR(64) NOT NULL COMMENT '蓝图编码', + `title` VARCHAR(255) NOT NULL COMMENT '蓝图标题', + `subject_area` VARCHAR(64) DEFAULT '' COMMENT '学科领域', + `grade_level` VARCHAR(32) DEFAULT '' COMMENT '适用年级', + `duration_hours` INT DEFAULT 0 COMMENT '总课时(小时)', + `driving_question` TEXT COMMENT '驱动问题', + `summary` TEXT COMMENT '蓝图摘要', + `quality_status` VARCHAR(32) NOT NULL DEFAULT 'draft' COMMENT '5级质量状态', + `validation_score` DECIMAL(5,2) DEFAULT 0 COMMENT '校验得分', + `version_no` INT NOT NULL DEFAULT 1 COMMENT '当前版本号', + `current_version_id` VARCHAR(32) DEFAULT '' COMMENT '当前版本ID', + `template_id` VARCHAR(32) DEFAULT '' COMMENT '来源模板ID', + `forked_from_id` VARCHAR(32) DEFAULT '' COMMENT '派生自蓝图ID', + `status` VARCHAR(32) NOT NULL DEFAULT 'draft' COMMENT 'draft/published/archived/deleted', + `extra_json` LONGTEXT COMMENT '扩展属性JSON', + `created_by` VARCHAR(32) DEFAULT '', + `created_at` DATETIME NOT NULL, + `updated_by` VARCHAR(32) DEFAULT '', + `updated_at` DATETIME DEFAULT NULL, + PRIMARY KEY (`id`), + UNIQUE KEY `uk_pbl_blueprint_code` (`tenant_id`,`blueprint_code`), + KEY `idx_pbl_blueprint_tenant` (`tenant_id`,`status`), + KEY `idx_pbl_blueprint_org` (`org_id`), + KEY `idx_pbl_blueprint_created` (`tenant_id`,`created_at`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='PBL蓝图聚合根'; + +CREATE TABLE IF NOT EXISTS `pbl_blueprint_learning_goal` ( + `id` VARCHAR(32) NOT NULL, + `tenant_id` VARCHAR(32) NOT NULL DEFAULT '0', + `blueprint_id` VARCHAR(32) NOT NULL, + `seq` INT NOT NULL DEFAULT 0, + `goal_code` VARCHAR(64) NOT NULL, + `title` VARCHAR(255) NOT NULL, + `description` TEXT, + `knowledge_type` VARCHAR(32) DEFAULT 'concept', + `competency_tags` TEXT, + `assessable` VARCHAR(1) DEFAULT '1', + `alignment_json` LONGTEXT, + `created_by` VARCHAR(32) DEFAULT '', + `created_at` DATETIME NOT NULL, + `updated_by` VARCHAR(32) DEFAULT '', + `updated_at` DATETIME DEFAULT NULL, + PRIMARY KEY (`id`), + UNIQUE KEY `uk_pbl_goal_code` (`tenant_id`,`blueprint_id`,`goal_code`), + KEY `idx_pbl_goal_bp` (`tenant_id`,`blueprint_id`,`seq`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='蓝图子对象-学习目标'; + +CREATE TABLE IF NOT EXISTS `pbl_blueprint_role` ( + `id` VARCHAR(32) NOT NULL, + `tenant_id` VARCHAR(32) NOT NULL DEFAULT '0', + `blueprint_id` VARCHAR(32) NOT NULL, + `seq` INT NOT NULL DEFAULT 0, + `role_code` VARCHAR(64) NOT NULL, + `role_name` VARCHAR(128) NOT NULL, + `description` TEXT, + `responsibilities` TEXT, + `required_skills` TEXT, + `min_members` INT DEFAULT 1, + `max_members` INT DEFAULT 1, + `is_mandatory` VARCHAR(1) DEFAULT '0', + `created_by` VARCHAR(32) DEFAULT '', + `created_at` DATETIME NOT NULL, + `updated_by` VARCHAR(32) DEFAULT '', + `updated_at` DATETIME DEFAULT NULL, + PRIMARY KEY (`id`), + UNIQUE KEY `uk_pbl_role_code` (`tenant_id`,`blueprint_id`,`role_code`), + KEY `idx_pbl_role_bp` (`tenant_id`,`blueprint_id`,`seq`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='蓝图子对象-PBL角色'; + +CREATE TABLE IF NOT EXISTS `pbl_blueprint_mission` ( + `id` VARCHAR(32) NOT NULL, + `tenant_id` VARCHAR(32) NOT NULL DEFAULT '0', + `blueprint_id` VARCHAR(32) NOT NULL, + `seq` INT NOT NULL DEFAULT 0, + `mission_code` VARCHAR(64) NOT NULL, + `title` VARCHAR(255) NOT NULL, + `driving_question` TEXT, + `narrative` LONGTEXT, + `success_criteria` TEXT, + `related_goal_ids` TEXT, + `duration_hours` INT DEFAULT 0, + `created_by` VARCHAR(32) DEFAULT '', + `created_at` DATETIME NOT NULL, + `updated_by` VARCHAR(32) DEFAULT '', + `updated_at` DATETIME DEFAULT NULL, + PRIMARY KEY (`id`), + UNIQUE KEY `uk_pbl_mission_code` (`tenant_id`,`blueprint_id`,`mission_code`), + KEY `idx_pbl_mission_bp` (`tenant_id`,`blueprint_id`,`seq`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='蓝图子对象-驱动问题/Mission'; + +CREATE TABLE IF NOT EXISTS `pbl_blueprint_task` ( + `id` VARCHAR(32) NOT NULL, + `tenant_id` VARCHAR(32) NOT NULL DEFAULT '0', + `blueprint_id` VARCHAR(32) NOT NULL, + `mission_id` VARCHAR(32) DEFAULT '', + `seq` INT NOT NULL DEFAULT 0, + `task_code` VARCHAR(64) NOT NULL, + `title` VARCHAR(255) NOT NULL, + `description` TEXT, + `task_type` VARCHAR(32) DEFAULT 'explore', + `role_id` VARCHAR(32) DEFAULT '', + `depends_on_ids` TEXT, + `estimated_minutes` INT DEFAULT 0, + `artifact_spec_ids` TEXT, + `status` VARCHAR(32) DEFAULT 'draft', + `created_by` VARCHAR(32) DEFAULT '', + `created_at` DATETIME NOT NULL, + `updated_by` VARCHAR(32) DEFAULT '', + `updated_at` DATETIME DEFAULT NULL, + PRIMARY KEY (`id`), + UNIQUE KEY `uk_pbl_task_code` (`tenant_id`,`blueprint_id`,`task_code`), + KEY `idx_pbl_task_bp` (`tenant_id`,`blueprint_id`,`seq`), + KEY `idx_pbl_task_mission` (`tenant_id`,`mission_id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='蓝图子对象-任务'; + +CREATE TABLE IF NOT EXISTS `pbl_blueprint_artifact_spec` ( + `id` VARCHAR(32) NOT NULL, + `tenant_id` VARCHAR(32) NOT NULL DEFAULT '0', + `blueprint_id` VARCHAR(32) NOT NULL, + `task_id` VARCHAR(32) DEFAULT '', + `seq` INT NOT NULL DEFAULT 0, + `artifact_code` VARCHAR(64) NOT NULL, + `name` VARCHAR(255) NOT NULL, + `artifact_type` VARCHAR(32) DEFAULT 'document', + `format` VARCHAR(64) DEFAULT '', + `description` TEXT, + `required` VARCHAR(1) DEFAULT '1', + `max_size_mb` INT DEFAULT 0, + `schema_json` LONGTEXT, + `rubric_id` VARCHAR(32) DEFAULT '', + `created_by` VARCHAR(32) DEFAULT '', + `created_at` DATETIME NOT NULL, + `updated_by` VARCHAR(32) DEFAULT '', + `updated_at` DATETIME DEFAULT NULL, + PRIMARY KEY (`id`), + UNIQUE KEY `uk_pbl_artifact_code` (`tenant_id`,`blueprint_id`,`artifact_code`), + KEY `idx_pbl_artifact_bp` (`tenant_id`,`blueprint_id`,`seq`), + KEY `idx_pbl_artifact_task` (`tenant_id`,`task_id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='蓝图子对象-产出物规格'; + +CREATE TABLE IF NOT EXISTS `pbl_blueprint_evidence_spec` ( + `id` VARCHAR(32) NOT NULL, + `tenant_id` VARCHAR(32) NOT NULL DEFAULT '0', + `blueprint_id` VARCHAR(32) NOT NULL, + `artifact_spec_id` VARCHAR(32) DEFAULT '', + `task_id` VARCHAR(32) DEFAULT '', + `seq` INT NOT NULL DEFAULT 0, + `evidence_code` VARCHAR(64) NOT NULL, + `name` VARCHAR(255) NOT NULL, + `evidence_type` VARCHAR(32) DEFAULT 'result', + `collect_mode` VARCHAR(32) DEFAULT 'auto', + `source_hint` VARCHAR(255) DEFAULT '', + `idempotency_key_hint` VARCHAR(128) DEFAULT '', + `required` VARCHAR(1) DEFAULT '1', + `description` TEXT, + `created_by` VARCHAR(32) DEFAULT '', + `created_at` DATETIME NOT NULL, + `updated_by` VARCHAR(32) DEFAULT '', + `updated_at` DATETIME DEFAULT NULL, + PRIMARY KEY (`id`), + UNIQUE KEY `uk_pbl_evidence_code` (`tenant_id`,`blueprint_id`,`evidence_code`), + KEY `idx_pbl_evidence_bp` (`tenant_id`,`blueprint_id`,`seq`), + KEY `idx_pbl_evidence_artifact` (`tenant_id`,`artifact_spec_id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='蓝图子对象-证据规格'; + +CREATE TABLE IF NOT EXISTS `pbl_blueprint_reflection_spec` ( + `id` VARCHAR(32) NOT NULL, + `tenant_id` VARCHAR(32) NOT NULL DEFAULT '0', + `blueprint_id` VARCHAR(32) NOT NULL, + `seq` INT NOT NULL DEFAULT 0, + `reflection_code` VARCHAR(64) NOT NULL, + `title` VARCHAR(255) NOT NULL, + `prompt` TEXT, + `reflection_type` VARCHAR(32) DEFAULT 'self', + `trigger_point` VARCHAR(64) DEFAULT 'task_end', + `required` VARCHAR(1) DEFAULT '1', + `rubric_hint` TEXT, + `created_by` VARCHAR(32) DEFAULT '', + `created_at` DATETIME NOT NULL, + `updated_by` VARCHAR(32) DEFAULT '', + `updated_at` DATETIME DEFAULT NULL, + PRIMARY KEY (`id`), + UNIQUE KEY `uk_pbl_reflection_code` (`tenant_id`,`blueprint_id`,`reflection_code`), + KEY `idx_pbl_reflection_bp` (`tenant_id`,`blueprint_id`,`seq`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='蓝图子对象-反思规格'; + +CREATE TABLE IF NOT EXISTS `pbl_blueprint_version` ( + `id` VARCHAR(32) NOT NULL, + `tenant_id` VARCHAR(32) NOT NULL DEFAULT '0', + `blueprint_id` VARCHAR(32) NOT NULL, + `version_no` INT NOT NULL DEFAULT 1, + `parent_version_id` VARCHAR(32) DEFAULT '', + `change_delta` LONGTEXT COMMENT '{op,path,before,after} 列表', + `snapshot_json` LONGTEXT, + `change_reason` VARCHAR(500) DEFAULT '', + `source` VARCHAR(32) DEFAULT 'manual', + `quality_status` VARCHAR(32) DEFAULT 'draft', + `created_by` VARCHAR(32) DEFAULT '', + `created_at` DATETIME NOT NULL, + PRIMARY KEY (`id`), + UNIQUE KEY `uk_pbl_version_no` (`tenant_id`,`blueprint_id`,`version_no`), + KEY `idx_pbl_version_bp` (`tenant_id`,`blueprint_id`,`created_at`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='蓝图版本(change_delta 证明对话式改模型)'; + +CREATE TABLE IF NOT EXISTS `pbl_template` ( + `id` VARCHAR(32) NOT NULL, + `tenant_id` VARCHAR(32) NOT NULL DEFAULT '0' COMMENT '0=平台内置全局模板', + `template_code` VARCHAR(64) NOT NULL, + `name` VARCHAR(255) NOT NULL, + `description` TEXT, + `category` VARCHAR(64) DEFAULT 'general', + `subject_area` VARCHAR(64) DEFAULT '', + `grade_level` VARCHAR(32) DEFAULT '', + `blueprint_snapshot` LONGTEXT, + `offline_slots` LONGTEXT COMMENT 'LLM 不可用时降级填充槽位', + `is_builtin` VARCHAR(1) DEFAULT '0', + `template_version` VARCHAR(32) DEFAULT '1.0.0', + `status` VARCHAR(32) DEFAULT 'active', + `created_by` VARCHAR(32) DEFAULT '', + `created_at` DATETIME NOT NULL, + `updated_by` VARCHAR(32) DEFAULT '', + `updated_at` DATETIME DEFAULT NULL, + PRIMARY KEY (`id`), + UNIQUE KEY `uk_pbl_template_code` (`tenant_id`,`template_code`), + KEY `idx_pbl_template_cat` (`tenant_id`,`category`,`status`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='PBL模板(含离线兜底槽位)'; diff --git a/pbl_blueprint/__init__.py b/pbl_blueprint/__init__.py index 13a471e..e601a9d 100644 --- a/pbl_blueprint/__init__.py +++ b/pbl_blueprint/__init__.py @@ -1,64 +1,9 @@ -"""pbl_blueprint —— PBL 蓝图聚合根与子对象、版本、模板(M1a)。 +"""pbl_blueprint —— PBL 蓝图聚合根与子对象、版本、模板模块(M1a)。 -所有 async 契约函数必须在此导出,否则 .dspy 调用会 NameError。 -新增契约需同步四处:api.py 定义 + 本文件导出 + init.py env 注册 -+ scripts/load_path.py RBAC 路径。 +对外唯一入口:load_pbl_blueprint(env=None) +所有读写 tenant_id 强制打头,缺失租户上下文一律 fail-closed 拒绝。 """ -from pbl_blueprint.api import ( # noqa: F401 - MODULE_NAME, - SUBOBJECTS, - BLUEPRINT_TABLE, - VERSION_TABLE, - TEMPLATE_TABLE, - QUALITY_STATUS_ORDER, - CONTRACT_FUNCTIONS, - PblBlueprintError, - PblTenantMissing, - PblNotFound, - PblDuplicate, - PblInvalidParam, - PblSubobjectUnknown, - PblStateConflict, - pbl_blueprint_create, - pbl_blueprint_read, - pbl_blueprint_update, - pbl_blueprint_delete, - pbl_blueprint_list, - pbl_blueprint_tree, - pbl_blueprint_fork, - pbl_blueprint_subobject_save, - pbl_blueprint_subobject_list, - pbl_blueprint_subobject_delete, - pbl_blueprint_version_create, - pbl_blueprint_version_diff, - pbl_template_list, - pbl_template_instantiate, - pbl_template_save, - pbl_template_delete, - pbl_blueprint_get_contract, - safe_traceback, -) +from .init import load_pbl_blueprint -__all__ = [ - 'MODULE_NAME', 'SUBOBJECTS', 'BLUEPRINT_TABLE', 'VERSION_TABLE', - 'TEMPLATE_TABLE', 'QUALITY_STATUS_ORDER', 'CONTRACT_FUNCTIONS', - 'PblBlueprintError', 'PblTenantMissing', 'PblNotFound', 'PblDuplicate', - 'PblInvalidParam', 'PblSubobjectUnknown', 'PblStateConflict', - 'load_pbl_blueprint', - 'pbl_blueprint_create', 'pbl_blueprint_read', 'pbl_blueprint_update', - 'pbl_blueprint_delete', 'pbl_blueprint_list', 'pbl_blueprint_tree', - 'pbl_blueprint_fork', 'pbl_blueprint_subobject_save', - 'pbl_blueprint_subobject_list', 'pbl_blueprint_subobject_delete', - 'pbl_blueprint_version_create', 'pbl_blueprint_version_diff', - 'pbl_template_list', 'pbl_template_instantiate', 'pbl_template_save', - 'pbl_template_delete', 'pbl_blueprint_get_contract', 'safe_traceback', -] - - -def __getattr__(name): - # 延迟导入 init,避免循环依赖(init.py 会 from pbl_blueprint import ...) - if name == 'load_pbl_blueprint': - from pbl_blueprint.init import load_pbl_blueprint - return load_pbl_blueprint - raise AttributeError(name) +__all__ = ['load_pbl_blueprint'] diff --git a/pbl_blueprint/api.py b/pbl_blueprint/api.py index baa0f63..441ffb9 100644 --- a/pbl_blueprint/api.py +++ b/pbl_blueprint/api.py @@ -1,1165 +1,359 @@ -"""pbl_blueprint.api —— PBL 蓝图聚合根 CRUD 与查询契约实现(M1a)。 +"""pbl_blueprint HTTP API 层(M1a)。 -设计约束(见 projects/pbls/docs/01-design/modules/pbl_blueprint.md): -1. tenant_id 强制打头:所有读/写/删 SQL 的 WHERE 第一条件必须是 tenant_id, - 缺失即 fail-closed 抛 PblTenantMissing,绝不降级为「全租户可见」。 -2. 库名不硬编码:统一 ServerEnv().get_module_dbname('pbl_blueprint')。 -3. sqlor 只用 C/U/D/R/I/sqlExe(+ sqlPaging 分页),不编造 save/list/insert。 -4. 聚合根写入与子对象写入均落审计(pbl_common.audit,缺失时本地降级)。 +路由前缀 /pbl_blueprint,全部接口第一入参 tenant_id(从请求上下文/入参取), +缺失租户上下文一律 fail-closed 返回 PBL_TENANT_REQUIRED。 """ -import json -import traceback +from .db import PblError, require_tenant +from .blueprint_crud import ( + create_blueprint, + update_blueprint, + delete_blueprint, + get_blueprint, + list_blueprints, + get_blueprint_tree, + fork_blueprint, + save_version, + list_versions, + diff_versions, + rollback_version, +) +from .subobjects import ( + SUBOBJECT_TYPES, + create_subobject, + update_subobject, + delete_subobject, + get_subobject, + list_subobjects, + batch_upsert_subobjects, +) +from .templates import ( + create_template, + update_template, + delete_template, + get_template, + list_templates, + instantiate_template, + build_offline_fallback_blueprint, +) -from ahserver.serverEnv import ServerEnv - -MODULE_NAME = 'pbl_blueprint' - -# ---------------------------------------------------------------- 错误码 -ERR_TENANT_MISSING = 'PBL_TENANT_MISSING' -ERR_NOT_FOUND = 'PBL_NOT_FOUND' -ERR_DUPLICATE = 'PBL_DUPLICATE' -ERR_INVALID_PARAM = 'PBL_INVALID_PARAM' -ERR_SUBOBJECT_UNKNOWN = 'PBL_SUBOBJECT_UNKNOWN' -ERR_STATE_CONFLICT = 'PBL_STATE_CONFLICT' - - -class PblBlueprintError(Exception): - """蓝图模块业务异常基类(fail-closed)。""" - - code = 'PBL_BLUEPRINT_ERROR' - - def __init__(self, message, code=None, **extra): - super().__init__(message) - self.message = message - if code: - self.code = code - self.extra = extra - - def to_dict(self): - d = {'status': 'error', 'code': self.code, 'message': self.message} - d.update(self.extra) - return d - - -class PblTenantMissing(PblBlueprintError): - code = ERR_TENANT_MISSING - - -class PblNotFound(PblBlueprintError): - code = ERR_NOT_FOUND - - -class PblDuplicate(PblBlueprintError): - code = ERR_DUPLICATE - - -class PblInvalidParam(PblBlueprintError): - code = ERR_INVALID_PARAM - - -class PblSubobjectUnknown(PblBlueprintError): - code = ERR_SUBOBJECT_UNKNOWN - - -class PblStateConflict(PblBlueprintError): - code = ERR_STATE_CONFLICT - - -# ---------------------------------------------------------------- 子对象注册表 -# 子对象名 -> (表名, 业务编码字段, 标题字段) -SUBOBJECTS = { - 'learning_goal': ('pbl_blueprint_learning_goal', 'goal_code', 'title'), - 'role': ('pbl_blueprint_role', 'role_code', 'role_name'), - 'mission': ('pbl_blueprint_mission', 'mission_code', 'title'), - 'task': ('pbl_blueprint_task', 'task_code', 'title'), - 'artifact_spec': ('pbl_blueprint_artifact_spec', 'artifact_code', 'name'), - 'evidence_spec': ('pbl_blueprint_evidence_spec', 'evidence_code', 'name'), - 'reflection_spec': ('pbl_blueprint_reflection_spec', 'reflection_code', 'title'), -} - -BLUEPRINT_TABLE = 'pbl_blueprint' -VERSION_TABLE = 'pbl_blueprint_version' -TEMPLATE_TABLE = 'pbl_template' - -# 蓝图主表可写字段(白名单,防止越权写 tenant_id/created_at 等) -BLUEPRINT_WRITABLE = [ - 'blueprint_code', 'title', 'subject_area', 'grade_level', 'duration_hours', - 'driving_question', 'summary', 'extra_json', 'template_id', 'status', -] -# 仅允许状态机内部/校验模块写的字段 -BLUEPRINT_PROTECTED = [ - 'id', 'tenant_id', 'org_id', 'quality_status', 'validation_score', - 'version_no', 'current_version_id', 'forked_from_id', - 'created_by', 'created_at', 'updated_by', 'updated_at', +# 本模块全部 API 路径(scripts/load_path.py 做 RBAC 注册用,单一事实源) +API_PATHS = [ + ('/pbl_blueprint/blueprint/create', 'POST', '新建蓝图'), + ('/pbl_blueprint/blueprint/update', 'POST', '更新蓝图'), + ('/pbl_blueprint/blueprint/delete', 'POST', '删除蓝图'), + ('/pbl_blueprint/blueprint/get', 'GET', '蓝图详情'), + ('/pbl_blueprint/blueprint/list', 'GET', '蓝图列表'), + ('/pbl_blueprint/blueprint/tree', 'GET', '蓝图子对象树'), + ('/pbl_blueprint/blueprint/fork', 'POST', '复制蓝图'), + ('/pbl_blueprint/version/save', 'POST', '保存版本快照'), + ('/pbl_blueprint/version/list', 'GET', '版本列表'), + ('/pbl_blueprint/version/diff', 'GET', '版本对比'), + ('/pbl_blueprint/version/rollback', 'POST', '版本回滚'), + ('/pbl_blueprint/subobject/create', 'POST', '新建子对象'), + ('/pbl_blueprint/subobject/update', 'POST', '更新子对象'), + ('/pbl_blueprint/subobject/delete', 'POST', '删除子对象'), + ('/pbl_blueprint/subobject/get', 'GET', '子对象详情'), + ('/pbl_blueprint/subobject/list', 'GET', '子对象列表'), + ('/pbl_blueprint/subobject/batch_upsert', 'POST', '批量写子对象'), + ('/pbl_blueprint/subobject/types', 'GET', '子对象类型枚举'), + ('/pbl_blueprint/template/create', 'POST', '新建模板'), + ('/pbl_blueprint/template/update', 'POST', '更新模板'), + ('/pbl_blueprint/template/delete', 'POST', '删除模板'), + ('/pbl_blueprint/template/get', 'GET', '模板详情'), + ('/pbl_blueprint/template/list', 'GET', '模板列表'), + ('/pbl_blueprint/template/instantiate', 'POST', '模板实例化为蓝图'), + ('/pbl_blueprint/template/offline_fallback', 'POST', '离线兜底生成蓝图'), ] -QUALITY_STATUS_ORDER = ['draft', 'partial', 'valid', 'complete', 'published'] + +def _ok(data=None): + return {'code': 0, 'msg': 'ok', 'data': data} -# ---------------------------------------------------------------- 基础设施 -def _env(): - return ServerEnv() +def _fail(err): + code = getattr(err, 'code', 'PBL_ERROR') + return {'code': code, 'msg': str(err), 'data': None} -def _dbname(): - """取库名——禁止硬编码。""" - env = _env() - fn = getattr(env, 'get_module_dbname', None) - if fn is None: - raise PblInvalidParam( - 'ServerEnv.get_module_dbname 未挂载,宿主应用 app/pbls.py 必须先注册', - code=ERR_INVALID_PARAM) - return fn(MODULE_NAME) - - -def _resolve_tenant_id(explicit=None, request=None): - """tenant_id 强制打头:显式参数 > pbl_common 上下文 > request 上下文。 - - 任一来源都拿不到 -> fail-closed 抛 PblTenantMissing。 - """ +def _tid(params): + """tenant_id 强制打头:从入参或上下文取,取不到即抛。""" tid = None - if explicit: - tid = str(explicit).strip() - if not tid: - try: - from pbl_common.api import tenant_id as _pc_tenant_id - tid = _pc_tenant_id() - except Exception: - tid = None - if not tid and request is not None: - ns = getattr(request, '_run_ns', None) - if ns is not None: - pk = getattr(ns, 'params_kw', None) or {} - tid = pk.get('tenant_id') or None - if not tid: - tid = getattr(ns, 'tenant_id', None) or None - if not tid: - raise PblTenantMissing( - 'tenant_id 缺失:蓝图模块所有读写必须显式携带租户上下文(fail-closed)') - return str(tid).strip() + if isinstance(params, dict): + tid = params.get('tenant_id') + if not tid: + ctx = params.get('context') or params.get('ctx') or {} + if isinstance(ctx, dict): + tid = ctx.get('tenant_id') + return require_tenant(tid) -def _now(): +def _operator(params): + if isinstance(params, dict): + op = params.get('operator') or params.get('user_id') + if op: + return op + ctx = params.get('context') or params.get('ctx') or {} + if isinstance(ctx, dict): + return ctx.get('user_id') + return None + + +# ---------------------------------------------------------------- 蓝图 + +def api_blueprint_create(params, env=None): try: - from appPublic.utils import curDateString - return curDateString() - except Exception: - import datetime - return datetime.datetime.now().strftime('%Y-%m-%d %H:%M:%S') + return _ok(create_blueprint(_tid(params), params.get('data') or params, + env=env, operator=_operator(params))) + except PblError as err: + return _fail(err) -def _new_id(): +def api_blueprint_update(params, env=None): try: - from appPublic.utils import getID - return getID() - except Exception: - import uuid - return uuid.uuid4().hex + return _ok(update_blueprint(_tid(params), params.get('blueprint_id') or params.get('id'), + params.get('data') or params, env=env, + operator=_operator(params))) + except PblError as err: + return _fail(err) -async def _user_id(request=None): - if request is None: - return '' - ns = getattr(request, '_run_ns', None) - if ns is None: - return '' - fn = getattr(ns, 'get_user', None) - if fn is None: - return '' +def api_blueprint_delete(params, env=None): try: - return (await fn()) or '' - except Exception: - return '' + return _ok(delete_blueprint(_tid(params), params.get('blueprint_id') or params.get('id'), + env=env, operator=_operator(params), + hard=bool(params.get('hard')))) + except PblError as err: + return _fail(err) -async def _org_id(request=None): - if request is None: - return '0' - ns = getattr(request, '_run_ns', None) - if ns is None: - return '0' - fn = getattr(ns, 'get_userorgid', None) - if fn is None: - return '0' +def api_blueprint_get(params, env=None): try: - return (await fn()) or '0' - except Exception: - return '0' + return _ok(get_blueprint(_tid(params), params.get('blueprint_id') or params.get('id'), + env=env, with_children=bool(params.get('with_children')))) + except PblError as err: + return _fail(err) -async def _audit(request, action, table, record_id, tenant_id, detail=None): - """审计写入:优先 pbl_common.audit,缺失时静默降级(不阻断主流程)。""" +def api_blueprint_list(params, env=None): try: - from pbl_common.audit import write_audit - await write_audit( - request=request, module=MODULE_NAME, action=action, table=table, - record_id=record_id, tenant_id=tenant_id, detail=detail or {}) - return True - except Exception: - return False + return _ok(list_blueprints(_tid(params), filters=params.get('filters') or params, + page=params.get('page', 1), + page_size=params.get('page_size', 20), env=env)) + except PblError as err: + return _fail(err) -def _clean(ns, writable, allow_extra=None): - """按白名单过滤写入字段;剔除 *_text 展示字段与空 id。""" - allow = set(writable) | set(allow_extra or []) - out = {} - for k, v in dict(ns).items(): - if k.endswith('_text'): - continue - if k in ('page', 'rows', 'sort', 'order', '_webbricks_'): - continue - if k not in allow: - continue - if isinstance(v, (dict, list)): - v = json.dumps(v, ensure_ascii=False) - out[k] = v - return out - - -def _row_to_dict(r): - """sqlExe/R 返回 DictObject,不能 dict(r);逐属性取值。""" - if r is None: - return None - if isinstance(r, dict): - return dict(r) +def api_blueprint_tree(params, env=None): try: - return {k: v for k, v in r.items()} - except Exception: - pass - out = {} - for k in getattr(r, '__dict__', {}) or {}: - out[k] = getattr(r, k) - return out + return _ok(get_blueprint_tree(_tid(params), + params.get('blueprint_id') or params.get('id'), env=env)) + except PblError as err: + return _fail(err) -async def _q_all(sor, sql, ns): - recs = await sor.sqlExe(sql, ns) - return [_row_to_dict(r) for r in (recs or [])] - - -async def _q_one(sor, sql, ns): - rows = await _q_all(sor, sql, ns) - return rows[0] if rows else None - - -# ================================================================ 蓝图 CRUD -async def pbl_blueprint_create(ns, request=None, tenant_id=None): - """创建蓝图聚合根。tenant_id 强制打头,blueprint_code 租户内唯一。""" - tid = _resolve_tenant_id(tenant_id or ns.get('tenant_id'), request) - data = _clean(ns, BLUEPRINT_WRITABLE) - title = (data.get('title') or '').strip() - if not title: - raise PblInvalidParam('title 不能为空') - code = (data.get('blueprint_code') or '').strip() - if not code: - code = 'BP' + _new_id()[-10:].upper() - data['blueprint_code'] = code - data['title'] = title - data['status'] = data.get('status') or 'draft' - - from sqlor import DBPools - db = DBPools() - async with db.sqlorContext(_dbname()) as sor: - dup = await _q_one( - sor, - "SELECT id FROM pbl_blueprint WHERE tenant_id=${tenant_id}$ " - "AND blueprint_code=${blueprint_code}$", - {'tenant_id': tid, 'blueprint_code': code}) - if dup: - raise PblDuplicate('蓝图编码已存在: %s' % code, blueprint_code=code) - rid = _new_id() - data['id'] = rid - data['tenant_id'] = tid - data['org_id'] = ns.get('org_id') or await _org_id(request) - data['quality_status'] = 'draft' - data['validation_score'] = 0 - data['version_no'] = 1 - data['created_by'] = await _user_id(request) - data['created_at'] = _now() - data['updated_by'] = data['created_by'] - data['updated_at'] = data['created_at'] - await sor.C(BLUEPRINT_TABLE, data) - - # 聚合根创建即产生 v1 版本快照(change_delta 证明对话式改模型) - ver_id = _new_id() - await sor.C(VERSION_TABLE, { - 'id': ver_id, 'tenant_id': tid, 'blueprint_id': rid, - 'version_no': 1, 'parent_version_id': '', - 'change_delta': json.dumps( - [{'op': 'create', 'path': '/pbl_blueprint', 'before': None, - 'after': {k: v for k, v in data.items() - if k not in ('extra_json',)}}], ensure_ascii=False), - 'snapshot_json': json.dumps(data, ensure_ascii=False, default=str), - 'change_reason': ns.get('change_reason') or '蓝图创建', - 'source': 'manual', 'quality_status': 'draft', - 'created_by': data['created_by'], 'created_at': data['created_at'], - }) - await sor.U(BLUEPRINT_TABLE, { - 'id': rid, 'tenant_id': tid, 'current_version_id': ver_id}) - - await _audit(request, 'create', BLUEPRINT_TABLE, rid, tid, - {'blueprint_code': code, 'title': title}) - return {'status': 'success', 'data': {'id': rid, 'tenant_id': tid, - 'blueprint_code': code, - 'version_no': 1, - 'current_version_id': ver_id}} - - -async def pbl_blueprint_read(ns, request=None, tenant_id=None): - """读取单个蓝图(含 with_children 时返回全部子对象)。""" - tid = _resolve_tenant_id(tenant_id or ns.get('tenant_id'), request) - rid = ns.get('id') or ns.get('blueprint_id') - if not rid: - raise PblInvalidParam('id 不能为空') - from sqlor import DBPools - db = DBPools() - async with db.sqlorContext(_dbname()) as sor: - row = await _q_one( - sor, - "SELECT * FROM pbl_blueprint WHERE tenant_id=${tenant_id}$ " - "AND id=${id}$", {'tenant_id': tid, 'id': rid}) - if not row: - raise PblNotFound('蓝图不存在或不属于当前租户', id=rid) - children = {} - if str(ns.get('with_children') or '1') in ('1', 'true', 'True', 'yes'): - for name, (tbl, _c, _t) in SUBOBJECTS.items(): - children[name] = await _q_all( - sor, - "SELECT * FROM %s WHERE tenant_id=${tenant_id}$ " - "AND blueprint_id=${blueprint_id}$ ORDER BY seq ASC" % tbl, - {'tenant_id': tid, 'blueprint_id': rid}) - children['version'] = await _q_all( - sor, - "SELECT id,version_no,parent_version_id,change_reason,source," - "quality_status,created_by,created_at FROM pbl_blueprint_version " - "WHERE tenant_id=${tenant_id}$ AND blueprint_id=${blueprint_id}$ " - "ORDER BY version_no DESC", - {'tenant_id': tid, 'blueprint_id': rid}) - data = dict(row) - if children: - data['children'] = children - return {'status': 'success', 'data': data} - - -async def pbl_blueprint_update(ns, request=None, tenant_id=None): - """更新蓝图主表字段(白名单),自动升版本并写 change_delta。""" - tid = _resolve_tenant_id(tenant_id or ns.get('tenant_id'), request) - rid = ns.get('id') or ns.get('blueprint_id') - if not rid: - raise PblInvalidParam('id 不能为空') - data = _clean(ns, BLUEPRINT_WRITABLE) - if not data: - raise PblInvalidParam('无可更新字段(受保护字段不可写: %s)' - % ','.join(BLUEPRINT_PROTECTED)) - from sqlor import DBPools - db = DBPools() - async with db.sqlorContext(_dbname()) as sor: - old = await _q_one( - sor, "SELECT * FROM pbl_blueprint WHERE tenant_id=${tenant_id}$ " - "AND id=${id}$", {'tenant_id': tid, 'id': rid}) - if not old: - raise PblNotFound('蓝图不存在或不属于当前租户', id=rid) - if old.get('status') == 'published' and 'status' not in data: - raise PblStateConflict('已发布蓝图不可直接修改,请先 fork 或回退状态', - id=rid) - delta = [] - for k, v in data.items(): - before = old.get(k) - if str(before) != str(v): - delta.append({'op': 'update', 'path': '/pbl_blueprint/%s' % k, - 'before': before, 'after': v}) - uid = await _user_id(request) - data['id'] = rid - data['tenant_id'] = tid - data['updated_by'] = uid - data['updated_at'] = _now() - await sor.U(BLUEPRINT_TABLE, data) - - new_no = int(old.get('version_no') or 1) + 1 - ver_id = _new_id() - merged = dict(old) - merged.update(data) - await sor.C(VERSION_TABLE, { - 'id': ver_id, 'tenant_id': tid, 'blueprint_id': rid, - 'version_no': new_no, - 'parent_version_id': old.get('current_version_id') or '', - 'change_delta': json.dumps(delta, ensure_ascii=False, default=str), - 'snapshot_json': json.dumps(merged, ensure_ascii=False, default=str), - 'change_reason': ns.get('change_reason') or '蓝图更新', - 'source': ns.get('source') or 'dialogue', - 'quality_status': old.get('quality_status') or 'draft', - 'created_by': uid, 'created_at': data['updated_at'], - }) - await sor.U(BLUEPRINT_TABLE, { - 'id': rid, 'tenant_id': tid, 'version_no': new_no, - 'current_version_id': ver_id, 'updated_by': uid, - 'updated_at': data['updated_at']}) - - await _audit(request, 'update', BLUEPRINT_TABLE, rid, tid, - {'version_no': new_no, 'fields': list(data.keys())}) - return {'status': 'success', - 'data': {'id': rid, 'version_no': new_no, - 'current_version_id': ver_id, 'change_delta': delta}} - - -async def pbl_blueprint_delete(ns, request=None, tenant_id=None): - """软删除蓝图(status=deleted),级联软删子对象不做物理删除。""" - tid = _resolve_tenant_id(tenant_id or ns.get('tenant_id'), request) - rid = ns.get('id') or ns.get('blueprint_id') - if not rid: - raise PblInvalidParam('id 不能为空') - hard = str(ns.get('hard') or '0') in ('1', 'true', 'True') - from sqlor import DBPools - db = DBPools() - async with db.sqlorContext(_dbname()) as sor: - old = await _q_one( - sor, "SELECT id,status FROM pbl_blueprint WHERE tenant_id=${tenant_id}$ " - "AND id=${id}$", {'tenant_id': tid, 'id': rid}) - if not old: - raise PblNotFound('蓝图不存在或不属于当前租户', id=rid) - if hard: - for _name, (tbl, _c, _t) in SUBOBJECTS.items(): - await sor.sqlExe( - "DELETE FROM %s WHERE tenant_id=${tenant_id}$ " - "AND blueprint_id=${blueprint_id}$" % tbl, - {'tenant_id': tid, 'blueprint_id': rid}) - await sor.sqlExe( - "DELETE FROM pbl_blueprint_version WHERE tenant_id=${tenant_id}$ " - "AND blueprint_id=${blueprint_id}$", - {'tenant_id': tid, 'blueprint_id': rid}) - await sor.D(BLUEPRINT_TABLE, {'tenant_id': tid, 'id': rid}) - else: - if old.get('status') == 'published': - raise PblStateConflict('已发布蓝图不可删除,请先归档', id=rid) - await sor.U(BLUEPRINT_TABLE, { - 'id': rid, 'tenant_id': tid, 'status': 'deleted', - 'updated_by': await _user_id(request), 'updated_at': _now()}) - await _audit(request, 'delete' if hard else 'soft_delete', - BLUEPRINT_TABLE, rid, tid, {'hard': hard}) - return {'status': 'success', 'data': {'id': rid, 'deleted': True, - 'hard': hard}} - - -async def pbl_blueprint_list(ns, request=None, tenant_id=None): - """蓝图列表查询(分页 + 租户隔离 + 状态/学科过滤)。""" - tid = _resolve_tenant_id(tenant_id or ns.get('tenant_id'), request) - where = ["tenant_id=${tenant_id}$"] - params = {'tenant_id': tid} - status = ns.get('status') - if status: - where.append("status=${status}$") - params['status'] = status - else: - where.append("status<>'deleted'") - kw = ns.get('keyword') or ns.get('q') - if kw: - where.append("(title LIKE ${kw}$ OR blueprint_code LIKE ${kw}$)") - params['kw'] = '%' + str(kw) + '%' - for f in ('subject_area', 'grade_level', 'quality_status', 'org_id', - 'template_id'): - if ns.get(f): - where.append("%s=${%s}$" % (f, f)) - params[f] = ns.get(f) - sql = ("SELECT id,tenant_id,org_id,blueprint_code,title,subject_area," - "grade_level,duration_hours,quality_status,validation_score," - "version_no,status,template_id,created_by,created_at,updated_at " - "FROM pbl_blueprint WHERE " + ' AND '.join(where)) - from sqlor import DBPools - db = DBPools() - async with db.sqlorContext(_dbname()) as sor: - page_ns = dict(params) - page_ns['page'] = int(ns.get('page') or 1) - page_ns['rows'] = int(ns.get('rows') or 20) - page_ns['sort'] = ns.get('sort') or 'created_at' - page_ns['order'] = ns.get('order') or 'desc' - try: - res = await sor.sqlPaging(sql, page_ns) - rows = [_row_to_dict(r) for r in (res.get('rows') or [])] - total = res.get('total') or len(rows) - except Exception: - rows = await _q_all(sor, sql + ' ORDER BY created_at DESC', params) - total = len(rows) - return {'status': 'success', 'data': rows, 'total': total, - 'tenant_id': tid} - - -async def pbl_blueprint_tree(ns, request=None, tenant_id=None): - """蓝图聚合树:蓝图 -> mission -> task -> artifact/evidence/reflection。""" - tid = _resolve_tenant_id(tenant_id or ns.get('tenant_id'), request) - rid = ns.get('id') or ns.get('blueprint_id') - if not rid: - raise PblInvalidParam('id 不能为空') - from sqlor import DBPools - db = DBPools() - async with db.sqlorContext(_dbname()) as sor: - root = await _q_one( - sor, "SELECT * FROM pbl_blueprint WHERE tenant_id=${tenant_id}$ " - "AND id=${id}$", {'tenant_id': tid, 'id': rid}) - if not root: - raise PblNotFound('蓝图不存在或不属于当前租户', id=rid) - kids = {} - for name, (tbl, _c, _t) in SUBOBJECTS.items(): - kids[name] = await _q_all( - sor, "SELECT * FROM %s WHERE tenant_id=${tenant_id}$ " - "AND blueprint_id=${blueprint_id}$ ORDER BY seq ASC" % tbl, - {'tenant_id': tid, 'blueprint_id': rid}) - - def _by_id(rows): - return {r.get('id'): dict(r) for r in rows} - - missions = _by_id(kids['mission']) - tasks = kids['task'] - artifacts = kids['artifact_spec'] - evidences = kids['evidence_spec'] - loose_tasks = [] - for t in tasks: - node = dict(t) - node['children'] = [a for a in artifacts if a.get('task_id') == t.get('id')] - node['evidence_specs'] = [e for e in evidences - if e.get('task_id') == t.get('id')] - mid = t.get('mission_id') - if mid and mid in missions: - missions[mid].setdefault('children', []).append(node) - else: - loose_tasks.append(node) - tree = dict(root) - tree['children'] = [m for m in missions.values()] + loose_tasks - tree['learning_goals'] = kids['learning_goal'] - tree['roles'] = kids['role'] - tree['reflection_specs'] = kids['reflection_spec'] - tree['unassigned_evidence_specs'] = [ - e for e in evidences if not e.get('task_id')] - return {'status': 'success', 'data': tree, 'tenant_id': tid} - - -async def pbl_blueprint_fork(ns, request=None, tenant_id=None): - """派生蓝图:深拷贝聚合根 + 全部子对象到新蓝图(新 id、新 code)。""" - tid = _resolve_tenant_id(tenant_id or ns.get('tenant_id'), request) - src = ns.get('id') or ns.get('blueprint_id') - if not src: - raise PblInvalidParam('id 不能为空') - from sqlor import DBPools - db = DBPools() - uid = await _user_id(request) - now = _now() - async with db.sqlorContext(_dbname()) as sor: - old = await _q_one( - sor, "SELECT * FROM pbl_blueprint WHERE tenant_id=${tenant_id}$ " - "AND id=${id}$", {'tenant_id': tid, 'id': src}) - if not old: - raise PblNotFound('源蓝图不存在或不属于当前租户', id=src) - new_id = _new_id() - new_code = (ns.get('blueprint_code') - or (str(old.get('blueprint_code') or 'BP') + '_copy')) - dup = await _q_one( - sor, "SELECT id FROM pbl_blueprint WHERE tenant_id=${tenant_id}$ " - "AND blueprint_code=${blueprint_code}$", - {'tenant_id': tid, 'blueprint_code': new_code}) - if dup: - raise PblDuplicate('派生蓝图编码已存在: %s' % new_code) - row = dict(old) - row.pop('id', None) - keep = {k: v for k, v in row.items() if k in BLUEPRINT_WRITABLE} - keep.update({ - 'id': new_id, 'tenant_id': tid, - 'org_id': old.get('org_id') or await _org_id(request), - 'blueprint_code': new_code, - 'title': ns.get('title') or (str(old.get('title') or '') + '(副本)'), - 'status': 'draft', 'quality_status': 'draft', 'validation_score': 0, - 'version_no': 1, 'forked_from_id': src, - 'template_id': old.get('template_id') or '', - 'created_by': uid, 'created_at': now, 'updated_by': uid, - 'updated_at': now, - }) - await sor.C(BLUEPRINT_TABLE, keep) - - id_map = {} - counts = {} - for name, (tbl, code_f, _t) in SUBOBJECTS.items(): - rows = await _q_all( - sor, "SELECT * FROM %s WHERE tenant_id=${tenant_id}$ " - "AND blueprint_id=${blueprint_id}$ ORDER BY seq ASC" % tbl, - {'tenant_id': tid, 'blueprint_id': src}) - for r in rows: - d = dict(r) - oid = d.pop('id', None) - nid = _new_id() - id_map[oid] = nid - d['id'] = nid - d['tenant_id'] = tid - d['blueprint_id'] = new_id - d['created_by'] = uid - d['created_at'] = now - d['updated_by'] = uid - d['updated_at'] = now - await sor.C(tbl, d) - counts[name] = counts.get(name, 0) + 1 - # 修正子对象内部引用(mission_id / task_id / artifact_spec_id / role_id) - ref_fixes = [ - ('pbl_blueprint_task', 'mission_id'), - ('pbl_blueprint_artifact_spec', 'task_id'), - ('pbl_blueprint_evidence_spec', 'task_id'), - ('pbl_blueprint_evidence_spec', 'artifact_spec_id'), - ('pbl_blueprint_task', 'role_id'), - ] - for tbl, col in ref_fixes: - for oid, nid in id_map.items(): - await sor.sqlExe( - "UPDATE %s SET %s=${new}$ WHERE tenant_id=${tenant_id}$ " - "AND blueprint_id=${blueprint_id}$ AND %s=${old}$" - % (tbl, col, col), - {'new': nid, 'old': oid, 'tenant_id': tid, - 'blueprint_id': new_id}) - - ver_id = _new_id() - await sor.C(VERSION_TABLE, { - 'id': ver_id, 'tenant_id': tid, 'blueprint_id': new_id, - 'version_no': 1, 'parent_version_id': '', - 'change_delta': json.dumps( - [{'op': 'fork', 'path': '/pbl_blueprint', - 'before': {'id': src}, 'after': {'id': new_id}}], - ensure_ascii=False), - 'snapshot_json': json.dumps(keep, ensure_ascii=False, default=str), - 'change_reason': ns.get('change_reason') or '从蓝图 %s 派生' % src, - 'source': 'fork', 'quality_status': 'draft', - 'created_by': uid, 'created_at': now}) - await sor.U(BLUEPRINT_TABLE, { - 'id': new_id, 'tenant_id': tid, 'current_version_id': ver_id}) - - await _audit(request, 'fork', BLUEPRINT_TABLE, new_id, tid, - {'from': src, 'copied': counts}) - return {'status': 'success', - 'data': {'id': new_id, 'blueprint_code': new_code, - 'forked_from_id': src, 'copied': counts, - 'current_version_id': ver_id}} - - -# ================================================================ 子对象契约 -def _subobject_meta(name): - if name not in SUBOBJECTS: - raise PblSubobjectUnknown( - '未知子对象类型: %s(合法: %s)' % (name, ','.join(sorted(SUBOBJECTS)))) - return SUBOBJECTS[name] - - -async def pbl_blueprint_subobject_save(ns, request=None, tenant_id=None): - """子对象新增/更新(有 id 走更新,无 id 走新增)。tenant_id 强制打头。""" - tid = _resolve_tenant_id(tenant_id or ns.get('tenant_id'), request) - name = ns.get('subobject') or ns.get('subobject_type') - tbl, code_f, title_f = _subobject_meta(name) - bp = ns.get('blueprint_id') - if not bp: - raise PblInvalidParam('blueprint_id 不能为空') - rid = ns.get('id') - uid = await _user_id(request) - now = _now() - from sqlor import DBPools - db = DBPools() - async with db.sqlorContext(_dbname()) as sor: - bprow = await _q_one( - sor, "SELECT id,status FROM pbl_blueprint WHERE tenant_id=${tenant_id}$ " - "AND id=${id}$", {'tenant_id': tid, 'id': bp}) - if not bprow: - raise PblNotFound('所属蓝图不存在或不属于当前租户', blueprint_id=bp) - if bprow.get('status') == 'published': - raise PblStateConflict('已发布蓝图不可修改子对象', blueprint_id=bp) - - # 字段白名单 = 该表 models 定义中除受保护列外的全部列 - cols = _subobject_columns(name) - writable = [c for c in cols if c not in ( - 'id', 'tenant_id', 'blueprint_id', 'created_by', 'created_at', - 'updated_by', 'updated_at')] - data = _clean(ns, writable) - code_val = (data.get(code_f) or '').strip() - if not code_val and not rid: - raise PblInvalidParam('%s 不能为空' % code_f) - - if rid: - old = await _q_one( - sor, "SELECT * FROM %s WHERE tenant_id=${tenant_id}$ " - "AND id=${id}$ AND blueprint_id=${blueprint_id}$" % tbl, - {'tenant_id': tid, 'id': rid, 'blueprint_id': bp}) - if not old: - raise PblNotFound('子对象不存在或不属于当前租户/蓝图', id=rid) - if not code_val: - data[code_f] = old.get(code_f) - data['id'] = rid - data['tenant_id'] = tid - data['blueprint_id'] = bp - data['updated_by'] = uid - data['updated_at'] = now - await sor.U(tbl, data) - action = 'update' - else: - dup = await _q_one( - sor, "SELECT id FROM %s WHERE tenant_id=${tenant_id}$ " - "AND blueprint_id=${blueprint_id}$ AND %s=${code}$" - % (tbl, code_f), - {'tenant_id': tid, 'blueprint_id': bp, 'code': data[code_f]}) - if dup: - raise PblDuplicate('子对象编码已存在: %s' % data[code_f]) - if 'seq' not in data or not data.get('seq'): - mx = await _q_one( - sor, "SELECT MAX(seq) AS m FROM %s " - "WHERE tenant_id=${tenant_id}$ " - "AND blueprint_id=${blueprint_id}$" % tbl, - {'tenant_id': tid, 'blueprint_id': bp}) - data['seq'] = int((mx or {}).get('m') or 0) + 1 - rid = _new_id() - data['id'] = rid - data['tenant_id'] = tid - data['blueprint_id'] = bp - data['created_by'] = uid - data['created_at'] = now - data['updated_by'] = uid - data['updated_at'] = now - await sor.C(tbl, data) - action = 'create' - await _bump_blueprint_version(sor, tid, bp, uid, now, [{ - 'op': action, 'path': '/%s/%s' % (name, rid), - 'before': None, 'after': {k: v for k, v in data.items()}}], - '%s %s' % (action, name)) - - await _audit(request, 'subobject_' + action, tbl, rid, tid, - {'blueprint_id': bp, 'subobject': name}) - return {'status': 'success', - 'data': {'id': rid, 'subobject': name, 'table': tbl, - 'action': action, 'blueprint_id': bp}} - - -async def pbl_blueprint_subobject_list(ns, request=None, tenant_id=None): - """子对象列表查询(按 blueprint_id + subobject 类型)。""" - tid = _resolve_tenant_id(tenant_id or ns.get('tenant_id'), request) - name = ns.get('subobject') or ns.get('subobject_type') - tbl, _c, _t = _subobject_meta(name) - bp = ns.get('blueprint_id') - if not bp: - raise PblInvalidParam('blueprint_id 不能为空') - from sqlor import DBPools - db = DBPools() - async with db.sqlorContext(_dbname()) as sor: - rows = await _q_all( - sor, "SELECT * FROM %s WHERE tenant_id=${tenant_id}$ " - "AND blueprint_id=${blueprint_id}$ ORDER BY seq ASC" % tbl, - {'tenant_id': tid, 'blueprint_id': bp}) - return {'status': 'success', 'data': rows, 'total': len(rows), - 'subobject': name, 'tenant_id': tid} - - -async def pbl_blueprint_subobject_delete(ns, request=None, tenant_id=None): - """子对象删除(物理删除,蓝图未发布时)。""" - tid = _resolve_tenant_id(tenant_id or ns.get('tenant_id'), request) - name = ns.get('subobject') or ns.get('subobject_type') - tbl, _c, _t = _subobject_meta(name) - rid = ns.get('id') - bp = ns.get('blueprint_id') - if not rid: - raise PblInvalidParam('id 不能为空') - from sqlor import DBPools - db = DBPools() - async with db.sqlorContext(_dbname()) as sor: - where = {'tenant_id': tid, 'id': rid} - sql = ("SELECT * FROM %s WHERE tenant_id=${tenant_id}$ AND id=${id}$" % tbl) - if bp: - where['blueprint_id'] = bp - sql += " AND blueprint_id=${blueprint_id}$" - old = await _q_one(sor, sql, where) - if not old: - raise PblNotFound('子对象不存在或不属于当前租户', id=rid) - bp_id = old.get('blueprint_id') - bprow = await _q_one( - sor, "SELECT status FROM pbl_blueprint WHERE tenant_id=${tenant_id}$ " - "AND id=${id}$", {'tenant_id': tid, 'id': bp_id}) - if bprow and bprow.get('status') == 'published': - raise PblStateConflict('已发布蓝图不可删除子对象', blueprint_id=bp_id) - await sor.D(tbl, {'tenant_id': tid, 'id': rid}) - await _bump_blueprint_version( - sor, tid, bp_id, await _user_id(request), _now(), - [{'op': 'delete', 'path': '/%s/%s' % (name, rid), - 'before': old, 'after': None}], 'delete %s' % name) - await _audit(request, 'subobject_delete', tbl, rid, tid, - {'blueprint_id': bp_id, 'subobject': name}) - return {'status': 'success', 'data': {'id': rid, 'deleted': True}} - - -_SUBOBJECT_COLUMNS_CACHE = {} - - -def _subobject_columns(name): - """从 models/{table}.json 读取列名(不猜列名)。""" - tbl = SUBOBJECTS[name][0] - if tbl in _SUBOBJECT_COLUMNS_CACHE: - return _SUBOBJECT_COLUMNS_CACHE[tbl] - import os - cols = [] - for base in (os.path.dirname(os.path.dirname(os.path.abspath(__file__))), - os.path.dirname(os.path.abspath(__file__))): - p = os.path.join(base, 'models', tbl + '.json') - if os.path.exists(p): - try: - with open(p, encoding='utf-8') as f: - mj = json.load(f) - cols = [fl.get('name') for fl in mj.get('fields', []) - if fl.get('name')] - except Exception: - cols = [] - break - if not cols: - cols = ['id', 'tenant_id', 'blueprint_id', 'seq', 'title', - 'created_at', 'updated_at'] - _SUBOBJECT_COLUMNS_CACHE[tbl] = cols - return cols - - -async def _bump_blueprint_version(sor, tid, bp_id, uid, now, delta, reason): - """子对象变更后升蓝图版本号并追加 change_delta 版本记录。""" - old = await _q_one( - sor, "SELECT version_no,current_version_id FROM pbl_blueprint " - "WHERE tenant_id=${tenant_id}$ AND id=${id}$", - {'tenant_id': tid, 'id': bp_id}) - if not old: - return None - new_no = int(old.get('version_no') or 1) + 1 - ver_id = _new_id() - await sor.C(VERSION_TABLE, { - 'id': ver_id, 'tenant_id': tid, 'blueprint_id': bp_id, - 'version_no': new_no, - 'parent_version_id': old.get('current_version_id') or '', - 'change_delta': json.dumps(delta, ensure_ascii=False, default=str), - 'snapshot_json': '', 'change_reason': reason, 'source': 'dialogue', - 'quality_status': 'draft', 'created_by': uid, 'created_at': now}) - await sor.U(BLUEPRINT_TABLE, { - 'id': bp_id, 'tenant_id': tid, 'version_no': new_no, - 'current_version_id': ver_id, 'updated_by': uid, 'updated_at': now}) - return ver_id - - -# ================================================================ 版本契约 -async def pbl_blueprint_version_create(ns, request=None, tenant_id=None): - """显式创建版本快照(对话式改模型/校验后打点)。""" - tid = _resolve_tenant_id(tenant_id or ns.get('tenant_id'), request) - bp = ns.get('blueprint_id') or ns.get('id') - if not bp: - raise PblInvalidParam('blueprint_id 不能为空') - from sqlor import DBPools - db = DBPools() - uid = await _user_id(request) - now = _now() - async with db.sqlorContext(_dbname()) as sor: - root = await _q_one( - sor, "SELECT * FROM pbl_blueprint WHERE tenant_id=${tenant_id}$ " - "AND id=${id}$", {'tenant_id': tid, 'id': bp}) - if not root: - raise PblNotFound('蓝图不存在或不属于当前租户', id=bp) - delta = ns.get('change_delta') - if isinstance(delta, (dict, list)): - delta = json.dumps(delta, ensure_ascii=False) - snapshot = ns.get('snapshot_json') - if not snapshot: - snap = dict(root) - for name, (tbl, _c, _t) in SUBOBJECTS.items(): - snap[name] = await _q_all( - sor, "SELECT * FROM %s WHERE tenant_id=${tenant_id}$ " - "AND blueprint_id=${blueprint_id}$" % tbl, - {'tenant_id': tid, 'blueprint_id': bp}) - snapshot = json.dumps(snap, ensure_ascii=False, default=str) - ver_id = _new_id() - new_no = int(root.get('version_no') or 1) + 1 - await sor.C(VERSION_TABLE, { - 'id': ver_id, 'tenant_id': tid, 'blueprint_id': bp, - 'version_no': new_no, - 'parent_version_id': root.get('current_version_id') or '', - 'change_delta': delta or '[]', 'snapshot_json': snapshot, - 'change_reason': ns.get('change_reason') or '手动打版本', - 'source': ns.get('source') or 'manual', - 'quality_status': ns.get('quality_status') - or root.get('quality_status') or 'draft', - 'created_by': uid, 'created_at': now}) - await sor.U(BLUEPRINT_TABLE, { - 'id': bp, 'tenant_id': tid, 'version_no': new_no, - 'current_version_id': ver_id, 'updated_by': uid, 'updated_at': now}) - await _audit(request, 'version_create', VERSION_TABLE, ver_id, tid, - {'blueprint_id': bp, 'version_no': new_no}) - return {'status': 'success', - 'data': {'id': ver_id, 'blueprint_id': bp, 'version_no': new_no}} - - -async def pbl_blueprint_version_diff(ns, request=None, tenant_id=None): - """版本对比:返回两版本 change_delta 与快照差异摘要。""" - tid = _resolve_tenant_id(tenant_id or ns.get('tenant_id'), request) - bp = ns.get('blueprint_id') - va = ns.get('from_version') or ns.get('version_a') - vb = ns.get('to_version') or ns.get('version_b') - if not bp: - raise PblInvalidParam('blueprint_id 不能为空') - from sqlor import DBPools - db = DBPools() - async with db.sqlorContext(_dbname()) as sor: - def _pick(v): - return v - rows = [] - if va and vb: - rows = await _q_all( - sor, "SELECT * FROM pbl_blueprint_version " - "WHERE tenant_id=${tenant_id}$ AND blueprint_id=${bp}$ " - "AND version_no IN (${va}$,${vb}$)", - {'tenant_id': tid, 'bp': bp, 'va': int(va), 'vb': int(vb)}) - else: - rows = await _q_all( - sor, "SELECT * FROM pbl_blueprint_version " - "WHERE tenant_id=${tenant_id}$ AND blueprint_id=${bp}$ " - "ORDER BY version_no DESC LIMIT 2", - {'tenant_id': tid, 'bp': bp}) - if not rows: - raise PblNotFound('未找到版本记录', blueprint_id=bp) - rows = sorted(rows, key=lambda r: int(r.get('version_no') or 0)) - older, newer = rows[0], rows[-1] - - def _load(r): - try: - return json.loads(r.get('change_delta') or '[]') - except Exception: - return [] - - diff = {'from_version': older.get('version_no'), - 'to_version': newer.get('version_no'), - 'from_id': older.get('id'), 'to_id': newer.get('id'), - 'changes': _load(newer), 'history': _load(older), - 'reasons': [older.get('change_reason'), newer.get('change_reason')]} +def api_blueprint_fork(params, env=None): try: - sa = json.loads(older.get('snapshot_json') or '{}') - sb = json.loads(newer.get('snapshot_json') or '{}') - diff['snapshot_field_diff'] = [ - {'field': k, 'before': sa.get(k), 'after': sb.get(k)} - for k in set(list(sa.keys()) + list(sb.keys())) - if str(sa.get(k)) != str(sb.get(k)) - and k not in ('updated_at', 'updated_by', 'version_no', - 'current_version_id')] - except Exception: - diff['snapshot_field_diff'] = [] - _pick(None) - return {'status': 'success', 'data': diff, 'tenant_id': tid} + return _ok(fork_blueprint(_tid(params), params.get('blueprint_id') or params.get('id'), + new_name=params.get('new_name'), + target_tenant_id=params.get('target_tenant_id'), + env=env, operator=_operator(params))) + except PblError as err: + return _fail(err) -# ================================================================ 模板契约 -async def pbl_template_list(ns, request=None, tenant_id=None): - """模板列表:本租户 + 平台内置(tenant_id='0')兜底可见。""" - tid = _resolve_tenant_id(tenant_id or ns.get('tenant_id'), request) - from sqlor import DBPools - db = DBPools() - params = {'tenant_id': tid, 'global_tid': '0'} - where = ["(tenant_id=${tenant_id}$ OR tenant_id=${global_tid}$)", - "status='active'"] - if ns.get('category'): - where.append("category=${category}$") - params['category'] = ns.get('category') - if ns.get('subject_area'): - where.append("subject_area=${subject_area}$") - params['subject_area'] = ns.get('subject_area') - kw = ns.get('keyword') - if kw: - where.append("(name LIKE ${kw}$ OR template_code LIKE ${kw}$)") - params['kw'] = '%' + str(kw) + '%' - sql = ("SELECT id,tenant_id,template_code,name,description,category," - "subject_area,grade_level,is_builtin,template_version,status," - "created_at FROM pbl_template WHERE " + ' AND '.join(where) - + " ORDER BY is_builtin DESC, created_at DESC") - async with db.sqlorContext(_dbname()) as sor: - rows = await _q_all(sor, sql, params) - return {'status': 'success', 'data': rows, 'total': len(rows), - 'tenant_id': tid} +# ---------------------------------------------------------------- 版本 - -async def pbl_template_instantiate(ns, request=None, tenant_id=None): - """模板实例化为新蓝图:展开 blueprint_snapshot + offline_slots 兜底。""" - tid = _resolve_tenant_id(tenant_id or ns.get('tenant_id'), request) - tpl_id = ns.get('template_id') or ns.get('id') - if not tpl_id: - raise PblInvalidParam('template_id 不能为空') - from sqlor import DBPools - db = DBPools() - async with db.sqlorContext(_dbname()) as sor: - tpl = await _q_one( - sor, "SELECT * FROM pbl_template WHERE id=${id}$ " - "AND (tenant_id=${tenant_id}$ OR tenant_id=${global_tid}$)", - {'id': tpl_id, 'tenant_id': tid, 'global_tid': '0'}) - if not tpl: - raise PblNotFound('模板不存在或当前租户不可见', template_id=tpl_id) +def api_version_save(params, env=None): try: - snap = json.loads(tpl.get('blueprint_snapshot') or '{}') - except Exception: - raise PblInvalidParam('模板 blueprint_snapshot 非法 JSON', - template_id=tpl_id) + return _ok(save_version(_tid(params), params.get('blueprint_id') or params.get('id'), + remark=params.get('remark'), env=env, + operator=_operator(params))) + except PblError as err: + return _fail(err) + + +def api_version_list(params, env=None): try: - slots = json.loads(tpl.get('offline_slots') or '[]') - except Exception: - slots = [] - - overrides = ns.get('overrides') or {} - if isinstance(overrides, str): - try: - overrides = json.loads(overrides) - except Exception: - overrides = {} - # 离线兜底:LLM 不可用时用 slot.default 填充缺失字段 - filled = [] - for s in slots: - path = (s or {}).get('path') or '' - key = path.rsplit('/', 1)[-1] if path else (s or {}).get('field') - if not key: - continue - if not snap.get(key) and s.get('default') is not None: - snap[key] = s.get('default') - filled.append(key) - snap.update({k: v for k, v in overrides.items() if k in BLUEPRINT_WRITABLE}) - snap['title'] = ns.get('title') or snap.get('title') \ - or (str(tpl.get('name') or 'PBL蓝图')) - snap['template_id'] = tpl_id - snap['blueprint_code'] = ns.get('blueprint_code') or snap.get( - 'blueprint_code') or '' - sub = snap.pop('subobjects', None) or {} - - res = await pbl_blueprint_create(snap, request=request, tenant_id=tid) - bp_id = res['data']['id'] - created = {} - if isinstance(sub, dict): - for name, items in sub.items(): - if name not in SUBOBJECTS or not isinstance(items, list): - continue - n = 0 - for it in items: - if not isinstance(it, dict): - continue - it = dict(it) - it.pop('id', None) - it['blueprint_id'] = bp_id - await pbl_blueprint_subobject_save( - dict(it, subobject=name), request=request, tenant_id=tid) - n += 1 - created[name] = n - await _audit(request, 'template_instantiate', BLUEPRINT_TABLE, bp_id, tid, - {'template_id': tpl_id, 'offline_filled': filled, - 'subobjects': created}) - return {'status': 'success', - 'data': {'id': bp_id, 'template_id': tpl_id, - 'blueprint_code': res['data']['blueprint_code'], - 'subobjects_created': created, - 'offline_slots_filled': filled}} + return _ok(list_versions(_tid(params), params.get('blueprint_id') or params.get('id'), + page=params.get('page', 1), + page_size=params.get('page_size', 20), env=env)) + except PblError as err: + return _fail(err) -async def pbl_template_save(ns, request=None, tenant_id=None): - """模板新增/更新(本租户私有模板;内置模板只读)。""" - tid = _resolve_tenant_id(tenant_id or ns.get('tenant_id'), request) - writable = ['template_code', 'name', 'description', 'category', - 'subject_area', 'grade_level', 'blueprint_snapshot', - 'offline_slots', 'template_version', 'status'] - data = _clean(ns, writable) - if not data.get('name'): - raise PblInvalidParam('name 不能为空') - rid = ns.get('id') - uid = await _user_id(request) - now = _now() - from sqlor import DBPools - db = DBPools() - async with db.sqlorContext(_dbname()) as sor: - if rid: - old = await _q_one( - sor, "SELECT * FROM pbl_template WHERE tenant_id=${tenant_id}$ " - "AND id=${id}$", {'tenant_id': tid, 'id': rid}) - if not old: - raise PblNotFound('模板不存在或不属于当前租户', id=rid) - if str(old.get('is_builtin')) == '1': - raise PblStateConflict('内置模板只读,请另存为租户模板', id=rid) - data['id'] = rid - data['tenant_id'] = tid - data['updated_by'] = uid - data['updated_at'] = now - await sor.U(TEMPLATE_TABLE, data) - action = 'update' - else: - code = data.get('template_code') or ('TPL' + _new_id()[-10:].upper()) - data['template_code'] = code - dup = await _q_one( - sor, "SELECT id FROM pbl_template WHERE tenant_id=${tenant_id}$ " - "AND template_code=${code}$", - {'tenant_id': tid, 'code': code}) - if dup: - raise PblDuplicate('模板编码已存在: %s' % code) - rid = _new_id() - data['id'] = rid - data['tenant_id'] = tid - data['is_builtin'] = '0' - data['status'] = data.get('status') or 'active' - data['template_version'] = data.get('template_version') or '1.0.0' - data['created_by'] = uid - data['created_at'] = now - data['updated_by'] = uid - data['updated_at'] = now - await sor.C(TEMPLATE_TABLE, data) - action = 'create' - await _audit(request, 'template_' + action, TEMPLATE_TABLE, rid, tid, - {'template_code': data.get('template_code')}) - return {'status': 'success', - 'data': {'id': rid, 'action': action, - 'template_code': data.get('template_code')}} +def api_version_diff(params, env=None): + try: + return _ok(diff_versions(_tid(params), params.get('blueprint_id') or params.get('id'), + params.get('version_a'), params.get('version_b'), env=env)) + except PblError as err: + return _fail(err) -async def pbl_template_delete(ns, request=None, tenant_id=None): - """删除租户私有模板(内置模板禁删)。""" - tid = _resolve_tenant_id(tenant_id or ns.get('tenant_id'), request) - rid = ns.get('id') or ns.get('template_id') - if not rid: - raise PblInvalidParam('id 不能为空') - from sqlor import DBPools - db = DBPools() - async with db.sqlorContext(_dbname()) as sor: - old = await _q_one( - sor, "SELECT id,is_builtin FROM pbl_template " - "WHERE tenant_id=${tenant_id}$ AND id=${id}$", - {'tenant_id': tid, 'id': rid}) - if not old: - raise PblNotFound('模板不存在或不属于当前租户', id=rid) - if str(old.get('is_builtin')) == '1': - raise PblStateConflict('内置模板不可删除', id=rid) - await sor.D(TEMPLATE_TABLE, {'tenant_id': tid, 'id': rid}) - await _audit(request, 'template_delete', TEMPLATE_TABLE, rid, tid, {}) - return {'status': 'success', 'data': {'id': rid, 'deleted': True}} +def api_version_rollback(params, env=None): + try: + return _ok(rollback_version(_tid(params), params.get('blueprint_id') or params.get('id'), + params.get('version_no'), env=env, + operator=_operator(params))) + except PblError as err: + return _fail(err) -# ================================================================ 只读契约(供下游模块) -async def pbl_blueprint_get_contract(ns, request=None, tenant_id=None): - """下游模块(compiler/validation/runtime)读取蓝图契约的只读入口。""" - tid = _resolve_tenant_id(tenant_id or ns.get('tenant_id'), request) - rid = ns.get('id') or ns.get('blueprint_id') - if not rid: - raise PblInvalidParam('id 不能为空') - res = await pbl_blueprint_read({'id': rid, 'with_children': '1'}, - request=request, tenant_id=tid) - data = res['data'] - kids = data.pop('children', {}) - return {'status': 'success', 'tenant_id': tid, - 'data': {'blueprint': data, - 'learning_goals': kids.get('learning_goal', []), - 'roles': kids.get('role', []), - 'missions': kids.get('mission', []), - 'tasks': kids.get('task', []), - 'artifact_specs': kids.get('artifact_spec', []), - 'evidence_specs': kids.get('evidence_spec', []), - 'reflection_specs': kids.get('reflection_spec', []), - 'versions': kids.get('version', [])}} +# ---------------------------------------------------------------- 子对象 + +def api_subobject_create(params, env=None): + try: + return _ok(create_subobject(_tid(params), params.get('obj_type'), + params.get('data') or params, env=env, + operator=_operator(params))) + except PblError as err: + return _fail(err) -CONTRACT_FUNCTIONS = [ - pbl_blueprint_create, pbl_blueprint_read, pbl_blueprint_update, - pbl_blueprint_delete, pbl_blueprint_list, pbl_blueprint_tree, - pbl_blueprint_fork, pbl_blueprint_subobject_save, - pbl_blueprint_subobject_list, pbl_blueprint_subobject_delete, - pbl_blueprint_version_create, pbl_blueprint_version_diff, - pbl_template_list, pbl_template_instantiate, pbl_template_save, - pbl_template_delete, pbl_blueprint_get_contract, -] +def api_subobject_update(params, env=None): + try: + return _ok(update_subobject(_tid(params), params.get('obj_type'), + params.get('obj_id') or params.get('id'), + params.get('data') or params, env=env, + operator=_operator(params))) + except PblError as err: + return _fail(err) -def safe_traceback(): - return traceback.format_exc() +def api_subobject_delete(params, env=None): + try: + return _ok(delete_subobject(_tid(params), params.get('obj_type'), + params.get('obj_id') or params.get('id'), env=env, + operator=_operator(params), hard=bool(params.get('hard')))) + except PblError as err: + return _fail(err) + + +def api_subobject_get(params, env=None): + try: + return _ok(get_subobject(_tid(params), params.get('obj_type'), + params.get('obj_id') or params.get('id'), env=env)) + except PblError as err: + return _fail(err) + + +def api_subobject_list(params, env=None): + try: + return _ok(list_subobjects(_tid(params), params.get('obj_type'), + params.get('blueprint_id') or params.get('id'), + filters=params.get('filters') or params, env=env)) + except PblError as err: + return _fail(err) + + +def api_subobject_batch_upsert(params, env=None): + try: + return _ok(batch_upsert_subobjects(_tid(params), params.get('obj_type'), + params.get('blueprint_id') or params.get('id'), + params.get('rows') or [], env=env, + operator=_operator(params))) + except PblError as err: + return _fail(err) + + +def api_subobject_types(params, env=None): + try: + _tid(params) + return _ok({'obj_types': list(SUBOBJECT_TYPES)}) + except PblError as err: + return _fail(err) + + +# ---------------------------------------------------------------- 模板 + +def api_template_create(params, env=None): + try: + return _ok(create_template(_tid(params), params.get('data') or params, env=env, + operator=_operator(params))) + except PblError as err: + return _fail(err) + + +def api_template_update(params, env=None): + try: + return _ok(update_template(_tid(params), params.get('template_id') or params.get('id'), + params.get('data') or params, env=env, + operator=_operator(params))) + except PblError as err: + return _fail(err) + + +def api_template_delete(params, env=None): + try: + return _ok(delete_template(_tid(params), params.get('template_id') or params.get('id'), + env=env, operator=_operator(params), + hard=bool(params.get('hard')))) + except PblError as err: + return _fail(err) + + +def api_template_get(params, env=None): + try: + return _ok(get_template(_tid(params), params.get('template_id') or params.get('id'), + env=env)) + except PblError as err: + return _fail(err) + + +def api_template_list(params, env=None): + try: + return _ok(list_templates(_tid(params), filters=params.get('filters') or params, + page=params.get('page', 1), + page_size=params.get('page_size', 20), env=env, + include_platform=params.get('include_platform', True))) + except PblError as err: + return _fail(err) + + +def api_template_instantiate(params, env=None): + try: + return _ok(instantiate_template(_tid(params), + params.get('template_id') or params.get('id'), + name=params.get('name'), env=env, + operator=_operator(params), + overrides=params.get('overrides') or {})) + except PblError as err: + return _fail(err) + + +def api_template_offline_fallback(params, env=None): + try: + return _ok(build_offline_fallback_blueprint(_tid(params), name=params.get('name'), + env=env, operator=_operator(params))) + except PblError as err: + return _fail(err) + + +# 路径 -> 处理函数(应用注册路由时遍历) +HANDLERS = { + '/pbl_blueprint/blueprint/create': api_blueprint_create, + '/pbl_blueprint/blueprint/update': api_blueprint_update, + '/pbl_blueprint/blueprint/delete': api_blueprint_delete, + '/pbl_blueprint/blueprint/get': api_blueprint_get, + '/pbl_blueprint/blueprint/list': api_blueprint_list, + '/pbl_blueprint/blueprint/tree': api_blueprint_tree, + '/pbl_blueprint/blueprint/fork': api_blueprint_fork, + '/pbl_blueprint/version/save': api_version_save, + '/pbl_blueprint/version/list': api_version_list, + '/pbl_blueprint/version/diff': api_version_diff, + '/pbl_blueprint/version/rollback': api_version_rollback, + '/pbl_blueprint/subobject/create': api_subobject_create, + '/pbl_blueprint/subobject/update': api_subobject_update, + '/pbl_blueprint/subobject/delete': api_subobject_delete, + '/pbl_blueprint/subobject/get': api_subobject_get, + '/pbl_blueprint/subobject/list': api_subobject_list, + '/pbl_blueprint/subobject/batch_upsert': api_subobject_batch_upsert, + '/pbl_blueprint/subobject/types': api_subobject_types, + '/pbl_blueprint/template/create': api_template_create, + '/pbl_blueprint/template/update': api_template_update, + '/pbl_blueprint/template/delete': api_template_delete, + '/pbl_blueprint/template/get': api_template_get, + '/pbl_blueprint/template/list': api_template_list, + '/pbl_blueprint/template/instantiate': api_template_instantiate, + '/pbl_blueprint/template/offline_fallback': api_template_offline_fallback, +} diff --git a/pbl_blueprint/blueprint_crud.py b/pbl_blueprint/blueprint_crud.py index 605f436..de7b575 100644 --- a/pbl_blueprint/blueprint_crud.py +++ b/pbl_blueprint/blueprint_crud.py @@ -1,346 +1,411 @@ -# -*- coding: utf-8 -*- -"""蓝图聚合根 CRUD 与查询契约接口(M1a)。 +"""pbl_blueprint 蓝图 CRUD 与查询契约(M1a)。 -契约函数(跨宿主/下游模块复用面,签名稳定): -- save_blueprint(payload, tenant_id=None) -> dict 新建/更新蓝图(含自动编码、乐观锁) -- get_blueprint(blueprint_id, tenant_id=None) -> dict 蓝图主记录 -- list_blueprints(filters=None, page=1, rows=20, tenant_id=None) -> dict {rows,total,page,rows_per_page} -- delete_blueprint(blueprint_id, tenant_id=None) -> dict 软删除(已发布需先撤回) -- blueprint_tree(blueprint_id, version_id=None, tenant_id=None) -> dict 聚合树(蓝图+版本+7类子对象) -- change_blueprint_status(blueprint_id, to_status, tenant_id=None, note='') -> dict 状态机流转 -- publish_blueprint(blueprint_id, tenant_id=None, note='') -> dict 发布版本快照(version_no 自增) -- fork_blueprint(blueprint_id, to_class_id=None, tenant_id=None) -> dict 派生副本(含子对象) -- count_subobjects(blueprint_id, tenant_id=None) -> dict 各子对象计数(校验引擎 M2 复用) - -状态机:draft -> in_review -> published -> archived;published -> in_review(改版) -租户隔离:tenant_id 强制打头,缺失 fail-closed;跨租户读取返回「不存在」。 +契约要点: +- 每个函数第一入参 tenant_id,内部 require_tenant() 强制校验,缺失即 PblTenantRequired(fail-closed)。 +- 只用 sqlor 标准 API:sor.C(表, dict) / sor.U(表, dict, where) / sor.D(表, where) / sor.R(表, where) / sor.I(...) / sor.sqlExe(sql)。 +- 软删除:deleted=1,不物理删除主表。 """ -import hashlib -from . import db as _db -from . import models as _m -from . import subobjects as _s +import datetime + +from .db import ( + PblError, + PblNotFound, + PblValidationError, + get_dbname, + get_env, + get_sor, + new_id, + require_tenant, +) +from .subobjects import SUBOBJECT_TABLES, SUBOBJECT_TYPES, list_subobjects TABLE = 'pbl_blueprint' -TABLE_VERSION = 'pbl_blueprint_version' +VERSION_TABLE = 'pbl_blueprint_version' -STATUS_DRAFT = 'draft' -STATUS_IN_REVIEW = 'in_review' -STATUS_PUBLISHED = 'published' -STATUS_ARCHIVED = 'archived' +EDITABLE = [ + 'code', 'name', 'subject', 'grade', 'duration_hours', 'status', + 'summary', 'config', 'source_blueprint_id', 'template_id', 'owner_id', 'remark', +] STATUS_FLOW = { - STATUS_DRAFT: [STATUS_IN_REVIEW, STATUS_ARCHIVED], - STATUS_IN_REVIEW: [STATUS_DRAFT, STATUS_PUBLISHED, STATUS_ARCHIVED], - STATUS_PUBLISHED: [STATUS_IN_REVIEW, STATUS_ARCHIVED], - STATUS_ARCHIVED: [], + 'draft': ['validating', 'archived'], + 'validating': ['validated', 'draft'], + 'validated': ['compiling', 'draft'], + 'compiling': ['compiled', 'validated'], + 'compiled': ['published', 'draft'], + 'published': ['archived', 'draft'], + 'archived': ['draft'], } -EDITABLE_FIELDS = [ - 'class_id', 'blueprint_code', 'blueprint_name', 'domain_code', 'grade_code', - 'subject_code', 'project_duration', 'student_count', 'group_count', - 'complexity_level', 'owner_user_id', 'estimated_cost', 'tags_json', - 'summary_text', 'status', 'current_version', -] - def _now(): - return _db.now_str() + return datetime.datetime.now().strftime('%Y-%m-%d %H:%M:%S') -def _require_tenant(tenant_id): - return tenant_id or _db.current_tenant_id() +def _where(tenant_id, extra=None): + """构造 where:tenant_id 永远打头。""" + cond = "tenant_id='%s'" % tenant_id.replace("'", "''") + if extra: + cond = cond + ' and ' + extra + return cond -def _fetch(table, rec_id, tenant_id): - return _db.select_one(table, where={'id': rec_id, 'tenant_id': tenant_id, - 'is_deleted': 0}) +def _pick(data, keys): + out = {} + for k in keys: + if k in data and data[k] is not None: + out[k] = data[k] + return out -def get_blueprint(blueprint_id, tenant_id=None): - """取蓝图主记录;不存在/跨租户 -> PBL-NOT-FOUND。""" - tid = _require_tenant(tenant_id) - if not blueprint_id: - raise _db.PblError(_db.ERR_PARAM_INVALID, 'blueprint_id 不能为空') - row = _fetch(TABLE, blueprint_id, tid) - if not row: - raise _db.PblError(_db.ERR_NOT_FOUND, '蓝图不存在或无权访问: %s' % blueprint_id) - return row +def _gen_code(sor, tenant_id): + """生成蓝图编码 PBL-{yyyyMMdd}-{seq4}。""" + day = datetime.datetime.now().strftime('%Y%m%d') + prefix = 'PBL-%s-' % day + rows = sor.R(TABLE, _where(tenant_id, "code like '%s%%'" % prefix)) + seq = len(rows or []) + 1 + return '%s%04d' % (prefix, seq) -def next_blueprint_code(tenant_id=None): - """自动生成蓝图编码 PBL-{年份}-{4位序号}(同租户内递增)。""" - tid = _require_tenant(tenant_id) - import datetime - year = datetime.datetime.now().strftime('%Y') - prefix = 'PBL-%s-' % year - rows = _db.select_rows(TABLE, where={'tenant_id': tid, 'blueprint_code~': prefix + '%'}, - fields='blueprint_code', orderby='blueprint_code desc') or [] - max_seq = 0 - for r in rows: - code = (r.get('blueprint_code') or '')[len(prefix):] - try: - max_seq = max(max_seq, int(code)) - except (TypeError, ValueError): - continue - return '%s%04d' % (prefix, max_seq + 1) +# ---------------------------------------------------------------- 蓝图 CRUD +def create_blueprint(tenant_id, data, env=None, operator=None): + """新建蓝图。:return: 蓝图记录 dict""" + tid = require_tenant(tenant_id) + if not isinstance(data, dict): + raise PblValidationError('data must be a dict') + if not data.get('name'): + raise PblValidationError('name is required') -def save_blueprint(payload, tenant_id=None): - """新建(无 id)或更新(有 id)蓝图。返回落库后的记录。""" - tid = _require_tenant(tenant_id) - payload = dict(payload or {}) - data = dict((k, payload[k]) for k in EDITABLE_FIELDS if k in payload) - rec_id = payload.get('id') - now = _now() - operator = _db.current_operator() + e = get_env(env) + sor = get_sor(e, get_dbname(e)) + if sor is None: + raise PblError('sqlor unavailable for module pbl_blueprint', code='PBL_DB_UNAVAILABLE') - if rec_id: - old = _fetch(TABLE, rec_id, tid) - if not old: - raise _db.PblError(_db.ERR_NOT_FOUND, '蓝图不存在或无权访问: %s' % rec_id) - if old.get('status') == STATUS_PUBLISHED: - raise _db.PblError(_db.ERR_STATE_FORBIDDEN, - '已发布蓝图不可直接编辑,请先撤回或派生新版本') - code = data.get('blueprint_code') or old.get('blueprint_code') - dup = _db.select_one(TABLE, where={'tenant_id': tid, 'blueprint_code': code, - 'id!': rec_id, 'is_deleted': 0}) - if dup: - raise _db.PblError(_db.ERR_PARAM_INVALID, '蓝图编码已存在: %s' % code) - data.update({'updated_by': operator, 'updated_at': now}) - _db.update_row(TABLE, data, {'id': rec_id, 'tenant_id': tid}) - result = _fetch(TABLE, rec_id, tid) - _db.audit('blueprint_update', TABLE, rec_id, before=old, after=result, tenant_id=tid) - return result - - if not data.get('blueprint_name'): - raise _db.PblError(_db.ERR_PARAM_INVALID, 'blueprint_name 必填') - if not data.get('blueprint_code'): - data['blueprint_code'] = next_blueprint_code(tid) - dup = _db.select_one(TABLE, where={'tenant_id': tid, - 'blueprint_code': data['blueprint_code'], - 'is_deleted': 0}) - if dup: - raise _db.PblError(_db.ERR_PARAM_INVALID, '蓝图编码已存在: %s' % data['blueprint_code']) - new_id = _db.gen_id() row = { - 'id': new_id, 'tenant_id': tid, 'class_id': data.get('class_id', ''), - 'blueprint_code': data['blueprint_code'], 'blueprint_name': data['blueprint_name'], - 'domain_code': data.get('domain_code', ''), 'grade_code': data.get('grade_code', ''), - 'subject_code': data.get('subject_code', ''), - 'project_duration': int(data.get('project_duration') or 0), - 'student_count': int(data.get('student_count') or 0), - 'group_count': int(data.get('group_count') or 0), - 'complexity_level': data.get('complexity_level', ''), - 'status': STATUS_DRAFT, 'current_version': 0, - 'owner_user_id': data.get('owner_user_id') or operator, - 'estimated_cost': float(data.get('estimated_cost') or 0), - 'tags_json': data.get('tags_json') or '[]', - 'summary_text': data.get('summary_text') or '', - 'is_deleted': 0, 'created_by': operator, 'created_at': now, - 'updated_by': operator, 'updated_at': now, + 'id': data.get('id') or new_id(), + 'tenant_id': tid, + 'code': data.get('code') or _gen_code(sor, tid), + 'name': data['name'], + 'subject': data.get('subject'), + 'grade': data.get('grade'), + 'duration_hours': data.get('duration_hours'), + 'version_no': 1, + 'status': data.get('status') or 'draft', + 'quality_level': data.get('quality_level') or 'L1', + 'summary': data.get('summary'), + 'config': data.get('config'), + 'source_blueprint_id': data.get('source_blueprint_id'), + 'template_id': data.get('template_id'), + 'owner_id': data.get('owner_id') or operator, + 'remark': data.get('remark'), + 'created_by': operator, + 'created_at': _now(), + 'updated_by': operator, + 'updated_at': _now(), + 'deleted': 0, } - _db.insert_row(TABLE, row) - _db.audit('blueprint_create', TABLE, new_id, after=row, tenant_id=tid) - return _fetch(TABLE, new_id, tid) - - -def list_blueprints(filters=None, page=1, rows=20, tenant_id=None): - """分页查询(租户强制过滤 + 软删除过滤 + 排序白名单)。""" - tid = _require_tenant(tenant_id) - filters = dict(filters or {}) - where = {'tenant_id': tid, 'is_deleted': 0} - allow_eq = ('status', 'domain_code', 'grade_code', 'subject_code', - 'class_id', 'complexity_level') - for k in allow_eq: - if filters.get(k): - where[k] = filters[k] - allow_like = ('blueprint_name', 'blueprint_code', 'owner_user_id') - for k in allow_like: - if filters.get(k): - where['%s~' % k] = '%%%s%%' % filters[k] - page = max(1, int(page or 1)) - rows = min(200, max(1, int(rows or 20))) - allowed_sort = ('created_at', 'updated_at', 'blueprint_code', 'status', - 'current_version', 'project_duration') - sortby = filters.get('sortby') if filters.get('sortby') in allowed_sort else 'created_at' - order = 'desc' if str(filters.get('sortorder', 'desc')).lower() == 'desc' else 'asc' - fields = ('id, tenant_id, class_id, blueprint_code, blueprint_name, domain_code, ' - 'grade_code, subject_code, project_duration, student_count, group_count, ' - 'complexity_level, status, current_version, owner_user_id, estimated_cost, ' - 'created_at, updated_at') - all_rows = _db.select_rows(TABLE, where=where, fields=fields, - orderby='%s %s' % (sortby, order)) or [] - total = len(all_rows) - start = (page - 1) * rows - return {'rows': all_rows[start:start + rows], 'total': total, - 'page': page, 'rows_per_page': rows} - - -def delete_blueprint(blueprint_id, tenant_id=None): - """软删除;已发布蓝图须先撤回/归档。级联软删子对象。""" - tid = _require_tenant(tenant_id) - old = _fetch(TABLE, blueprint_id, tid) - if not old: - raise _db.PblError(_db.ERR_NOT_FOUND, '蓝图不存在或无权访问: %s' % blueprint_id) - if old.get('status') == STATUS_PUBLISHED: - raise _db.PblError(_db.ERR_REFERENCED, '已发布蓝图不可删除,请先归档') - now = _now() - _db.update_row(TABLE, {'is_deleted': 1, 'updated_by': _db.current_operator(), - 'updated_at': now}, {'id': blueprint_id, 'tenant_id': tid}) - _s.soft_delete_by_blueprint(blueprint_id, tid) - _db.audit('blueprint_delete', TABLE, blueprint_id, before=old, tenant_id=tid) - return {'id': blueprint_id, 'deleted': True} - - -def change_blueprint_status(blueprint_id, to_status, tenant_id=None, note=''): - """状态机流转(非法流转 -> PBL-STATE-001)。""" - tid = _require_tenant(tenant_id) - row = _fetch(TABLE, blueprint_id, tid) - if not row: - raise _db.PblError(_db.ERR_NOT_FOUND, '蓝图不存在或无权访问: %s' % blueprint_id) - cur = row.get('status') or STATUS_DRAFT - if to_status not in STATUS_FLOW: - raise _db.PblError(_db.ERR_STATE_FORBIDDEN, '未知目标状态: %s' % to_status) - if to_status not in STATUS_FLOW.get(cur, []): - raise _db.PblError(_db.ERR_STATE_FORBIDDEN, - '状态不允许 %s -> %s' % (cur, to_status)) - if to_status == STATUS_PUBLISHED: - return publish_blueprint(blueprint_id, tenant_id=tid, note=note) - now = _now() - _db.update_row(TABLE, {'status': to_status, 'updated_by': _db.current_operator(), - 'updated_at': now}, {'id': blueprint_id, 'tenant_id': tid}) - _db.audit('blueprint_status', TABLE, blueprint_id, - before={'status': cur}, after={'status': to_status, 'note': note}, tenant_id=tid) - return {'id': blueprint_id, 'status': to_status, 'from_status': cur} - - -def _snapshot(blueprint_id, tid): - bp = _fetch(TABLE, blueprint_id, tid) - snap = {'blueprint': dict((k, v) for k, v in (bp or {}).items() - if k not in ('is_deleted',))} - snap['subobjects'] = {} - for stype, table in _m.SUBOBJECT_TYPE_TABLE.items(): - snap['subobjects'][stype] = _db.select_rows( - table, where={'tenant_id': tid, 'blueprint_id': blueprint_id, 'is_deleted': 0}, - orderby='seq_no asc') or [] - return snap - - -def _hash(text): - return hashlib.sha256(text.encode('utf-8')).hexdigest()[:64] - - -def publish_blueprint(blueprint_id, tenant_id=None, note=''): - """发布:生成版本快照(version_no 自增)并把蓝图置 published。""" - tid = _require_tenant(tenant_id) - bp = _fetch(TABLE, blueprint_id, tid) - if not bp: - raise _db.PblError(_db.ERR_NOT_FOUND, '蓝图不存在或无权访问: %s' % blueprint_id) - if bp.get('status') not in (STATUS_IN_REVIEW, STATUS_DRAFT, STATUS_PUBLISHED): - raise _db.PblError(_db.ERR_STATE_FORBIDDEN, - '当前状态不可发布: %s' % bp.get('status')) - counts = _s.count_subobjects(blueprint_id, tid) - if not counts.get('learning_goal'): - raise _db.PblError(_db.ERR_STATE_FORBIDDEN, '缺少学习目标子对象,无法发布') - if not counts.get('mission'): - raise _db.PblError(_db.ERR_STATE_FORBIDDEN, '缺少真实情境任务,无法发布') - snap = _snapshot(blueprint_id, tid) - snap_text = _db.dumps(snap) - version_no = int(bp.get('current_version') or 0) + 1 - now = _now() - operator = _db.current_operator() - vid = _db.gen_id() - _db.insert_row(TABLE_VERSION, { - 'id': vid, 'tenant_id': tid, 'class_id': bp.get('class_id') or '', - 'blueprint_id': blueprint_id, 'version_no': version_no, - 'version_name': 'v%d' % version_no, 'version_status': 'published', - 'snapshot_json': snap_text, 'content_hash': _hash(snap_text), - 'published_by': operator, 'published_at': now, 'change_note': note or '', - 'base_version_id': bp.get('published_version_id') or '', - 'is_deleted': 0, 'created_by': operator, 'created_at': now, - 'updated_by': operator, 'updated_at': now, - }) - _db.update_row(TABLE, {'status': STATUS_PUBLISHED, 'current_version': version_no, - 'published_version_id': vid, 'updated_by': operator, - 'updated_at': now}, {'id': blueprint_id, 'tenant_id': tid}) - _db.audit('blueprint_publish', TABLE, blueprint_id, - after={'version_no': version_no, 'version_id': vid, - 'content_hash': _hash(snap_text)}, tenant_id=tid) - return {'id': blueprint_id, 'version_id': vid, 'version_no': version_no, - 'status': STATUS_PUBLISHED, 'content_hash': _hash(snap_text)} - - -def list_versions(blueprint_id, tenant_id=None): - tid = _require_tenant(tenant_id) - rows = _db.select_rows(TABLE_VERSION, - where={'tenant_id': tid, 'blueprint_id': blueprint_id, - 'is_deleted': 0}, - fields='id, blueprint_id, version_no, version_name, ' - 'version_status, content_hash, published_by, published_at, ' - 'change_note, created_at', - orderby='version_no desc') or [] - return {'rows': rows, 'total': len(rows)} - - -def get_version(version_id, tenant_id=None): - tid = _require_tenant(tenant_id) - row = _fetch(TABLE_VERSION, version_id, tid) - if not row: - raise _db.PblError(_db.ERR_NOT_FOUND, '版本不存在或无权访问: %s' % version_id) - row['snapshot'] = _db.loads(row.get('snapshot_json'), {}) + sor.C(TABLE, row) return row -def blueprint_tree(blueprint_id, version_id=None, tenant_id=None): - """聚合树:蓝图主记录 + (可选版本快照)+ 7 类子对象 + 计数。""" - tid = _require_tenant(tenant_id) - bp = _fetch(TABLE, blueprint_id, tid) - if not bp: - raise _db.PblError(_db.ERR_NOT_FOUND, '蓝图不存在或无权访问: %s' % blueprint_id) - children = _s.list_all_subobjects(blueprint_id, tid) - tree = {'blueprint': bp, 'children': children, - 'counts': dict((k, len(v)) for k, v in children.items()), - 'version': None} - if version_id: - tree['version'] = get_version(version_id, tid) +def update_blueprint(tenant_id, blueprint_id, data, env=None, operator=None): + """更新蓝图(仅 EDITABLE 字段 + 状态流转校验)。""" + tid = require_tenant(tenant_id) + if not blueprint_id: + raise PblValidationError('blueprint_id is required') + old = get_blueprint(tid, blueprint_id, env=env) + patch = _pick(data or {}, EDITABLE) + if not patch: + return old + new_status = patch.get('status') + if new_status and new_status != old.get('status'): + allowed = STATUS_FLOW.get(old.get('status') or 'draft', []) + if new_status not in allowed: + raise PblValidationError( + 'illegal status transition %s -> %s' % (old.get('status'), new_status)) + if new_status == 'published': + patch['published_at'] = _now() + patch['updated_by'] = operator + patch['updated_at'] = _now() + e = get_env(env) + sor = get_sor(e, get_dbname(e)) + sor.U(TABLE, patch, _where(tid, "id='%s'" % blueprint_id)) + merged = dict(old) + merged.update(patch) + return merged + + +def delete_blueprint(tenant_id, blueprint_id, env=None, operator=None, hard=False): + """软删除蓝图(连带子对象与版本一并软删)。""" + tid = require_tenant(tenant_id) + if not blueprint_id: + raise PblValidationError('blueprint_id is required') + e = get_env(env) + sor = get_sor(e, get_dbname(e)) + patch = {'deleted': 1, 'updated_by': operator, 'updated_at': _now()} + if hard: + sor.D(TABLE, _where(tid, "id='%s'" % blueprint_id)) + else: + sor.U(TABLE, patch, _where(tid, "id='%s'" % blueprint_id)) + for tbl in list(SUBOBJECT_TABLES.values()) + [VERSION_TABLE]: + if hard: + sor.D(tbl, _where(tid, "blueprint_id='%s'" % blueprint_id)) + else: + sor.U(tbl, patch, _where(tid, "blueprint_id='%s'" % blueprint_id)) + return {'id': blueprint_id, 'deleted': 1} + + +def get_blueprint(tenant_id, blueprint_id, env=None, with_children=False): + """按 id 取蓝图;with_children=True 时附带 7 类子对象树。""" + tid = require_tenant(tenant_id) + if not blueprint_id: + raise PblValidationError('blueprint_id is required') + e = get_env(env) + sor = get_sor(e, get_dbname(e)) + rows = sor.R(TABLE, _where(tid, "id='%s' and deleted=0" % blueprint_id)) + if not rows: + raise PblNotFound('blueprint not found: %s' % blueprint_id, code='PBL_BLUEPRINT_NOT_FOUND') + row = rows[0] + if with_children: + row['children'] = get_blueprint_tree(tid, blueprint_id, env=env) + return row + + +def list_blueprints(tenant_id, filters=None, page=1, page_size=20, env=None): + """分页查询蓝图列表。filters 支持 status/subject/grade/owner_id/keyword/template_id。""" + tid = require_tenant(tenant_id) + filters = filters or {} + conds = ["deleted=0"] + for key in ('status', 'subject', 'grade', 'owner_id', 'template_id', 'quality_level'): + if filters.get(key): + conds.append("%s='%s'" % (key, str(filters[key]).replace("'", "''"))) + if filters.get('keyword'): + kw = str(filters['keyword']).replace("'", "''") + conds.append("(name like '%%%s%%' or code like '%%%s%%')" % (kw, kw)) + where = _where(tid, ' and '.join(conds)) + + e = get_env(env) + sor = get_sor(e, get_dbname(e)) + page = max(int(page or 1), 1) + page_size = min(max(int(page_size or 20), 1), 200) + offset = (page - 1) * page_size + sql = ("select * from %s where %s order by updated_at desc limit %d offset %d" + % (TABLE, where, page_size, offset)) + rows = sor.sqlExe(sql) + count_sql = "select count(1) as total from %s where %s" % (TABLE, where) + total_rows = sor.sqlExe(count_sql) + total = 0 + if total_rows: + first = total_rows[0] + total = first.get('total') if isinstance(first, dict) else first + return {'total': int(total or 0), 'page': page, 'page_size': page_size, 'rows': rows or []} + + +def get_blueprint_tree(tenant_id, blueprint_id, env=None): + """返回蓝图 7 类子对象树:{obj_type: [rows...]}。""" + tid = require_tenant(tenant_id) + tree = {} + for obj_type in SUBOBJECT_TYPES: + tree[obj_type] = list_subobjects(tid, obj_type, blueprint_id, env=env) return tree -def fork_blueprint(blueprint_id, to_class_id=None, tenant_id=None, new_name=None): - """派生副本:复制主记录 + 全部子对象到新蓝图(draft, version 0)。""" - tid = _require_tenant(tenant_id) - src = _fetch(TABLE, blueprint_id, tid) - if not src: - raise _db.PblError(_db.ERR_NOT_FOUND, '蓝图不存在或无权访问: %s' % blueprint_id) - now = _now() - operator = _db.current_operator() - new_id = _db.gen_id() - row = dict((k, v) for k, v in src.items()) - row.update({ - 'id': new_id, 'tenant_id': tid, - 'class_id': to_class_id if to_class_id is not None else (src.get('class_id') or ''), - 'blueprint_code': next_blueprint_code(tid), - 'blueprint_name': new_name or ('%s (副本)' % (src.get('blueprint_name') or '')), - 'status': STATUS_DRAFT, 'current_version': 0, 'published_version_id': '', - 'source_blueprint_id': blueprint_id, 'source_template_id': '', - 'is_deleted': 0, 'created_by': operator, 'created_at': now, - 'updated_by': operator, 'updated_at': now, - }) - _db.insert_row(TABLE, row) - copied = _s.copy_subobjects(blueprint_id, new_id, tid) - _db.audit('blueprint_fork', TABLE, new_id, - after={'source_blueprint_id': blueprint_id, 'copied': copied}, tenant_id=tid) - return {'id': new_id, 'source_blueprint_id': blueprint_id, - 'blueprint_code': row['blueprint_code'], 'copied_subobjects': copied} +def fork_blueprint(tenant_id, blueprint_id, new_name=None, target_tenant_id=None, + env=None, operator=None): + """复制蓝图(含全部子对象)为新蓝图,记录 source_blueprint_id。""" + tid = require_tenant(tenant_id) + dst_tid = require_tenant(target_tenant_id or tid) + src = get_blueprint(tid, blueprint_id, env=env) + e = get_env(env) + sor = get_sor(e, get_dbname(e)) + + new_bp = { + 'id': new_id(), + 'tenant_id': dst_tid, + 'code': _gen_code(sor, dst_tid), + 'name': new_name or ('%s-副本' % src.get('name')), + 'subject': src.get('subject'), + 'grade': src.get('grade'), + 'duration_hours': src.get('duration_hours'), + 'version_no': 1, + 'status': 'draft', + 'quality_level': src.get('quality_level') or 'L1', + 'summary': src.get('summary'), + 'config': src.get('config'), + 'source_blueprint_id': src['id'], + 'template_id': src.get('template_id'), + 'owner_id': operator or src.get('owner_id'), + 'remark': src.get('remark'), + 'created_by': operator, + 'created_at': _now(), + 'updated_by': operator, + 'updated_at': _now(), + 'deleted': 0, + } + sor.C(TABLE, new_bp) + + id_map = {} + for obj_type in SUBOBJECT_TYPES: + rows = list_subobjects(tid, obj_type, blueprint_id, env=env) + for row in rows or []: + old_id = row.get('id') + new_oid = new_id() + id_map[old_id] = new_oid + child = dict(row) + child['id'] = new_oid + child['tenant_id'] = dst_tid + child['blueprint_id'] = new_bp['id'] + for fk in ('task_id', 'parent_id', 'mission_id', 'learning_goal_id'): + if child.get(fk) and child[fk] in id_map: + child[fk] = id_map[child[fk]] + child['created_by'] = operator + child['created_at'] = _now() + child['updated_by'] = operator + child['updated_at'] = _now() + child['deleted'] = 0 + sor.C(SUBOBJECT_TABLES[obj_type], child) + + new_bp['children_count'] = len(id_map) + return new_bp -def status_options(): - return [{'code': STATUS_DRAFT, 'label': '草稿'}, {'code': STATUS_IN_REVIEW, 'label': '评审中'}, - {'code': STATUS_PUBLISHED, 'label': '已发布'}, {'code': STATUS_ARCHIVED, 'label': '已归档'}] +# ---------------------------------------------------------------- 版本契约 + +def save_version(tenant_id, blueprint_id, remark=None, env=None, operator=None): + """保存当前蓝图为一个新版本快照,计算 change_delta,主表 version_no 自增。""" + tid = require_tenant(tenant_id) + bp = get_blueprint(tid, blueprint_id, env=env, with_children=True) + e = get_env(env) + sor = get_sor(e, get_dbname(e)) + + snapshot = { + 'blueprint': {k: v for k, v in bp.items() if k != 'children'}, + 'children': bp.get('children') or {}, + } + prev_no = int(bp.get('version_no') or 1) - 1 + change_delta = {'added': [], 'updated': [], 'removed': []} + if prev_no >= 1: + prev_rows = sor.R(VERSION_TABLE, _where( + tid, "blueprint_id='%s' and version_no=%d and deleted=0" % (blueprint_id, prev_no))) + if prev_rows: + change_delta = _calc_delta(prev_rows[0].get('snapshot') or {}, snapshot) + + new_no = int(bp.get('version_no') or 0) + 1 + version = { + 'id': new_id(), + 'tenant_id': tid, + 'blueprint_id': blueprint_id, + 'version_no': new_no, + 'snapshot': snapshot, + 'change_delta': change_delta, + 'quality_level': bp.get('quality_level'), + 'status': 'saved', + 'remark': remark, + 'created_by': operator, + 'created_at': _now(), + 'updated_by': operator, + 'updated_at': _now(), + 'deleted': 0, + } + sor.C(VERSION_TABLE, version) + sor.U(TABLE, {'version_no': new_no, 'updated_by': operator, 'updated_at': _now()}, + _where(tid, "id='%s'" % blueprint_id)) + return version -def domain_options(): - return [{'code': c, 'label': l} for c, l in [ - ('science', '科学'), ('math', '数学'), ('chinese', '语文'), ('english', '英语'), - ('art', '艺术'), ('technology', '技术'), ('social_studies', '社会'), ('cross_subject', '跨学科')]] +def _calc_delta(prev, curr): + """对比两版快照,产出 added/updated/removed 三段增量。""" + delta = {'added': [], 'updated': [], 'removed': []} + prev_ids, curr_ids = {}, {} + for obj_type, rows in (prev.get('children') or {}).items(): + for row in rows or []: + prev_ids[row.get('id')] = (obj_type, row) + for obj_type, rows in (curr.get('children') or {}).items(): + for row in rows or []: + curr_ids[row.get('id')] = (obj_type, row) + for oid, (obj_type, row) in curr_ids.items(): + if oid not in prev_ids: + delta['added'].append({'obj_type': obj_type, 'id': oid, 'name': row.get('name')}) + else: + old_row = prev_ids[oid][1] + changed = [k for k in row if k in old_row and row[k] != old_row[k] + and k not in ('updated_at', 'updated_by')] + if changed: + delta['updated'].append({'obj_type': obj_type, 'id': oid, 'fields': changed}) + for oid, (obj_type, row) in prev_ids.items(): + if oid not in curr_ids: + delta['removed'].append({'obj_type': obj_type, 'id': oid, 'name': row.get('name')}) + return delta + + +def list_versions(tenant_id, blueprint_id, page=1, page_size=20, env=None): + """列出蓝图版本(不含 snapshot 大字段)。""" + tid = require_tenant(tenant_id) + e = get_env(env) + sor = get_sor(e, get_dbname(e)) + page = max(int(page or 1), 1) + page_size = min(max(int(page_size or 20), 1), 200) + offset = (page - 1) * page_size + where = _where(tid, "blueprint_id='%s' and deleted=0" % blueprint_id) + sql = ("select id,tenant_id,blueprint_id,version_no,quality_level,status,remark," + "created_by,created_at from %s where %s order by version_no desc limit %d offset %d" + % (VERSION_TABLE, where, page_size, offset)) + return {'rows': sor.sqlExe(sql) or [], 'page': page, 'page_size': page_size} + + +def diff_versions(tenant_id, blueprint_id, version_a, version_b, env=None): + """对比两个版本,返回 change_delta(a -> b)。""" + tid = require_tenant(tenant_id) + e = get_env(env) + sor = get_sor(e, get_dbname(e)) + rows_a = sor.R(VERSION_TABLE, _where( + tid, "blueprint_id='%s' and version_no=%d and deleted=0" % (blueprint_id, int(version_a)))) + rows_b = sor.R(VERSION_TABLE, _where( + tid, "blueprint_id='%s' and version_no=%d and deleted=0" % (blueprint_id, int(version_b)))) + if not rows_a or not rows_b: + raise PblNotFound('version not found', code='PBL_VERSION_NOT_FOUND') + return { + 'from': int(version_a), + 'to': int(version_b), + 'delta': _calc_delta(rows_a[0].get('snapshot') or {}, rows_b[0].get('snapshot') or {}), + } + + +def rollback_version(tenant_id, blueprint_id, version_no, env=None, operator=None): + """回滚蓝图到指定版本:先存当前版本快照,再按目标快照重写主表与子对象。""" + tid = require_tenant(tenant_id) + e = get_env(env) + sor = get_sor(e, get_dbname(e)) + rows = sor.R(VERSION_TABLE, _where( + tid, "blueprint_id='%s' and version_no=%d and deleted=0" % (blueprint_id, int(version_no)))) + if not rows: + raise PblNotFound('version not found: %s' % version_no, code='PBL_VERSION_NOT_FOUND') + save_version(tid, blueprint_id, remark='auto-snapshot before rollback', env=env, operator=operator) + + snapshot = rows[0].get('snapshot') or {} + bp_snap = snapshot.get('blueprint') or {} + patch = _pick(bp_snap, EDITABLE) + patch['updated_by'] = operator + patch['updated_at'] = _now() + if patch: + sor.U(TABLE, patch, _where(tid, "id='%s'" % blueprint_id)) + + for obj_type in SUBOBJECT_TYPES: + sor.D(SUBOBJECT_TABLES[obj_type], _where(tid, "blueprint_id='%s'" % blueprint_id)) + for obj_type, obj_rows in (snapshot.get('children') or {}).items(): + if obj_type not in SUBOBJECT_TABLES: + continue + for row in obj_rows or []: + new_row = dict(row) + new_row['tenant_id'] = tid + new_row['blueprint_id'] = blueprint_id + new_row['deleted'] = 0 + new_row['updated_by'] = operator + new_row['updated_at'] = _now() + sor.C(SUBOBJECT_TABLES[obj_type], new_row) + + sor.U(VERSION_TABLE, {'status': 'rolled_back', 'updated_by': operator, 'updated_at': _now()}, + _where(tid, "blueprint_id='%s' and version_no=%d" % (blueprint_id, int(version_no)))) + return {'blueprint_id': blueprint_id, 'rolled_back_to': int(version_no)} diff --git a/pbl_blueprint/db.py b/pbl_blueprint/db.py index 996890a..bfe6fda 100644 --- a/pbl_blueprint/db.py +++ b/pbl_blueprint/db.py @@ -1,200 +1,153 @@ -"""pbl_blueprint 数据访问适配层(唯一允许直接调用 sqlor 的地方)。 +"""pbl_blueprint 数据访问层。 -规范约束: -- 只使用 sqlor 标准 API:sor.C / sor.U / sor.D / sor.R / sor.I / sor.sqlExe - (禁止 save()/list()/insert()/query() 等编造 API)。 -- 库名一律通过 ServerEnv().get_module_dbname('pbl_blueprint') 获取,禁止硬编码 DBNAME。 -- 租户隔离:所有读写强制带 tenant_id,缺失即 fail-closed 抛错。 +- 库名一律 ServerEnv().get_module_dbname('pbl_blueprint'),禁止硬编码 DBNAME。 +- 所有 SQL 只用 sqlor 标准 API:sor.C / sor.U / sor.D / sor.R / sor.I / sor.sqlExe。 +- tenant_id 强制打头:任何读写条件第一列必须是 tenant_id,缺失即抛 PblTenantRequired。 """ -import json + +import os import uuid MODULE_NAME = 'pbl_blueprint' -# ---------------------------------------------------------------- 错误码 -ERR_DBNAME_MISSING = 'PBL-DB-001' -ERR_TENANT_MISSING = 'PBL-TENANT-001' -ERR_NOT_FOUND = 'PBL-NOT-FOUND' -ERR_STATE_FORBIDDEN = 'PBL-STATE-001' -ERR_PARAM_INVALID = 'PBL-PARAM-001' -ERR_REFERENCED = 'PBL-REF-001' +TABLES = [ + 'pbl_blueprint', + 'pbl_blueprint_version', + 'pbl_blueprint_template', + 'pbl_blueprint_template_item', + 'pbl_blueprint_task', + 'pbl_blueprint_mission', + 'pbl_blueprint_role', + 'pbl_blueprint_learning_goal', + 'pbl_blueprint_evidence_spec', + 'pbl_blueprint_artifact_spec', + 'pbl_blueprint_reflection_spec', +] class PblError(Exception): - """统一业务异常,携带错误码 + i18n 文案键。""" + """pbl 模块统一异常基类。""" - def __init__(self, code, msg, i18n_key=None, detail=None): - Exception.__init__(self, '[%s] %s' % (code, msg)) - self.code = code - self.msg = msg - self.i18n_key = i18n_key or ('pbl_blueprint.err.%s' % code.lower()) - self.detail = detail or {} + code = 'PBL_ERROR' - def to_dict(self): - return {'code': self.code, 'msg': self.msg, - 'i18n_key': self.i18n_key, 'detail': self.detail} + def __init__(self, message, code=None, **extra): + super(PblError, self).__init__(message) + self.message = message + if code: + self.code = code + self.extra = extra -# ---------------------------------------------------------------- 环境/库名 -def server_env(): - from ahserver import ServerEnv - return ServerEnv() +class PblTenantRequired(PblError): + """租户上下文缺失(fail-closed)。""" + + code = 'PBL_TENANT_REQUIRED' -def get_dbname(): - """取本模块库名:优先 ServerEnv 注册值,其次 pbl_common 适配层。""" - dbname = None +class PblNotFound(PblError): + """记录不存在。""" + + code = 'PBL_NOT_FOUND' + + +class PblValidationError(PblError): + """入参校验失败。""" + + code = 'PBL_VALIDATION_ERROR' + + +def new_id(): + """32 位无横线 UUID 主键。""" + return uuid.uuid4().hex + + +def get_env(env=None): + if env is not None: + return env try: - env = server_env() - getter = getattr(env, 'get_module_dbname', None) - if callable(getter): - dbname = getter(MODULE_NAME) + from sage import ServerEnv + return ServerEnv() except Exception: - dbname = None - if not dbname: + return None + + +def get_dbname(env=None): + """取本模块库名,禁止硬编码。""" + e = get_env(env) + if e is None: + return None + getter = getattr(e, 'get_module_dbname', None) + if callable(getter): try: - from pbl_common.db_adapter import get_module_dbname as _g - dbname = _g(MODULE_NAME) + return getter(MODULE_NAME) except Exception: - dbname = None - if not dbname: - raise PblError(ERR_DBNAME_MISSING, - '模块 %s 未注册库名(ServerEnv.set_module_dbname 缺失)' % MODULE_NAME) - return dbname + return None + return None -def current_tenant_id(required=True): - """取当前租户;优先复用 pbl_common 租户上下文,其次 ServerEnv 上挂载的上下文。""" - tid = None - try: - from pbl_common.tenant import get_tenant_id - tid = get_tenant_id() - except Exception: - tid = None - if not tid: +def get_sor(env=None, dbname=None): + """取 sqlor 数据访问对象。""" + e = get_env(env) + if e is None: + return None + db = dbname or get_dbname(e) + for attr in ('sqlor', 'sor'): + obj = getattr(e, attr, None) + if obj is not None: + return obj + getter = getattr(e, 'get_sqlor', None) + if callable(getter): try: - ctx = getattr(server_env(), 'pbl_tenant_ctx', None) or {} - tid = ctx.get('tenant_id') if isinstance(ctx, dict) else None + return getter(db) if db else getter() except Exception: - tid = None - if not tid and required: - raise PblError(ERR_TENANT_MISSING, '缺少租户上下文,拒绝访问蓝图数据') - return tid or '' + return None + return None -def current_operator(): - try: - from pbl_common.tenant import get_current_user - return get_current_user() or 'system' - except Exception: - pass - try: - return getattr(server_env(), 'pbl_current_user', None) or 'system' - except Exception: - return 'system' +def require_tenant(tenant_id): + """tenant_id 强制打头校验:空/非字符串一律拒绝。""" + if tenant_id is None: + raise PblTenantRequired('tenant_id is required (租户上下文缺失,拒绝访问)') + if not isinstance(tenant_id, str) or not tenant_id.strip(): + raise PblTenantRequired('tenant_id must be a non-empty string') + return tenant_id.strip() -# ---------------------------------------------------------------- 主键 -def gen_id(): - """32 位字符串主键(对应 models 中 id: str/32)。""" - try: - from pbl_common.ids import new_id - v = new_id() - if v: - return str(v)[:32] - except Exception: - pass - return uuid.uuid4().hex[:32] - - -# ---------------------------------------------------------------- sqlor 封装 -def _sor(): - import sqlor - return sqlor - - -def _call(func_name, *args, **kw): - sor = _sor() - fn = getattr(sor, func_name, None) - if fn is None: - raise PblError(ERR_DBNAME_MISSING, 'sqlor 缺少 API: %s' % func_name) - kw = dict(kw) - kw.setdefault('dbname', get_dbname()) - try: - return fn(*args, **kw) - except TypeError: - # 兼容位置参数签名(dbname 位置化)的 sqlor 版本 - kw.pop('dbname', None) - return fn(*args, **kw) - - -def insert_row(table, data): - return _call('C', table, data) - - -def insert_rows(table, rows): - return _call('I', table, rows) - - -def update_row(table, data, where): - return _call('U', table, data, where) - - -def delete_row(table, where): - return _call('D', table, where) - - -def select_rows(table, where=None, fields='*', orderby=None, limit=None): - return _call('R', table, where=where, fields=fields, - orderby=orderby, limit=limit) - - -def select_one(table, where=None, fields='*'): - rows = select_rows(table, where=where, fields=fields, limit=1) or [] - return rows[0] if rows else None - - -def sql_exe(sql, params=None): - return _call('sqlExe', sql, params if params is not None else {}) - - -# ---------------------------------------------------------------- 通用工具 -def dumps(obj): - return json.dumps(obj if obj is not None else {}, ensure_ascii=False, sort_keys=False) - - -def loads(text, default=None): - if text in (None, ''): - return {} if default is None else default - if isinstance(text, (dict, list)): - return text - try: - return json.loads(text) - except Exception: - return {} if default is None else default - - -def now_str(): - import datetime - return datetime.datetime.now().strftime('%Y-%m-%d %H:%M:%S') - - -def tenant_where(tenant_id, extra=None): - """构造强制打头的租户条件。""" - where = {'tenant_id': tenant_id or current_tenant_id()} - if extra: - where.update(extra) - return where - - -def audit(action, table, record_id, before=None, after=None, tenant_id=None): - """审计写入委托 pbl_common;不可用时静默降级(不阻塞主流程)。""" - try: - from pbl_common.audit import write_audit - write_audit(module=MODULE_NAME, action=action, table=table, - record_id=record_id, before=before, after=after, - tenant_id=tenant_id or current_tenant_id(required=False), - operator=current_operator()) - return True - except Exception: +def ensure_tables(env=None, dbname=None): + """幂等建表:执行 sql/pbl_blueprint.core.sql 与 sql/pbl_blueprint.subobjects.sql。""" + e = get_env(env) + db = dbname or get_dbname(e) + sor = get_sor(e, db) + if sor is None: return False + sql_dir = os.path.join(os.path.dirname(os.path.abspath(__file__)), 'sql') + executed = [] + for fname in ('pbl_blueprint.core.sql', 'pbl_blueprint.subobjects.sql'): + path = os.path.join(sql_dir, fname) + if not os.path.isfile(path): + continue + with open(path, 'r', encoding='utf-8') as fp: + content = fp.read() + for stmt in _split_sql(content): + try: + sor.sqlExe(stmt) + executed.append(stmt[:40]) + except Exception: + continue + return executed + + +def _split_sql(content): + """按分号切分 DDL,剔除注释行与空语句。""" + lines = [] + for line in content.splitlines(): + stripped = line.strip() + if not stripped or stripped.startswith('--'): + continue + lines.append(line) + stmts = [] + for chunk in '\n'.join(lines).split(';'): + chunk = chunk.strip() + if chunk: + stmts.append(chunk) + return stmts diff --git a/pbl_blueprint/init.py b/pbl_blueprint/init.py index 52da618..6afe7b5 100644 --- a/pbl_blueprint/init.py +++ b/pbl_blueprint/init.py @@ -1,88 +1,162 @@ -"""pbl_blueprint.init —— 模块挂载入口。 +"""pbl_blueprint 模块装配入口。 -宿主应用(apps/pbls/app/pbls.py)在 init() 中: - from pbl_blueprint.init import load_pbl_blueprint - load_pbl_blueprint() - -本函数把全部契约函数注册到 ServerEnv(进程级单例),.dspy 里即可直接调用。 -注意:不要在此处调用 get_user/get_userorgid —— 那是 request._run_ns 上的 -每请求上下文,ServerEnv 上取不到(会静默返回 None)。 +三处同步注册(module-development-spec): + 1) 函数定义:本文件 load_pbl_blueprint() + 2) 包导出: __init__.py -> from .init import load_pbl_blueprint + 3) env 注册:ServerEnv().register_module('pbl_blueprint', load_pbl_blueprint) """ -from ahserver.serverEnv import ServerEnv +import os -from pbl_blueprint.api import ( - pbl_blueprint_create, - pbl_blueprint_read, - pbl_blueprint_update, - pbl_blueprint_delete, - pbl_blueprint_list, - pbl_blueprint_tree, - pbl_blueprint_fork, - pbl_blueprint_subobject_save, - pbl_blueprint_subobject_list, - pbl_blueprint_subobject_delete, - pbl_blueprint_version_create, - pbl_blueprint_version_diff, - pbl_template_list, - pbl_template_instantiate, - pbl_template_save, - pbl_template_delete, - pbl_blueprint_get_contract, - PblBlueprintError, - safe_traceback, +from .db import ensure_tables, get_dbname +from .blueprint_crud import ( + create_blueprint, + update_blueprint, + delete_blueprint, + get_blueprint, + list_blueprints, + get_blueprint_tree, + fork_blueprint, + save_version, + list_versions, + diff_versions, + rollback_version, +) +from .subobjects import ( + SUBOBJECT_TYPES, + create_subobject, + update_subobject, + delete_subobject, + get_subobject, + list_subobjects, + batch_upsert_subobjects, +) +from .templates import ( + create_template, + update_template, + delete_template, + list_templates, + get_template, + instantiate_template, + build_offline_fallback_blueprint, ) -_loaded = False +MODULE_NAME = 'pbl_blueprint' + +# 本模块全部表(核心 4 + 子对象 7) +TABLES = [ + 'pbl_blueprint', + 'pbl_blueprint_version', + 'pbl_blueprint_template', + 'pbl_blueprint_template_item', + 'pbl_blueprint_task', + 'pbl_blueprint_mission', + 'pbl_blueprint_role', + 'pbl_blueprint_learning_goal', + 'pbl_blueprint_evidence_spec', + 'pbl_blueprint_artifact_spec', + 'pbl_blueprint_reflection_spec', +] -def load_pbl_blueprint(): - """注册 pbl_blueprint 全部契约到 ServerEnv(幂等)。""" - global _loaded - env = ServerEnv() +def load_pbl_blueprint(env=None): + """挂载 pbl_blueprint 模块到应用。 - # ---- 蓝图聚合根 CRUD + 查询 - env.pbl_blueprint_create = pbl_blueprint_create - env.pbl_blueprint_read = pbl_blueprint_read - env.pbl_blueprint_update = pbl_blueprint_update - env.pbl_blueprint_delete = pbl_blueprint_delete - env.pbl_blueprint_list = pbl_blueprint_list - env.pbl_blueprint_tree = pbl_blueprint_tree - env.pbl_blueprint_fork = pbl_blueprint_fork + :param env: ServerEnv 实例(应用 init() 里逐个 load 时传入);为空则自建。 + :return: 模块契约字典(供应用/其他模块按契约调用) + """ + if env is None: + try: + from sage import ServerEnv + env = ServerEnv() + except Exception: + env = None - # ---- 子对象契约 - env.pbl_blueprint_subobject_save = pbl_blueprint_subobject_save - env.pbl_blueprint_subobject_list = pbl_blueprint_subobject_list - env.pbl_blueprint_subobject_delete = pbl_blueprint_subobject_delete + dbname = get_dbname(env) - # ---- 版本契约 - env.pbl_blueprint_version_create = pbl_blueprint_version_create - env.pbl_blueprint_version_diff = pbl_blueprint_version_diff + # 建表(幂等,IF NOT EXISTS) + ensure_tables(env, dbname) - # ---- 模板契约 - env.pbl_template_list = pbl_template_list - env.pbl_template_instantiate = pbl_template_instantiate - env.pbl_template_save = pbl_template_save - env.pbl_template_delete = pbl_template_delete + # CRUD 定义注册(json/*.json) + _register_crud_json(env) - # ---- 下游模块只读契约(compiler / validation / runtime) - env.pbl_blueprint_get_contract = pbl_blueprint_get_contract + contract = { + 'module': MODULE_NAME, + 'dbname': dbname, + 'tables': list(TABLES), + 'subobject_types': list(SUBOBJECT_TYPES), + # 蓝图 CRUD 与查询契约 + 'create_blueprint': create_blueprint, + 'update_blueprint': update_blueprint, + 'delete_blueprint': delete_blueprint, + 'get_blueprint': get_blueprint, + 'list_blueprints': list_blueprints, + 'get_blueprint_tree': get_blueprint_tree, + 'fork_blueprint': fork_blueprint, + # 版本契约 + 'save_version': save_version, + 'list_versions': list_versions, + 'diff_versions': diff_versions, + 'rollback_version': rollback_version, + # 7 类子对象泛化契约 + 'create_subobject': create_subobject, + 'update_subobject': update_subobject, + 'delete_subobject': delete_subobject, + 'get_subobject': get_subobject, + 'list_subobjects': list_subobjects, + 'batch_upsert_subobjects': batch_upsert_subobjects, + # 模板契约 + 'create_template': create_template, + 'update_template': update_template, + 'delete_template': delete_template, + 'get_template': get_template, + 'list_templates': list_templates, + 'instantiate_template': instantiate_template, + 'build_offline_fallback_blueprint': build_offline_fallback_blueprint, + } - # ---- 异常类型与工具(供 dspy 统一错误处理) - env.PblBlueprintError = PblBlueprintError - env.pbl_blueprint_safe_traceback = safe_traceback + if env is not None: + for fn in ('register_module', 'set_module_contract', 'register_contract'): + reg = getattr(env, fn, None) + if callable(reg): + try: + reg(MODULE_NAME, contract) + except TypeError: + try: + reg(contract) + except Exception: + pass + except Exception: + pass + break - # xls2ui 生成的 CRUD 包装用复数表名,需同时注册(否则新增按钮 500) - env.create_pbl_blueprints = pbl_blueprint_create - env.update_pbl_blueprints = pbl_blueprint_update - env.delete_pbl_blueprints = pbl_blueprint_delete - env.create_pbl_templates = pbl_template_save - env.update_pbl_templates = pbl_template_save - env.delete_pbl_templates = pbl_template_delete - - _loaded = True - return env + return contract -def is_loaded(): - return _loaded +def _register_crud_json(env): + """把 json/*.json 的 CRUD 定义注册到 env(若平台提供注册钩子)。""" + json_dir = os.path.join(os.path.dirname(os.path.abspath(__file__)), 'json') + if not os.path.isdir(json_dir): + return + defs = {} + for fname in sorted(os.listdir(json_dir)): + if not fname.endswith('.json'): + continue + path = os.path.join(json_dir, fname) + try: + import json as _json + with open(path, 'r', encoding='utf-8') as fp: + defs[fname[:-5]] = _json.load(fp) + except Exception: + continue + if env is None: + return defs + for fn in ('register_crud', 'load_crud_json', 'set_crud_defs'): + reg = getattr(env, fn, None) + if callable(reg): + try: + reg(defs) + except Exception: + pass + break + return defs diff --git a/pbl_blueprint/json/pbl_blueprint.json b/pbl_blueprint/json/pbl_blueprint.json index 1e7e1b0..0cc5843 100644 --- a/pbl_blueprint/json/pbl_blueprint.json +++ b/pbl_blueprint/json/pbl_blueprint.json @@ -1,124 +1,58 @@ { - "pbl_blueprint": { - "summary": "PBL蓝图聚合根", - "alias": "pbl_blueprint_crud", - "params": { - "sortby": "created_at", - "sortorder": "desc", - "tenant_scoped": true, - "browserfields": { - "fields": [ - "id", - "tenant_id", - "class_id", - "blueprint_code", - "blueprint_name", - "domain_code", - "grade_code", - "subject_code", - "complexity_level", - "status", - "created_at", - "updated_at" - ], - "alters": { - "domain_code": { - "uitype": "code", - "appcode": "pbl_blueprint_domain", - "label": "领域" - }, - "grade_code": { - "uitype": "code", - "appcode": "pbl_grade_code", - "label": "学段" - }, - "subject_code": { - "uitype": "code", - "appcode": "pbl_subject_code", - "label": "学科" - }, - "complexity_level": { - "uitype": "code", - "appcode": "pbl_complexity_level", - "label": "复杂度" - }, - "status": { - "uitype": "code", - "appcode": "pbl_blueprint_status", - "label": "蓝图状态" - } - }, - "exclouded": [ - "snapshot_json", - "content_json", - "item_payload_json", - "quality_criteria", - "knowledge_points", - "tags_json" - ] - }, - "editable": { - "new_data_url": "{{entire_url('../api/blueprint_save.dspy')}}", - "update_data_url": "{{entire_url('../api/blueprint_save.dspy')}}", - "delete_data_url": "{{entire_url('../api/blueprint_delete.dspy')}}", - "list_data_url": "{{entire_url('../api/blueprint_list.dspy')}}", - "get_data_url": "{{entire_url('../api/blueprint_get.dspy')}}", - "editexclouded": [ - "id", - "tenant_id", - "created_by", - "created_at" - ] - }, - "required": [ - "id", - "tenant_id", - "blueprint_code", - "blueprint_name" - ], - "data_filter": { - "AND": [ - { - "field": "tenant_id", - "op": "=", - "var": "tenant_id" - } - ] - } + "tblname": "pbl_blueprint", + "params": { + "editable": [ + "code", + "name", + "subject", + "grade", + "duration_hours", + "status", + "summary", + "config", + "source_blueprint_id", + "template_id", + "owner_id", + "remark" + ], + "browserfields": [ + "code", + "name", + "subject", + "grade", + "duration_hours", + "version_no", + "status", + "quality_level", + "owner_id", + "template_id", + "published_at", + "updated_at" + ], + "searchfields": [ + "tenant_id", + "code", + "name", + "subject", + "status", + "owner_id" + ], + "orderby": "updated_at desc", + "defaultfilter": { + "deleted": 0 }, - "codes": { - "domain_code": { - "appcode": "pbl_blueprint_domain", - "label": "领域" - }, - "grade_code": { - "appcode": "pbl_grade_code", - "label": "学段" - }, - "subject_code": { - "appcode": "pbl_subject_code", - "label": "学科" - }, - "complexity_level": { - "appcode": "pbl_complexity_level", - "label": "复杂度" - }, - "status": { - "appcode": "pbl_blueprint_status", - "label": "蓝图状态" - } - }, - "table_meta": { - "pk": "id", - "tenant_field": "tenant_id", - "soft_delete_field": "is_deleted", - "engine": "postgresql", - "indexes": [ - "primary", - "uk_blueprint_tenant_code", - "idx_blueprint_tenant_status", - "idx_blueprint_class" - ] - } + "caption": "PBL蓝图", + "readonly": [ + "id", + "tenant_id", + "version_no", + "quality_level", + "published_at", + "created_by", + "created_at", + "updated_by", + "updated_at", + "deleted" + ] } -} \ No newline at end of file +} diff --git a/pbl_blueprint/json/pbl_blueprint_artifact_spec.json b/pbl_blueprint/json/pbl_blueprint_artifact_spec.json index d823f2a..22a61a0 100644 --- a/pbl_blueprint/json/pbl_blueprint_artifact_spec.json +++ b/pbl_blueprint/json/pbl_blueprint_artifact_spec.json @@ -1,89 +1,57 @@ { - "pbl_blueprint_artifact_spec": { - "summary": "蓝图子对象-产出物规格(Artifact)", - "alias": "pbl_blueprint_artifact_spec_crud", - "params": { - "sortby": "created_at", - "sortorder": "desc", - "tenant_scoped": true, - "browserfields": { - "fields": [ - "id", - "tenant_id", - "class_id", - "code", - "name", - "seq_no", - "status", - "created_at", - "updated_at" - ], - "alters": { - "status": { - "uitype": "code", - "appcode": "pbl_subobject_status", - "label": "状态" - }, - "artifact_type": { - "uitype": "code", - "appcode": "pbl_artifact_type", - "label": "产出物类型" - } - }, - "exclouded": [ - "snapshot_json", - "content_json", - "item_payload_json", - "quality_criteria", - "knowledge_points", - "tags_json" - ] - }, - "editable": { - "new_data_url": "{{entire_url('../api/subobject_save.dspy')}}", - "update_data_url": "{{entire_url('../api/subobject_save.dspy')}}", - "delete_data_url": "{{entire_url('../api/subobject_delete.dspy')}}", - "list_data_url": "{{entire_url('../api/subobject_list.dspy')}}", - "get_data_url": "{{entire_url('../api/subobject_get.dspy')}}", - "editexclouded": [ - "id", - "tenant_id", - "created_by", - "created_at" - ] - }, - "required": [ - "id", - "tenant_id", - "blueprint_id", - "name" - ], - "data_filter": { - "AND": [ - { - "field": "tenant_id", - "op": "=", - "var": "tenant_id" - } - ] - } + "tblname": "pbl_blueprint_artifact_spec", + "params": { + "editable": [ + "blueprint_id", + "task_id", + "mission_id", + "seq", + "code", + "name", + "artifact_type", + "description", + "spec", + "accept_criteria", + "required", + "weight", + "status" + ], + "browserfields": [ + "blueprint_id", + "task_id", + "mission_id", + "seq", + "code", + "name", + "artifact_type", + "required", + "weight", + "status", + "updated_at" + ], + "searchfields": [ + "tenant_id", + "blueprint_id", + "task_id", + "mission_id", + "artifact_type", + "code", + "name", + "status" + ], + "orderby": "seq asc", + "defaultfilter": { + "deleted": 0 }, - "codes": { - "artifact_type": { - "appcode": "pbl_artifact_type", - "label": "产出物类型" - } - }, - "table_meta": { - "pk": "id", - "tenant_field": "tenant_id", - "soft_delete_field": "is_deleted", - "engine": "postgresql", - "indexes": [ - "primary", - "idx_pbl_blueprint_artifact_spec_tenant_bp", - "uk_pbl_blueprint_artifact_spec_tenant_code" - ] - } + "caption": "蓝图子对象-产出物规格", + "readonly": [ + "id", + "tenant_id", + "created_by", + "created_at", + "updated_by", + "updated_at", + "deleted" + ] } -} \ No newline at end of file +} diff --git a/pbl_blueprint/json/pbl_blueprint_evidence_spec.json b/pbl_blueprint/json/pbl_blueprint_evidence_spec.json index e220561..76668df 100644 --- a/pbl_blueprint/json/pbl_blueprint_evidence_spec.json +++ b/pbl_blueprint/json/pbl_blueprint_evidence_spec.json @@ -1,89 +1,60 @@ { - "pbl_blueprint_evidence_spec": { - "summary": "蓝图子对象-证据规格(Evidence)", - "alias": "pbl_blueprint_evidence_spec_crud", - "params": { - "sortby": "created_at", - "sortorder": "desc", - "tenant_scoped": true, - "browserfields": { - "fields": [ - "id", - "tenant_id", - "class_id", - "code", - "name", - "seq_no", - "status", - "created_at", - "updated_at" - ], - "alters": { - "status": { - "uitype": "code", - "appcode": "pbl_subobject_status", - "label": "状态" - }, - "evidence_type": { - "uitype": "code", - "appcode": "pbl_evidence_type", - "label": "证据类型" - } - }, - "exclouded": [ - "snapshot_json", - "content_json", - "item_payload_json", - "quality_criteria", - "knowledge_points", - "tags_json" - ] - }, - "editable": { - "new_data_url": "{{entire_url('../api/subobject_save.dspy')}}", - "update_data_url": "{{entire_url('../api/subobject_save.dspy')}}", - "delete_data_url": "{{entire_url('../api/subobject_delete.dspy')}}", - "list_data_url": "{{entire_url('../api/subobject_list.dspy')}}", - "get_data_url": "{{entire_url('../api/subobject_get.dspy')}}", - "editexclouded": [ - "id", - "tenant_id", - "created_by", - "created_at" - ] - }, - "required": [ - "id", - "tenant_id", - "blueprint_id", - "name" - ], - "data_filter": { - "AND": [ - { - "field": "tenant_id", - "op": "=", - "var": "tenant_id" - } - ] - } + "tblname": "pbl_blueprint_evidence_spec", + "params": { + "editable": [ + "blueprint_id", + "task_id", + "mission_id", + "learning_goal_id", + "seq", + "code", + "name", + "evidence_type", + "collect_mode", + "description", + "spec", + "rule", + "weight", + "status" + ], + "browserfields": [ + "blueprint_id", + "task_id", + "mission_id", + "learning_goal_id", + "seq", + "code", + "name", + "evidence_type", + "collect_mode", + "weight", + "status", + "updated_at" + ], + "searchfields": [ + "tenant_id", + "blueprint_id", + "task_id", + "mission_id", + "learning_goal_id", + "evidence_type", + "code", + "name", + "status" + ], + "orderby": "seq asc", + "defaultfilter": { + "deleted": 0 }, - "codes": { - "evidence_type": { - "appcode": "pbl_evidence_type", - "label": "证据类型" - } - }, - "table_meta": { - "pk": "id", - "tenant_field": "tenant_id", - "soft_delete_field": "is_deleted", - "engine": "postgresql", - "indexes": [ - "primary", - "idx_pbl_blueprint_evidence_spec_tenant_bp", - "uk_pbl_blueprint_evidence_spec_tenant_code" - ] - } + "caption": "蓝图子对象-证据规格", + "readonly": [ + "id", + "tenant_id", + "created_by", + "created_at", + "updated_by", + "updated_at", + "deleted" + ] } -} \ No newline at end of file +} diff --git a/pbl_blueprint/json/pbl_blueprint_learning_goal.json b/pbl_blueprint/json/pbl_blueprint_learning_goal.json index a68ddfd..c80e4bc 100644 --- a/pbl_blueprint/json/pbl_blueprint_learning_goal.json +++ b/pbl_blueprint/json/pbl_blueprint_learning_goal.json @@ -1,89 +1,51 @@ { - "pbl_blueprint_learning_goal": { - "summary": "蓝图子对象-学习目标(Goal)", - "alias": "pbl_blueprint_learning_goal_crud", - "params": { - "sortby": "created_at", - "sortorder": "desc", - "tenant_scoped": true, - "browserfields": { - "fields": [ - "id", - "tenant_id", - "class_id", - "code", - "name", - "seq_no", - "status", - "created_at", - "updated_at" - ], - "alters": { - "status": { - "uitype": "code", - "appcode": "pbl_subobject_status", - "label": "状态" - }, - "goal_type": { - "uitype": "code", - "appcode": "pbl_goal_type", - "label": "目标类型" - } - }, - "exclouded": [ - "snapshot_json", - "content_json", - "item_payload_json", - "quality_criteria", - "knowledge_points", - "tags_json" - ] - }, - "editable": { - "new_data_url": "{{entire_url('../api/subobject_save.dspy')}}", - "update_data_url": "{{entire_url('../api/subobject_save.dspy')}}", - "delete_data_url": "{{entire_url('../api/subobject_delete.dspy')}}", - "list_data_url": "{{entire_url('../api/subobject_list.dspy')}}", - "get_data_url": "{{entire_url('../api/subobject_get.dspy')}}", - "editexclouded": [ - "id", - "tenant_id", - "created_by", - "created_at" - ] - }, - "required": [ - "id", - "tenant_id", - "blueprint_id", - "name" - ], - "data_filter": { - "AND": [ - { - "field": "tenant_id", - "op": "=", - "var": "tenant_id" - } - ] - } + "tblname": "pbl_blueprint_learning_goal", + "params": { + "editable": [ + "blueprint_id", + "task_id", + "seq", + "code", + "name", + "dimension", + "description", + "spec", + "weight", + "status" + ], + "browserfields": [ + "blueprint_id", + "task_id", + "seq", + "code", + "name", + "dimension", + "weight", + "status", + "updated_at" + ], + "searchfields": [ + "tenant_id", + "blueprint_id", + "task_id", + "dimension", + "code", + "name", + "status" + ], + "orderby": "seq asc", + "defaultfilter": { + "deleted": 0 }, - "codes": { - "goal_type": { - "appcode": "pbl_goal_type", - "label": "目标类型" - } - }, - "table_meta": { - "pk": "id", - "tenant_field": "tenant_id", - "soft_delete_field": "is_deleted", - "engine": "postgresql", - "indexes": [ - "primary", - "idx_pbl_blueprint_learning_goal_tenant_bp", - "uk_pbl_blueprint_learning_goal_tenant_code" - ] - } + "caption": "蓝图子对象-学习目标", + "readonly": [ + "id", + "tenant_id", + "created_by", + "created_at", + "updated_by", + "updated_at", + "deleted" + ] } -} \ No newline at end of file +} diff --git a/pbl_blueprint/json/pbl_blueprint_mission.json b/pbl_blueprint/json/pbl_blueprint_mission.json index b7632da..e480635 100644 --- a/pbl_blueprint/json/pbl_blueprint_mission.json +++ b/pbl_blueprint/json/pbl_blueprint_mission.json @@ -1,89 +1,47 @@ { - "pbl_blueprint_mission": { - "summary": "蓝图子对象-真实情境任务(Mission)", - "alias": "pbl_blueprint_mission_crud", - "params": { - "sortby": "created_at", - "sortorder": "desc", - "tenant_scoped": true, - "browserfields": { - "fields": [ - "id", - "tenant_id", - "class_id", - "code", - "name", - "seq_no", - "status", - "created_at", - "updated_at" - ], - "alters": { - "status": { - "uitype": "code", - "appcode": "pbl_subobject_status", - "label": "状态" - }, - "mission_type": { - "uitype": "code", - "appcode": "pbl_mission_type", - "label": "情境类型" - } - }, - "exclouded": [ - "snapshot_json", - "content_json", - "item_payload_json", - "quality_criteria", - "knowledge_points", - "tags_json" - ] - }, - "editable": { - "new_data_url": "{{entire_url('../api/subobject_save.dspy')}}", - "update_data_url": "{{entire_url('../api/subobject_save.dspy')}}", - "delete_data_url": "{{entire_url('../api/subobject_delete.dspy')}}", - "list_data_url": "{{entire_url('../api/subobject_list.dspy')}}", - "get_data_url": "{{entire_url('../api/subobject_get.dspy')}}", - "editexclouded": [ - "id", - "tenant_id", - "created_by", - "created_at" - ] - }, - "required": [ - "id", - "tenant_id", - "blueprint_id", - "name" - ], - "data_filter": { - "AND": [ - { - "field": "tenant_id", - "op": "=", - "var": "tenant_id" - } - ] - } + "tblname": "pbl_blueprint_mission", + "params": { + "editable": [ + "blueprint_id", + "task_id", + "seq", + "code", + "name", + "description", + "spec", + "unlock_condition", + "status" + ], + "browserfields": [ + "blueprint_id", + "task_id", + "seq", + "code", + "name", + "status", + "updated_at" + ], + "searchfields": [ + "tenant_id", + "blueprint_id", + "task_id", + "code", + "name", + "status" + ], + "orderby": "seq asc", + "defaultfilter": { + "deleted": 0 }, - "codes": { - "mission_type": { - "appcode": "pbl_mission_type", - "label": "情境类型" - } - }, - "table_meta": { - "pk": "id", - "tenant_field": "tenant_id", - "soft_delete_field": "is_deleted", - "engine": "postgresql", - "indexes": [ - "primary", - "idx_pbl_blueprint_mission_tenant_bp", - "uk_pbl_blueprint_mission_tenant_code" - ] - } + "caption": "蓝图子对象-关卡", + "readonly": [ + "id", + "tenant_id", + "created_by", + "created_at", + "updated_by", + "updated_at", + "deleted" + ] } -} \ No newline at end of file +} diff --git a/pbl_blueprint/json/pbl_blueprint_reflection_spec.json b/pbl_blueprint/json/pbl_blueprint_reflection_spec.json index 18919ae..d979261 100644 --- a/pbl_blueprint/json/pbl_blueprint_reflection_spec.json +++ b/pbl_blueprint/json/pbl_blueprint_reflection_spec.json @@ -1,98 +1,58 @@ { - "pbl_blueprint_reflection_spec": { - "summary": "蓝图子对象-反思规格(Reflection)", - "alias": "pbl_blueprint_reflection_spec_crud", - "params": { - "sortby": "created_at", - "sortorder": "desc", - "tenant_scoped": true, - "browserfields": { - "fields": [ - "id", - "tenant_id", - "class_id", - "code", - "name", - "seq_no", - "status", - "created_at", - "updated_at" - ], - "alters": { - "status": { - "uitype": "code", - "appcode": "pbl_subobject_status", - "label": "状态" - }, - "reflect_type": { - "uitype": "code", - "appcode": "pbl_reflect_type", - "label": "反思类型" - }, - "frequency": { - "uitype": "code", - "appcode": "pbl_reflect_frequency", - "label": "频次" - } - }, - "exclouded": [ - "snapshot_json", - "content_json", - "item_payload_json", - "quality_criteria", - "knowledge_points", - "tags_json" - ] - }, - "editable": { - "new_data_url": "{{entire_url('../api/subobject_save.dspy')}}", - "update_data_url": "{{entire_url('../api/subobject_save.dspy')}}", - "delete_data_url": "{{entire_url('../api/subobject_delete.dspy')}}", - "list_data_url": "{{entire_url('../api/subobject_list.dspy')}}", - "get_data_url": "{{entire_url('../api/subobject_get.dspy')}}", - "editexclouded": [ - "id", - "tenant_id", - "created_by", - "created_at" - ] - }, - "required": [ - "id", - "tenant_id", - "blueprint_id", - "name" - ], - "data_filter": { - "AND": [ - { - "field": "tenant_id", - "op": "=", - "var": "tenant_id" - } - ] - } + "tblname": "pbl_blueprint_reflection_spec", + "params": { + "editable": [ + "blueprint_id", + "task_id", + "mission_id", + "seq", + "code", + "name", + "trigger_point", + "reflection_type", + "description", + "spec", + "rubric_ref", + "weight", + "status" + ], + "browserfields": [ + "blueprint_id", + "task_id", + "mission_id", + "seq", + "code", + "name", + "trigger_point", + "reflection_type", + "weight", + "status", + "updated_at" + ], + "searchfields": [ + "tenant_id", + "blueprint_id", + "task_id", + "mission_id", + "trigger_point", + "reflection_type", + "code", + "name", + "status" + ], + "orderby": "seq asc", + "defaultfilter": { + "deleted": 0 }, - "codes": { - "reflect_type": { - "appcode": "pbl_reflect_type", - "label": "反思类型" - }, - "frequency": { - "appcode": "pbl_reflect_frequency", - "label": "频次" - } - }, - "table_meta": { - "pk": "id", - "tenant_field": "tenant_id", - "soft_delete_field": "is_deleted", - "engine": "postgresql", - "indexes": [ - "primary", - "idx_pbl_blueprint_reflection_spec_tenant_bp", - "uk_pbl_blueprint_reflection_spec_tenant_code" - ] - } + "caption": "蓝图子对象-反思规格", + "readonly": [ + "id", + "tenant_id", + "created_by", + "created_at", + "updated_by", + "updated_at", + "deleted" + ] } -} \ No newline at end of file +} diff --git a/pbl_blueprint/json/pbl_blueprint_role.json b/pbl_blueprint/json/pbl_blueprint_role.json index 0457a25..b9ad04e 100644 --- a/pbl_blueprint/json/pbl_blueprint_role.json +++ b/pbl_blueprint/json/pbl_blueprint_role.json @@ -1,89 +1,47 @@ { - "pbl_blueprint_role": { - "summary": "蓝图子对象-角色(Role)", - "alias": "pbl_blueprint_role_crud", - "params": { - "sortby": "created_at", - "sortorder": "desc", - "tenant_scoped": true, - "browserfields": { - "fields": [ - "id", - "tenant_id", - "class_id", - "code", - "name", - "seq_no", - "status", - "created_at", - "updated_at" - ], - "alters": { - "status": { - "uitype": "code", - "appcode": "pbl_subobject_status", - "label": "状态" - }, - "role_category": { - "uitype": "code", - "appcode": "pbl_role_category", - "label": "角色类别" - } - }, - "exclouded": [ - "snapshot_json", - "content_json", - "item_payload_json", - "quality_criteria", - "knowledge_points", - "tags_json" - ] - }, - "editable": { - "new_data_url": "{{entire_url('../api/subobject_save.dspy')}}", - "update_data_url": "{{entire_url('../api/subobject_save.dspy')}}", - "delete_data_url": "{{entire_url('../api/subobject_delete.dspy')}}", - "list_data_url": "{{entire_url('../api/subobject_list.dspy')}}", - "get_data_url": "{{entire_url('../api/subobject_get.dspy')}}", - "editexclouded": [ - "id", - "tenant_id", - "created_by", - "created_at" - ] - }, - "required": [ - "id", - "tenant_id", - "blueprint_id", - "name" - ], - "data_filter": { - "AND": [ - { - "field": "tenant_id", - "op": "=", - "var": "tenant_id" - } - ] - } + "tblname": "pbl_blueprint_role", + "params": { + "editable": [ + "blueprint_id", + "task_id", + "seq", + "code", + "name", + "description", + "spec", + "permissions", + "status" + ], + "browserfields": [ + "blueprint_id", + "task_id", + "seq", + "code", + "name", + "status", + "updated_at" + ], + "searchfields": [ + "tenant_id", + "blueprint_id", + "task_id", + "code", + "name", + "status" + ], + "orderby": "seq asc", + "defaultfilter": { + "deleted": 0 }, - "codes": { - "role_category": { - "appcode": "pbl_role_category", - "label": "角色类别" - } - }, - "table_meta": { - "pk": "id", - "tenant_field": "tenant_id", - "soft_delete_field": "is_deleted", - "engine": "postgresql", - "indexes": [ - "primary", - "idx_pbl_blueprint_role_tenant_bp", - "uk_pbl_blueprint_role_tenant_code" - ] - } + "caption": "蓝图子对象-角色", + "readonly": [ + "id", + "tenant_id", + "created_by", + "created_at", + "updated_by", + "updated_at", + "deleted" + ] } -} \ No newline at end of file +} diff --git a/pbl_blueprint/json/pbl_blueprint_task.json b/pbl_blueprint/json/pbl_blueprint_task.json index 15a14c3..d08a47f 100644 --- a/pbl_blueprint/json/pbl_blueprint_task.json +++ b/pbl_blueprint/json/pbl_blueprint_task.json @@ -1,89 +1,46 @@ { - "pbl_blueprint_task": { - "summary": "蓝图子对象-任务分解(Task)", - "alias": "pbl_blueprint_task_crud", - "params": { - "sortby": "created_at", - "sortorder": "desc", - "tenant_scoped": true, - "browserfields": { - "fields": [ - "id", - "tenant_id", - "class_id", - "code", - "name", - "seq_no", - "status", - "created_at", - "updated_at" - ], - "alters": { - "status": { - "uitype": "code", - "appcode": "pbl_subobject_status", - "label": "状态" - }, - "task_type": { - "uitype": "code", - "appcode": "pbl_task_type", - "label": "任务类型" - } - }, - "exclouded": [ - "snapshot_json", - "content_json", - "item_payload_json", - "quality_criteria", - "knowledge_points", - "tags_json" - ] - }, - "editable": { - "new_data_url": "{{entire_url('../api/subobject_save.dspy')}}", - "update_data_url": "{{entire_url('../api/subobject_save.dspy')}}", - "delete_data_url": "{{entire_url('../api/subobject_delete.dspy')}}", - "list_data_url": "{{entire_url('../api/subobject_list.dspy')}}", - "get_data_url": "{{entire_url('../api/subobject_get.dspy')}}", - "editexclouded": [ - "id", - "tenant_id", - "created_by", - "created_at" - ] - }, - "required": [ - "id", - "tenant_id", - "blueprint_id", - "name" - ], - "data_filter": { - "AND": [ - { - "field": "tenant_id", - "op": "=", - "var": "tenant_id" - } - ] - } + "tblname": "pbl_blueprint_task", + "params": { + "editable": [ + "blueprint_id", + "parent_id", + "seq", + "code", + "name", + "description", + "spec", + "status" + ], + "browserfields": [ + "blueprint_id", + "seq", + "code", + "name", + "parent_id", + "status", + "updated_at" + ], + "searchfields": [ + "tenant_id", + "blueprint_id", + "parent_id", + "code", + "name", + "status" + ], + "orderby": "seq asc", + "defaultfilter": { + "deleted": 0 }, - "codes": { - "task_type": { - "appcode": "pbl_task_type", - "label": "任务类型" - } - }, - "table_meta": { - "pk": "id", - "tenant_field": "tenant_id", - "soft_delete_field": "is_deleted", - "engine": "postgresql", - "indexes": [ - "primary", - "idx_pbl_blueprint_task_tenant_bp", - "uk_pbl_blueprint_task_tenant_code" - ] - } + "caption": "蓝图子对象-任务", + "readonly": [ + "id", + "tenant_id", + "created_by", + "created_at", + "updated_by", + "updated_at", + "deleted" + ] } -} \ No newline at end of file +} diff --git a/pbl_blueprint/json/pbl_blueprint_template.json b/pbl_blueprint/json/pbl_blueprint_template.json index ca8590e..9c5b29b 100644 --- a/pbl_blueprint/json/pbl_blueprint_template.json +++ b/pbl_blueprint/json/pbl_blueprint_template.json @@ -1,141 +1,49 @@ { - "pbl_blueprint_template": { - "summary": "PBL蓝图模板", - "alias": "pbl_blueprint_template_crud", - "params": { - "sortby": "created_at", - "sortorder": "desc", - "tenant_scoped": true, - "browserfields": { - "fields": [ - "id", - "tenant_id", - "class_id", - "template_code", - "template_name", - "domain_code", - "grade_code", - "subject_code", - "complexity_level", - "status", - "created_at", - "updated_at" - ], - "alters": { - "template_type": { - "uitype": "code", - "appcode": "pbl_template_type", - "label": "模板类型" - }, - "domain_code": { - "uitype": "code", - "appcode": "pbl_blueprint_domain", - "label": "领域" - }, - "grade_code": { - "uitype": "code", - "appcode": "pbl_grade_code", - "label": "学段" - }, - "subject_code": { - "uitype": "code", - "appcode": "pbl_subject_code", - "label": "学科" - }, - "complexity_level": { - "uitype": "code", - "appcode": "pbl_complexity_level", - "label": "复杂度" - }, - "scope": { - "uitype": "code", - "appcode": "pbl_template_scope", - "label": "可见范围" - }, - "status": { - "uitype": "code", - "appcode": "pbl_template_status", - "label": "状态" - } - }, - "exclouded": [ - "snapshot_json", - "content_json", - "item_payload_json", - "quality_criteria", - "knowledge_points", - "tags_json" - ] - }, - "editable": { - "new_data_url": "{{entire_url('../api/template_save.dspy')}}", - "update_data_url": "{{entire_url('../api/template_save.dspy')}}", - "delete_data_url": "{{entire_url('../api/template_delete.dspy')}}", - "list_data_url": "{{entire_url('../api/template_list.dspy')}}", - "get_data_url": "{{entire_url('../api/template_get.dspy')}}", - "editexclouded": [ - "id", - "tenant_id", - "created_by", - "created_at" - ] - }, - "required": [ - "id", - "tenant_id", - "template_code", - "template_name" - ], - "data_filter": { - "AND": [ - { - "field": "tenant_id", - "op": "=", - "var": "tenant_id" - } - ] - } + "tblname": "pbl_blueprint_template", + "params": { + "editable": [ + "code", + "name", + "category", + "subject", + "grade", + "duration_hours", + "description", + "content", + "status" + ], + "browserfields": [ + "code", + "name", + "category", + "subject", + "grade", + "duration_hours", + "status", + "use_count", + "updated_at" + ], + "searchfields": [ + "tenant_id", + "code", + "name", + "category", + "status" + ], + "orderby": "use_count desc, updated_at desc", + "defaultfilter": { + "deleted": 0 }, - "codes": { - "template_type": { - "appcode": "pbl_template_type", - "label": "模板类型" - }, - "domain_code": { - "appcode": "pbl_blueprint_domain", - "label": "领域" - }, - "grade_code": { - "appcode": "pbl_grade_code", - "label": "学段" - }, - "subject_code": { - "appcode": "pbl_subject_code", - "label": "学科" - }, - "complexity_level": { - "appcode": "pbl_complexity_level", - "label": "复杂度" - }, - "scope": { - "appcode": "pbl_template_scope", - "label": "可见范围" - }, - "status": { - "appcode": "pbl_template_status", - "label": "状态" - } - }, - "table_meta": { - "pk": "id", - "tenant_field": "tenant_id", - "soft_delete_field": "is_deleted", - "engine": "postgresql", - "indexes": [ - "primary", - "uk_template_tenant_code", - "idx_template_tenant_status" - ] - } + "caption": "PBL蓝图模板", + "readonly": [ + "id", + "tenant_id", + "use_count", + "created_by", + "created_at", + "updated_by", + "updated_at", + "deleted" + ] } -} \ No newline at end of file +} diff --git a/pbl_blueprint/json/pbl_blueprint_template_item.json b/pbl_blueprint/json/pbl_blueprint_template_item.json index 1869bd3..e49aa97 100644 --- a/pbl_blueprint/json/pbl_blueprint_template_item.json +++ b/pbl_blueprint/json/pbl_blueprint_template_item.json @@ -1,82 +1,43 @@ { - "pbl_blueprint_template_item": { - "summary": "PBL蓝图模板明细", - "alias": "pbl_blueprint_template_item_crud", - "params": { - "sortby": "created_at", - "sortorder": "desc", - "tenant_scoped": true, - "browserfields": { - "fields": [ - "id", - "tenant_id", - "class_id", - "subobject_type", - "seq_no", - "created_at", - "updated_at" - ], - "alters": { - "subobject_type": { - "uitype": "code", - "appcode": "pbl_subobject_type", - "label": "子对象类型" - } - }, - "exclouded": [ - "snapshot_json", - "content_json", - "item_payload_json", - "quality_criteria", - "knowledge_points", - "tags_json" - ] - }, - "editable": { - "new_data_url": "{{entire_url('../api/template_item_save.dspy')}}", - "update_data_url": "{{entire_url('../api/template_item_save.dspy')}}", - "delete_data_url": "{{entire_url('../api/template_item_delete.dspy')}}", - "list_data_url": "{{entire_url('../api/template_item_list.dspy')}}", - "get_data_url": "{{entire_url('../api/template_item_get.dspy')}}", - "editexclouded": [ - "id", - "tenant_id", - "created_by", - "created_at" - ] - }, - "required": [ - "id", - "tenant_id", - "template_id", - "subobject_type" - ], - "data_filter": { - "AND": [ - { - "field": "tenant_id", - "op": "=", - "var": "tenant_id" - } - ] - } + "tblname": "pbl_blueprint_template_item", + "params": { + "editable": [ + "template_id", + "obj_type", + "parent_ref", + "ref_key", + "seq", + "name", + "spec" + ], + "browserfields": [ + "template_id", + "obj_type", + "seq", + "name", + "ref_key", + "parent_ref", + "updated_at" + ], + "searchfields": [ + "tenant_id", + "template_id", + "obj_type", + "name" + ], + "orderby": "obj_type asc, seq asc", + "defaultfilter": { + "deleted": 0 }, - "codes": { - "subobject_type": { - "appcode": "pbl_subobject_type", - "label": "子对象类型" - } - }, - "table_meta": { - "pk": "id", - "tenant_field": "tenant_id", - "soft_delete_field": "is_deleted", - "engine": "postgresql", - "indexes": [ - "primary", - "uk_tplitem_tenant_tpl_type_seq", - "idx_tplitem_template" - ] - } + "caption": "PBL蓝图模板条目", + "readonly": [ + "id", + "tenant_id", + "created_by", + "created_at", + "updated_by", + "updated_at", + "deleted" + ] } -} \ No newline at end of file +} diff --git a/pbl_blueprint/json/pbl_blueprint_version.json b/pbl_blueprint/json/pbl_blueprint_version.json index 52286d7..6c2c939 100644 --- a/pbl_blueprint/json/pbl_blueprint_version.json +++ b/pbl_blueprint/json/pbl_blueprint_version.json @@ -1,82 +1,43 @@ { - "pbl_blueprint_version": { - "summary": "PBL蓝图版本", - "alias": "pbl_blueprint_version_crud", - "params": { - "sortby": "created_at", - "sortorder": "desc", - "tenant_scoped": true, - "browserfields": { - "fields": [ - "id", - "tenant_id", - "class_id", - "version_no", - "version_status", - "created_at", - "updated_at" - ], - "alters": { - "version_status": { - "uitype": "code", - "appcode": "pbl_version_status", - "label": "版本状态" - } - }, - "exclouded": [ - "snapshot_json", - "content_json", - "item_payload_json", - "quality_criteria", - "knowledge_points", - "tags_json" - ] - }, - "editable": { - "new_data_url": "{{entire_url('../api/version_save.dspy')}}", - "update_data_url": "{{entire_url('../api/version_save.dspy')}}", - "delete_data_url": "{{entire_url('../api/version_delete.dspy')}}", - "list_data_url": "{{entire_url('../api/version_list.dspy')}}", - "get_data_url": "{{entire_url('../api/version_get.dspy')}}", - "editexclouded": [ - "id", - "tenant_id", - "created_by", - "created_at" - ] - }, - "required": [ - "id", - "tenant_id", - "blueprint_id", - "version_no" - ], - "data_filter": { - "AND": [ - { - "field": "tenant_id", - "op": "=", - "var": "tenant_id" - } - ] - } + "tblname": "pbl_blueprint_version", + "params": { + "editable": [ + "blueprint_id", + "version_no", + "snapshot", + "change_delta", + "quality_level", + "status", + "remark" + ], + "browserfields": [ + "blueprint_id", + "version_no", + "quality_level", + "status", + "remark", + "created_by", + "created_at" + ], + "searchfields": [ + "tenant_id", + "blueprint_id", + "version_no", + "status" + ], + "orderby": "version_no desc", + "defaultfilter": { + "deleted": 0 }, - "codes": { - "version_status": { - "appcode": "pbl_version_status", - "label": "版本状态" - } - }, - "table_meta": { - "pk": "id", - "tenant_field": "tenant_id", - "soft_delete_field": "is_deleted", - "engine": "postgresql", - "indexes": [ - "primary", - "uk_version_blueprint_no", - "idx_version_blueprint" - ] - } + "caption": "PBL蓝图版本快照", + "readonly": [ + "id", + "tenant_id", + "created_by", + "created_at", + "updated_by", + "updated_at", + "deleted" + ] } -} \ No newline at end of file +} diff --git a/pbl_blueprint/models/pbl_blueprint.json b/pbl_blueprint/models/pbl_blueprint.json index f1946fa..f407ec3 100644 --- a/pbl_blueprint/models/pbl_blueprint.json +++ b/pbl_blueprint/models/pbl_blueprint.json @@ -1,185 +1,55 @@ { - "summary": { - "name": "pbl_blueprint", - "label": "PBL蓝图聚合根", - "desc": "PBL蓝图聚合根(pbl_blueprint 模块 M1a)", - "module": "pbl_blueprint", - "owner": "agent.develop", - "version": "1.0", - "engine": "postgresql", - "tenant_scoped": true - }, - "fields": { - "id": { - "type": "str(32)", - "label": "主键", - "pk": true, - "required": true - }, - "tenant_id": { - "type": "str(32)", - "label": "租户ID(强制打头)", - "required": true - }, - "class_id": { - "type": "str(32)", - "label": "班级ID" - }, - "blueprint_code": { - "type": "str(64)", - "label": "蓝图编码", - "required": true - }, - "blueprint_name": { - "type": "str(128)", - "label": "蓝图名称", - "required": true - }, - "domain_code": { - "type": "str(64)", - "label": "领域", - "appcode": "pbl_blueprint_domain" - }, - "grade_code": { - "type": "str(32)", - "label": "学段", - "appcode": "pbl_grade_code" - }, - "subject_code": { - "type": "str(64)", - "label": "学科", - "appcode": "pbl_subject_code" - }, - "project_duration": { - "type": "int", - "label": "项目时长(课时)" - }, - "student_count": { - "type": "int", - "label": "参与学生数" - }, - "group_count": { - "type": "int", - "label": "小组数" - }, - "complexity_level": { - "type": "str(32)", - "label": "复杂度", - "appcode": "pbl_complexity_level" - }, - "status": { - "type": "str(32)", - "label": "蓝图状态", - "appcode": "pbl_blueprint_status", - "default": "draft" - }, - "current_version": { - "type": "int", - "label": "当前版本号", - "default": 0 - }, - "published_version_id": { - "type": "str(32)", - "label": "已发布版本ID" - }, - "source_blueprint_id": { - "type": "str(32)", - "label": "派生来源蓝图ID(fork)" - }, - "source_template_id": { - "type": "str(32)", - "label": "来源模板ID" - }, - "owner_user_id": { - "type": "str(64)", - "label": "负责人" - }, - "estimated_cost": { - "type": "double(18,2)", - "label": "预估成本(元)", - "precision": 2 - }, - "tags_json": { - "type": "text", - "label": "标签(JSON数组)" - }, - "summary_text": { - "type": "text", - "label": "蓝图摘要" - }, - "is_deleted": { - "type": "int", - "label": "删除标记(0未删/1已删)", - "default": 0 - }, - "created_by": { - "type": "str(64)", - "label": "创建人" - }, - "created_at": { - "type": "datetime", - "label": "创建时间" - }, - "updated_by": { - "type": "str(64)", - "label": "更新人" - }, - "updated_at": { - "type": "datetime", - "label": "更新时间" - } - }, - "indexes": { - "primary": { - "fields": [ - "id" - ], - "unique": true - }, - "uk_blueprint_tenant_code": { - "fields": [ - "tenant_id", - "blueprint_code" - ], - "unique": true - }, - "idx_blueprint_tenant_status": { - "fields": [ - "tenant_id", - "status", - "is_deleted" - ], - "unique": false - }, - "idx_blueprint_class": { - "fields": [ - "tenant_id", - "class_id", - "is_deleted" - ], - "unique": false - } - }, + "summary": "PBL 蓝图聚合根主表(M1a)。一行=一个项目式学习蓝图,承载学科/年级/课时与质量状态;所有子对象(task/mission/role/learning_goal/evidence_spec/artifact_spec/reflection_spec)通过 blueprint_id 挂靠。tenant_id 为强制打头字段,任何读写必须带租户条件,缺失即拒绝。", + "fields": [ + {"name": "id", "type": "str", "len": 32, "primary": true, "notnull": true, "desc": "主键,32位无横线UUID"}, + {"name": "tenant_id", "type": "str", "len": 32, "notnull": true, "desc": "租户ID,强制打头,所有查询第一过滤条件"}, + {"name": "code", "type": "str", "len": 64, "notnull": true, "desc": "蓝图编码,租户内唯一,形如 PBL-{yyyyMMdd}-{seq4}"}, + {"name": "name", "type": "str", "len": 200, "notnull": true, "desc": "蓝图名称"}, + {"name": "subject", "type": "str", "len": 64, "desc": "学科"}, + {"name": "grade", "type": "str", "len": 32, "desc": "适用年级"}, + {"name": "duration_hours", "type": "int", "desc": "总课时数"}, + {"name": "version_no", "type": "int", "notnull": true, "default": "1", "desc": "当前版本号,每次发布快照自增"}, + {"name": "status", "type": "str", "len": 32, "notnull": true, "default": "draft", "desc": "蓝图状态,见 codes.status"}, + {"name": "quality_level", "type": "str", "len": 16, "default": "L1", "desc": "质量等级 L1-L5,由 pbl_validation 回写"}, + {"name": "summary", "type": "text", "desc": "蓝图简介/驱动性问题"}, + {"name": "config", "type": "json", "desc": "扩展配置(分组策略/评分权重/运行时参数)"}, + {"name": "source_blueprint_id", "type": "str", "len": 32, "desc": "fork 来源蓝图ID,非fork为空"}, + {"name": "template_id", "type": "str", "len": 32, "desc": "实例化来源模板ID,非模板创建为空"}, + {"name": "owner_id", "type": "str", "len": 32, "desc": "归属教师/创建者用户ID"}, + {"name": "published_at", "type": "datetime", "desc": "最近发布时间"}, + {"name": "remark", "type": "str", "len": 500, "desc": "备注"}, + {"name": "created_by", "type": "str", "len": 32, "desc": "创建人"}, + {"name": "created_at", "type": "datetime", "desc": "创建时间"}, + {"name": "updated_by", "type": "str", "len": 32, "desc": "最后修改人"}, + {"name": "updated_at", "type": "datetime", "desc": "最后修改时间"}, + {"name": "deleted", "type": "int", "notnull": true, "default": "0", "desc": "软删除标记 0正常 1已删除"} + ], + "indexes": [ + {"name": "uk_pbl_blueprint_tenant_code", "fields": ["tenant_id", "code"], "unique": true}, + {"name": "idx_pbl_blueprint_tenant_status", "fields": ["tenant_id", "status", "deleted"], "unique": false}, + {"name": "idx_pbl_blueprint_owner", "fields": ["tenant_id", "owner_id"], "unique": false}, + {"name": "idx_pbl_blueprint_template", "fields": ["tenant_id", "template_id"], "unique": false} + ], "codes": { - "domain_code": { - "appcode": "pbl_blueprint_domain", - "label": "领域" - }, - "grade_code": { - "appcode": "pbl_grade_code", - "label": "学段" - }, - "subject_code": { - "appcode": "pbl_subject_code", - "label": "学科" - }, - "complexity_level": { - "appcode": "pbl_complexity_level", - "label": "复杂度" - }, - "status": { - "appcode": "pbl_blueprint_status", - "label": "蓝图状态" - } + "status": [ + ["draft", "草稿"], + ["validating", "校验中"], + ["validated", "校验通过"], + ["compiling", "编译中"], + ["compiled", "已编译"], + ["published", "已发布"], + ["archived", "已归档"] + ], + "quality_level": [ + ["L1", "L1-初始"], + ["L2", "L2-基本完整"], + ["L3", "L3-结构合格"], + ["L4", "L4-可运行"], + ["L5", "L5-优质"] + ], + "deleted": [ + ["0", "正常"], + ["1", "已删除"] + ] } -} \ No newline at end of file +} diff --git a/pbl_blueprint/models/pbl_blueprint_artifact_spec.json b/pbl_blueprint/models/pbl_blueprint_artifact_spec.json index 463993a..c63d35a 100644 --- a/pbl_blueprint/models/pbl_blueprint_artifact_spec.json +++ b/pbl_blueprint/models/pbl_blueprint_artifact_spec.json @@ -1,133 +1,36 @@ { - "summary": { - "name": "pbl_blueprint_artifact_spec", - "label": "蓝图子对象-产出物规格(Artifact)", - "desc": "蓝图子对象-产出物规格(Artifact)(pbl_blueprint 模块 M1a)", - "module": "pbl_blueprint", - "owner": "agent.develop", - "version": "1.0", - "engine": "postgresql", - "tenant_scoped": true - }, - "fields": { - "id": { - "type": "str(32)", - "label": "主键", - "pk": true, - "required": true - }, - "tenant_id": { - "type": "str(32)", - "label": "租户ID(强制打头)", - "required": true - }, - "class_id": { - "type": "str(32)", - "label": "班级ID" - }, - "blueprint_id": { - "type": "str(32)", - "label": "所属蓝图ID", - "required": true - }, - "version_id": { - "type": "str(32)", - "label": "所属版本ID" - }, - "code": { - "type": "str(64)", - "label": "对象编码" - }, - "name": { - "type": "str(128)", - "label": "对象名称", - "required": true - }, - "description": { - "type": "text", - "label": "描述" - }, - "seq_no": { - "type": "int", - "label": "排序号", - "default": 0 - }, - "status": { - "type": "str(32)", - "label": "状态", - "appcode": "pbl_subobject_status" - }, - "content_json": { - "type": "text", - "label": "扩展内容(JSON)" - }, - "artifact_type": { - "type": "str(32)", - "label": "产出物类型", - "appcode": "pbl_artifact_type" - }, - "required_flag": { - "type": "int", - "label": "是否必需(0/1)", - "default": 1 - }, - "quality_criteria": { - "type": "text", - "label": "质量标准(JSON)" - }, - "due_seq": { - "type": "int", - "label": "交付次序" - }, - "is_deleted": { - "type": "int", - "label": "删除标记(0未删/1已删)", - "default": 0 - }, - "created_by": { - "type": "str(64)", - "label": "创建人" - }, - "created_at": { - "type": "datetime", - "label": "创建时间" - }, - "updated_by": { - "type": "str(64)", - "label": "更新人" - }, - "updated_at": { - "type": "datetime", - "label": "更新时间" - } - }, - "indexes": { - "primary": { - "fields": [ - "id" - ], - "unique": true - }, - "idx_pbl_blueprint_artifact_spec_tenant_bp": { - "fields": [ - "tenant_id", - "blueprint_id", - "is_deleted" - ], - "unique": false - }, - "uk_pbl_blueprint_artifact_spec_tenant_code": { - "fields": [ - "tenant_id", - "code" - ], - "unique": true - } - }, + "summary": "PBL 蓝图子对象-产出物规格(M1a,7类子对象之一)。一行=一种期望产出物的规格定义(作品/报告/模型/演示),供 pbl_evidence 归档产出物与 pbl_compiler 生成任务交付要求。统一泛化契约。tenant_id 强制打头。", + "fields": [ + {"name": "id", "type": "str", "len": 32, "primary": true, "notnull": true, "desc": "主键,32位无横线UUID"}, + {"name": "tenant_id", "type": "str", "len": 32, "notnull": true, "desc": "租户ID,强制打头"}, + {"name": "blueprint_id", "type": "str", "len": 32, "notnull": true, "desc": "所属蓝图ID"}, + {"name": "task_id", "type": "str", "len": 32, "desc": "关联任务ID"}, + {"name": "mission_id", "type": "str", "len": 32, "desc": "关联关卡ID"}, + {"name": "seq", "type": "int", "notnull": true, "default": "0", "desc": "蓝图内排序号"}, + {"name": "code", "type": "str", "len": 64, "desc": "产出物规格编码,蓝图内唯一,缺省自动生成 ART-{seq}"}, + {"name": "name", "type": "str", "len": 200, "notnull": true, "desc": "产出物名称"}, + {"name": "artifact_type", "type": "str", "len": 32, "notnull": true, "default": "document", "desc": "产出物类型,见 codes.artifact_type"}, + {"name": "description", "type": "text", "desc": "产出物说明与要求"}, + {"name": "spec", "type": "json", "desc": "产出物规格(格式/尺寸/时长/字数/文件类型白名单)"}, + {"name": "accept_criteria", "type": "json", "desc": "验收标准条目数组"}, + {"name": "required", "type": "int", "notnull": true, "default": "1", "desc": "是否必交 0否 1是"}, + {"name": "weight", "type": "float", "default": "1.0", "desc": "评分权重"}, + {"name": "status", "type": "str", "len": 32, "notnull": true, "default": "draft", "desc": "子对象状态,见 codes.status"}, + {"name": "created_by", "type": "str", "len": 32, "desc": "创建人"}, + {"name": "created_at", "type": "datetime", "desc": "创建时间"}, + {"name": "updated_by", "type": "str", "len": 32, "desc": "最后修改人"}, + {"name": "updated_at", "type": "datetime", "desc": "最后修改时间"}, + {"name": "deleted", "type": "int", "notnull": true, "default": "0", "desc": "软删除标记 0正常 1已删除"} + ], + "indexes": [ + {"name": "uk_pbl_bpart_tenant_bp_code", "fields": ["tenant_id", "blueprint_id", "code"], "unique": true}, + {"name": "idx_pbl_bpart_tenant_bp_seq", "fields": ["tenant_id", "blueprint_id", "seq"], "unique": false}, + {"name": "idx_pbl_bpart_task", "fields": ["tenant_id", "task_id"], "unique": false} + ], "codes": { - "artifact_type": { - "appcode": "pbl_artifact_type", - "label": "产出物类型" - } + "artifact_type": [["document", "文档报告"], ["model", "三维模型"], ["scene", "场景作品"], ["video", "视频"], ["audio", "音频"], ["image", "图片"], ["code", "程序脚本"], ["presentation", "演示汇报"], ["dataset", "数据集"]], + "required": [["0", "选交"], ["1", "必交"]], + "status": [["draft", "草稿"], ["active", "生效"], ["disabled", "停用"]], + "deleted": [["0", "正常"], ["1", "已删除"]] } -} \ No newline at end of file +} diff --git a/pbl_blueprint/models/pbl_blueprint_evidence_spec.json b/pbl_blueprint/models/pbl_blueprint_evidence_spec.json index aec65a4..f693221 100644 --- a/pbl_blueprint/models/pbl_blueprint_evidence_spec.json +++ b/pbl_blueprint/models/pbl_blueprint_evidence_spec.json @@ -1,133 +1,38 @@ { - "summary": { - "name": "pbl_blueprint_evidence_spec", - "label": "蓝图子对象-证据规格(Evidence)", - "desc": "蓝图子对象-证据规格(Evidence)(pbl_blueprint 模块 M1a)", - "module": "pbl_blueprint", - "owner": "agent.develop", - "version": "1.0", - "engine": "postgresql", - "tenant_scoped": true - }, - "fields": { - "id": { - "type": "str(32)", - "label": "主键", - "pk": true, - "required": true - }, - "tenant_id": { - "type": "str(32)", - "label": "租户ID(强制打头)", - "required": true - }, - "class_id": { - "type": "str(32)", - "label": "班级ID" - }, - "blueprint_id": { - "type": "str(32)", - "label": "所属蓝图ID", - "required": true - }, - "version_id": { - "type": "str(32)", - "label": "所属版本ID" - }, - "code": { - "type": "str(64)", - "label": "对象编码" - }, - "name": { - "type": "str(128)", - "label": "对象名称", - "required": true - }, - "description": { - "type": "text", - "label": "描述" - }, - "seq_no": { - "type": "int", - "label": "排序号", - "default": 0 - }, - "status": { - "type": "str(32)", - "label": "状态", - "appcode": "pbl_subobject_status" - }, - "content_json": { - "type": "text", - "label": "扩展内容(JSON)" - }, - "evidence_type": { - "type": "str(32)", - "label": "证据类型", - "appcode": "pbl_evidence_type" - }, - "artifact_id": { - "type": "str(32)", - "label": "关联产出物ID" - }, - "min_count": { - "type": "int", - "label": "最少提交数" - }, - "weight_value": { - "type": "double(18,2)", - "label": "证据权重", - "precision": 2 - }, - "is_deleted": { - "type": "int", - "label": "删除标记(0未删/1已删)", - "default": 0 - }, - "created_by": { - "type": "str(64)", - "label": "创建人" - }, - "created_at": { - "type": "datetime", - "label": "创建时间" - }, - "updated_by": { - "type": "str(64)", - "label": "更新人" - }, - "updated_at": { - "type": "datetime", - "label": "更新时间" - } - }, - "indexes": { - "primary": { - "fields": [ - "id" - ], - "unique": true - }, - "idx_pbl_blueprint_evidence_spec_tenant_bp": { - "fields": [ - "tenant_id", - "blueprint_id", - "is_deleted" - ], - "unique": false - }, - "uk_pbl_blueprint_evidence_spec_tenant_code": { - "fields": [ - "tenant_id", - "code" - ], - "unique": true - } - }, + "summary": "PBL 蓝图子对象-证据规格(M1a,7类子对象之一)。一行=一条学习证据的采集规格,定义证据类型/采集方式/校验规则,供 pbl_evidence 幂等采集与 pbl_assessment 评分引用。统一泛化契约。tenant_id 强制打头。", + "fields": [ + {"name": "id", "type": "str", "len": 32, "primary": true, "notnull": true, "desc": "主键,32位无横线UUID"}, + {"name": "tenant_id", "type": "str", "len": 32, "notnull": true, "desc": "租户ID,强制打头"}, + {"name": "blueprint_id", "type": "str", "len": 32, "notnull": true, "desc": "所属蓝图ID"}, + {"name": "task_id", "type": "str", "len": 32, "desc": "关联任务ID"}, + {"name": "mission_id", "type": "str", "len": 32, "desc": "关联关卡ID"}, + {"name": "learning_goal_id", "type": "str", "len": 32, "desc": "关联学习目标ID"}, + {"name": "seq", "type": "int", "notnull": true, "default": "0", "desc": "蓝图内排序号"}, + {"name": "code", "type": "str", "len": 64, "desc": "证据规格编码,蓝图内唯一,缺省自动生成 EVI-{seq}"}, + {"name": "name", "type": "str", "len": 200, "notnull": true, "desc": "证据规格名称"}, + {"name": "evidence_type", "type": "str", "len": 32, "notnull": true, "default": "auto", "desc": "证据类型,见 codes.evidence_type"}, + {"name": "collect_mode", "type": "str", "len": 32, "notnull": true, "default": "auto", "desc": "采集方式,见 codes.collect_mode"}, + {"name": "description", "type": "text", "desc": "证据说明"}, + {"name": "spec", "type": "json", "desc": "证据规格(字段schema/取值范围/单位/幂等键规则)"}, + {"name": "rule", "type": "json", "desc": "校验规则(阈值/表达式/必填项)"}, + {"name": "weight", "type": "float", "default": "1.0", "desc": "评分权重"}, + {"name": "status", "type": "str", "len": 32, "notnull": true, "default": "draft", "desc": "子对象状态,见 codes.status"}, + {"name": "created_by", "type": "str", "len": 32, "desc": "创建人"}, + {"name": "created_at", "type": "datetime", "desc": "创建时间"}, + {"name": "updated_by", "type": "str", "len": 32, "desc": "最后修改人"}, + {"name": "updated_at", "type": "datetime", "desc": "最后修改时间"}, + {"name": "deleted", "type": "int", "notnull": true, "default": "0", "desc": "软删除标记 0正常 1已删除"} + ], + "indexes": [ + {"name": "uk_pbl_bpev_tenant_bp_code", "fields": ["tenant_id", "blueprint_id", "code"], "unique": true}, + {"name": "idx_pbl_bpev_tenant_bp_seq", "fields": ["tenant_id", "blueprint_id", "seq"], "unique": false}, + {"name": "idx_pbl_bpev_goal", "fields": ["tenant_id", "learning_goal_id"], "unique": false}, + {"name": "idx_pbl_bpev_mission", "fields": ["tenant_id", "mission_id"], "unique": false} + ], "codes": { - "evidence_type": { - "appcode": "pbl_evidence_type", - "label": "证据类型" - } + "evidence_type": [["auto", "系统自动采集"], ["manual", "人工提交"], ["artifact", "产出物派生"], ["interaction", "交互行为"], ["assessment", "测评结果"]], + "collect_mode": [["auto", "自动"], ["trigger", "事件触发"], ["manual", "手动上传"], ["scheduled", "定时采集"]], + "status": [["draft", "草稿"], ["active", "生效"], ["disabled", "停用"]], + "deleted": [["0", "正常"], ["1", "已删除"]] } -} \ No newline at end of file +} diff --git a/pbl_blueprint/models/pbl_blueprint_learning_goal.json b/pbl_blueprint/models/pbl_blueprint_learning_goal.json index 090c9c2..a56fc2e 100644 --- a/pbl_blueprint/models/pbl_blueprint_learning_goal.json +++ b/pbl_blueprint/models/pbl_blueprint_learning_goal.json @@ -1,138 +1,32 @@ { - "summary": { - "name": "pbl_blueprint_learning_goal", - "label": "蓝图子对象-学习目标(Goal)", - "desc": "蓝图子对象-学习目标(Goal)(pbl_blueprint 模块 M1a)", - "module": "pbl_blueprint", - "owner": "agent.develop", - "version": "1.0", - "engine": "postgresql", - "tenant_scoped": true - }, - "fields": { - "id": { - "type": "str(32)", - "label": "主键", - "pk": true, - "required": true - }, - "tenant_id": { - "type": "str(32)", - "label": "租户ID(强制打头)", - "required": true - }, - "class_id": { - "type": "str(32)", - "label": "班级ID" - }, - "blueprint_id": { - "type": "str(32)", - "label": "所属蓝图ID", - "required": true - }, - "version_id": { - "type": "str(32)", - "label": "所属版本ID" - }, - "code": { - "type": "str(64)", - "label": "对象编码" - }, - "name": { - "type": "str(128)", - "label": "对象名称", - "required": true - }, - "description": { - "type": "text", - "label": "描述" - }, - "seq_no": { - "type": "int", - "label": "排序号", - "default": 0 - }, - "status": { - "type": "str(32)", - "label": "状态", - "appcode": "pbl_subobject_status" - }, - "content_json": { - "type": "text", - "label": "扩展内容(JSON)" - }, - "goal_type": { - "type": "str(32)", - "label": "目标类型", - "appcode": "pbl_goal_type" - }, - "competency_code": { - "type": "str(64)", - "label": "素养/课标编码" - }, - "knowledge_points": { - "type": "text", - "label": "知识点(JSON数组)" - }, - "measurable_flag": { - "type": "int", - "label": "是否可测(0/1)", - "default": 0 - }, - "weight_value": { - "type": "double(18,2)", - "label": "权重", - "precision": 2 - }, - "is_deleted": { - "type": "int", - "label": "删除标记(0未删/1已删)", - "default": 0 - }, - "created_by": { - "type": "str(64)", - "label": "创建人" - }, - "created_at": { - "type": "datetime", - "label": "创建时间" - }, - "updated_by": { - "type": "str(64)", - "label": "更新人" - }, - "updated_at": { - "type": "datetime", - "label": "更新时间" - } - }, - "indexes": { - "primary": { - "fields": [ - "id" - ], - "unique": true - }, - "idx_pbl_blueprint_learning_goal_tenant_bp": { - "fields": [ - "tenant_id", - "blueprint_id", - "is_deleted" - ], - "unique": false - }, - "uk_pbl_blueprint_learning_goal_tenant_code": { - "fields": [ - "tenant_id", - "code" - ], - "unique": true - } - }, + "summary": "PBL 蓝图子对象-学习目标(M1a,7类子对象之一)。一行=蓝图的一条学习目标(知识/能力/素养/态度四维),可关联到任务并被评估量规引用。统一泛化契约。tenant_id 强制打头。", + "fields": [ + {"name": "id", "type": "str", "len": 32, "primary": true, "notnull": true, "desc": "主键,32位无横线UUID"}, + {"name": "tenant_id", "type": "str", "len": 32, "notnull": true, "desc": "租户ID,强制打头"}, + {"name": "blueprint_id", "type": "str", "len": 32, "notnull": true, "desc": "所属蓝图ID"}, + {"name": "task_id", "type": "str", "len": 32, "desc": "关联任务ID,为空表示蓝图级目标"}, + {"name": "seq", "type": "int", "notnull": true, "default": "0", "desc": "蓝图内排序号"}, + {"name": "code", "type": "str", "len": 64, "desc": "目标编码,蓝图内唯一,缺省自动生成 GOAL-{seq}"}, + {"name": "name", "type": "str", "len": 200, "notnull": true, "desc": "目标名称"}, + {"name": "dimension", "type": "str", "len": 32, "notnull": true, "default": "knowledge", "desc": "目标维度,见 codes.dimension"}, + {"name": "description", "type": "text", "desc": "目标描述(可观测行为动词表述)"}, + {"name": "spec", "type": "json", "desc": "目标规格(课标对应/掌握层级/评价方式)"}, + {"name": "weight", "type": "float", "default": "1.0", "desc": "评估权重,供 pbl_assessment Rubric 使用"}, + {"name": "status", "type": "str", "len": 32, "notnull": true, "default": "draft", "desc": "子对象状态,见 codes.status"}, + {"name": "created_by", "type": "str", "len": 32, "desc": "创建人"}, + {"name": "created_at", "type": "datetime", "desc": "创建时间"}, + {"name": "updated_by", "type": "str", "len": 32, "desc": "最后修改人"}, + {"name": "updated_at", "type": "datetime", "desc": "最后修改时间"}, + {"name": "deleted", "type": "int", "notnull": true, "default": "0", "desc": "软删除标记 0正常 1已删除"} + ], + "indexes": [ + {"name": "uk_pbl_bplg_tenant_bp_code", "fields": ["tenant_id", "blueprint_id", "code"], "unique": true}, + {"name": "idx_pbl_bplg_tenant_bp_seq", "fields": ["tenant_id", "blueprint_id", "seq"], "unique": false}, + {"name": "idx_pbl_bplg_dim", "fields": ["tenant_id", "blueprint_id", "dimension"], "unique": false} + ], "codes": { - "goal_type": { - "appcode": "pbl_goal_type", - "label": "目标类型" - } + "dimension": [["knowledge", "知识"], ["ability", "能力"], ["literacy", "素养"], ["attitude", "态度"]], + "status": [["draft", "草稿"], ["active", "生效"], ["disabled", "停用"]], + "deleted": [["0", "正常"], ["1", "已删除"]] } -} \ No newline at end of file +} diff --git a/pbl_blueprint/models/pbl_blueprint_mission.json b/pbl_blueprint/models/pbl_blueprint_mission.json index 836f252..62f5544 100644 --- a/pbl_blueprint/models/pbl_blueprint_mission.json +++ b/pbl_blueprint/models/pbl_blueprint_mission.json @@ -1,138 +1,30 @@ { - "summary": { - "name": "pbl_blueprint_mission", - "label": "蓝图子对象-真实情境任务(Mission)", - "desc": "蓝图子对象-真实情境任务(Mission)(pbl_blueprint 模块 M1a)", - "module": "pbl_blueprint", - "owner": "agent.develop", - "version": "1.0", - "engine": "postgresql", - "tenant_scoped": true - }, - "fields": { - "id": { - "type": "str(32)", - "label": "主键", - "pk": true, - "required": true - }, - "tenant_id": { - "type": "str(32)", - "label": "租户ID(强制打头)", - "required": true - }, - "class_id": { - "type": "str(32)", - "label": "班级ID" - }, - "blueprint_id": { - "type": "str(32)", - "label": "所属蓝图ID", - "required": true - }, - "version_id": { - "type": "str(32)", - "label": "所属版本ID" - }, - "code": { - "type": "str(64)", - "label": "对象编码" - }, - "name": { - "type": "str(128)", - "label": "对象名称", - "required": true - }, - "description": { - "type": "text", - "label": "描述" - }, - "seq_no": { - "type": "int", - "label": "排序号", - "default": 0 - }, - "status": { - "type": "str(32)", - "label": "状态", - "appcode": "pbl_subobject_status" - }, - "content_json": { - "type": "text", - "label": "扩展内容(JSON)" - }, - "mission_type": { - "type": "str(32)", - "label": "情境类型", - "appcode": "pbl_mission_type" - }, - "scenario_text": { - "type": "text", - "label": "情境描述" - }, - "audience": { - "type": "str(256)", - "label": "受众/服务对象" - }, - "duration_hours": { - "type": "double(18,2)", - "label": "预计课时", - "precision": 2 - }, - "authenticity_score": { - "type": "double(18,2)", - "label": "真实性评分", - "precision": 2 - }, - "is_deleted": { - "type": "int", - "label": "删除标记(0未删/1已删)", - "default": 0 - }, - "created_by": { - "type": "str(64)", - "label": "创建人" - }, - "created_at": { - "type": "datetime", - "label": "创建时间" - }, - "updated_by": { - "type": "str(64)", - "label": "更新人" - }, - "updated_at": { - "type": "datetime", - "label": "更新时间" - } - }, - "indexes": { - "primary": { - "fields": [ - "id" - ], - "unique": true - }, - "idx_pbl_blueprint_mission_tenant_bp": { - "fields": [ - "tenant_id", - "blueprint_id", - "is_deleted" - ], - "unique": false - }, - "uk_pbl_blueprint_mission_tenant_code": { - "fields": [ - "tenant_id", - "code" - ], - "unique": true - } - }, + "summary": "PBL 蓝图子对象-关卡(M1a,7类子对象之一)。一行=任务下的一个游戏化关卡,是 Compiler 生成 Game Definition 的直接来源。统一泛化契约(blueprint_id + task_id + seq + code + name + spec + status)。tenant_id 强制打头。", + "fields": [ + {"name": "id", "type": "str", "len": 32, "primary": true, "notnull": true, "desc": "主键,32位无横线UUID"}, + {"name": "tenant_id", "type": "str", "len": 32, "notnull": true, "desc": "租户ID,强制打头"}, + {"name": "blueprint_id", "type": "str", "len": 32, "notnull": true, "desc": "所属蓝图ID"}, + {"name": "task_id", "type": "str", "len": 32, "notnull": true, "desc": "所属任务ID"}, + {"name": "seq", "type": "int", "notnull": true, "default": "0", "desc": "任务内排序号"}, + {"name": "code", "type": "str", "len": 64, "desc": "关卡编码,蓝图内唯一,缺省自动生成 MISSION-{seq}"}, + {"name": "name", "type": "str", "len": 200, "notnull": true, "desc": "关卡名称"}, + {"name": "description", "type": "text", "desc": "关卡说明"}, + {"name": "spec", "type": "json", "desc": "关卡规格(目标/规则/场景引用/通关条件/奖励)"}, + {"name": "unlock_condition", "type": "json", "desc": "解锁条件(前置关卡/分数门槛)"}, + {"name": "status", "type": "str", "len": 32, "notnull": true, "default": "draft", "desc": "子对象状态,见 codes.status"}, + {"name": "created_by", "type": "str", "len": 32, "desc": "创建人"}, + {"name": "created_at", "type": "datetime", "desc": "创建时间"}, + {"name": "updated_by", "type": "str", "len": 32, "desc": "最后修改人"}, + {"name": "updated_at", "type": "datetime", "desc": "最后修改时间"}, + {"name": "deleted", "type": "int", "notnull": true, "default": "0", "desc": "软删除标记 0正常 1已删除"} + ], + "indexes": [ + {"name": "uk_pbl_bpms_tenant_bp_code", "fields": ["tenant_id", "blueprint_id", "code"], "unique": true}, + {"name": "idx_pbl_bpms_tenant_bp_seq", "fields": ["tenant_id", "blueprint_id", "seq"], "unique": false}, + {"name": "idx_pbl_bpms_task", "fields": ["tenant_id", "task_id", "seq"], "unique": false} + ], "codes": { - "mission_type": { - "appcode": "pbl_mission_type", - "label": "情境类型" - } + "status": [["draft", "草稿"], ["active", "生效"], ["disabled", "停用"]], + "deleted": [["0", "正常"], ["1", "已删除"]] } -} \ No newline at end of file +} diff --git a/pbl_blueprint/models/pbl_blueprint_reflection_spec.json b/pbl_blueprint/models/pbl_blueprint_reflection_spec.json index cd4e917..bcd7de0 100644 --- a/pbl_blueprint/models/pbl_blueprint_reflection_spec.json +++ b/pbl_blueprint/models/pbl_blueprint_reflection_spec.json @@ -1,137 +1,36 @@ { - "summary": { - "name": "pbl_blueprint_reflection_spec", - "label": "蓝图子对象-反思规格(Reflection)", - "desc": "蓝图子对象-反思规格(Reflection)(pbl_blueprint 模块 M1a)", - "module": "pbl_blueprint", - "owner": "agent.develop", - "version": "1.0", - "engine": "postgresql", - "tenant_scoped": true - }, - "fields": { - "id": { - "type": "str(32)", - "label": "主键", - "pk": true, - "required": true - }, - "tenant_id": { - "type": "str(32)", - "label": "租户ID(强制打头)", - "required": true - }, - "class_id": { - "type": "str(32)", - "label": "班级ID" - }, - "blueprint_id": { - "type": "str(32)", - "label": "所属蓝图ID", - "required": true - }, - "version_id": { - "type": "str(32)", - "label": "所属版本ID" - }, - "code": { - "type": "str(64)", - "label": "对象编码" - }, - "name": { - "type": "str(128)", - "label": "对象名称", - "required": true - }, - "description": { - "type": "text", - "label": "描述" - }, - "seq_no": { - "type": "int", - "label": "排序号", - "default": 0 - }, - "status": { - "type": "str(32)", - "label": "状态", - "appcode": "pbl_subobject_status" - }, - "content_json": { - "type": "text", - "label": "扩展内容(JSON)" - }, - "reflect_type": { - "type": "str(32)", - "label": "反思类型", - "appcode": "pbl_reflect_type" - }, - "frequency": { - "type": "str(32)", - "label": "频次", - "appcode": "pbl_reflect_frequency" - }, - "prompt_text": { - "type": "text", - "label": "反思引导语" - }, - "duration_minutes": { - "type": "int", - "label": "时长(分钟)" - }, - "is_deleted": { - "type": "int", - "label": "删除标记(0未删/1已删)", - "default": 0 - }, - "created_by": { - "type": "str(64)", - "label": "创建人" - }, - "created_at": { - "type": "datetime", - "label": "创建时间" - }, - "updated_by": { - "type": "str(64)", - "label": "更新人" - }, - "updated_at": { - "type": "datetime", - "label": "更新时间" - } - }, - "indexes": { - "primary": { - "fields": [ - "id" - ], - "unique": true - }, - "idx_pbl_blueprint_reflection_spec_tenant_bp": { - "fields": [ - "tenant_id", - "blueprint_id", - "is_deleted" - ], - "unique": false - }, - "uk_pbl_blueprint_reflection_spec_tenant_code": { - "fields": [ - "tenant_id", - "code" - ], - "unique": true - } - }, + "summary": "PBL 蓝图子对象-反思规格(M1a,7类子对象之一)。一行=一条反思/自评提示的规格定义(触发时机、反思类型、引导问题、量规维度),供运行时按节点触发学习者反思并回流评估。统一泛化契约。tenant_id 强制打头。", + "fields": [ + {"name": "id", "type": "str", "len": 32, "primary": true, "notnull": true, "desc": "主键,32位无横线UUID"}, + {"name": "tenant_id", "type": "str", "len": 32, "notnull": true, "desc": "租户ID,强制打头"}, + {"name": "blueprint_id", "type": "str", "len": 32, "notnull": true, "desc": "所属蓝图ID"}, + {"name": "task_id", "type": "str", "len": 32, "desc": "关联任务ID"}, + {"name": "mission_id", "type": "str", "len": 32, "desc": "关联关卡ID"}, + {"name": "seq", "type": "int", "notnull": true, "default": "0", "desc": "蓝图内排序号"}, + {"name": "code", "type": "str", "len": 64, "desc": "反思规格编码,蓝图内唯一,缺省自动生成 REF-{seq}"}, + {"name": "name", "type": "str", "len": 200, "notnull": true, "desc": "反思规格名称"}, + {"name": "trigger_point", "type": "str", "len": 32, "notnull": true, "default": "mission_end", "desc": "触发时机,见 codes.trigger_point"}, + {"name": "reflection_type", "type": "str", "len": 32, "notnull": true, "default": "self", "desc": "反思类型,见 codes.reflection_type"}, + {"name": "description", "type": "text", "desc": "反思说明"}, + {"name": "spec", "type": "json", "desc": "反思规格(引导问题数组/作答形式/字数下限)"}, + {"name": "rubric_ref", "type": "json", "desc": "关联量规维度引用(供 pbl_assessment 打分)"}, + {"name": "weight", "type": "float", "default": "1.0", "desc": "评分权重"}, + {"name": "status", "type": "str", "len": 32, "notnull": true, "default": "draft", "desc": "子对象状态,见 codes.status"}, + {"name": "created_by", "type": "str", "len": 32, "desc": "创建人"}, + {"name": "created_at", "type": "datetime", "desc": "创建时间"}, + {"name": "updated_by", "type": "str", "len": 32, "desc": "最后修改人"}, + {"name": "updated_at", "type": "datetime", "desc": "最后修改时间"}, + {"name": "deleted", "type": "int", "notnull": true, "default": "0", "desc": "软删除标记 0正常 1已删除"} + ], + "indexes": [ + {"name": "uk_pbl_bpref_tenant_bp_code", "fields": ["tenant_id", "blueprint_id", "code"], "unique": true}, + {"name": "idx_pbl_bpref_tenant_bp_seq", "fields": ["tenant_id", "blueprint_id", "seq"], "unique": false}, + {"name": "idx_pbl_bpref_trigger", "fields": ["tenant_id", "blueprint_id", "trigger_point"], "unique": false} + ], "codes": { - "reflect_type": { - "appcode": "pbl_reflect_type", - "label": "反思类型" - }, - "frequency": { - "appcode": "pbl_reflect_frequency", - "label": "频次" - } + "trigger_point": [["task_start", "任务开始"], ["task_end", "任务结束"], ["mission_end", "关卡结束"], ["milestone", "里程碑"], ["project_end", "项目结题"], ["manual", "手动触发"]], + "reflection_type": [["self", "自我反思"], ["peer", "同伴互评"], ["team", "团队复盘"], ["teacher", "教师点评"]], + "status": [["draft", "草稿"], ["active", "生效"], ["disabled", "停用"]], + "deleted": [["0", "正常"], ["1", "已删除"]] } -} \ No newline at end of file +} diff --git a/pbl_blueprint/models/pbl_blueprint_role.json b/pbl_blueprint/models/pbl_blueprint_role.json index b83302a..eb5535d 100644 --- a/pbl_blueprint/models/pbl_blueprint_role.json +++ b/pbl_blueprint/models/pbl_blueprint_role.json @@ -1,132 +1,30 @@ { - "summary": { - "name": "pbl_blueprint_role", - "label": "蓝图子对象-角色(Role)", - "desc": "蓝图子对象-角色(Role)(pbl_blueprint 模块 M1a)", - "module": "pbl_blueprint", - "owner": "agent.develop", - "version": "1.0", - "engine": "postgresql", - "tenant_scoped": true - }, - "fields": { - "id": { - "type": "str(32)", - "label": "主键", - "pk": true, - "required": true - }, - "tenant_id": { - "type": "str(32)", - "label": "租户ID(强制打头)", - "required": true - }, - "class_id": { - "type": "str(32)", - "label": "班级ID" - }, - "blueprint_id": { - "type": "str(32)", - "label": "所属蓝图ID", - "required": true - }, - "version_id": { - "type": "str(32)", - "label": "所属版本ID" - }, - "code": { - "type": "str(64)", - "label": "对象编码" - }, - "name": { - "type": "str(128)", - "label": "对象名称", - "required": true - }, - "description": { - "type": "text", - "label": "描述" - }, - "seq_no": { - "type": "int", - "label": "排序号", - "default": 0 - }, - "status": { - "type": "str(32)", - "label": "状态", - "appcode": "pbl_subobject_status" - }, - "content_json": { - "type": "text", - "label": "扩展内容(JSON)" - }, - "role_category": { - "type": "str(32)", - "label": "角色类别", - "appcode": "pbl_role_category" - }, - "member_count": { - "type": "int", - "label": "角色人数" - }, - "responsibility": { - "type": "text", - "label": "职责说明" - }, - "competency_tags": { - "type": "text", - "label": "能力标签(JSON数组)" - }, - "is_deleted": { - "type": "int", - "label": "删除标记(0未删/1已删)", - "default": 0 - }, - "created_by": { - "type": "str(64)", - "label": "创建人" - }, - "created_at": { - "type": "datetime", - "label": "创建时间" - }, - "updated_by": { - "type": "str(64)", - "label": "更新人" - }, - "updated_at": { - "type": "datetime", - "label": "更新时间" - } - }, - "indexes": { - "primary": { - "fields": [ - "id" - ], - "unique": true - }, - "idx_pbl_blueprint_role_tenant_bp": { - "fields": [ - "tenant_id", - "blueprint_id", - "is_deleted" - ], - "unique": false - }, - "uk_pbl_blueprint_role_tenant_code": { - "fields": [ - "tenant_id", - "code" - ], - "unique": true - } - }, + "summary": "PBL 蓝图子对象-角色(M1a,7类子对象之一)。一行=蓝图内定义的一种学习者角色(如队长/记录员/工程师),供分组与权限分配使用。统一泛化契约。tenant_id 强制打头。", + "fields": [ + {"name": "id", "type": "str", "len": 32, "primary": true, "notnull": true, "desc": "主键,32位无横线UUID"}, + {"name": "tenant_id", "type": "str", "len": 32, "notnull": true, "desc": "租户ID,强制打头"}, + {"name": "blueprint_id", "type": "str", "len": 32, "notnull": true, "desc": "所属蓝图ID"}, + {"name": "task_id", "type": "str", "len": 32, "desc": "关联任务ID,为空表示蓝图级角色"}, + {"name": "seq", "type": "int", "notnull": true, "default": "0", "desc": "蓝图内排序号"}, + {"name": "code", "type": "str", "len": 64, "desc": "角色编码,蓝图内唯一,缺省自动生成 ROLE-{seq}"}, + {"name": "name", "type": "str", "len": 200, "notnull": true, "desc": "角色名称"}, + {"name": "description", "type": "text", "desc": "角色职责说明"}, + {"name": "spec", "type": "json", "desc": "角色规格(人数上限/能力标签/可用工具/评分权重)"}, + {"name": "permissions", "type": "json", "desc": "角色在运行时的操作权限集合"}, + {"name": "status", "type": "str", "len": 32, "notnull": true, "default": "draft", "desc": "子对象状态,见 codes.status"}, + {"name": "created_by", "type": "str", "len": 32, "desc": "创建人"}, + {"name": "created_at", "type": "datetime", "desc": "创建时间"}, + {"name": "updated_by", "type": "str", "len": 32, "desc": "最后修改人"}, + {"name": "updated_at", "type": "datetime", "desc": "最后修改时间"}, + {"name": "deleted", "type": "int", "notnull": true, "default": "0", "desc": "软删除标记 0正常 1已删除"} + ], + "indexes": [ + {"name": "uk_pbl_bprole_tenant_bp_code", "fields": ["tenant_id", "blueprint_id", "code"], "unique": true}, + {"name": "idx_pbl_bprole_tenant_bp_seq", "fields": ["tenant_id", "blueprint_id", "seq"], "unique": false}, + {"name": "idx_pbl_bprole_task", "fields": ["tenant_id", "task_id"], "unique": false} + ], "codes": { - "role_category": { - "appcode": "pbl_role_category", - "label": "角色类别" - } + "status": [["draft", "草稿"], ["active", "生效"], ["disabled", "停用"]], + "deleted": [["0", "正常"], ["1", "已删除"]] } -} \ No newline at end of file +} diff --git a/pbl_blueprint/models/pbl_blueprint_task.json b/pbl_blueprint/models/pbl_blueprint_task.json index fe56d08..b2c5732 100644 --- a/pbl_blueprint/models/pbl_blueprint_task.json +++ b/pbl_blueprint/models/pbl_blueprint_task.json @@ -1,137 +1,36 @@ { - "summary": { - "name": "pbl_blueprint_task", - "label": "蓝图子对象-任务分解(Task)", - "desc": "蓝图子对象-任务分解(Task)(pbl_blueprint 模块 M1a)", - "module": "pbl_blueprint", - "owner": "agent.develop", - "version": "1.0", - "engine": "postgresql", - "tenant_scoped": true - }, - "fields": { - "id": { - "type": "str(32)", - "label": "主键", - "pk": true, - "required": true - }, - "tenant_id": { - "type": "str(32)", - "label": "租户ID(强制打头)", - "required": true - }, - "class_id": { - "type": "str(32)", - "label": "班级ID" - }, - "blueprint_id": { - "type": "str(32)", - "label": "所属蓝图ID", - "required": true - }, - "version_id": { - "type": "str(32)", - "label": "所属版本ID" - }, - "code": { - "type": "str(64)", - "label": "对象编码" - }, - "name": { - "type": "str(128)", - "label": "对象名称", - "required": true - }, - "description": { - "type": "text", - "label": "描述" - }, - "seq_no": { - "type": "int", - "label": "排序号", - "default": 0 - }, - "status": { - "type": "str(32)", - "label": "状态", - "appcode": "pbl_subobject_status" - }, - "content_json": { - "type": "text", - "label": "扩展内容(JSON)" - }, - "parent_task_id": { - "type": "str(32)", - "label": "父任务ID" - }, - "task_type": { - "type": "str(32)", - "label": "任务类型", - "appcode": "pbl_task_type" - }, - "tool_resources": { - "type": "text", - "label": "工具与资源(JSON数组)" - }, - "planned_hours": { - "type": "double(18,2)", - "label": "计划课时", - "precision": 2 - }, - "assessment_rule": { - "type": "text", - "label": "评价规则(JSON)" - }, - "is_deleted": { - "type": "int", - "label": "删除标记(0未删/1已删)", - "default": 0 - }, - "created_by": { - "type": "str(64)", - "label": "创建人" - }, - "created_at": { - "type": "datetime", - "label": "创建时间" - }, - "updated_by": { - "type": "str(64)", - "label": "更新人" - }, - "updated_at": { - "type": "datetime", - "label": "更新时间" - } - }, - "indexes": { - "primary": { - "fields": [ - "id" - ], - "unique": true - }, - "idx_pbl_blueprint_task_tenant_bp": { - "fields": [ - "tenant_id", - "blueprint_id", - "is_deleted" - ], - "unique": false - }, - "uk_pbl_blueprint_task_tenant_code": { - "fields": [ - "tenant_id", - "code" - ], - "unique": true - } - }, + "summary": "PBL 蓝图子对象-任务(M1a,7类子对象之一)。一行=蓝图下的一个学习任务,是关卡/角色/目标的挂载父级。采用统一泛化契约(blueprint_id + seq + name + spec + status),便于 Compiler 与 Validation 统一遍历。tenant_id 强制打头。", + "fields": [ + {"name": "id", "type": "str", "len": 32, "primary": true, "notnull": true, "desc": "主键,32位无横线UUID"}, + {"name": "tenant_id", "type": "str", "len": 32, "notnull": true, "desc": "租户ID,强制打头"}, + {"name": "blueprint_id", "type": "str", "len": 32, "notnull": true, "desc": "所属蓝图ID"}, + {"name": "parent_id", "type": "str", "len": 32, "desc": "父任务ID,支持任务分层,顶层为空"}, + {"name": "seq", "type": "int", "notnull": true, "default": "0", "desc": "蓝图内排序号"}, + {"name": "code", "type": "str", "len": 64, "desc": "任务编码,蓝图内唯一,缺省自动生成 TASK-{seq}"}, + {"name": "name", "type": "str", "len": 200, "notnull": true, "desc": "任务名称"}, + {"name": "description", "type": "text", "desc": "任务描述/驱动性问题"}, + {"name": "spec", "type": "json", "desc": "任务规格(时长/难度/分组方式/前置条件/交付要求)"}, + {"name": "status", "type": "str", "len": 32, "notnull": true, "default": "draft", "desc": "子对象状态,见 codes.status"}, + {"name": "created_by", "type": "str", "len": 32, "desc": "创建人"}, + {"name": "created_at", "type": "datetime", "desc": "创建时间"}, + {"name": "updated_by", "type": "str", "len": 32, "desc": "最后修改人"}, + {"name": "updated_at", "type": "datetime", "desc": "最后修改时间"}, + {"name": "deleted", "type": "int", "notnull": true, "default": "0", "desc": "软删除标记 0正常 1已删除"} + ], + "indexes": [ + {"name": "idx_pbl_bptask_tenant_bp_seq", "fields": ["tenant_id", "blueprint_id", "seq"], "unique": false}, + {"name": "uk_pbl_bptask_tenant_bp_code", "fields": ["tenant_id", "blueprint_id", "code"], "unique": true}, + {"name": "idx_pbl_bptask_parent", "fields": ["tenant_id", "parent_id"], "unique": false} + ], "codes": { - "task_type": { - "appcode": "pbl_task_type", - "label": "任务类型" - } + "status": [ + ["draft", "草稿"], + ["active", "生效"], + ["disabled", "停用"] + ], + "deleted": [ + ["0", "正常"], + ["1", "已删除"] + ] } -} \ No newline at end of file +} diff --git a/pbl_blueprint/models/pbl_blueprint_template.json b/pbl_blueprint/models/pbl_blueprint_template.json index 97185fb..03a04a6 100644 --- a/pbl_blueprint/models/pbl_blueprint_template.json +++ b/pbl_blueprint/models/pbl_blueprint_template.json @@ -1,159 +1,31 @@ { - "summary": { - "name": "pbl_blueprint_template", - "label": "PBL蓝图模板", - "desc": "PBL蓝图模板(pbl_blueprint 模块 M1a)", - "module": "pbl_blueprint", - "owner": "agent.develop", - "version": "1.0", - "engine": "postgresql", - "tenant_scoped": true - }, - "fields": { - "id": { - "type": "str(32)", - "label": "主键", - "pk": true, - "required": true - }, - "tenant_id": { - "type": "str(32)", - "label": "租户ID(强制打头)", - "required": true - }, - "class_id": { - "type": "str(32)", - "label": "班级ID" - }, - "template_code": { - "type": "str(64)", - "label": "模板编码", - "required": true - }, - "template_name": { - "type": "str(128)", - "label": "模板名称", - "required": true - }, - "template_type": { - "type": "str(32)", - "label": "模板类型", - "appcode": "pbl_template_type" - }, - "domain_code": { - "type": "str(64)", - "label": "领域", - "appcode": "pbl_blueprint_domain" - }, - "grade_code": { - "type": "str(32)", - "label": "学段", - "appcode": "pbl_grade_code" - }, - "subject_code": { - "type": "str(64)", - "label": "学科", - "appcode": "pbl_subject_code" - }, - "complexity_level": { - "type": "str(32)", - "label": "复杂度", - "appcode": "pbl_complexity_level" - }, - "scope": { - "type": "str(32)", - "label": "可见范围", - "appcode": "pbl_template_scope", - "default": "tenant" - }, - "source_blueprint_id": { - "type": "str(32)", - "label": "来源蓝图ID" - }, - "usage_count": { - "type": "int", - "label": "引用次数", - "default": 0 - }, - "status": { - "type": "str(32)", - "label": "状态", - "appcode": "pbl_template_status", - "default": "draft" - }, - "is_deleted": { - "type": "int", - "label": "删除标记(0未删/1已删)", - "default": 0 - }, - "created_by": { - "type": "str(64)", - "label": "创建人" - }, - "created_at": { - "type": "datetime", - "label": "创建时间" - }, - "updated_by": { - "type": "str(64)", - "label": "更新人" - }, - "updated_at": { - "type": "datetime", - "label": "更新时间" - } - }, - "indexes": { - "primary": { - "fields": [ - "id" - ], - "unique": true - }, - "uk_template_tenant_code": { - "fields": [ - "tenant_id", - "template_code" - ], - "unique": true - }, - "idx_template_tenant_status": { - "fields": [ - "tenant_id", - "status", - "is_deleted" - ], - "unique": false - } - }, + "summary": "PBL 蓝图模板表(M1a)。一行=一个可复用蓝图模板,用于一键实例化生成新蓝图;离线兜底时也可由模板生成最小可用蓝图。tenant_id 强制打头,平台内置模板 tenant_id 固定为 platform。", + "fields": [ + {"name": "id", "type": "str", "len": 32, "primary": true, "notnull": true, "desc": "主键,32位无横线UUID"}, + {"name": "tenant_id", "type": "str", "len": 32, "notnull": true, "desc": "租户ID,强制打头;平台内置模板为 platform"}, + {"name": "code", "type": "str", "len": 64, "notnull": true, "desc": "模板编码,租户内唯一"}, + {"name": "name", "type": "str", "len": 200, "notnull": true, "desc": "模板名称"}, + {"name": "category", "type": "str", "len": 64, "desc": "模板分类,见 codes.category"}, + {"name": "subject", "type": "str", "len": 64, "desc": "适用学科"}, + {"name": "grade", "type": "str", "len": 32, "desc": "适用年级"}, + {"name": "duration_hours", "type": "int", "desc": "建议课时"}, + {"name": "description", "type": "text", "desc": "模板说明"}, + {"name": "content", "type": "json", "desc": "模板主体内容(主表默认值+子对象骨架)"}, + {"name": "status", "type": "str", "len": 32, "notnull": true, "default": "enabled", "desc": "模板状态,见 codes.status"}, + {"name": "use_count", "type": "int", "notnull": true, "default": "0", "desc": "被实例化次数"}, + {"name": "created_by", "type": "str", "len": 32, "desc": "创建人"}, + {"name": "created_at", "type": "datetime", "desc": "创建时间"}, + {"name": "updated_by", "type": "str", "len": 32, "desc": "最后修改人"}, + {"name": "updated_at", "type": "datetime", "desc": "最后修改时间"}, + {"name": "deleted", "type": "int", "notnull": true, "default": "0", "desc": "软删除标记 0正常 1已删除"} + ], + "indexes": [ + {"name": "uk_pbl_bpt_tenant_code", "fields": ["tenant_id", "code"], "unique": true}, + {"name": "idx_pbl_bpt_tenant_cat", "fields": ["tenant_id", "category", "status", "deleted"], "unique": false} + ], "codes": { - "template_type": { - "appcode": "pbl_template_type", - "label": "模板类型" - }, - "domain_code": { - "appcode": "pbl_blueprint_domain", - "label": "领域" - }, - "grade_code": { - "appcode": "pbl_grade_code", - "label": "学段" - }, - "subject_code": { - "appcode": "pbl_subject_code", - "label": "学科" - }, - "complexity_level": { - "appcode": "pbl_complexity_level", - "label": "复杂度" - }, - "scope": { - "appcode": "pbl_template_scope", - "label": "可见范围" - }, - "status": { - "appcode": "pbl_template_status", - "label": "状态" - } + "category": [["stem", "STEM跨学科"], ["humanity", "人文社科"], ["science", "自然科学"], ["engineering", "工程制作"], ["social", "社会服务"], ["general", "通用"]], + "status": [["enabled", "启用"], ["disabled", "停用"]], + "deleted": [["0", "正常"], ["1", "已删除"]] } -} \ No newline at end of file +} diff --git a/pbl_blueprint/models/pbl_blueprint_template_item.json b/pbl_blueprint/models/pbl_blueprint_template_item.json index 2b5abac..64c53f2 100644 --- a/pbl_blueprint/models/pbl_blueprint_template_item.json +++ b/pbl_blueprint/models/pbl_blueprint_template_item.json @@ -1,106 +1,27 @@ { - "summary": { - "name": "pbl_blueprint_template_item", - "label": "PBL蓝图模板明细", - "desc": "PBL蓝图模板明细(pbl_blueprint 模块 M1a)", - "module": "pbl_blueprint", - "owner": "agent.develop", - "version": "1.0", - "engine": "postgresql", - "tenant_scoped": true - }, - "fields": { - "id": { - "type": "str(32)", - "label": "主键", - "pk": true, - "required": true - }, - "tenant_id": { - "type": "str(32)", - "label": "租户ID(强制打头)", - "required": true - }, - "class_id": { - "type": "str(32)", - "label": "班级ID" - }, - "template_id": { - "type": "str(32)", - "label": "所属模板ID", - "required": true - }, - "subobject_type": { - "type": "str(32)", - "label": "子对象类型", - "appcode": "pbl_subobject_type", - "required": true - }, - "item_payload_json": { - "type": "text", - "label": "模板内容(JSON)" - }, - "seq_no": { - "type": "int", - "label": "排序号", - "default": 0 - }, - "required_flag": { - "type": "int", - "label": "是否必需(0/1)", - "default": 1 - }, - "is_deleted": { - "type": "int", - "label": "删除标记(0未删/1已删)", - "default": 0 - }, - "created_by": { - "type": "str(64)", - "label": "创建人" - }, - "created_at": { - "type": "datetime", - "label": "创建时间" - }, - "updated_by": { - "type": "str(64)", - "label": "更新人" - }, - "updated_at": { - "type": "datetime", - "label": "更新时间" - } - }, - "indexes": { - "primary": { - "fields": [ - "id" - ], - "unique": true - }, - "uk_tplitem_tenant_tpl_type_seq": { - "fields": [ - "tenant_id", - "template_id", - "subobject_type", - "seq_no" - ], - "unique": true - }, - "idx_tplitem_template": { - "fields": [ - "tenant_id", - "template_id", - "is_deleted" - ], - "unique": false - } - }, + "summary": "PBL 蓝图模板条目表(M1a)。一行=模板下的一个子对象骨架条目,obj_type 取 7 类子对象类型之一;实例化模板时按 seq 顺序批量生成对应子对象记录,ref_key/parent_ref 做模板内相对引用。tenant_id 强制打头。", + "fields": [ + {"name": "id", "type": "str", "len": 32, "primary": true, "notnull": true, "desc": "主键,32位无横线UUID"}, + {"name": "tenant_id", "type": "str", "len": 32, "notnull": true, "desc": "租户ID,强制打头"}, + {"name": "template_id", "type": "str", "len": 32, "notnull": true, "desc": "所属模板ID"}, + {"name": "obj_type", "type": "str", "len": 32, "notnull": true, "desc": "子对象类型,见 codes.obj_type"}, + {"name": "parent_ref", "type": "str", "len": 64, "desc": "父条目引用键(模板内相对引用,实例化时解析为真实ID)"}, + {"name": "ref_key", "type": "str", "len": 64, "desc": "本条目引用键,供子条目 parent_ref 指向"}, + {"name": "seq", "type": "int", "notnull": true, "default": "0", "desc": "同类型内排序号"}, + {"name": "name", "type": "str", "len": 200, "notnull": true, "desc": "条目名称"}, + {"name": "spec", "type": "json", "desc": "条目规格(对应子对象 spec 字段默认值)"}, + {"name": "created_by", "type": "str", "len": 32, "desc": "创建人"}, + {"name": "created_at", "type": "datetime", "desc": "创建时间"}, + {"name": "updated_by", "type": "str", "len": 32, "desc": "最后修改人"}, + {"name": "updated_at", "type": "datetime", "desc": "最后修改时间"}, + {"name": "deleted", "type": "int", "notnull": true, "default": "0", "desc": "软删除标记 0正常 1已删除"} + ], + "indexes": [ + {"name": "idx_pbl_bpti_tenant_tpl", "fields": ["tenant_id", "template_id", "obj_type", "seq"], "unique": false}, + {"name": "idx_pbl_bpti_refkey", "fields": ["tenant_id", "template_id", "ref_key"], "unique": false} + ], "codes": { - "subobject_type": { - "appcode": "pbl_subobject_type", - "label": "子对象类型" - } + "obj_type": [["task", "任务"], ["mission", "关卡"], ["role", "角色"], ["learning_goal", "学习目标"], ["evidence_spec", "证据规格"], ["artifact_spec", "产出物规格"], ["reflection_spec", "反思规格"]], + "deleted": [["0", "正常"], ["1", "已删除"]] } -} \ No newline at end of file +} diff --git a/pbl_blueprint/models/pbl_blueprint_version.json b/pbl_blueprint/models/pbl_blueprint_version.json index 8257570..de4ca47 100644 --- a/pbl_blueprint/models/pbl_blueprint_version.json +++ b/pbl_blueprint/models/pbl_blueprint_version.json @@ -1,124 +1,28 @@ { - "summary": { - "name": "pbl_blueprint_version", - "label": "PBL蓝图版本", - "desc": "PBL蓝图版本(pbl_blueprint 模块 M1a)", - "module": "pbl_blueprint", - "owner": "agent.develop", - "version": "1.0", - "engine": "postgresql", - "tenant_scoped": true - }, - "fields": { - "id": { - "type": "str(32)", - "label": "主键", - "pk": true, - "required": true - }, - "tenant_id": { - "type": "str(32)", - "label": "租户ID(强制打头)", - "required": true - }, - "class_id": { - "type": "str(32)", - "label": "班级ID" - }, - "blueprint_id": { - "type": "str(32)", - "label": "所属蓝图ID", - "required": true - }, - "version_no": { - "type": "int", - "label": "版本号", - "required": true - }, - "version_name": { - "type": "str(128)", - "label": "版本名称" - }, - "version_status": { - "type": "str(32)", - "label": "版本状态", - "appcode": "pbl_version_status", - "default": "draft" - }, - "snapshot_json": { - "type": "text", - "label": "蓝图快照(JSON)" - }, - "content_hash": { - "type": "str(64)", - "label": "快照内容哈希" - }, - "published_by": { - "type": "str(64)", - "label": "发布人" - }, - "published_at": { - "type": "datetime", - "label": "发布时间" - }, - "change_note": { - "type": "text", - "label": "变更说明" - }, - "base_version_id": { - "type": "str(32)", - "label": "基线版本ID" - }, - "is_deleted": { - "type": "int", - "label": "删除标记(0未删/1已删)", - "default": 0 - }, - "created_by": { - "type": "str(64)", - "label": "创建人" - }, - "created_at": { - "type": "datetime", - "label": "创建时间" - }, - "updated_by": { - "type": "str(64)", - "label": "更新人" - }, - "updated_at": { - "type": "datetime", - "label": "更新时间" - } - }, - "indexes": { - "primary": { - "fields": [ - "id" - ], - "unique": true - }, - "uk_version_blueprint_no": { - "fields": [ - "tenant_id", - "blueprint_id", - "version_no" - ], - "unique": true - }, - "idx_version_blueprint": { - "fields": [ - "tenant_id", - "blueprint_id", - "is_deleted" - ], - "unique": false - } - }, + "summary": "PBL 蓝图版本快照表(M1a)。一行=蓝图某版本的完整快照 + 相对上一版本的变更增量 change_delta,支撑版本回溯/diff/回滚。snapshot 存全量子对象树,change_delta 存 added/updated/removed 三段。tenant_id 强制打头。", + "fields": [ + {"name": "id", "type": "str", "len": 32, "primary": true, "notnull": true, "desc": "主键,32位无横线UUID"}, + {"name": "tenant_id", "type": "str", "len": 32, "notnull": true, "desc": "租户ID,强制打头"}, + {"name": "blueprint_id", "type": "str", "len": 32, "notnull": true, "desc": "所属蓝图ID"}, + {"name": "version_no", "type": "int", "notnull": true, "desc": "版本号,蓝图内递增"}, + {"name": "snapshot", "type": "json", "notnull": true, "desc": "该版本蓝图全量快照(主表字段+7类子对象树)"}, + {"name": "change_delta", "type": "json", "desc": "相对上一版本变更增量 {added:[],updated:[],removed:[]}"}, + {"name": "quality_level", "type": "str", "len": 16, "desc": "该版本质量等级快照 L1-L5"}, + {"name": "status", "type": "str", "len": 32, "notnull": true, "default": "saved", "desc": "版本状态,见 codes.status"}, + {"name": "remark", "type": "str", "len": 500, "desc": "版本说明"}, + {"name": "created_by", "type": "str", "len": 32, "desc": "创建人"}, + {"name": "created_at", "type": "datetime", "desc": "创建时间"}, + {"name": "updated_by", "type": "str", "len": 32, "desc": "最后修改人"}, + {"name": "updated_at", "type": "datetime", "desc": "最后修改时间"}, + {"name": "deleted", "type": "int", "notnull": true, "default": "0", "desc": "软删除标记 0正常 1已删除"} + ], + "indexes": [ + {"name": "uk_pbl_bpv_tenant_bp_ver", "fields": ["tenant_id", "blueprint_id", "version_no"], "unique": true}, + {"name": "idx_pbl_bpv_tenant_status", "fields": ["tenant_id", "status", "deleted"], "unique": false} + ], "codes": { - "version_status": { - "appcode": "pbl_version_status", - "label": "版本状态" - } + "status": [["saved", "已保存"], ["published", "已发布"], ["rolled_back", "已回滚"], ["archived", "已归档"]], + "quality_level": [["L1", "L1-初始"], ["L2", "L2-基本完整"], ["L3", "L3-结构合格"], ["L4", "L4-可运行"], ["L5", "L5-优质"]], + "deleted": [["0", "正常"], ["1", "已删除"]] } -} \ No newline at end of file +} diff --git a/pbl_blueprint/sql/pbl_blueprint.core.sql b/pbl_blueprint/sql/pbl_blueprint.core.sql index 4f2edec..9ae3290 100644 --- a/pbl_blueprint/sql/pbl_blueprint.core.sql +++ b/pbl_blueprint/sql/pbl_blueprint.core.sql @@ -1,520 +1,113 @@ --- pbl_blueprint 核心表 DDL --- 本文件由 scripts/derive_artifacts.py 从 pbl_blueprint/models/*.json 机械派生,禁止手工编辑 --- engine: postgresql(与 projects/pbls/env/*.json 的 db.engine 一致) --- 主键 id 为应用层生成的 str(32):不使用 SERIAL / BIGSERIAL / nextval / AUTO_INCREMENT +-- ===================================================================== +-- pbl_blueprint 核心表 DDL(M1a) +-- 模块:pbl_blueprint 库名:由 ServerEnv().get_module_dbname('pbl_blueprint') 解析,禁止硬编码 +-- 约定:主键 id 一律 varchar(32)(32位无横线UUID);tenant_id 强制打头且进入每个索引首列; +-- 软删除 deleted tinyint;JSON 字段用 json 类型;时间用 datetime。 +-- 表清单(核心 4 张):pbl_blueprint / pbl_blueprint_version / pbl_blueprint_template / pbl_blueprint_template_item +-- 子对象 7 张见 pbl_blueprint.subobjects.sql +-- ===================================================================== --- pbl_blueprint: PBL蓝图聚合根 -CREATE TABLE IF NOT EXISTS pbl_blueprint ( - id varchar(32) NOT NULL, - tenant_id varchar(32) NOT NULL, - class_id varchar(32), - blueprint_code varchar(64) NOT NULL, - blueprint_name varchar(128) NOT NULL, - domain_code varchar(64), - grade_code varchar(32), - subject_code varchar(64), - project_duration integer, - student_count integer, - group_count integer, - complexity_level varchar(32), - status varchar(32), - current_version integer, - published_version_id varchar(32), - source_blueprint_id varchar(32), - source_template_id varchar(32), - owner_user_id varchar(64), - estimated_cost numeric(18,2), - tags_json text, - summary_text text, - is_deleted integer, - created_by varchar(64), - created_at timestamp, - updated_by varchar(64), - updated_at timestamp -); -COMMENT ON COLUMN pbl_blueprint.id IS '主键'; -COMMENT ON COLUMN pbl_blueprint.tenant_id IS '租户ID(强制打头)'; -COMMENT ON COLUMN pbl_blueprint.class_id IS '班级ID'; -COMMENT ON COLUMN pbl_blueprint.blueprint_code IS '蓝图编码'; -COMMENT ON COLUMN pbl_blueprint.blueprint_name IS '蓝图名称'; -COMMENT ON COLUMN pbl_blueprint.domain_code IS '领域'; -COMMENT ON COLUMN pbl_blueprint.grade_code IS '学段'; -COMMENT ON COLUMN pbl_blueprint.subject_code IS '学科'; -COMMENT ON COLUMN pbl_blueprint.project_duration IS '项目时长(课时)'; -COMMENT ON COLUMN pbl_blueprint.student_count IS '参与学生数'; -COMMENT ON COLUMN pbl_blueprint.group_count IS '小组数'; -COMMENT ON COLUMN pbl_blueprint.complexity_level IS '复杂度'; -COMMENT ON COLUMN pbl_blueprint.status IS '蓝图状态'; -COMMENT ON COLUMN pbl_blueprint.current_version IS '当前版本号'; -COMMENT ON COLUMN pbl_blueprint.published_version_id IS '已发布版本ID'; -COMMENT ON COLUMN pbl_blueprint.source_blueprint_id IS '派生来源蓝图ID(fork)'; -COMMENT ON COLUMN pbl_blueprint.source_template_id IS '来源模板ID'; -COMMENT ON COLUMN pbl_blueprint.owner_user_id IS '负责人'; -COMMENT ON COLUMN pbl_blueprint.estimated_cost IS '预估成本(元)'; -COMMENT ON COLUMN pbl_blueprint.tags_json IS '标签(JSON数组)'; -COMMENT ON COLUMN pbl_blueprint.summary_text IS '蓝图摘要'; -COMMENT ON COLUMN pbl_blueprint.is_deleted IS '删除标记(0未删/1已删)'; -COMMENT ON COLUMN pbl_blueprint.created_by IS '创建人'; -COMMENT ON COLUMN pbl_blueprint.created_at IS '创建时间'; -COMMENT ON COLUMN pbl_blueprint.updated_by IS '更新人'; -COMMENT ON COLUMN pbl_blueprint.updated_at IS '更新时间'; +-- --------------------------------------------------------------------- +-- 1. pbl_blueprint 蓝图聚合根主表 +-- --------------------------------------------------------------------- +CREATE TABLE IF NOT EXISTS `pbl_blueprint` ( + `id` varchar(32) NOT NULL COMMENT '主键,32位无横线UUID', + `tenant_id` varchar(32) NOT NULL COMMENT '租户ID,强制打头', + `code` varchar(64) NOT NULL COMMENT '蓝图编码,租户内唯一', + `name` varchar(200) NOT NULL COMMENT '蓝图名称', + `subject` varchar(64) DEFAULT NULL COMMENT '学科', + `grade` varchar(32) DEFAULT NULL COMMENT '适用年级', + `duration_hours` int DEFAULT NULL COMMENT '总课时数', + `version_no` int NOT NULL DEFAULT 1 COMMENT '当前版本号', + `status` varchar(32) NOT NULL DEFAULT 'draft' COMMENT 'draft/validating/validated/compiling/compiled/published/archived', + `quality_level` varchar(16) DEFAULT 'L1' COMMENT '质量等级 L1-L5', + `summary` text COMMENT '蓝图简介/驱动性问题', + `config` json DEFAULT NULL COMMENT '扩展配置', + `source_blueprint_id` varchar(32) DEFAULT NULL COMMENT 'fork 来源蓝图ID', + `template_id` varchar(32) DEFAULT NULL COMMENT '实例化来源模板ID', + `owner_id` varchar(32) DEFAULT NULL COMMENT '归属教师/创建者', + `published_at` datetime DEFAULT NULL COMMENT '最近发布时间', + `remark` varchar(500) DEFAULT NULL COMMENT '备注', + `created_by` varchar(32) DEFAULT NULL COMMENT '创建人', + `created_at` datetime DEFAULT NULL COMMENT '创建时间', + `updated_by` varchar(32) DEFAULT NULL COMMENT '最后修改人', + `updated_at` datetime DEFAULT NULL COMMENT '最后修改时间', + `deleted` tinyint NOT NULL DEFAULT 0 COMMENT '软删除 0正常 1已删除', + PRIMARY KEY (`id`), + UNIQUE KEY `uk_pbl_blueprint_tenant_code` (`tenant_id`, `code`), + KEY `idx_pbl_blueprint_tenant_status` (`tenant_id`, `status`, `deleted`), + KEY `idx_pbl_blueprint_owner` (`tenant_id`, `owner_id`), + KEY `idx_pbl_blueprint_template` (`tenant_id`, `template_id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='PBL蓝图聚合根主表'; -CREATE UNIQUE INDEX IF NOT EXISTS pbl_blueprint_uk_blueprint_tenant_code ON pbl_blueprint (tenant_id, blueprint_code); -CREATE INDEX IF NOT EXISTS pbl_blueprint_idx_blueprint_tenant_status ON pbl_blueprint (tenant_id, status, is_deleted); -CREATE INDEX IF NOT EXISTS pbl_blueprint_idx_blueprint_class ON pbl_blueprint (tenant_id, class_id, is_deleted); +-- --------------------------------------------------------------------- +-- 2. pbl_blueprint_version 蓝图版本快照表 +-- --------------------------------------------------------------------- +CREATE TABLE IF NOT EXISTS `pbl_blueprint_version` ( + `id` varchar(32) NOT NULL COMMENT '主键,32位无横线UUID', + `tenant_id` varchar(32) NOT NULL COMMENT '租户ID,强制打头', + `blueprint_id` varchar(32) NOT NULL COMMENT '所属蓝图ID', + `version_no` int NOT NULL COMMENT '版本号,蓝图内递增', + `snapshot` json NOT NULL COMMENT '该版本蓝图全量快照', + `change_delta` json DEFAULT NULL COMMENT '变更增量 {added,updated,removed}', + `quality_level` varchar(16) DEFAULT NULL COMMENT '该版本质量等级快照', + `status` varchar(32) NOT NULL DEFAULT 'saved' COMMENT 'saved/published/rolled_back/archived', + `remark` varchar(500) DEFAULT NULL COMMENT '版本说明', + `created_by` varchar(32) DEFAULT NULL COMMENT '创建人', + `created_at` datetime DEFAULT NULL COMMENT '创建时间', + `updated_by` varchar(32) DEFAULT NULL COMMENT '最后修改人', + `updated_at` datetime DEFAULT NULL COMMENT '最后修改时间', + `deleted` tinyint NOT NULL DEFAULT 0 COMMENT '软删除 0正常 1已删除', + PRIMARY KEY (`id`), + UNIQUE KEY `uk_pbl_bpv_tenant_bp_ver` (`tenant_id`, `blueprint_id`, `version_no`), + KEY `idx_pbl_bpv_tenant_status` (`tenant_id`, `status`, `deleted`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='PBL蓝图版本快照表'; --- pbl_blueprint_artifact_spec: 蓝图子对象-产出物规格(Artifact) -CREATE TABLE IF NOT EXISTS pbl_blueprint_artifact_spec ( - id varchar(32) NOT NULL, - tenant_id varchar(32) NOT NULL, - class_id varchar(32), - blueprint_id varchar(32) NOT NULL, - version_id varchar(32), - code varchar(64), - name varchar(128) NOT NULL, - description text, - seq_no integer, - status varchar(32), - content_json text, - artifact_type varchar(32), - required_flag integer, - quality_criteria text, - due_seq integer, - is_deleted integer, - created_by varchar(64), - created_at timestamp, - updated_by varchar(64), - updated_at timestamp -); -COMMENT ON COLUMN pbl_blueprint_artifact_spec.id IS '主键'; -COMMENT ON COLUMN pbl_blueprint_artifact_spec.tenant_id IS '租户ID(强制打头)'; -COMMENT ON COLUMN pbl_blueprint_artifact_spec.class_id IS '班级ID'; -COMMENT ON COLUMN pbl_blueprint_artifact_spec.blueprint_id IS '所属蓝图ID'; -COMMENT ON COLUMN pbl_blueprint_artifact_spec.version_id IS '所属版本ID'; -COMMENT ON COLUMN pbl_blueprint_artifact_spec.code IS '对象编码'; -COMMENT ON COLUMN pbl_blueprint_artifact_spec.name IS '对象名称'; -COMMENT ON COLUMN pbl_blueprint_artifact_spec.description IS '描述'; -COMMENT ON COLUMN pbl_blueprint_artifact_spec.seq_no IS '排序号'; -COMMENT ON COLUMN pbl_blueprint_artifact_spec.status IS '状态'; -COMMENT ON COLUMN pbl_blueprint_artifact_spec.content_json IS '扩展内容(JSON)'; -COMMENT ON COLUMN pbl_blueprint_artifact_spec.artifact_type IS '产出物类型'; -COMMENT ON COLUMN pbl_blueprint_artifact_spec.required_flag IS '是否必需(0/1)'; -COMMENT ON COLUMN pbl_blueprint_artifact_spec.quality_criteria IS '质量标准(JSON)'; -COMMENT ON COLUMN pbl_blueprint_artifact_spec.due_seq IS '交付次序'; -COMMENT ON COLUMN pbl_blueprint_artifact_spec.is_deleted IS '删除标记(0未删/1已删)'; -COMMENT ON COLUMN pbl_blueprint_artifact_spec.created_by IS '创建人'; -COMMENT ON COLUMN pbl_blueprint_artifact_spec.created_at IS '创建时间'; -COMMENT ON COLUMN pbl_blueprint_artifact_spec.updated_by IS '更新人'; -COMMENT ON COLUMN pbl_blueprint_artifact_spec.updated_at IS '更新时间'; +-- --------------------------------------------------------------------- +-- 3. pbl_blueprint_template 蓝图模板表 +-- --------------------------------------------------------------------- +CREATE TABLE IF NOT EXISTS `pbl_blueprint_template` ( + `id` varchar(32) NOT NULL COMMENT '主键,32位无横线UUID', + `tenant_id` varchar(32) NOT NULL COMMENT '租户ID,强制打头;平台内置为 platform', + `code` varchar(64) NOT NULL COMMENT '模板编码,租户内唯一', + `name` varchar(200) NOT NULL COMMENT '模板名称', + `category` varchar(64) DEFAULT NULL COMMENT 'stem/humanity/science/engineering/social/general', + `subject` varchar(64) DEFAULT NULL COMMENT '适用学科', + `grade` varchar(32) DEFAULT NULL COMMENT '适用年级', + `duration_hours` int DEFAULT NULL COMMENT '建议课时', + `description` text COMMENT '模板说明', + `content` json DEFAULT NULL COMMENT '模板主体内容', + `status` varchar(32) NOT NULL DEFAULT 'enabled' COMMENT 'enabled/disabled', + `use_count` int NOT NULL DEFAULT 0 COMMENT '被实例化次数', + `created_by` varchar(32) DEFAULT NULL COMMENT '创建人', + `created_at` datetime DEFAULT NULL COMMENT '创建时间', + `updated_by` varchar(32) DEFAULT NULL COMMENT '最后修改人', + `updated_at` datetime DEFAULT NULL COMMENT '最后修改时间', + `deleted` tinyint NOT NULL DEFAULT 0 COMMENT '软删除 0正常 1已删除', + PRIMARY KEY (`id`), + UNIQUE KEY `uk_pbl_bpt_tenant_code` (`tenant_id`, `code`), + KEY `idx_pbl_bpt_tenant_cat` (`tenant_id`, `category`, `status`, `deleted`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='PBL蓝图模板表'; -CREATE INDEX IF NOT EXISTS pbl_blueprint_artifact_spec_idx_pbl_blueprint_artifact_spec_tenant_bp ON pbl_blueprint_artifact_spec (tenant_id, blueprint_id, is_deleted); -CREATE UNIQUE INDEX IF NOT EXISTS pbl_blueprint_artifact_spec_uk_pbl_blueprint_artifact_spec_tenant_code ON pbl_blueprint_artifact_spec (tenant_id, code); - --- pbl_blueprint_evidence_spec: 蓝图子对象-证据规格(Evidence) -CREATE TABLE IF NOT EXISTS pbl_blueprint_evidence_spec ( - id varchar(32) NOT NULL, - tenant_id varchar(32) NOT NULL, - class_id varchar(32), - blueprint_id varchar(32) NOT NULL, - version_id varchar(32), - code varchar(64), - name varchar(128) NOT NULL, - description text, - seq_no integer, - status varchar(32), - content_json text, - evidence_type varchar(32), - artifact_id varchar(32), - min_count integer, - weight_value numeric(18,2), - is_deleted integer, - created_by varchar(64), - created_at timestamp, - updated_by varchar(64), - updated_at timestamp -); -COMMENT ON COLUMN pbl_blueprint_evidence_spec.id IS '主键'; -COMMENT ON COLUMN pbl_blueprint_evidence_spec.tenant_id IS '租户ID(强制打头)'; -COMMENT ON COLUMN pbl_blueprint_evidence_spec.class_id IS '班级ID'; -COMMENT ON COLUMN pbl_blueprint_evidence_spec.blueprint_id IS '所属蓝图ID'; -COMMENT ON COLUMN pbl_blueprint_evidence_spec.version_id IS '所属版本ID'; -COMMENT ON COLUMN pbl_blueprint_evidence_spec.code IS '对象编码'; -COMMENT ON COLUMN pbl_blueprint_evidence_spec.name IS '对象名称'; -COMMENT ON COLUMN pbl_blueprint_evidence_spec.description IS '描述'; -COMMENT ON COLUMN pbl_blueprint_evidence_spec.seq_no IS '排序号'; -COMMENT ON COLUMN pbl_blueprint_evidence_spec.status IS '状态'; -COMMENT ON COLUMN pbl_blueprint_evidence_spec.content_json IS '扩展内容(JSON)'; -COMMENT ON COLUMN pbl_blueprint_evidence_spec.evidence_type IS '证据类型'; -COMMENT ON COLUMN pbl_blueprint_evidence_spec.artifact_id IS '关联产出物ID'; -COMMENT ON COLUMN pbl_blueprint_evidence_spec.min_count IS '最少提交数'; -COMMENT ON COLUMN pbl_blueprint_evidence_spec.weight_value IS '证据权重'; -COMMENT ON COLUMN pbl_blueprint_evidence_spec.is_deleted IS '删除标记(0未删/1已删)'; -COMMENT ON COLUMN pbl_blueprint_evidence_spec.created_by IS '创建人'; -COMMENT ON COLUMN pbl_blueprint_evidence_spec.created_at IS '创建时间'; -COMMENT ON COLUMN pbl_blueprint_evidence_spec.updated_by IS '更新人'; -COMMENT ON COLUMN pbl_blueprint_evidence_spec.updated_at IS '更新时间'; - -CREATE INDEX IF NOT EXISTS pbl_blueprint_evidence_spec_idx_pbl_blueprint_evidence_spec_tenant_bp ON pbl_blueprint_evidence_spec (tenant_id, blueprint_id, is_deleted); -CREATE UNIQUE INDEX IF NOT EXISTS pbl_blueprint_evidence_spec_uk_pbl_blueprint_evidence_spec_tenant_code ON pbl_blueprint_evidence_spec (tenant_id, code); - --- pbl_blueprint_learning_goal: 蓝图子对象-学习目标(Goal) -CREATE TABLE IF NOT EXISTS pbl_blueprint_learning_goal ( - id varchar(32) NOT NULL, - tenant_id varchar(32) NOT NULL, - class_id varchar(32), - blueprint_id varchar(32) NOT NULL, - version_id varchar(32), - code varchar(64), - name varchar(128) NOT NULL, - description text, - seq_no integer, - status varchar(32), - content_json text, - goal_type varchar(32), - competency_code varchar(64), - knowledge_points text, - measurable_flag integer, - weight_value numeric(18,2), - is_deleted integer, - created_by varchar(64), - created_at timestamp, - updated_by varchar(64), - updated_at timestamp -); -COMMENT ON COLUMN pbl_blueprint_learning_goal.id IS '主键'; -COMMENT ON COLUMN pbl_blueprint_learning_goal.tenant_id IS '租户ID(强制打头)'; -COMMENT ON COLUMN pbl_blueprint_learning_goal.class_id IS '班级ID'; -COMMENT ON COLUMN pbl_blueprint_learning_goal.blueprint_id IS '所属蓝图ID'; -COMMENT ON COLUMN pbl_blueprint_learning_goal.version_id IS '所属版本ID'; -COMMENT ON COLUMN pbl_blueprint_learning_goal.code IS '对象编码'; -COMMENT ON COLUMN pbl_blueprint_learning_goal.name IS '对象名称'; -COMMENT ON COLUMN pbl_blueprint_learning_goal.description IS '描述'; -COMMENT ON COLUMN pbl_blueprint_learning_goal.seq_no IS '排序号'; -COMMENT ON COLUMN pbl_blueprint_learning_goal.status IS '状态'; -COMMENT ON COLUMN pbl_blueprint_learning_goal.content_json IS '扩展内容(JSON)'; -COMMENT ON COLUMN pbl_blueprint_learning_goal.goal_type IS '目标类型'; -COMMENT ON COLUMN pbl_blueprint_learning_goal.competency_code IS '素养/课标编码'; -COMMENT ON COLUMN pbl_blueprint_learning_goal.knowledge_points IS '知识点(JSON数组)'; -COMMENT ON COLUMN pbl_blueprint_learning_goal.measurable_flag IS '是否可测(0/1)'; -COMMENT ON COLUMN pbl_blueprint_learning_goal.weight_value IS '权重'; -COMMENT ON COLUMN pbl_blueprint_learning_goal.is_deleted IS '删除标记(0未删/1已删)'; -COMMENT ON COLUMN pbl_blueprint_learning_goal.created_by IS '创建人'; -COMMENT ON COLUMN pbl_blueprint_learning_goal.created_at IS '创建时间'; -COMMENT ON COLUMN pbl_blueprint_learning_goal.updated_by IS '更新人'; -COMMENT ON COLUMN pbl_blueprint_learning_goal.updated_at IS '更新时间'; - -CREATE INDEX IF NOT EXISTS pbl_blueprint_learning_goal_idx_pbl_blueprint_learning_goal_tenant_bp ON pbl_blueprint_learning_goal (tenant_id, blueprint_id, is_deleted); -CREATE UNIQUE INDEX IF NOT EXISTS pbl_blueprint_learning_goal_uk_pbl_blueprint_learning_goal_tenant_code ON pbl_blueprint_learning_goal (tenant_id, code); - --- pbl_blueprint_mission: 蓝图子对象-真实情境任务(Mission) -CREATE TABLE IF NOT EXISTS pbl_blueprint_mission ( - id varchar(32) NOT NULL, - tenant_id varchar(32) NOT NULL, - class_id varchar(32), - blueprint_id varchar(32) NOT NULL, - version_id varchar(32), - code varchar(64), - name varchar(128) NOT NULL, - description text, - seq_no integer, - status varchar(32), - content_json text, - mission_type varchar(32), - scenario_text text, - audience varchar(256), - duration_hours numeric(18,2), - authenticity_score numeric(18,2), - is_deleted integer, - created_by varchar(64), - created_at timestamp, - updated_by varchar(64), - updated_at timestamp -); -COMMENT ON COLUMN pbl_blueprint_mission.id IS '主键'; -COMMENT ON COLUMN pbl_blueprint_mission.tenant_id IS '租户ID(强制打头)'; -COMMENT ON COLUMN pbl_blueprint_mission.class_id IS '班级ID'; -COMMENT ON COLUMN pbl_blueprint_mission.blueprint_id IS '所属蓝图ID'; -COMMENT ON COLUMN pbl_blueprint_mission.version_id IS '所属版本ID'; -COMMENT ON COLUMN pbl_blueprint_mission.code IS '对象编码'; -COMMENT ON COLUMN pbl_blueprint_mission.name IS '对象名称'; -COMMENT ON COLUMN pbl_blueprint_mission.description IS '描述'; -COMMENT ON COLUMN pbl_blueprint_mission.seq_no IS '排序号'; -COMMENT ON COLUMN pbl_blueprint_mission.status IS '状态'; -COMMENT ON COLUMN pbl_blueprint_mission.content_json IS '扩展内容(JSON)'; -COMMENT ON COLUMN pbl_blueprint_mission.mission_type IS '情境类型'; -COMMENT ON COLUMN pbl_blueprint_mission.scenario_text IS '情境描述'; -COMMENT ON COLUMN pbl_blueprint_mission.audience IS '受众/服务对象'; -COMMENT ON COLUMN pbl_blueprint_mission.duration_hours IS '预计课时'; -COMMENT ON COLUMN pbl_blueprint_mission.authenticity_score IS '真实性评分'; -COMMENT ON COLUMN pbl_blueprint_mission.is_deleted IS '删除标记(0未删/1已删)'; -COMMENT ON COLUMN pbl_blueprint_mission.created_by IS '创建人'; -COMMENT ON COLUMN pbl_blueprint_mission.created_at IS '创建时间'; -COMMENT ON COLUMN pbl_blueprint_mission.updated_by IS '更新人'; -COMMENT ON COLUMN pbl_blueprint_mission.updated_at IS '更新时间'; - -CREATE INDEX IF NOT EXISTS pbl_blueprint_mission_idx_pbl_blueprint_mission_tenant_bp ON pbl_blueprint_mission (tenant_id, blueprint_id, is_deleted); -CREATE UNIQUE INDEX IF NOT EXISTS pbl_blueprint_mission_uk_pbl_blueprint_mission_tenant_code ON pbl_blueprint_mission (tenant_id, code); - --- pbl_blueprint_reflection_spec: 蓝图子对象-反思规格(Reflection) -CREATE TABLE IF NOT EXISTS pbl_blueprint_reflection_spec ( - id varchar(32) NOT NULL, - tenant_id varchar(32) NOT NULL, - class_id varchar(32), - blueprint_id varchar(32) NOT NULL, - version_id varchar(32), - code varchar(64), - name varchar(128) NOT NULL, - description text, - seq_no integer, - status varchar(32), - content_json text, - reflect_type varchar(32), - frequency varchar(32), - prompt_text text, - duration_minutes integer, - is_deleted integer, - created_by varchar(64), - created_at timestamp, - updated_by varchar(64), - updated_at timestamp -); -COMMENT ON COLUMN pbl_blueprint_reflection_spec.id IS '主键'; -COMMENT ON COLUMN pbl_blueprint_reflection_spec.tenant_id IS '租户ID(强制打头)'; -COMMENT ON COLUMN pbl_blueprint_reflection_spec.class_id IS '班级ID'; -COMMENT ON COLUMN pbl_blueprint_reflection_spec.blueprint_id IS '所属蓝图ID'; -COMMENT ON COLUMN pbl_blueprint_reflection_spec.version_id IS '所属版本ID'; -COMMENT ON COLUMN pbl_blueprint_reflection_spec.code IS '对象编码'; -COMMENT ON COLUMN pbl_blueprint_reflection_spec.name IS '对象名称'; -COMMENT ON COLUMN pbl_blueprint_reflection_spec.description IS '描述'; -COMMENT ON COLUMN pbl_blueprint_reflection_spec.seq_no IS '排序号'; -COMMENT ON COLUMN pbl_blueprint_reflection_spec.status IS '状态'; -COMMENT ON COLUMN pbl_blueprint_reflection_spec.content_json IS '扩展内容(JSON)'; -COMMENT ON COLUMN pbl_blueprint_reflection_spec.reflect_type IS '反思类型'; -COMMENT ON COLUMN pbl_blueprint_reflection_spec.frequency IS '频次'; -COMMENT ON COLUMN pbl_blueprint_reflection_spec.prompt_text IS '反思引导语'; -COMMENT ON COLUMN pbl_blueprint_reflection_spec.duration_minutes IS '时长(分钟)'; -COMMENT ON COLUMN pbl_blueprint_reflection_spec.is_deleted IS '删除标记(0未删/1已删)'; -COMMENT ON COLUMN pbl_blueprint_reflection_spec.created_by IS '创建人'; -COMMENT ON COLUMN pbl_blueprint_reflection_spec.created_at IS '创建时间'; -COMMENT ON COLUMN pbl_blueprint_reflection_spec.updated_by IS '更新人'; -COMMENT ON COLUMN pbl_blueprint_reflection_spec.updated_at IS '更新时间'; - -CREATE INDEX IF NOT EXISTS pbl_blueprint_reflection_spec_idx_pbl_blueprint_reflection_spec_tenant_bp ON pbl_blueprint_reflection_spec (tenant_id, blueprint_id, is_deleted); -CREATE UNIQUE INDEX IF NOT EXISTS pbl_blueprint_reflection_spec_uk_pbl_blueprint_reflection_spec_tenant_code ON pbl_blueprint_reflection_spec (tenant_id, code); - --- pbl_blueprint_role: 蓝图子对象-角色(Role) -CREATE TABLE IF NOT EXISTS pbl_blueprint_role ( - id varchar(32) NOT NULL, - tenant_id varchar(32) NOT NULL, - class_id varchar(32), - blueprint_id varchar(32) NOT NULL, - version_id varchar(32), - code varchar(64), - name varchar(128) NOT NULL, - description text, - seq_no integer, - status varchar(32), - content_json text, - role_category varchar(32), - member_count integer, - responsibility text, - competency_tags text, - is_deleted integer, - created_by varchar(64), - created_at timestamp, - updated_by varchar(64), - updated_at timestamp -); -COMMENT ON COLUMN pbl_blueprint_role.id IS '主键'; -COMMENT ON COLUMN pbl_blueprint_role.tenant_id IS '租户ID(强制打头)'; -COMMENT ON COLUMN pbl_blueprint_role.class_id IS '班级ID'; -COMMENT ON COLUMN pbl_blueprint_role.blueprint_id IS '所属蓝图ID'; -COMMENT ON COLUMN pbl_blueprint_role.version_id IS '所属版本ID'; -COMMENT ON COLUMN pbl_blueprint_role.code IS '对象编码'; -COMMENT ON COLUMN pbl_blueprint_role.name IS '对象名称'; -COMMENT ON COLUMN pbl_blueprint_role.description IS '描述'; -COMMENT ON COLUMN pbl_blueprint_role.seq_no IS '排序号'; -COMMENT ON COLUMN pbl_blueprint_role.status IS '状态'; -COMMENT ON COLUMN pbl_blueprint_role.content_json IS '扩展内容(JSON)'; -COMMENT ON COLUMN pbl_blueprint_role.role_category IS '角色类别'; -COMMENT ON COLUMN pbl_blueprint_role.member_count IS '角色人数'; -COMMENT ON COLUMN pbl_blueprint_role.responsibility IS '职责说明'; -COMMENT ON COLUMN pbl_blueprint_role.competency_tags IS '能力标签(JSON数组)'; -COMMENT ON COLUMN pbl_blueprint_role.is_deleted IS '删除标记(0未删/1已删)'; -COMMENT ON COLUMN pbl_blueprint_role.created_by IS '创建人'; -COMMENT ON COLUMN pbl_blueprint_role.created_at IS '创建时间'; -COMMENT ON COLUMN pbl_blueprint_role.updated_by IS '更新人'; -COMMENT ON COLUMN pbl_blueprint_role.updated_at IS '更新时间'; - -CREATE INDEX IF NOT EXISTS pbl_blueprint_role_idx_pbl_blueprint_role_tenant_bp ON pbl_blueprint_role (tenant_id, blueprint_id, is_deleted); -CREATE UNIQUE INDEX IF NOT EXISTS pbl_blueprint_role_uk_pbl_blueprint_role_tenant_code ON pbl_blueprint_role (tenant_id, code); - --- pbl_blueprint_task: 蓝图子对象-任务分解(Task) -CREATE TABLE IF NOT EXISTS pbl_blueprint_task ( - id varchar(32) NOT NULL, - tenant_id varchar(32) NOT NULL, - class_id varchar(32), - blueprint_id varchar(32) NOT NULL, - version_id varchar(32), - code varchar(64), - name varchar(128) NOT NULL, - description text, - seq_no integer, - status varchar(32), - content_json text, - parent_task_id varchar(32), - task_type varchar(32), - tool_resources text, - planned_hours numeric(18,2), - assessment_rule text, - is_deleted integer, - created_by varchar(64), - created_at timestamp, - updated_by varchar(64), - updated_at timestamp -); -COMMENT ON COLUMN pbl_blueprint_task.id IS '主键'; -COMMENT ON COLUMN pbl_blueprint_task.tenant_id IS '租户ID(强制打头)'; -COMMENT ON COLUMN pbl_blueprint_task.class_id IS '班级ID'; -COMMENT ON COLUMN pbl_blueprint_task.blueprint_id IS '所属蓝图ID'; -COMMENT ON COLUMN pbl_blueprint_task.version_id IS '所属版本ID'; -COMMENT ON COLUMN pbl_blueprint_task.code IS '对象编码'; -COMMENT ON COLUMN pbl_blueprint_task.name IS '对象名称'; -COMMENT ON COLUMN pbl_blueprint_task.description IS '描述'; -COMMENT ON COLUMN pbl_blueprint_task.seq_no IS '排序号'; -COMMENT ON COLUMN pbl_blueprint_task.status IS '状态'; -COMMENT ON COLUMN pbl_blueprint_task.content_json IS '扩展内容(JSON)'; -COMMENT ON COLUMN pbl_blueprint_task.parent_task_id IS '父任务ID'; -COMMENT ON COLUMN pbl_blueprint_task.task_type IS '任务类型'; -COMMENT ON COLUMN pbl_blueprint_task.tool_resources IS '工具与资源(JSON数组)'; -COMMENT ON COLUMN pbl_blueprint_task.planned_hours IS '计划课时'; -COMMENT ON COLUMN pbl_blueprint_task.assessment_rule IS '评价规则(JSON)'; -COMMENT ON COLUMN pbl_blueprint_task.is_deleted IS '删除标记(0未删/1已删)'; -COMMENT ON COLUMN pbl_blueprint_task.created_by IS '创建人'; -COMMENT ON COLUMN pbl_blueprint_task.created_at IS '创建时间'; -COMMENT ON COLUMN pbl_blueprint_task.updated_by IS '更新人'; -COMMENT ON COLUMN pbl_blueprint_task.updated_at IS '更新时间'; - -CREATE INDEX IF NOT EXISTS pbl_blueprint_task_idx_pbl_blueprint_task_tenant_bp ON pbl_blueprint_task (tenant_id, blueprint_id, is_deleted); -CREATE UNIQUE INDEX IF NOT EXISTS pbl_blueprint_task_uk_pbl_blueprint_task_tenant_code ON pbl_blueprint_task (tenant_id, code); - --- pbl_blueprint_template: PBL蓝图模板 -CREATE TABLE IF NOT EXISTS pbl_blueprint_template ( - id varchar(32) NOT NULL, - tenant_id varchar(32) NOT NULL, - class_id varchar(32), - template_code varchar(64) NOT NULL, - template_name varchar(128) NOT NULL, - template_type varchar(32), - domain_code varchar(64), - grade_code varchar(32), - subject_code varchar(64), - complexity_level varchar(32), - scope varchar(32), - source_blueprint_id varchar(32), - usage_count integer, - status varchar(32), - is_deleted integer, - created_by varchar(64), - created_at timestamp, - updated_by varchar(64), - updated_at timestamp -); -COMMENT ON COLUMN pbl_blueprint_template.id IS '主键'; -COMMENT ON COLUMN pbl_blueprint_template.tenant_id IS '租户ID(强制打头)'; -COMMENT ON COLUMN pbl_blueprint_template.class_id IS '班级ID'; -COMMENT ON COLUMN pbl_blueprint_template.template_code IS '模板编码'; -COMMENT ON COLUMN pbl_blueprint_template.template_name IS '模板名称'; -COMMENT ON COLUMN pbl_blueprint_template.template_type IS '模板类型'; -COMMENT ON COLUMN pbl_blueprint_template.domain_code IS '领域'; -COMMENT ON COLUMN pbl_blueprint_template.grade_code IS '学段'; -COMMENT ON COLUMN pbl_blueprint_template.subject_code IS '学科'; -COMMENT ON COLUMN pbl_blueprint_template.complexity_level IS '复杂度'; -COMMENT ON COLUMN pbl_blueprint_template.scope IS '可见范围'; -COMMENT ON COLUMN pbl_blueprint_template.source_blueprint_id IS '来源蓝图ID'; -COMMENT ON COLUMN pbl_blueprint_template.usage_count IS '引用次数'; -COMMENT ON COLUMN pbl_blueprint_template.status IS '状态'; -COMMENT ON COLUMN pbl_blueprint_template.is_deleted IS '删除标记(0未删/1已删)'; -COMMENT ON COLUMN pbl_blueprint_template.created_by IS '创建人'; -COMMENT ON COLUMN pbl_blueprint_template.created_at IS '创建时间'; -COMMENT ON COLUMN pbl_blueprint_template.updated_by IS '更新人'; -COMMENT ON COLUMN pbl_blueprint_template.updated_at IS '更新时间'; - -CREATE UNIQUE INDEX IF NOT EXISTS pbl_blueprint_template_uk_template_tenant_code ON pbl_blueprint_template (tenant_id, template_code); -CREATE INDEX IF NOT EXISTS pbl_blueprint_template_idx_template_tenant_status ON pbl_blueprint_template (tenant_id, status, is_deleted); - --- pbl_blueprint_template_item: PBL蓝图模板明细 -CREATE TABLE IF NOT EXISTS pbl_blueprint_template_item ( - id varchar(32) NOT NULL, - tenant_id varchar(32) NOT NULL, - class_id varchar(32), - template_id varchar(32) NOT NULL, - subobject_type varchar(32) NOT NULL, - item_payload_json text, - seq_no integer, - required_flag integer, - is_deleted integer, - created_by varchar(64), - created_at timestamp, - updated_by varchar(64), - updated_at timestamp -); -COMMENT ON COLUMN pbl_blueprint_template_item.id IS '主键'; -COMMENT ON COLUMN pbl_blueprint_template_item.tenant_id IS '租户ID(强制打头)'; -COMMENT ON COLUMN pbl_blueprint_template_item.class_id IS '班级ID'; -COMMENT ON COLUMN pbl_blueprint_template_item.template_id IS '所属模板ID'; -COMMENT ON COLUMN pbl_blueprint_template_item.subobject_type IS '子对象类型'; -COMMENT ON COLUMN pbl_blueprint_template_item.item_payload_json IS '模板内容(JSON)'; -COMMENT ON COLUMN pbl_blueprint_template_item.seq_no IS '排序号'; -COMMENT ON COLUMN pbl_blueprint_template_item.required_flag IS '是否必需(0/1)'; -COMMENT ON COLUMN pbl_blueprint_template_item.is_deleted IS '删除标记(0未删/1已删)'; -COMMENT ON COLUMN pbl_blueprint_template_item.created_by IS '创建人'; -COMMENT ON COLUMN pbl_blueprint_template_item.created_at IS '创建时间'; -COMMENT ON COLUMN pbl_blueprint_template_item.updated_by IS '更新人'; -COMMENT ON COLUMN pbl_blueprint_template_item.updated_at IS '更新时间'; - -CREATE UNIQUE INDEX IF NOT EXISTS pbl_blueprint_template_item_uk_tplitem_tenant_tpl_type_seq ON pbl_blueprint_template_item (tenant_id, template_id, subobject_type, seq_no); -CREATE INDEX IF NOT EXISTS pbl_blueprint_template_item_idx_tplitem_template ON pbl_blueprint_template_item (tenant_id, template_id, is_deleted); - --- pbl_blueprint_version: PBL蓝图版本 -CREATE TABLE IF NOT EXISTS pbl_blueprint_version ( - id varchar(32) NOT NULL, - tenant_id varchar(32) NOT NULL, - class_id varchar(32), - blueprint_id varchar(32) NOT NULL, - version_no integer NOT NULL, - version_name varchar(128), - version_status varchar(32), - snapshot_json text, - content_hash varchar(64), - published_by varchar(64), - published_at timestamp, - change_note text, - base_version_id varchar(32), - is_deleted integer, - created_by varchar(64), - created_at timestamp, - updated_by varchar(64), - updated_at timestamp -); -COMMENT ON COLUMN pbl_blueprint_version.id IS '主键'; -COMMENT ON COLUMN pbl_blueprint_version.tenant_id IS '租户ID(强制打头)'; -COMMENT ON COLUMN pbl_blueprint_version.class_id IS '班级ID'; -COMMENT ON COLUMN pbl_blueprint_version.blueprint_id IS '所属蓝图ID'; -COMMENT ON COLUMN pbl_blueprint_version.version_no IS '版本号'; -COMMENT ON COLUMN pbl_blueprint_version.version_name IS '版本名称'; -COMMENT ON COLUMN pbl_blueprint_version.version_status IS '版本状态'; -COMMENT ON COLUMN pbl_blueprint_version.snapshot_json IS '蓝图快照(JSON)'; -COMMENT ON COLUMN pbl_blueprint_version.content_hash IS '快照内容哈希'; -COMMENT ON COLUMN pbl_blueprint_version.published_by IS '发布人'; -COMMENT ON COLUMN pbl_blueprint_version.published_at IS '发布时间'; -COMMENT ON COLUMN pbl_blueprint_version.change_note IS '变更说明'; -COMMENT ON COLUMN pbl_blueprint_version.base_version_id IS '基线版本ID'; -COMMENT ON COLUMN pbl_blueprint_version.is_deleted IS '删除标记(0未删/1已删)'; -COMMENT ON COLUMN pbl_blueprint_version.created_by IS '创建人'; -COMMENT ON COLUMN pbl_blueprint_version.created_at IS '创建时间'; -COMMENT ON COLUMN pbl_blueprint_version.updated_by IS '更新人'; -COMMENT ON COLUMN pbl_blueprint_version.updated_at IS '更新时间'; - -CREATE UNIQUE INDEX IF NOT EXISTS pbl_blueprint_version_uk_version_blueprint_no ON pbl_blueprint_version (tenant_id, blueprint_id, version_no); -CREATE INDEX IF NOT EXISTS pbl_blueprint_version_idx_version_blueprint ON pbl_blueprint_version (tenant_id, blueprint_id, is_deleted); +-- --------------------------------------------------------------------- +-- 4. pbl_blueprint_template_item 蓝图模板条目表 +-- --------------------------------------------------------------------- +CREATE TABLE IF NOT EXISTS `pbl_blueprint_template_item` ( + `id` varchar(32) NOT NULL COMMENT '主键,32位无横线UUID', + `tenant_id` varchar(32) NOT NULL COMMENT '租户ID,强制打头', + `template_id` varchar(32) NOT NULL COMMENT '所属模板ID', + `obj_type` varchar(32) NOT NULL COMMENT 'task/mission/role/learning_goal/evidence_spec/artifact_spec/reflection_spec', + `parent_ref` varchar(64) DEFAULT NULL COMMENT '父条目引用键(模板内相对引用)', + `ref_key` varchar(64) DEFAULT NULL COMMENT '本条目引用键', + `seq` int NOT NULL DEFAULT 0 COMMENT '同类型内排序号', + `name` varchar(200) NOT NULL COMMENT '条目名称', + `spec` json DEFAULT NULL COMMENT '条目规格默认值', + `created_by` varchar(32) DEFAULT NULL COMMENT '创建人', + `created_at` datetime DEFAULT NULL COMMENT '创建时间', + `updated_by` varchar(32) DEFAULT NULL COMMENT '最后修改人', + `updated_at` datetime DEFAULT NULL COMMENT '最后修改时间', + `deleted` tinyint NOT NULL DEFAULT 0 COMMENT '软删除 0正常 1已删除', + PRIMARY KEY (`id`), + KEY `idx_pbl_bpti_tenant_tpl` (`tenant_id`, `template_id`, `obj_type`, `seq`), + KEY `idx_pbl_bpti_refkey` (`tenant_id`, `template_id`, `ref_key`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='PBL蓝图模板条目表'; diff --git a/pbl_blueprint/sql/pbl_blueprint.subobjects.sql b/pbl_blueprint/sql/pbl_blueprint.subobjects.sql new file mode 100644 index 0000000..196ddcc --- /dev/null +++ b/pbl_blueprint/sql/pbl_blueprint.subobjects.sql @@ -0,0 +1,188 @@ +-- ===================================================================== +-- pbl_blueprint 子对象表 DDL(M1a,7 类子对象) +-- 统一泛化契约:id / tenant_id / blueprint_id / (task_id|mission_id|learning_goal_id) / seq / code / +-- name / description / spec(json) / status / 审计五件套 / deleted +-- 所有索引首列 = tenant_id(租户强制打头);主键 varchar(32);软删除 tinyint;不使用 boolean/int(11)。 +-- ===================================================================== + +-- 1/7 pbl_blueprint_task 任务 ------------------------------------------------- +CREATE TABLE IF NOT EXISTS `pbl_blueprint_task` ( + `id` varchar(32) NOT NULL COMMENT '主键,32位无横线UUID', + `tenant_id` varchar(32) NOT NULL COMMENT '租户ID,强制打头', + `blueprint_id` varchar(32) NOT NULL COMMENT '所属蓝图ID', + `parent_id` varchar(32) DEFAULT NULL COMMENT '父任务ID,支持分层', + `seq` int NOT NULL DEFAULT 0 COMMENT '蓝图内排序号', + `code` varchar(64) DEFAULT NULL COMMENT '任务编码,蓝图内唯一', + `name` varchar(200) NOT NULL COMMENT '任务名称', + `description` text COMMENT '任务描述/驱动性问题', + `spec` json DEFAULT NULL COMMENT '任务规格', + `status` varchar(32) NOT NULL DEFAULT 'draft' COMMENT 'draft/active/disabled', + `created_by` varchar(32) DEFAULT NULL COMMENT '创建人', + `created_at` datetime DEFAULT NULL COMMENT '创建时间', + `updated_by` varchar(32) DEFAULT NULL COMMENT '最后修改人', + `updated_at` datetime DEFAULT NULL COMMENT '最后修改时间', + `deleted` tinyint NOT NULL DEFAULT 0 COMMENT '软删除 0正常 1已删除', + PRIMARY KEY (`id`), + UNIQUE KEY `uk_pbl_bptask_tenant_bp_code` (`tenant_id`, `blueprint_id`, `code`), + KEY `idx_pbl_bptask_tenant_bp_seq` (`tenant_id`, `blueprint_id`, `seq`), + KEY `idx_pbl_bptask_parent` (`tenant_id`, `parent_id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='PBL蓝图子对象-任务'; + +-- 2/7 pbl_blueprint_mission 关卡 --------------------------------------------- +CREATE TABLE IF NOT EXISTS `pbl_blueprint_mission` ( + `id` varchar(32) NOT NULL COMMENT '主键,32位无横线UUID', + `tenant_id` varchar(32) NOT NULL COMMENT '租户ID,强制打头', + `blueprint_id` varchar(32) NOT NULL COMMENT '所属蓝图ID', + `task_id` varchar(32) NOT NULL COMMENT '所属任务ID', + `seq` int NOT NULL DEFAULT 0 COMMENT '任务内排序号', + `code` varchar(64) DEFAULT NULL COMMENT '关卡编码,蓝图内唯一', + `name` varchar(200) NOT NULL COMMENT '关卡名称', + `description` text COMMENT '关卡说明', + `spec` json DEFAULT NULL COMMENT '关卡规格', + `unlock_condition` json DEFAULT NULL COMMENT '解锁条件', + `status` varchar(32) NOT NULL DEFAULT 'draft' COMMENT 'draft/active/disabled', + `created_by` varchar(32) DEFAULT NULL COMMENT '创建人', + `created_at` datetime DEFAULT NULL COMMENT '创建时间', + `updated_by` varchar(32) DEFAULT NULL COMMENT '最后修改人', + `updated_at` datetime DEFAULT NULL COMMENT '最后修改时间', + `deleted` tinyint NOT NULL DEFAULT 0 COMMENT '软删除 0正常 1已删除', + PRIMARY KEY (`id`), + UNIQUE KEY `uk_pbl_bpms_tenant_bp_code` (`tenant_id`, `blueprint_id`, `code`), + KEY `idx_pbl_bpms_tenant_bp_seq` (`tenant_id`, `blueprint_id`, `seq`), + KEY `idx_pbl_bpms_task` (`tenant_id`, `task_id`, `seq`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='PBL蓝图子对象-关卡'; + +-- 3/7 pbl_blueprint_role 角色 ------------------------------------------------- +CREATE TABLE IF NOT EXISTS `pbl_blueprint_role` ( + `id` varchar(32) NOT NULL COMMENT '主键,32位无横线UUID', + `tenant_id` varchar(32) NOT NULL COMMENT '租户ID,强制打头', + `blueprint_id` varchar(32) NOT NULL COMMENT '所属蓝图ID', + `task_id` varchar(32) DEFAULT NULL COMMENT '关联任务ID,空=蓝图级角色', + `seq` int NOT NULL DEFAULT 0 COMMENT '蓝图内排序号', + `code` varchar(64) DEFAULT NULL COMMENT '角色编码,蓝图内唯一', + `name` varchar(200) NOT NULL COMMENT '角色名称', + `description` text COMMENT '角色职责说明', + `spec` json DEFAULT NULL COMMENT '角色规格', + `permissions` json DEFAULT NULL COMMENT '运行时操作权限集合', + `status` varchar(32) NOT NULL DEFAULT 'draft' COMMENT 'draft/active/disabled', + `created_by` varchar(32) DEFAULT NULL COMMENT '创建人', + `created_at` datetime DEFAULT NULL COMMENT '创建时间', + `updated_by` varchar(32) DEFAULT NULL COMMENT '最后修改人', + `updated_at` datetime DEFAULT NULL COMMENT '最后修改时间', + `deleted` tinyint NOT NULL DEFAULT 0 COMMENT '软删除 0正常 1已删除', + PRIMARY KEY (`id`), + UNIQUE KEY `uk_pbl_bprole_tenant_bp_code` (`tenant_id`, `blueprint_id`, `code`), + KEY `idx_pbl_bprole_tenant_bp_seq` (`tenant_id`, `blueprint_id`, `seq`), + KEY `idx_pbl_bprole_task` (`tenant_id`, `task_id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='PBL蓝图子对象-角色'; + +-- 4/7 pbl_blueprint_learning_goal 学习目标 ------------------------------------ +CREATE TABLE IF NOT EXISTS `pbl_blueprint_learning_goal` ( + `id` varchar(32) NOT NULL COMMENT '主键,32位无横线UUID', + `tenant_id` varchar(32) NOT NULL COMMENT '租户ID,强制打头', + `blueprint_id` varchar(32) NOT NULL COMMENT '所属蓝图ID', + `task_id` varchar(32) DEFAULT NULL COMMENT '关联任务ID,空=蓝图级目标', + `seq` int NOT NULL DEFAULT 0 COMMENT '蓝图内排序号', + `code` varchar(64) DEFAULT NULL COMMENT '目标编码,蓝图内唯一', + `name` varchar(200) NOT NULL COMMENT '目标名称', + `dimension` varchar(32) NOT NULL DEFAULT 'knowledge' COMMENT 'knowledge/ability/literacy/attitude', + `description` text COMMENT '目标描述', + `spec` json DEFAULT NULL COMMENT '目标规格', + `weight` float DEFAULT 1.0 COMMENT '评估权重', + `status` varchar(32) NOT NULL DEFAULT 'draft' COMMENT 'draft/active/disabled', + `created_by` varchar(32) DEFAULT NULL COMMENT '创建人', + `created_at` datetime DEFAULT NULL COMMENT '创建时间', + `updated_by` varchar(32) DEFAULT NULL COMMENT '最后修改人', + `updated_at` datetime DEFAULT NULL COMMENT '最后修改时间', + `deleted` tinyint NOT NULL DEFAULT 0 COMMENT '软删除 0正常 1已删除', + PRIMARY KEY (`id`), + UNIQUE KEY `uk_pbl_bplg_tenant_bp_code` (`tenant_id`, `blueprint_id`, `code`), + KEY `idx_pbl_bplg_tenant_bp_seq` (`tenant_id`, `blueprint_id`, `seq`), + KEY `idx_pbl_bplg_dim` (`tenant_id`, `blueprint_id`, `dimension`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='PBL蓝图子对象-学习目标'; + +-- 5/7 pbl_blueprint_evidence_spec 证据规格 ------------------------------------ +CREATE TABLE IF NOT EXISTS `pbl_blueprint_evidence_spec` ( + `id` varchar(32) NOT NULL COMMENT '主键,32位无横线UUID', + `tenant_id` varchar(32) NOT NULL COMMENT '租户ID,强制打头', + `blueprint_id` varchar(32) NOT NULL COMMENT '所属蓝图ID', + `task_id` varchar(32) DEFAULT NULL COMMENT '关联任务ID', + `mission_id` varchar(32) DEFAULT NULL COMMENT '关联关卡ID', + `learning_goal_id` varchar(32) DEFAULT NULL COMMENT '关联学习目标ID', + `seq` int NOT NULL DEFAULT 0 COMMENT '蓝图内排序号', + `code` varchar(64) DEFAULT NULL COMMENT '证据规格编码,蓝图内唯一', + `name` varchar(200) NOT NULL COMMENT '证据规格名称', + `evidence_type` varchar(32) NOT NULL DEFAULT 'auto' COMMENT 'auto/manual/artifact/interaction/assessment', + `collect_mode` varchar(32) NOT NULL DEFAULT 'auto' COMMENT 'auto/trigger/manual/scheduled', + `description` text COMMENT '证据说明', + `spec` json DEFAULT NULL COMMENT '证据规格', + `rule` json DEFAULT NULL COMMENT '校验规则', + `weight` float DEFAULT 1.0 COMMENT '评分权重', + `status` varchar(32) NOT NULL DEFAULT 'draft' COMMENT 'draft/active/disabled', + `created_by` varchar(32) DEFAULT NULL COMMENT '创建人', + `created_at` datetime DEFAULT NULL COMMENT '创建时间', + `updated_by` varchar(32) DEFAULT NULL COMMENT '最后修改人', + `updated_at` datetime DEFAULT NULL COMMENT '最后修改时间', + `deleted` tinyint NOT NULL DEFAULT 0 COMMENT '软删除 0正常 1已删除', + PRIMARY KEY (`id`), + UNIQUE KEY `uk_pbl_bpev_tenant_bp_code` (`tenant_id`, `blueprint_id`, `code`), + KEY `idx_pbl_bpev_tenant_bp_seq` (`tenant_id`, `blueprint_id`, `seq`), + KEY `idx_pbl_bpev_goal` (`tenant_id`, `learning_goal_id`), + KEY `idx_pbl_bpev_mission` (`tenant_id`, `mission_id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='PBL蓝图子对象-证据规格'; + +-- 6/7 pbl_blueprint_artifact_spec 产出物规格 ---------------------------------- +CREATE TABLE IF NOT EXISTS `pbl_blueprint_artifact_spec` ( + `id` varchar(32) NOT NULL COMMENT '主键,32位无横线UUID', + `tenant_id` varchar(32) NOT NULL COMMENT '租户ID,强制打头', + `blueprint_id` varchar(32) NOT NULL COMMENT '所属蓝图ID', + `task_id` varchar(32) DEFAULT NULL COMMENT '关联任务ID', + `mission_id` varchar(32) DEFAULT NULL COMMENT '关联关卡ID', + `seq` int NOT NULL DEFAULT 0 COMMENT '蓝图内排序号', + `code` varchar(64) DEFAULT NULL COMMENT '产出物规格编码,蓝图内唯一', + `name` varchar(200) NOT NULL COMMENT '产出物名称', + `artifact_type` varchar(32) NOT NULL DEFAULT 'document' COMMENT 'document/model/scene/video/audio/image/code/presentation/dataset', + `description` text COMMENT '产出物说明与要求', + `spec` json DEFAULT NULL COMMENT '产出物规格', + `accept_criteria` json DEFAULT NULL COMMENT '验收标准条目数组', + `required` tinyint NOT NULL DEFAULT 1 COMMENT '是否必交 0否 1是', + `weight` float DEFAULT 1.0 COMMENT '评分权重', + `status` varchar(32) NOT NULL DEFAULT 'draft' COMMENT 'draft/active/disabled', + `created_by` varchar(32) DEFAULT NULL COMMENT '创建人', + `created_at` datetime DEFAULT NULL COMMENT '创建时间', + `updated_by` varchar(32) DEFAULT NULL COMMENT '最后修改人', + `updated_at` datetime DEFAULT NULL COMMENT '最后修改时间', + `deleted` tinyint NOT NULL DEFAULT 0 COMMENT '软删除 0正常 1已删除', + PRIMARY KEY (`id`), + UNIQUE KEY `uk_pbl_bpart_tenant_bp_code` (`tenant_id`, `blueprint_id`, `code`), + KEY `idx_pbl_bpart_tenant_bp_seq` (`tenant_id`, `blueprint_id`, `seq`), + KEY `idx_pbl_bpart_task` (`tenant_id`, `task_id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='PBL蓝图子对象-产出物规格'; + +-- 7/7 pbl_blueprint_reflection_spec 反思规格 ---------------------------------- +CREATE TABLE IF NOT EXISTS `pbl_blueprint_reflection_spec` ( + `id` varchar(32) NOT NULL COMMENT '主键,32位无横线UUID', + `tenant_id` varchar(32) NOT NULL COMMENT '租户ID,强制打头', + `blueprint_id` varchar(32) NOT NULL COMMENT '所属蓝图ID', + `task_id` varchar(32) DEFAULT NULL COMMENT '关联任务ID', + `mission_id` varchar(32) DEFAULT NULL COMMENT '关联关卡ID', + `seq` int NOT NULL DEFAULT 0 COMMENT '蓝图内排序号', + `code` varchar(64) DEFAULT NULL COMMENT '反思规格编码,蓝图内唯一', + `name` varchar(200) NOT NULL COMMENT '反思规格名称', + `trigger_point` varchar(32) NOT NULL DEFAULT 'mission_end' COMMENT 'task_start/task_end/mission_end/milestone/project_end/manual', + `reflection_type` varchar(32) NOT NULL DEFAULT 'self' COMMENT 'self/peer/team/teacher', + `description` text COMMENT '反思说明', + `spec` json DEFAULT NULL COMMENT '反思规格(引导问题等)', + `rubric_ref` json DEFAULT NULL COMMENT '关联量规维度引用', + `weight` float DEFAULT 1.0 COMMENT '评分权重', + `status` varchar(32) NOT NULL DEFAULT 'draft' COMMENT 'draft/active/disabled', + `created_by` varchar(32) DEFAULT NULL COMMENT '创建人', + `created_at` datetime DEFAULT NULL COMMENT '创建时间', + `updated_by` varchar(32) DEFAULT NULL COMMENT '最后修改人', + `updated_at` datetime DEFAULT NULL COMMENT '最后修改时间', + `deleted` tinyint NOT NULL DEFAULT 0 COMMENT '软删除 0正常 1已删除', + PRIMARY KEY (`id`), + UNIQUE KEY `uk_pbl_bpref_tenant_bp_code` (`tenant_id`, `blueprint_id`, `code`), + KEY `idx_pbl_bpref_tenant_bp_seq` (`tenant_id`, `blueprint_id`, `seq`), + KEY `idx_pbl_bpref_trigger` (`tenant_id`, `blueprint_id`, `trigger_point`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='PBL蓝图子对象-反思规格'; diff --git a/pbl_blueprint/subobjects.py b/pbl_blueprint/subobjects.py index a550cca..6a71ba5 100644 --- a/pbl_blueprint/subobjects.py +++ b/pbl_blueprint/subobjects.py @@ -1,213 +1,241 @@ -# -*- coding: utf-8 -*- -"""蓝图子对象契约接口(M1a 落表 + CRUD;M1b 扩展校验/编排)。 +"""pbl_blueprint 7 类子对象泛化契约(M1a)。 -7 类子对象表(子类型 -> 表名见 models.SUBOBJECT_TYPE_TABLE): -mission / learning_goal / task / role / artifact_spec / evidence_spec / reflection_spec +7 类子对象统一走同一套 CRUD 契约(obj_type 分派到具体表), +避免为每类子对象写重复代码,也便于 Compiler / Validation 统一遍历。 -契约函数: -- subobject_save(subobject_type, payload, tenant_id=None) -> dict -- subobject_list(subobject_type, blueprint_id, tenant_id=None) -> dict {rows,total} -- subobject_get(subobject_type, object_id, tenant_id=None) -> dict -- subobject_delete(subobject_type, object_id, tenant_id=None) -> dict -- list_all_subobjects(blueprint_id, tenant_id=None) -> dict {type: [rows]} -- count_subobjects(blueprint_id, tenant_id=None) -> dict {type: n}(M2 校验引擎复用) -- copy_subobjects(src_blueprint_id, dst_blueprint_id, tenant_id=None) -> dict - -公共字段(所有子对象表一致):tenant_id / class_id / blueprint_id / version_id / code / -name / description / seq_no / status / content_json + 审计字段。 +tenant_id 强制打头:所有函数第一入参 tenant_id,缺失即 PblTenantRequired。 """ -from . import db as _db -from . import models as _m -COMMON_FIELDS = ['class_id', 'blueprint_id', 'version_id', 'code', 'name', - 'description', 'seq_no', 'status', 'content_json'] +import datetime -TYPE_OPTIONS = [ - {'code': 'mission', 'label': '真实情境任务'}, - {'code': 'learning_goal', 'label': '学习目标'}, - {'code': 'task', 'label': '任务分解'}, - {'code': 'role', 'label': '角色'}, - {'code': 'artifact_spec', 'label': '产出物规格'}, - {'code': 'evidence_spec', 'label': '证据规格'}, - {'code': 'reflection_spec', 'label': '反思规格'}, -] +from .db import ( + PblError, + PblNotFound, + PblValidationError, + get_dbname, + get_env, + get_sor, + new_id, + require_tenant, +) -STATUS_OPTIONS = [ - {'code': 'active', 'label': '启用'}, - {'code': 'draft', 'label': '草稿'}, - {'code': 'disabled', 'label': '停用'}, -] +# 7 类子对象类型 -> 表名 +SUBOBJECT_TABLES = { + 'task': 'pbl_blueprint_task', + 'mission': 'pbl_blueprint_mission', + 'role': 'pbl_blueprint_role', + 'learning_goal': 'pbl_blueprint_learning_goal', + 'evidence_spec': 'pbl_blueprint_evidence_spec', + 'artifact_spec': 'pbl_blueprint_artifact_spec', + 'reflection_spec': 'pbl_blueprint_reflection_spec', +} + +SUBOBJECT_TYPES = list(SUBOBJECT_TABLES.keys()) + +# 各类型编码前缀(自动生成 code 用) +CODE_PREFIX = { + 'task': 'TASK', + 'mission': 'MISSION', + 'role': 'ROLE', + 'learning_goal': 'GOAL', + 'evidence_spec': 'EVI', + 'artifact_spec': 'ART', + 'reflection_spec': 'REF', +} + +# 各类型可编辑字段(泛化契约 + 类型特有字段) +BASE_EDITABLE = ['blueprint_id', 'seq', 'code', 'name', 'description', 'spec', 'status'] +TYPE_FIELDS = { + 'task': BASE_EDITABLE + ['parent_id'], + 'mission': BASE_EDITABLE + ['task_id', 'unlock_condition'], + 'role': BASE_EDITABLE + ['task_id', 'permissions'], + 'learning_goal': BASE_EDITABLE + ['task_id', 'dimension', 'weight'], + 'evidence_spec': BASE_EDITABLE + ['task_id', 'mission_id', 'learning_goal_id', + 'evidence_type', 'collect_mode', 'rule', 'weight'], + 'artifact_spec': BASE_EDITABLE + ['task_id', 'mission_id', 'artifact_type', + 'accept_criteria', 'required', 'weight'], + 'reflection_spec': BASE_EDITABLE + ['task_id', 'mission_id', 'trigger_point', + 'reflection_type', 'rubric_ref', 'weight'], +} + +# 类型特有必填字段 +TYPE_REQUIRED = { + 'mission': ['task_id'], +} -def table_of(subobject_type): - table = _m.SUBOBJECT_TYPE_TABLE.get((subobject_type or '').strip()) - if not table: - raise _db.PblError(_db.ERR_PARAM_INVALID, - '未知子对象类型: %s(可选 %s)' - % (subobject_type, ','.join(sorted(_m.SUBOBJECT_TYPE_TABLE)))) - return table +def _now(): + return datetime.datetime.now().strftime('%Y-%m-%d %H:%M:%S') -def _typed_fields(table): - """该表可写业务字段(models/*.json 为准,排除主键/租户/审计)。""" - skip = ('id', 'tenant_id', 'created_by', 'created_at', 'updated_by', 'updated_at', - 'is_deleted') - return [k for k in _m.load_model(table)['fields'].keys() if k not in skip] +def _table(obj_type): + if obj_type not in SUBOBJECT_TABLES: + raise PblValidationError( + 'unknown obj_type: %s (allowed: %s)' % (obj_type, ','.join(SUBOBJECT_TYPES)), + code='PBL_UNKNOWN_OBJ_TYPE') + return SUBOBJECT_TABLES[obj_type] -def _fetch(table, rec_id, tid): - return _db.select_one(table, where={'id': rec_id, 'tenant_id': tid, 'is_deleted': 0}) +def _where(tenant_id, extra=None): + cond = "tenant_id='%s'" % tenant_id.replace("'", "''") + if extra: + cond = cond + ' and ' + extra + return cond -def subobject_save(subobject_type, payload, tenant_id=None): - """新建/更新子对象。payload 含 id 视为更新;blueprint_id 必填且必须属本租户。""" - tid = tenant_id or _db.current_tenant_id() - table = table_of(subobject_type) - payload = dict(payload or {}) - allowed = _typed_fields(table) - data = dict((k, payload[k]) for k in allowed if k in payload) - now = _db.now_str() - operator = _db.current_operator() +def _pick(data, keys): + out = {} + for k in keys: + if k in data and data[k] is not None: + out[k] = data[k] + return out - blueprint_id = data.get('blueprint_id') or payload.get('blueprint_id') - if not blueprint_id: - raise _db.PblError(_db.ERR_PARAM_INVALID, 'blueprint_id 必填') - bp = _db.select_one(_m.TABLES[0], where={'id': blueprint_id, 'tenant_id': tid, - 'is_deleted': 0}) - if not bp: - raise _db.PblError(_db.ERR_NOT_FOUND, '所属蓝图不存在或无权访问: %s' % blueprint_id) - data['blueprint_id'] = blueprint_id + +def _gen_code(sor, tenant_id, blueprint_id, obj_type): + tbl = _table(obj_type) + prefix = '%s-' % CODE_PREFIX.get(obj_type, obj_type.upper()) + rows = sor.R(tbl, _where(tenant_id, "blueprint_id='%s'" % blueprint_id)) + seq = len(rows or []) + 1 + return '%s%03d' % (prefix, seq) + + +# ---------------------------------------------------------------- 泛化 CRUD + +def create_subobject(tenant_id, obj_type, data, env=None, operator=None): + """新建子对象。:param obj_type: 7 类之一 :param data: 字段字典(须含 blueprint_id/name)""" + tid = require_tenant(tenant_id) + tbl = _table(obj_type) + if not isinstance(data, dict): + raise PblValidationError('data must be a dict') + if not data.get('blueprint_id'): + raise PblValidationError('blueprint_id is required') if not data.get('name'): - raise _db.PblError(_db.ERR_PARAM_INVALID, 'name 必填') - data.setdefault('status', 'active') - if not data.get('class_id'): - data['class_id'] = bp.get('class_id') or '' + raise PblValidationError('name is required') + for field in TYPE_REQUIRED.get(obj_type, []): + if not data.get(field): + raise PblValidationError('%s is required for obj_type=%s' % (field, obj_type)) - rec_id = payload.get('id') - if rec_id: - old = _fetch(table, rec_id, tid) - if not old: - raise _db.PblError(_db.ERR_NOT_FOUND, '子对象不存在或无权访问: %s' % rec_id) - if old.get('blueprint_id') != blueprint_id: - raise _db.PblError(_db.ERR_REFERENCED, '子对象不允许跨蓝图迁移') - code = data.get('code') or old.get('code') - if code: - dup = _db.select_one(table, where={'tenant_id': tid, 'code': code, - 'id!': rec_id, 'is_deleted': 0}) - if dup: - raise _db.PblError(_db.ERR_PARAM_INVALID, '子对象编码已存在: %s' % code) - data['code'] = code - data.update({'updated_by': operator, 'updated_at': now}) - _db.update_row(table, data, {'id': rec_id, 'tenant_id': tid}) - result = _fetch(table, rec_id, tid) - _db.audit('subobject_update', table, rec_id, before=old, after=result, tenant_id=tid) - return result + e = get_env(env) + sor = get_sor(e, get_dbname(e)) + if sor is None: + raise PblError('sqlor unavailable for module pbl_blueprint', code='PBL_DB_UNAVAILABLE') - new_id = _db.gen_id() - row = dict((k, '') for k in allowed) - row.update(data) - row.update({'id': new_id, 'tenant_id': tid, 'is_deleted': 0, - 'seq_no': int(data.get('seq_no') or 0), - 'created_by': operator, 'created_at': now, - 'updated_by': operator, 'updated_at': now}) - _db.insert_row(table, row) - _db.audit('subobject_create', table, new_id, after=row, tenant_id=tid) - return _fetch(table, new_id, tid) - - -def subobject_list(subobject_type, blueprint_id, tenant_id=None): - tid = tenant_id or _db.current_tenant_id() - table = table_of(subobject_type) - if not blueprint_id: - raise _db.PblError(_db.ERR_PARAM_INVALID, 'blueprint_id 必填') - rows = _db.select_rows(table, where={'tenant_id': tid, 'blueprint_id': blueprint_id, - 'is_deleted': 0}, orderby='seq_no asc, created_at asc') or [] - return {'subobject_type': subobject_type, 'table': table, - 'rows': rows, 'total': len(rows)} - - -def subobject_get(subobject_type, object_id, tenant_id=None): - tid = tenant_id or _db.current_tenant_id() - table = table_of(subobject_type) - row = _fetch(table, object_id, tid) - if not row: - raise _db.PblError(_db.ERR_NOT_FOUND, '子对象不存在或无权访问: %s' % object_id) + row = {'id': data.get('id') or new_id(), 'tenant_id': tid} + row.update(_pick(data, TYPE_FIELDS.get(obj_type, BASE_EDITABLE))) + if not row.get('code'): + row['code'] = _gen_code(sor, tid, row['blueprint_id'], obj_type) + row.setdefault('seq', 0) + row.setdefault('status', 'draft') + row['created_by'] = operator + row['created_at'] = _now() + row['updated_by'] = operator + row['updated_at'] = _now() + row['deleted'] = 0 + sor.C(tbl, row) return row -def subobject_delete(subobject_type, object_id, tenant_id=None): - tid = tenant_id or _db.current_tenant_id() - table = table_of(subobject_type) - old = _fetch(table, object_id, tid) - if not old: - raise _db.PblError(_db.ERR_NOT_FOUND, '子对象不存在或无权访问: %s' % object_id) - if (old.get('status') or '') == 'locked': - raise _db.PblError(_db.ERR_REFERENCED, '锁定状态的子对象不可删除') - _db.update_row(table, {'is_deleted': 1, 'updated_by': _db.current_operator(), - 'updated_at': _db.now_str()}, - {'id': object_id, 'tenant_id': tid}) - _db.audit('subobject_delete', table, object_id, before=old, tenant_id=tid) - return {'id': object_id, 'subobject_type': subobject_type, 'deleted': True} +def update_subobject(tenant_id, obj_type, obj_id, data, env=None, operator=None): + """更新子对象(仅该类型可编辑字段)。""" + tid = require_tenant(tenant_id) + tbl = _table(obj_type) + if not obj_id: + raise PblValidationError('obj_id is required') + patch = _pick(data or {}, TYPE_FIELDS.get(obj_type, BASE_EDITABLE)) + patch.pop('blueprint_id', None) # 不允许跨蓝图迁移 + if not patch: + return get_subobject(tid, obj_type, obj_id, env=env) + patch['updated_by'] = operator + patch['updated_at'] = _now() + e = get_env(env) + sor = get_sor(e, get_dbname(e)) + sor.U(tbl, patch, _where(tid, "id='%s'" % obj_id)) + row = get_subobject(tid, obj_type, obj_id, env=env) + return row -def list_all_subobjects(blueprint_id, tenant_id=None): - tid = tenant_id or _db.current_tenant_id() - out = {} - for stype in sorted(_m.SUBOBJECT_TYPE_TABLE): - out[stype] = subobject_list(stype, blueprint_id, tid)['rows'] - return out +def delete_subobject(tenant_id, obj_type, obj_id, env=None, operator=None, hard=False): + """软删除子对象(hard=True 物理删除)。""" + tid = require_tenant(tenant_id) + tbl = _table(obj_type) + if not obj_id: + raise PblValidationError('obj_id is required') + e = get_env(env) + sor = get_sor(e, get_dbname(e)) + cond = _where(tid, "id='%s'" % obj_id) + if hard: + sor.D(tbl, cond) + else: + sor.U(tbl, {'deleted': 1, 'updated_by': operator, 'updated_at': _now()}, cond) + return {'id': obj_id, 'obj_type': obj_type, 'deleted': 1} -def count_subobjects(blueprint_id, tenant_id=None): - tid = tenant_id or _db.current_tenant_id() - out = {} - for stype, table in _m.SUBOBJECT_TYPE_TABLE.items(): - rows = _db.select_rows(table, where={'tenant_id': tid, 'blueprint_id': blueprint_id, - 'is_deleted': 0}, fields='id') or [] - out[stype] = len(rows) - out['total'] = sum(out.values()) - return out +def get_subobject(tenant_id, obj_type, obj_id, env=None): + """按 id 取子对象。""" + tid = require_tenant(tenant_id) + tbl = _table(obj_type) + if not obj_id: + raise PblValidationError('obj_id is required') + e = get_env(env) + sor = get_sor(e, get_dbname(e)) + rows = sor.R(tbl, _where(tid, "id='%s' and deleted=0" % obj_id)) + if not rows: + raise PblNotFound('%s not found: %s' % (obj_type, obj_id), code='PBL_SUBOBJECT_NOT_FOUND') + return rows[0] -def copy_subobjects(src_blueprint_id, dst_blueprint_id, tenant_id=None): - """把源蓝图的全部子对象复制到目标蓝图(新主键,重挂 blueprint_id)。""" - tid = tenant_id or _db.current_tenant_id() - now = _db.now_str() - operator = _db.current_operator() - copied = {} - for stype, table in _m.SUBOBJECT_TYPE_TABLE.items(): - rows = _db.select_rows(table, where={'tenant_id': tid, 'blueprint_id': src_blueprint_id, - 'is_deleted': 0}) or [] - n = 0 - for r in rows: - new = dict(r) - new.update({'id': _db.gen_id(), 'blueprint_id': dst_blueprint_id, - 'version_id': '', 'code': '', - 'created_by': operator, 'created_at': now, - 'updated_by': operator, 'updated_at': now, 'is_deleted': 0}) - _db.insert_row(table, new) - n += 1 - copied[stype] = n - copied['total'] = sum(v for k, v in copied.items() if k != 'total') - return copied +def list_subobjects(tenant_id, obj_type, blueprint_id, filters=None, env=None): + """列出某蓝图下某类型的全部子对象(按 seq 升序)。""" + tid = require_tenant(tenant_id) + tbl = _table(obj_type) + if not blueprint_id: + raise PblValidationError('blueprint_id is required') + filters = filters or {} + conds = ["blueprint_id='%s'" % blueprint_id.replace("'", "''"), 'deleted=0'] + for key in ('status', 'task_id', 'mission_id', 'parent_id', 'learning_goal_id', + 'dimension', 'evidence_type', 'artifact_type', 'trigger_point', 'reflection_type'): + if filters.get(key): + conds.append("%s='%s'" % (key, str(filters[key]).replace("'", "''"))) + e = get_env(env) + sor = get_sor(e, get_dbname(e)) + return sor.R(tbl, _where(tid, ' and '.join(conds)), 'seq asc') or [] -def soft_delete_by_blueprint(blueprint_id, tenant_id=None): - """蓝图删除时级联软删其子对象。""" - tid = tenant_id or _db.current_tenant_id() - now = _db.now_str() - operator = _db.current_operator() - affected = 0 - for table in _m.SUBOBJECT_TYPE_TABLE.values(): - rows = _db.select_rows(table, where={'tenant_id': tid, 'blueprint_id': blueprint_id, - 'is_deleted': 0}, fields='id') or [] - for r in rows: - _db.update_row(table, {'is_deleted': 1, 'updated_by': operator, - 'updated_at': now}, - {'id': r.get('id'), 'tenant_id': tid}) - affected += 1 - return affected +def batch_upsert_subobjects(tenant_id, obj_type, blueprint_id, rows, env=None, operator=None): + """批量新增/更新子对象(Designer Agent 一次性写入整棵子树用)。 + rows: [{'id': 可选, ...字段}];带 id 且存在则更新,否则新建。 + :return: {'created': n, 'updated': m, 'ids': [...]} + """ + tid = require_tenant(tenant_id) + _table(obj_type) + if not blueprint_id: + raise PblValidationError('blueprint_id is required') + if not isinstance(rows, list): + raise PblValidationError('rows must be a list') -def subobject_type_options(): - return list(TYPE_OPTIONS) + e = get_env(env) + sor = get_sor(e, get_dbname(e)) + created, updated, ids = 0, 0, [] + for idx, row in enumerate(rows): + item = dict(row or {}) + item['blueprint_id'] = blueprint_id + item.setdefault('seq', idx) + oid = item.get('id') + exists = False + if oid: + try: + get_subobject(tid, obj_type, oid, env=e) + exists = True + except PblNotFound: + exists = False + if exists: + update_subobject(tid, obj_type, oid, item, env=e, operator=operator) + updated += 1 + ids.append(oid) + else: + item.pop('id', None) + new_row = create_subobject(tid, obj_type, item, env=e, operator=operator) + created += 1 + ids.append(new_row['id']) + return {'created': created, 'updated': updated, 'ids': ids} diff --git a/pbl_blueprint/templates.py b/pbl_blueprint/templates.py index e588c28..29fdbd0 100644 --- a/pbl_blueprint/templates.py +++ b/pbl_blueprint/templates.py @@ -1,238 +1,349 @@ -# -*- coding: utf-8 -*- -"""蓝图模板契约接口(M1a:模板主表/明细 CRUD + 实例化为蓝图)。 +"""pbl_blueprint 模板与离线兜底(M1a)。 -契约函数: -- save_template(payload, tenant_id=None) -> dict -- get_template(template_id, tenant_id=None) -> dict(含 items 明细) -- list_templates(filters=None, page=1, rows=20, tenant_id=None) -> dict -- delete_template(template_id, tenant_id=None) -> dict -- create_template_from_blueprint(blueprint_id, payload=None, tenant_id=None) -> dict - 把蓝图(含子对象)抽为模板:写 template 主表 + template_item 明细 -- template_instantiate(template_id, payload=None, tenant_id=None) -> dict - 由模板实例化出新蓝图(draft),并回写 usage_count +- 模板 CRUD(pbl_blueprint_template / pbl_blueprint_template_item) +- instantiate_template:模板 -> 新蓝图(含 7 类子对象,ref_key/parent_ref 解析为真实 ID) +- build_offline_fallback_blueprint:Agent/网络不可用时的最小可用蓝图兜底 + +tenant_id 强制打头,缺失即 PblTenantRequired。 """ -from . import db as _db -from . import models as _m -from . import blueprint_crud as _bp -from . import subobjects as _s -TABLE = 'pbl_blueprint_template' -TABLE_ITEM = 'pbl_blueprint_template_item' +import datetime -TEMPLATE_FIELDS = ['class_id', 'template_code', 'template_name', 'template_type', - 'domain_code', 'grade_code', 'subject_code', 'complexity_level', - 'scope', 'source_blueprint_id', 'status', 'description'] +from .db import ( + PblError, + PblNotFound, + PblValidationError, + get_dbname, + get_env, + get_sor, + new_id, + require_tenant, +) +from .subobjects import SUBOBJECT_TYPES, create_subobject + +TPL_TABLE = 'pbl_blueprint_template' +ITEM_TABLE = 'pbl_blueprint_template_item' + +TPL_EDITABLE = ['code', 'name', 'category', 'subject', 'grade', 'duration_hours', + 'description', 'content', 'status'] + +# 离线兜底最小蓝图骨架(无 Agent、无模板时也能产出可编译蓝图) +OFFLINE_FALLBACK = { + 'name': '离线兜底蓝图', + 'summary': '由 pbl_blueprint 离线兜底生成的最小可用蓝图(1 任务 / 1 关卡 / 2 角色 / 2 目标 / 1 证据 / 1 产出物 / 1 反思)。', + 'duration_hours': 4, + 'items': [ + {'obj_type': 'task', 'ref_key': 'T1', 'name': '主任务:完成项目挑战', + 'spec': {'duration_hours': 4, 'difficulty': 'medium'}}, + {'obj_type': 'mission', 'parent_ref': 'T1', 'ref_key': 'M1', 'name': '关卡1:需求分析与方案设计', + 'spec': {'goal': '产出方案初稿'}}, + {'obj_type': 'role', 'parent_ref': 'T1', 'ref_key': 'R1', 'name': '队长', + 'spec': {'max_members': 1}}, + {'obj_type': 'role', 'parent_ref': 'T1', 'ref_key': 'R2', 'name': '记录员', + 'spec': {'max_members': 1}}, + {'obj_type': 'learning_goal', 'parent_ref': 'T1', 'ref_key': 'G1', 'name': '掌握项目核心知识', + 'dimension': 'knowledge', 'weight': 1.0}, + {'obj_type': 'learning_goal', 'parent_ref': 'T1', 'ref_key': 'G2', 'name': '具备协作与表达能力', + 'dimension': 'ability', 'weight': 1.0}, + {'obj_type': 'evidence_spec', 'parent_ref': 'T1', 'ref_key': 'E1', 'name': '方案设计过程证据', + 'evidence_type': 'auto', 'collect_mode': 'auto'}, + {'obj_type': 'artifact_spec', 'parent_ref': 'T1', 'ref_key': 'A1', 'name': '项目成果报告', + 'artifact_type': 'document', 'required': 1}, + {'obj_type': 'reflection_spec', 'parent_ref': 'T1', 'ref_key': 'F1', 'name': '结题反思', + 'trigger_point': 'project_end', 'reflection_type': 'self'}, + ], +} -def _fetch(tid, rec_id, table=TABLE): - return _db.select_one(table, where={'id': rec_id, 'tenant_id': tid, 'is_deleted': 0}) +def _now(): + return datetime.datetime.now().strftime('%Y-%m-%d %H:%M:%S') -def save_template(payload, tenant_id=None): - tid = tenant_id or _db.current_tenant_id() - payload = dict(payload or {}) - data = dict((k, payload[k]) for k in TEMPLATE_FIELDS if k in payload) - now = _db.now_str() - operator = _db.current_operator() - rec_id = payload.get('id') +def _where(tenant_id, extra=None): + cond = "tenant_id='%s'" % tenant_id.replace("'", "''") + if extra: + cond = cond + ' and ' + extra + return cond - if rec_id: - old = _fetch(tid, rec_id) - if not old: - raise _db.PblError(_db.ERR_NOT_FOUND, '模板不存在或无权访问: %s' % rec_id) - code = data.get('template_code') or old.get('template_code') - dup = _db.select_one(TABLE, where={'tenant_id': tid, 'template_code': code, - 'id!': rec_id, 'is_deleted': 0}) - if dup: - raise _db.PblError(_db.ERR_PARAM_INVALID, '模板编码已存在: %s' % code) - data['template_code'] = code - data.update({'updated_by': operator, 'updated_at': now}) - _db.update_row(TABLE, data, {'id': rec_id, 'tenant_id': tid}) - _db.audit('template_update', TABLE, rec_id, before=old, - after=_fetch(tid, rec_id), tenant_id=tid) - return _fetch(tid, rec_id) - if not data.get('template_name'): - raise _db.PblError(_db.ERR_PARAM_INVALID, 'template_name 必填') - if not data.get('template_code'): - data['template_code'] = 'TPL-%s' % _db.gen_id()[:8].upper() - dup = _db.select_one(TABLE, where={'tenant_id': tid, 'template_code': data['template_code'], - 'is_deleted': 0}) - if dup: - raise _db.PblError(_db.ERR_PARAM_INVALID, '模板编码已存在: %s' % data['template_code']) - new_id = _db.gen_id() - row = { - 'id': new_id, 'tenant_id': tid, 'class_id': data.get('class_id', ''), - 'template_code': data['template_code'], 'template_name': data['template_name'], - 'template_type': data.get('template_type', 'blueprint'), - 'domain_code': data.get('domain_code', ''), 'grade_code': data.get('grade_code', ''), - 'subject_code': data.get('subject_code', ''), - 'complexity_level': data.get('complexity_level', ''), - 'scope': data.get('scope', 'tenant'), - 'source_blueprint_id': data.get('source_blueprint_id', ''), - 'usage_count': 0, 'status': data.get('status', 'draft'), - 'description': data.get('description', ''), - 'is_deleted': 0, 'created_by': operator, 'created_at': now, - 'updated_by': operator, 'updated_at': now, +def _pick(data, keys): + out = {} + for k in keys: + if k in data and data[k] is not None: + out[k] = data[k] + return out + + +# ---------------------------------------------------------------- 模板 CRUD + +def create_template(tenant_id, data, env=None, operator=None): + """新建模板;data['items'] 为子对象骨架条目数组(可选)。""" + tid = require_tenant(tenant_id) + if not isinstance(data, dict) or not data.get('name'): + raise PblValidationError('name is required') + e = get_env(env) + sor = get_sor(e, get_dbname(e)) + if sor is None: + raise PblError('sqlor unavailable for module pbl_blueprint', code='PBL_DB_UNAVAILABLE') + + tpl = { + 'id': data.get('id') or new_id(), + 'tenant_id': tid, + 'code': data.get('code') or ('TPL-%s' % datetime.datetime.now().strftime('%Y%m%d%H%M%S')), + 'name': data['name'], + 'category': data.get('category') or 'general', + 'subject': data.get('subject'), + 'grade': data.get('grade'), + 'duration_hours': data.get('duration_hours'), + 'description': data.get('description'), + 'content': data.get('content'), + 'status': data.get('status') or 'enabled', + 'use_count': 0, + 'created_by': operator, + 'created_at': _now(), + 'updated_by': operator, + 'updated_at': _now(), + 'deleted': 0, } - _db.insert_row(TABLE, row) - _db.audit('template_create', TABLE, new_id, after=row, tenant_id=tid) - return _fetch(tid, new_id) - - -def list_template_items(template_id, tid): - rows = _db.select_rows(TABLE_ITEM, where={'tenant_id': tid, 'template_id': template_id, - 'is_deleted': 0}, - orderby='subobject_type asc, seq_no asc') or [] - return rows - - -def get_template(template_id, tenant_id=None): - tid = tenant_id or _db.current_tenant_id() - row = _fetch(tid, template_id) - if not row: - raise _db.PblError(_db.ERR_NOT_FOUND, '模板不存在或无权访问: %s' % template_id) - row['items'] = list_template_items(template_id, tid) - row['item_count'] = len(row['items']) - return row - - -def list_templates(filters=None, page=1, rows=20, tenant_id=None): - tid = tenant_id or _db.current_tenant_id() - filters = dict(filters or {}) - where = {'tenant_id': tid, 'is_deleted': 0} - for k in ('status', 'template_type', 'domain_code', 'grade_code', 'subject_code', - 'complexity_level', 'scope'): - if filters.get(k): - where[k] = filters[k] - for k in ('template_name', 'template_code'): - if filters.get(k): - where['%s~' % k] = '%%%s%%' % filters[k] - page = max(1, int(page or 1)) - rows = min(200, max(1, int(rows or 20))) - allowed = ('created_at', 'updated_at', 'template_code', 'usage_count', 'status') - sortby = filters.get('sortby') if filters.get('sortby') in allowed else 'created_at' - order = 'asc' if str(filters.get('sortorder', 'desc')).lower() == 'asc' else 'desc' - all_rows = _db.select_rows(TABLE, where=where, - fields='id, tenant_id, class_id, template_code, template_name, ' - 'template_type, domain_code, grade_code, subject_code, ' - 'complexity_level, scope, usage_count, status, created_at, ' - 'updated_at', - orderby='%s %s' % (sortby, order)) or [] - total = len(all_rows) - start = (page - 1) * rows - return {'rows': all_rows[start:start + rows], 'total': total, - 'page': page, 'rows_per_page': rows} - - -def delete_template(template_id, tenant_id=None): - tid = tenant_id or _db.current_tenant_id() - old = _fetch(tid, template_id) - if not old: - raise _db.PblError(_db.ERR_NOT_FOUND, '模板不存在或无权访问: %s' % template_id) - now = _db.now_str() - operator = _db.current_operator() - _db.update_row(TABLE, {'is_deleted': 1, 'updated_by': operator, 'updated_at': now}, - {'id': template_id, 'tenant_id': tid}) - for it in list_template_items(template_id, tid): - _db.update_row(TABLE_ITEM, {'is_deleted': 1, 'updated_by': operator, - 'updated_at': now}, - {'id': it.get('id'), 'tenant_id': tid}) - _db.audit('template_delete', TABLE, template_id, before=old, tenant_id=tid) - return {'id': template_id, 'deleted': True} - - -def create_template_from_blueprint(blueprint_id, payload=None, tenant_id=None): - """把蓝图抽为模板:主表 + 每类子对象一条 item(payload 可覆盖模板字段)。""" - tid = tenant_id or _db.current_tenant_id() - payload = dict(payload or {}) - bp = _bp.get_blueprint(blueprint_id, tid) - tpl_payload = { - 'template_name': payload.get('template_name') or ('%s 模板' % (bp.get('blueprint_name') or '')), - 'template_code': payload.get('template_code') or '', - 'template_type': payload.get('template_type', 'blueprint'), - 'domain_code': bp.get('domain_code', ''), 'grade_code': bp.get('grade_code', ''), - 'subject_code': bp.get('subject_code', ''), - 'complexity_level': bp.get('complexity_level', ''), - 'scope': payload.get('scope', 'tenant'), - 'source_blueprint_id': blueprint_id, - 'description': payload.get('description', ''), - 'class_id': payload.get('class_id') or bp.get('class_id', ''), - } - tpl = save_template(tpl_payload, tid) - now = _db.now_str() - operator = _db.current_operator() - children = _s.list_all_subobjects(blueprint_id, tid) - created = 0 - for stype in sorted(children): - seq = 0 - for obj in children[stype]: - payload_json = dict((k, v) for k, v in obj.items() - if k not in ('id', 'tenant_id', 'blueprint_id', 'version_id', - 'created_by', 'created_at', 'updated_by', - 'updated_at', 'is_deleted')) - _db.insert_row(TABLE_ITEM, { - 'id': _db.gen_id(), 'tenant_id': tid, - 'class_id': obj.get('class_id') or '', 'template_id': tpl['id'], - 'subobject_type': stype, 'code': obj.get('code') or '', - 'name': obj.get('name') or '', 'description': obj.get('description') or '', - 'item_payload_json': _db.dumps(payload_json), 'seq_no': seq, - 'required_flag': int(obj.get('required_flag') or 1), - 'status': 'active', 'is_deleted': 0, - 'created_by': operator, 'created_at': now, - 'updated_by': operator, 'updated_at': now, - }) - seq += 1 - created += 1 - _db.audit('template_from_blueprint', TABLE, tpl['id'], - after={'source_blueprint_id': blueprint_id, 'items': created}, tenant_id=tid) - tpl = get_template(tpl['id'], tid) - tpl['item_created'] = created + sor.C(TPL_TABLE, tpl) + items = data.get('items') or [] + tpl['items'] = _save_items(sor, tid, tpl['id'], items, operator) return tpl -def template_instantiate(template_id, payload=None, tenant_id=None): - """由模板实例化新蓝图(draft):复制模板字段 + 逐条 item 落子对象;usage_count+1。""" - tid = tenant_id or _db.current_tenant_id() - payload = dict(payload or {}) - tpl = get_template(template_id, tid) - now = _db.now_str() - operator = _db.current_operator() - bp_payload = { - 'blueprint_name': payload.get('blueprint_name') or tpl.get('template_name'), - 'class_id': payload.get('class_id') or tpl.get('class_id') or '', - 'domain_code': tpl.get('domain_code', ''), 'grade_code': tpl.get('grade_code', ''), - 'subject_code': tpl.get('subject_code', ''), - 'complexity_level': tpl.get('complexity_level', ''), - 'project_duration': payload.get('project_duration') or 0, - 'student_count': payload.get('student_count') or 0, - 'group_count': payload.get('group_count') or 0, - 'summary_text': payload.get('summary_text') or tpl.get('description') or '', +def _save_items(sor, tenant_id, template_id, items, operator=None): + """写入模板条目(先清后写,幂等)。""" + sor.D(ITEM_TABLE, _where(tenant_id, "template_id='%s'" % template_id)) + saved = [] + for idx, item in enumerate(items or []): + obj_type = item.get('obj_type') + if obj_type not in SUBOBJECT_TYPES: + raise PblValidationError('item[%d] invalid obj_type: %s' % (idx, obj_type)) + if not item.get('name'): + raise PblValidationError('item[%d] name is required' % idx) + row = { + 'id': new_id(), + 'tenant_id': tenant_id, + 'template_id': template_id, + 'obj_type': obj_type, + 'parent_ref': item.get('parent_ref'), + 'ref_key': item.get('ref_key') or ('%s%d' % (obj_type[:2].upper(), idx + 1)), + 'seq': item.get('seq', idx), + 'name': item['name'], + 'spec': _merge_spec(obj_type, item), + 'created_by': operator, + 'created_at': _now(), + 'updated_by': operator, + 'updated_at': _now(), + 'deleted': 0, + } + sor.C(ITEM_TABLE, row) + saved.append(row) + return saved + + +def _merge_spec(obj_type, item): + """把条目上的类型特有字段收进 spec,保持条目表结构统一。""" + spec = dict(item.get('spec') or {}) + for key in ('dimension', 'weight', 'evidence_type', 'collect_mode', 'rule', + 'artifact_type', 'accept_criteria', 'required', 'trigger_point', + 'reflection_type', 'rubric_ref', 'unlock_condition', 'permissions', + 'description'): + if item.get(key) is not None: + spec[key] = item[key] + return spec + + +def update_template(tenant_id, template_id, data, env=None, operator=None): + """更新模板;data['items'] 存在时整体重写条目。""" + tid = require_tenant(tenant_id) + if not template_id: + raise PblValidationError('template_id is required') + e = get_env(env) + sor = get_sor(e, get_dbname(e)) + patch = _pick(data or {}, TPL_EDITABLE) + if patch: + patch['updated_by'] = operator + patch['updated_at'] = _now() + sor.U(TPL_TABLE, patch, _where(tid, "id='%s'" % template_id)) + if 'items' in (data or {}): + _save_items(sor, tid, template_id, data['items'], operator) + return get_template(tid, template_id, env=e) + + +def delete_template(tenant_id, template_id, env=None, operator=None, hard=False): + """软删除模板(连带条目)。""" + tid = require_tenant(tenant_id) + if not template_id: + raise PblValidationError('template_id is required') + e = get_env(env) + sor = get_sor(e, get_dbname(e)) + patch = {'deleted': 1, 'updated_by': operator, 'updated_at': _now()} + for tbl, cond in ((TPL_TABLE, "id='%s'" % template_id), + (ITEM_TABLE, "template_id='%s'" % template_id)): + if hard: + sor.D(tbl, _where(tid, cond)) + else: + sor.U(tbl, patch, _where(tid, cond)) + return {'id': template_id, 'deleted': 1} + + +def get_template(tenant_id, template_id, env=None, with_items=True): + """取模板详情(含条目)。""" + tid = require_tenant(tenant_id) + if not template_id: + raise PblValidationError('template_id is required') + e = get_env(env) + sor = get_sor(e, get_dbname(e)) + rows = sor.R(TPL_TABLE, _where(tid, "id='%s' and deleted=0" % template_id)) + if not rows: + raise PblNotFound('template not found: %s' % template_id, code='PBL_TEMPLATE_NOT_FOUND') + tpl = rows[0] + if with_items: + tpl['items'] = sor.R(ITEM_TABLE, _where( + tid, "template_id='%s' and deleted=0" % template_id), 'obj_type asc, seq asc') or [] + return tpl + + +def list_templates(tenant_id, filters=None, page=1, page_size=20, env=None, + include_platform=True): + """分页查询模板列表;include_platform=True 时并入 tenant_id='platform' 的内置模板。""" + tid = require_tenant(tenant_id) + filters = filters or {} + conds = ['deleted=0'] + for key in ('category', 'subject', 'grade', 'status'): + if filters.get(key): + conds.append("%s='%s'" % (key, str(filters[key]).replace("'", "''"))) + if filters.get('keyword'): + kw = str(filters['keyword']).replace("'", "''") + conds.append("(name like '%%%s%%' or code like '%%%s%%')" % (kw, kw)) + tenant_cond = "tenant_id='%s'" % tid.replace("'", "''") + if include_platform and tid != 'platform': + tenant_cond = "(%s or tenant_id='platform')" % tenant_cond + + e = get_env(env) + sor = get_sor(e, get_dbname(e)) + page = max(int(page or 1), 1) + page_size = min(max(int(page_size or 20), 1), 200) + offset = (page - 1) * page_size + where = '%s and %s' % (tenant_cond, ' and '.join(conds)) + sql = ("select * from %s where %s order by use_count desc, updated_at desc limit %d offset %d" + % (TPL_TABLE, where, page_size, offset)) + rows = sor.sqlExe(sql) + total_rows = sor.sqlExe("select count(1) as total from %s where %s" % (TPL_TABLE, where)) + total = 0 + if total_rows: + first = total_rows[0] + total = first.get('total') if isinstance(first, dict) else first + return {'total': int(total or 0), 'page': page, 'page_size': page_size, 'rows': rows or []} + + +# ---------------------------------------------------------------- 实例化 / 兜底 + +def instantiate_template(tenant_id, template_id, name=None, env=None, operator=None, + overrides=None): + """由模板实例化一个新蓝图(含全部子对象),模板 use_count 自增。""" + from .blueprint_crud import TABLE as BP_TABLE, create_blueprint, _gen_code + + tid = require_tenant(tenant_id) + tpl = get_template(tid, template_id, env=env) + overrides = overrides or {} + + e = get_env(env) + sor = get_sor(e, get_dbname(e)) + bp_data = { + 'name': name or overrides.get('name') or tpl.get('name'), + 'subject': overrides.get('subject', tpl.get('subject')), + 'grade': overrides.get('grade', tpl.get('grade')), + 'duration_hours': overrides.get('duration_hours', tpl.get('duration_hours')), + 'summary': overrides.get('summary') or (tpl.get('content') or {}).get('summary') + if isinstance(tpl.get('content'), dict) else overrides.get('summary'), + 'template_id': template_id, + 'status': 'draft', } - bp = _bp.save_blueprint(bp_payload, tid) - _db.update_row(_m.TABLES[0], {'source_template_id': template_id}, - {'id': bp['id'], 'tenant_id': tid}) - made = {} - for item in tpl.get('items') or []: - stype = item.get('subobject_type') - if stype not in _m.SUBOBJECT_TYPE_TABLE: + bp = create_blueprint(tid, bp_data, env=e, operator=operator) + + ref_map = {} + items = tpl.get('items') or [] + ordered = sorted(items, key=lambda x: (SUBOBJECT_TYPES.index(x.get('obj_type')) + if x.get('obj_type') in SUBOBJECT_TYPES else 99, + x.get('seq') or 0)) + for item in ordered: + obj_type = item.get('obj_type') + if obj_type not in SUBOBJECT_TYPES: continue - body = _db.loads(item.get('item_payload_json'), {}) - body.update({'blueprint_id': bp['id'], 'class_id': bp.get('class_id') or '', - 'name': body.get('name') or item.get('name') or stype, - 'code': '', 'seq_no': int(item.get('seq_no') or 0)}) - _s.subobject_save(stype, body, tid) - made[stype] = made.get(stype, 0) + 1 - _db.update_row(TABLE, {'usage_count': int(tpl.get('usage_count') or 0) + 1, - 'updated_by': operator, 'updated_at': now}, - {'id': template_id, 'tenant_id': tid}) - _db.audit('template_instantiate', TABLE, template_id, - after={'blueprint_id': bp['id'], 'subobjects': made}, tenant_id=tid) - return {'template_id': template_id, 'blueprint_id': bp['id'], - 'blueprint_code': bp.get('blueprint_code'), 'subobjects_created': made, - 'status': bp.get('status')} + spec = dict(item.get('spec') or {}) + payload = { + 'blueprint_id': bp['id'], + 'name': item.get('name'), + 'description': spec.pop('description', None), + 'seq': item.get('seq') or 0, + 'spec': spec, + 'status': 'active', + } + for key in ('dimension', 'weight', 'evidence_type', 'collect_mode', 'rule', + 'artifact_type', 'accept_criteria', 'required', 'trigger_point', + 'reflection_type', 'rubric_ref', 'unlock_condition', 'permissions'): + if key in spec: + payload[key] = spec.pop(key) + parent_ref = item.get('parent_ref') + if parent_ref and parent_ref in ref_map: + parent_id = ref_map[parent_ref] + if obj_type == 'task': + payload['parent_id'] = parent_id + else: + payload['task_id'] = parent_id + row = create_subobject(tid, obj_type, payload, env=e, operator=operator) + if item.get('ref_key'): + ref_map[item['ref_key']] = row['id'] + + sor.U(TPL_TABLE, {'use_count': int(tpl.get('use_count') or 0) + 1, + 'updated_by': operator, 'updated_at': _now()}, + _where(tid, "id='%s'" % template_id)) + bp['from_template'] = template_id + bp['children_count'] = len(ref_map) or len(ordered) + return bp -def template_status_options(): - return [{'code': 'draft', 'label': '草稿'}, {'code': 'published', 'label': '已发布'}, - {'code': 'archived', 'label': '已归档'}] +def build_offline_fallback_blueprint(tenant_id, name=None, env=None, operator=None): + """离线兜底:不依赖 Agent/模板,直接落一个最小可用蓝图(含 7 类子对象各至少 1 条)。""" + from .blueprint_crud import create_blueprint + + tid = require_tenant(tenant_id) + skeleton = dict(OFFLINE_FALLBACK) + bp = create_blueprint(tid, { + 'name': name or skeleton['name'], + 'summary': skeleton['summary'], + 'duration_hours': skeleton['duration_hours'], + 'status': 'draft', + 'remark': 'offline-fallback', + }, env=env, operator=operator) + + ref_map = {} + created = 0 + for item in skeleton['items']: + obj_type = item['obj_type'] + payload = { + 'blueprint_id': bp['id'], + 'name': item['name'], + 'seq': created, + 'spec': item.get('spec') or {}, + 'status': 'active', + } + for key in ('dimension', 'weight', 'evidence_type', 'collect_mode', 'artifact_type', + 'required', 'trigger_point', 'reflection_type'): + if key in item: + payload[key] = item[key] + parent_ref = item.get('parent_ref') + if parent_ref and parent_ref in ref_map: + if obj_type == 'task': + payload['parent_id'] = ref_map[parent_ref] + else: + payload['task_id'] = ref_map[parent_ref] + row = create_subobject(tid, obj_type, payload, env=env, operator=operator) + if item.get('ref_key'): + ref_map[item['ref_key']] = row['id'] + created += 1 + bp['children_count'] = created + bp['offline_fallback'] = True + return bp diff --git a/pyproject.toml b/pyproject.toml index c19e3f9..525506e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,14 +1,31 @@ [build-system] -requires = ["setuptools>=45", "wheel"] +requires = ["setuptools>=61.0"] build-backend = "setuptools.build_meta" [project] name = "pbl_blueprint" version = "1.0.0" -description = "PBL 蓝图聚合根与子对象、版本、模板(M1a/M1b)" +description = "PBL 蓝图聚合根与子对象、版本、模板模块(M1a)——蓝图 CRUD/树/fork、7 类子对象泛化契约、版本 change_delta、模板实例化与离线兜底;tenant_id 强制打头" +readme = "README.md" requires-python = ">=3.8" -dependencies = ["sqlor", "bricks_for_python"] +license = { text = "Proprietary" } +authors = [{ name = "sdlc agent.develop" }] +keywords = ["pbl", "blueprint", "sage", "module"] +dependencies = [] -[tool.setuptools.packages.find] -where = ["."] -include = ["pbl_blueprint*"] +[project.optional-dependencies] +dev = ["pytest>=7.0"] + +[tool.setuptools] +packages = ["pbl_blueprint"] +include-package-data = true + +[tool.setuptools.package-data] +pbl_blueprint = [ + "json/*.json", + "models/*.json", + "sql/*.sql", + "wwwroot/*.ui", + "init/*.json", + "skill/*.md", +] diff --git a/scripts/load_path.py b/scripts/load_path.py index 2afd51f..39f5340 100644 --- a/scripts/load_path.py +++ b/scripts/load_path.py @@ -1,143 +1,130 @@ -"""scripts/load_path.py —— pbl_blueprint 模块 RBAC 路径注册。 +"""pbl_blueprint 模块 RBAC 路径注册脚本。 -规则(module-development-spec): -- 禁止任何通配符(% / *),每条路径显式列出; -- 角色分层:any(无需登录)/ logined(登录即可)/ owner.superuser(管理); -- CRUD 子目录标准 5 条一组:目录 + index.ui + get_/add_/update_/delete_.dspy。 +用途:把本模块全部新 API 路径 + 页面路径注册到平台 RBAC(权限路径表), +使 owner.admin / 租户管理员可授权,未注册路径一律 403。 -用法:cd <宿主应用根> && ./py3/bin/python /modules/pbl_blueprint/scripts/load_path.py +执行方式(应用部署后一次性 / 幂等可重复执行): + python -m pbl_blueprint.scripts.load_path +或在应用 init() 后由部署脚本调用 load_paths(env)。 """ import os import sys -MODULE = 'pbl_blueprint' +MODULE_NAME = 'pbl_blueprint' -# 契约 API(登录可用) -PATHS_LOGINED = [ - '/pbl_blueprint', - '/pbl_blueprint/index.ui', - '/pbl_blueprint/api/pbl_blueprint_create.dspy', - '/pbl_blueprint/api/pbl_blueprint_read.dspy', - '/pbl_blueprint/api/pbl_blueprint_update.dspy', - '/pbl_blueprint/api/pbl_blueprint_delete.dspy', - '/pbl_blueprint/api/pbl_blueprint_list.dspy', - '/pbl_blueprint/api/pbl_blueprint_tree.dspy', - '/pbl_blueprint/api/pbl_blueprint_fork.dspy', - '/pbl_blueprint/api/pbl_blueprint_subobject_save.dspy', - '/pbl_blueprint/api/pbl_blueprint_subobject_list.dspy', - '/pbl_blueprint/api/pbl_blueprint_subobject_delete.dspy', - '/pbl_blueprint/api/pbl_blueprint_version_create.dspy', - '/pbl_blueprint/api/pbl_blueprint_version_diff.dspy', - '/pbl_blueprint/api/pbl_template_list.dspy', - '/pbl_blueprint/api/pbl_template_instantiate.dspy', - '/pbl_blueprint/api/pbl_template_save.dspy', - '/pbl_blueprint/api/pbl_template_delete.dspy', +# ---- 本模块全部新 API 路径(与 api.py 的 API_PATHS 保持一致,单一事实源)---- +API_PATHS = [ + ('/pbl_blueprint/blueprint/create', 'POST', '新建蓝图'), + ('/pbl_blueprint/blueprint/update', 'POST', '更新蓝图'), + ('/pbl_blueprint/blueprint/delete', 'POST', '删除蓝图'), + ('/pbl_blueprint/blueprint/get', 'GET', '蓝图详情'), + ('/pbl_blueprint/blueprint/list', 'GET', '蓝图列表'), + ('/pbl_blueprint/blueprint/tree', 'GET', '蓝图子对象树'), + ('/pbl_blueprint/blueprint/fork', 'POST', '复制蓝图'), + ('/pbl_blueprint/version/save', 'POST', '保存版本快照'), + ('/pbl_blueprint/version/list', 'GET', '版本列表'), + ('/pbl_blueprint/version/diff', 'GET', '版本对比'), + ('/pbl_blueprint/version/rollback', 'POST', '版本回滚'), + ('/pbl_blueprint/subobject/create', 'POST', '新建子对象'), + ('/pbl_blueprint/subobject/update', 'POST', '更新子对象'), + ('/pbl_blueprint/subobject/delete', 'POST', '删除子对象'), + ('/pbl_blueprint/subobject/get', 'GET', '子对象详情'), + ('/pbl_blueprint/subobject/list', 'GET', '子对象列表'), + ('/pbl_blueprint/subobject/batch_upsert', 'POST', '批量写子对象'), + ('/pbl_blueprint/subobject/types', 'GET', '子对象类型枚举'), + ('/pbl_blueprint/template/create', 'POST', '新建模板'), + ('/pbl_blueprint/template/update', 'POST', '更新模板'), + ('/pbl_blueprint/template/delete', 'POST', '删除模板'), + ('/pbl_blueprint/template/get', 'GET', '模板详情'), + ('/pbl_blueprint/template/list', 'GET', '模板列表'), + ('/pbl_blueprint/template/instantiate', 'POST', '模板实例化为蓝图'), + ('/pbl_blueprint/template/offline_fallback', 'POST', '离线兜底生成蓝图'), ] -# 静态资源(无需登录) -PATHS_ANY = [ - '/pbl_blueprint/menu.ui', +# ---- 页面路径(wwwroot/*.ui 挂载点)---- +PAGE_PATHS = [ + ('/pbl_blueprint/index', 'GET', 'PBL蓝图管理主页'), + ('/pbl_blueprint/blueprint/edit', 'GET', '蓝图编辑页'), + ('/pbl_blueprint/template/list', 'GET', '蓝图模板库页'), ] -# 管理级操作(删除/模板维护) -PATHS_SUPERUSER = [ - '/pbl_blueprint/api/pbl_template_delete.dspy', +# ---- 表级数据权限路径(CRUD 定义 json/*.json 对应)---- +TABLE_PATHS = [ + ('/pbl_blueprint/tbl/pbl_blueprint', 'CRUD', '蓝图主表数据权限'), + ('/pbl_blueprint/tbl/pbl_blueprint_version', 'CRUD', '蓝图版本表数据权限'), + ('/pbl_blueprint/tbl/pbl_blueprint_template', 'CRUD', '蓝图模板表数据权限'), + ('/pbl_blueprint/tbl/pbl_blueprint_template_item', 'CRUD', '蓝图模板条目表数据权限'), + ('/pbl_blueprint/tbl/pbl_blueprint_task', 'CRUD', '子对象-任务数据权限'), + ('/pbl_blueprint/tbl/pbl_blueprint_mission', 'CRUD', '子对象-关卡数据权限'), + ('/pbl_blueprint/tbl/pbl_blueprint_role', 'CRUD', '子对象-角色数据权限'), + ('/pbl_blueprint/tbl/pbl_blueprint_learning_goal', 'CRUD', '子对象-学习目标数据权限'), + ('/pbl_blueprint/tbl/pbl_blueprint_evidence_spec', 'CRUD', '子对象-证据规格数据权限'), + ('/pbl_blueprint/tbl/pbl_blueprint_artifact_spec', 'CRUD', '子对象-产出物规格数据权限'), + ('/pbl_blueprint/tbl/pbl_blueprint_reflection_spec', 'CRUD', '子对象-反思规格数据权限'), ] -# CRUD 生成目录(每个 alias 5 条) -CRUD_ALIASES = [ - 'pbl_blueprint', - 'pbl_blueprint_learning_goal', - 'pbl_blueprint_role', - 'pbl_blueprint_mission', - 'pbl_blueprint_task', - 'pbl_blueprint_artifact_spec', - 'pbl_blueprint_evidence_spec', - 'pbl_blueprint_reflection_spec', - 'pbl_blueprint_version', - 'pbl_template', -] +ALL_PATHS = API_PATHS + PAGE_PATHS + TABLE_PATHS -def _crud_paths(): - out = [] - for alias in CRUD_ALIASES: - base = '/%s/%s' % (MODULE, alias) - out.append((base, 'logined')) - out.append((base + '/index.ui', 'logined')) - out.append((base + '/get_%s.dspy' % alias, 'logined')) - out.append((base + '/add_%s.dspy' % alias, 'logined')) - out.append((base + '/update_%s.dspy' % alias, 'logined')) - out.append((base + '/delete_%s.dspy' % alias, 'logined')) - return out +def load_paths(env=None): + """把 ALL_PATHS 注册进 RBAC(幂等:存在则更新描述,不存在则新增)。 + :return: {'registered': n, 'skipped': m, 'failed': k} + """ + result = {'registered': 0, 'skipped': 0, 'failed': 0, 'paths': []} + if env is None: + try: + from sage import ServerEnv + env = ServerEnv() + except Exception: + env = None -def all_paths(): - """返回 [(path, role), ...] 全量显式路径清单。""" - items = [(p, 'logined') for p in PATHS_LOGINED] - items += [(p, 'any') for p in PATHS_ANY] - items += [(p, 'owner.superuser') for p in PATHS_SUPERUSER] - items += _crud_paths() - # 去重保序 - seen = set() - out = [] - for p, r in items: - if p in seen: + rbac = None + if env is not None: + for attr in ('rbac', 'RBAC'): + rbac = getattr(env, attr, None) + if rbac is not None: + break + if rbac is None: + getter = getattr(env, 'get_rbac', None) + if callable(getter): + try: + rbac = getter() + except Exception: + rbac = None + + for path, method, caption in ALL_PATHS: + item = {'path': path, 'method': method, 'caption': caption, 'module': MODULE_NAME} + result['paths'].append(item) + if rbac is None: + result['skipped'] += 1 continue - seen.add(p) - out.append((p, r)) - return out - - -def _find_app_root(): - """向上查找宿主应用根(含 load_path.py / set_role_perm.py 的目录)。""" - here = os.path.dirname(os.path.abspath(__file__)) - cur = here - for _ in range(8): - for cand in (os.path.join(cur, 'set_role_perm.py'), - os.path.join(cur, 'load_path.py')): - if os.path.exists(cand): - return cur - nxt = os.path.dirname(cur) - if nxt == cur: - break - cur = nxt - for env_key in ('SAGE_ROOT', 'PBL_APP_ROOT'): - v = os.environ.get(env_key) - if v and os.path.isdir(v): - return v - return None + try: + registered = False + for fn_name in ('register_path', 'add_path', 'load_path', 'register'): + fn = getattr(rbac, fn_name, None) + if callable(fn): + try: + fn(path, method, caption) + except TypeError: + fn(item) + registered = True + break + if registered: + result['registered'] += 1 + else: + result['skipped'] += 1 + except Exception: + result['failed'] += 1 + return result def main(): - paths = all_paths() - root = _find_app_root() - if root is None: - print('[pbl_blueprint] 未找到宿主应用根(set_role_perm.py),' - '仅打印待注册路径 %d 条:' % len(paths)) - for p, r in paths: - print('%s %s' % (p, r)) - return 0 - sys.path.insert(0, root) - try: - import set_role_perm - except Exception as e: - print('[pbl_blueprint] 导入 set_role_perm 失败: %s' % e) - for p, r in paths: - print('%s %s' % (p, r)) - return 1 - fn = getattr(set_role_perm, 'set_role_perm', None) or \ - getattr(set_role_perm, 'main', None) - ok = 0 - for p, r in paths: - try: - if fn is not None: - fn(p, r) - ok += 1 - except Exception as e: - print('[pbl_blueprint] 注册失败 %s %s: %s' % (p, r, e)) - print('[pbl_blueprint] RBAC 路径注册完成 %d/%d' % (ok, len(paths))) + sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) + res = load_paths() + print('[pbl_blueprint] load_path done: registered=%d skipped=%d failed=%d total=%d' % ( + res['registered'], res['skipped'], res['failed'], len(res['paths']))) return 0 diff --git a/skill/SKILL.md b/skill/SKILL.md index 33f1d47..d166c1b 100644 --- a/skill/SKILL.md +++ b/skill/SKILL.md @@ -1,40 +1,131 @@ -# pbl_blueprint 模块技能(自动生成骨架 + 人工补充) +--- +name: pbl_blueprint +description: PBL 蓝图聚合根与子对象、版本、模板(M1a)。蓝图 CRUD/树/fork、7 类子对象泛化契约、版本 change_delta、模板实例化与离线兜底。所有读写 tenant_id 强制打头,缺失即 fail-closed 拒绝。 +--- -## 定位 -PBL 蓝图聚合根与子对象、版本、模板(M1a/M1b) +# pbl_blueprint 模块技能 -## 挂载 -`from pbl_blueprint.init import load_pbl_blueprint` → `load_pbl_blueprint()`(应用 app/pbls.py init() 中按序调用) +## 1. 模块定位 +`pbl_blueprint` 是 PBL(Project-Based Learning)平台的**蓝图域聚合根模块**,负责: +- 蓝图主表 `pbl_blueprint` 的 CRUD / 列表 / 树 / fork +- 7 类子对象的**泛化契约**(一套 CRUD 覆盖 7 张表) +- 版本快照 `pbl_blueprint_version`(含 `change_delta` 增量、diff、rollback) +- 模板 `pbl_blueprint_template` / `pbl_blueprint_template_item`(实例化 + 离线兜底) -## 数据表(10 张) -- `pbl_blueprint`:PBL 蓝图聚合根(第8章 schema 落库) -- `pbl_blueprint_learning_goal`:蓝图子对象 - 学习目标 -- `pbl_blueprint_role`:蓝图子对象 - PBL 角色 -- `pbl_blueprint_mission`:蓝图子对象 - 驱动问题/Mission -- `pbl_blueprint_task`:蓝图子对象 - 任务 -- `pbl_blueprint_artifact_spec`:蓝图子对象 - 产出物规格 -- `pbl_blueprint_evidence_spec`:蓝图子对象 - 证据规格 -- `pbl_blueprint_reflection_spec`:蓝图子对象 - 反思规格 -- `pbl_blueprint_version`:蓝图版本(change_delta 证明对话式改模型) -- `pbl_template`:PBL 模板(含离线兜底槽位) +模块是 **Python 包**(无 app.py / 无端口 / 无 Dockerfile),通过 `load_pbl_blueprint(env)` 挂到应用 `apps/pbls`。 -## 契约接口(13 个,路径 `/pbl_blueprint/api/.dspy`) -- `pbl_blueprint_create` -- `pbl_blueprint_read` -- `pbl_blueprint_update` -- `pbl_blueprint_delete` -- `pbl_blueprint_list` -- `pbl_blueprint_tree` -- `pbl_blueprint_fork` -- `pbl_blueprint_subobject_save` -- `pbl_blueprint_subobject_list` -- `pbl_blueprint_version_create` -- `pbl_blueprint_version_diff` -- `pbl_template_list` -- `pbl_template_instantiate` +## 2. 目录结构 +``` +modules/pbl_blueprint/ +├── pyproject.toml +├── scripts/load_path.py # RBAC 路径注册(API/页面/表权限) +└── pbl_blueprint/ # 包目录 = 模块名(禁 src/) + ├── __init__.py # 导出 load_pbl_blueprint + ├── init.py # 装配入口 + env 注册 + 契约字典 + ├── db.py # 库名解析 / sqlor 获取 / 异常体系 / 建表 + ├── blueprint_crud.py # 蓝图 CRUD + 树 + fork + 版本契约 + ├── subobjects.py # 7 类子对象泛化 CRUD + ├── templates.py # 模板 CRUD + 实例化 + 离线兜底 + ├── api.py # HTTP API 层 + API_PATHS + HANDLERS + ├── models/*.json # 表定义(四段式 summary/fields/indexes/codes) + ├── json/*.json # CRUD 定义(根键 tblname + params.editable/browserfields) + ├── sql/pbl_blueprint.core.sql # 核心 4 表 DDL + ├── sql/pbl_blueprint.subobjects.sql # 子对象 7 表 DDL + ├── init/data.json # 初始化数据(内置模板) + └── wwwroot/index.ui # 前端页面 +``` -## 陷阱 -- 库名一律 `ServerEnv().get_module_dbname('pbl_blueprint')`,禁止硬编码 DBNAME。 -- sqlor 只有 `C/U/D/R/I/sqlExe`;查询走 pbl_common.api 的 q_all/q_one(已适配)。 -- 所有读写强制带 `tenant_id`(pbl_common.api.tenant_id()),缺失即 fail-closed 报错。 -- 新增契约需同步三处:api.py 定义 + __init__.py 导出 + init.py env 注册 + scripts/load_path.py 路径。 +## 3. 数据表(11 张) +核心 4 张:`pbl_blueprint`、`pbl_blueprint_version`、`pbl_blueprint_template`、`pbl_blueprint_template_item` +子对象 7 张:`pbl_blueprint_task`、`pbl_blueprint_mission`、`pbl_blueprint_role`、 +`pbl_blueprint_learning_goal`、`pbl_blueprint_evidence_spec`、`pbl_blueprint_artifact_spec`、 +`pbl_blueprint_reflection_spec` + +统一约定: +- 主键 `id` = varchar(32),32 位无横线 UUID(`db.new_id()`) +- `tenant_id` varchar(32) NOT NULL,**每个索引首列** +- 软删除 `deleted` tinyint(0 正常 / 1 已删除) +- 结构化字段用 `json` 类型(`spec` / `config` / `snapshot` / `change_delta` / `rule` / `permissions`) +- 审计五件套 `created_by/created_at/updated_by/updated_at/deleted` + +## 4. 子对象泛化契约(重点) +7 类子对象共用一套函数,`obj_type` 分派到表: + +| obj_type | 表 | 编码前缀 | 特有字段 | +|---|---|---|---| +| task | pbl_blueprint_task | TASK | parent_id | +| mission | pbl_blueprint_mission | MISSION | task_id(必填), unlock_condition | +| role | pbl_blueprint_role | ROLE | task_id, permissions | +| learning_goal | pbl_blueprint_learning_goal | GOAL | task_id, dimension, weight | +| evidence_spec | pbl_blueprint_evidence_spec | EVI | task_id, mission_id, learning_goal_id, evidence_type, collect_mode, rule, weight | +| artifact_spec | pbl_blueprint_artifact_spec | ART | task_id, mission_id, artifact_type, accept_criteria, required, weight | +| reflection_spec | pbl_blueprint_reflection_spec | REF | task_id, mission_id, trigger_point, reflection_type, rubric_ref, weight | + +```python +from pbl_blueprint import load_pbl_blueprint +contract = load_pbl_blueprint(env) +contract['create_subobject'](tenant_id, 'mission', {'blueprint_id': bp_id, 'task_id': t_id, 'name': '关卡1'}, env=env) +contract['list_subobjects'](tenant_id, 'task', bp_id, env=env) +contract['batch_upsert_subobjects'](tenant_id, 'task', bp_id, rows, env=env) # Designer Agent 整树写入 +``` + +## 5. 蓝图契约 +```python +create_blueprint(tenant_id, data, env, operator) # 自动生成 code PBL-{yyyyMMdd}-{seq4} +update_blueprint(tenant_id, blueprint_id, data, ...) # 状态流转校验 STATUS_FLOW +delete_blueprint(tenant_id, blueprint_id, ...) # 软删(连带子对象+版本) +get_blueprint(tenant_id, blueprint_id, with_children) # with_children=True 附带 7 类子树 +list_blueprints(tenant_id, filters, page, page_size) # 分页 + keyword/status/subject 过滤 +get_blueprint_tree(tenant_id, blueprint_id) # {obj_type: [rows]} +fork_blueprint(tenant_id, blueprint_id, new_name, target_tenant_id) # 跨租户复制,ID 重映射 +``` +状态机:`draft → validating → validated → compiling → compiled → published → archived`(非法流转抛 `PblValidationError`)。 + +## 6. 版本契约 +```python +save_version(tenant_id, blueprint_id, remark) # 存全量 snapshot + 计算 change_delta,主表 version_no 自增 +list_versions(tenant_id, blueprint_id, page, page_size) +diff_versions(tenant_id, blueprint_id, version_a, version_b) # {added, updated, removed} +rollback_version(tenant_id, blueprint_id, version_no) # 先自动存当前版本再回滚 +``` +`change_delta` 结构:`{"added":[{"obj_type","id","name"}], "updated":[{"obj_type","id","fields"}], "removed":[...]}` + +## 7. 模板与离线兜底 +```python +create_template(tenant_id, {'name':..., 'items':[{'obj_type':'task','ref_key':'T1','name':...}, ...]}) +instantiate_template(tenant_id, template_id, name, overrides) # ref_key/parent_ref 解析为真实 ID,use_count 自增 +build_offline_fallback_blueprint(tenant_id, name) # Agent/网络不可用时的最小可用蓝图(9 条子对象) +``` +模板条目用 `ref_key` / `parent_ref` 做**模板内相对引用**,实例化时按 `SUBOBJECT_TYPES` 顺序解析为真实 ID(父先于子)。 + +## 8. tenant_id 强制打头(铁律) +- 每个契约函数第一入参 `tenant_id`,内部 `db.require_tenant()` 校验 +- 缺失/空/非字符串 → 抛 `PblTenantRequired`(code=`PBL_TENANT_REQUIRED`),**fail-closed 不放行** +- 所有 where 条件由 `_where(tenant_id, extra)` 构造,`tenant_id=` 永远在最前 +- API 层 `_tid(params)` 从入参或 `context.tenant_id` 取,取不到直接失败 +- 平台内置模板 `tenant_id='platform'`,`list_templates(include_platform=True)` 才并入 + +## 9. 异常码 +| code | 含义 | +|---|---| +| PBL_TENANT_REQUIRED | 租户上下文缺失 | +| PBL_VALIDATION_ERROR | 入参校验失败 | +| PBL_NOT_FOUND / PBL_BLUEPRINT_NOT_FOUND / PBL_SUBOBJECT_NOT_FOUND / PBL_VERSION_NOT_FOUND / PBL_TEMPLATE_NOT_FOUND | 记录不存在 | +| PBL_UNKNOWN_OBJ_TYPE | 未知子对象类型 | +| PBL_DB_UNAVAILABLE | sqlor 不可用 | + +## 10. 数据访问约束 +- 库名:`ServerEnv().get_module_dbname('pbl_blueprint')`,**禁止硬编码 DBNAME** +- SQL 只用 sqlor 标准 API:`sor.C / sor.U / sor.D / sor.R / sor.sqlExe`,禁编造 save/list/insert +- 建表:`db.ensure_tables()` 幂等执行 `sql/*.sql`(`CREATE TABLE IF NOT EXISTS`) + +## 11. API 路径(25 个,见 api.py API_PATHS) +前缀 `/pbl_blueprint/`,分四组:`blueprint/*`(7)、`version/*`(4)、`subobject/*`(7)、`template/*`(7)。 +RBAC 注册:`python -m pbl_blueprint.scripts.load_path`(幂等,注册 API + 页面 + 表权限共 39 条路径)。 + +## 12. 下游模块依赖 +- `pbl_validation`(M2)读 `get_blueprint_tree` 做 14 维校验,回写 `quality_level` +- `pbl_compiler`(M3)读蓝图树生成 Game Definition +- `pbl_evidence`(M5)按 `evidence_spec` / `artifact_spec` 采集 +- `pbl_assessment`(M6)按 `learning_goal.weight` / `reflection_spec.rubric_ref` 评分 +- `pbl_agent_runtime`(M4)用 `batch_upsert_subobjects` 写 Designer 产出,fail-closed 裁决 diff --git a/wwwroot/api/pbl_blueprint_create.dspy b/wwwroot/api/pbl_blueprint_create.dspy new file mode 100644 index 0000000..f0f33b2 --- /dev/null +++ b/wwwroot/api/pbl_blueprint_create.dspy @@ -0,0 +1,12 @@ +# pbl_blueprint_create.dspy —— 创建蓝图聚合根(薄包装,逻辑在 pbl_blueprint.api) +debug('pbl_blueprint_create.dspy: START params_kw=%s' % dict(params_kw)) +try: + tenant_id = params_kw.get('tenant_id') or None + res = await pbl_blueprint_create(dict(params_kw), request=request, tenant_id=tenant_id) + return res +except PblBlueprintError as e: + debug('pbl_blueprint_create.dspy: BIZ_ERR %s' % e.to_dict()) + return e.to_dict() +except Exception as e: + exception('pbl_blueprint_create.dspy: %s' % format_exc()) + return {'status': 'error', 'code': 'PBL_INTERNAL', 'message': str(e)} diff --git a/wwwroot/api/pbl_blueprint_delete.dspy b/wwwroot/api/pbl_blueprint_delete.dspy new file mode 100644 index 0000000..a75acdf --- /dev/null +++ b/wwwroot/api/pbl_blueprint_delete.dspy @@ -0,0 +1,12 @@ +# pbl_blueprint_delete.dspy —— 删除蓝图(默认软删 status=deleted;hard=1 物理级联删) +debug('pbl_blueprint_delete.dspy: START params_kw=%s' % dict(params_kw)) +try: + tenant_id = params_kw.get('tenant_id') or None + res = await pbl_blueprint_delete(dict(params_kw), request=request, tenant_id=tenant_id) + return res +except PblBlueprintError as e: + debug('pbl_blueprint_delete.dspy: BIZ_ERR %s' % e.to_dict()) + return e.to_dict() +except Exception as e: + exception('pbl_blueprint_delete.dspy: %s' % format_exc()) + return {'status': 'error', 'code': 'PBL_INTERNAL', 'message': str(e)} diff --git a/wwwroot/api/pbl_blueprint_fork.dspy b/wwwroot/api/pbl_blueprint_fork.dspy new file mode 100644 index 0000000..588e217 --- /dev/null +++ b/wwwroot/api/pbl_blueprint_fork.dspy @@ -0,0 +1,12 @@ +# pbl_blueprint_fork.dspy —— 派生蓝图(深拷贝聚合根+子对象并重映射内部引用) +debug('pbl_blueprint_fork.dspy: START params_kw=%s' % dict(params_kw)) +try: + tenant_id = params_kw.get('tenant_id') or None + res = await pbl_blueprint_fork(dict(params_kw), request=request, tenant_id=tenant_id) + return res +except PblBlueprintError as e: + debug('pbl_blueprint_fork.dspy: BIZ_ERR %s' % e.to_dict()) + return e.to_dict() +except Exception as e: + exception('pbl_blueprint_fork.dspy: %s' % format_exc()) + return {'status': 'error', 'code': 'PBL_INTERNAL', 'message': str(e)} diff --git a/wwwroot/api/pbl_blueprint_list.dspy b/wwwroot/api/pbl_blueprint_list.dspy new file mode 100644 index 0000000..a524884 --- /dev/null +++ b/wwwroot/api/pbl_blueprint_list.dspy @@ -0,0 +1,13 @@ +# pbl_blueprint_list.dspy —— 蓝图列表(分页 + 租户隔离 + 状态/学科/关键词过滤) +debug('pbl_blueprint_list.dspy: START params_kw=%s' % dict(params_kw)) +try: + tenant_id = params_kw.get('tenant_id') or None + res = await pbl_blueprint_list(dict(params_kw), request=request, tenant_id=tenant_id) + return res +except PblBlueprintError as e: + debug('pbl_blueprint_list.dspy: BIZ_ERR %s' % e.to_dict()) + return e.to_dict() +except Exception as e: + exception('pbl_blueprint_list.dspy: %s' % format_exc()) + return {'status': 'error', 'code': 'PBL_INTERNAL', 'message': str(e), + 'total': 0, 'data': []} diff --git a/wwwroot/api/pbl_blueprint_read.dspy b/wwwroot/api/pbl_blueprint_read.dspy new file mode 100644 index 0000000..1984d21 --- /dev/null +++ b/wwwroot/api/pbl_blueprint_read.dspy @@ -0,0 +1,12 @@ +# pbl_blueprint_read.dspy —— 读取单个蓝图(with_children=1 返回全部子对象) +debug('pbl_blueprint_read.dspy: START params_kw=%s' % dict(params_kw)) +try: + tenant_id = params_kw.get('tenant_id') or None + res = await pbl_blueprint_read(dict(params_kw), request=request, tenant_id=tenant_id) + return res +except PblBlueprintError as e: + debug('pbl_blueprint_read.dspy: BIZ_ERR %s' % e.to_dict()) + return e.to_dict() +except Exception as e: + exception('pbl_blueprint_read.dspy: %s' % format_exc()) + return {'status': 'error', 'code': 'PBL_INTERNAL', 'message': str(e)} diff --git a/wwwroot/api/pbl_blueprint_subobject_delete.dspy b/wwwroot/api/pbl_blueprint_subobject_delete.dspy new file mode 100644 index 0000000..ddbe6ab --- /dev/null +++ b/wwwroot/api/pbl_blueprint_subobject_delete.dspy @@ -0,0 +1,12 @@ +# pbl_blueprint_subobject_delete.dspy —— 子对象删除(蓝图未发布时) +debug('pbl_blueprint_subobject_delete.dspy: START params_kw=%s' % dict(params_kw)) +try: + tenant_id = params_kw.get('tenant_id') or None + res = await pbl_blueprint_subobject_delete(dict(params_kw), request=request, tenant_id=tenant_id) + return res +except PblBlueprintError as e: + debug('pbl_blueprint_subobject_delete.dspy: BIZ_ERR %s' % e.to_dict()) + return e.to_dict() +except Exception as e: + exception('pbl_blueprint_subobject_delete.dspy: %s' % format_exc()) + return {'status': 'error', 'code': 'PBL_INTERNAL', 'message': str(e)} diff --git a/wwwroot/api/pbl_blueprint_subobject_list.dspy b/wwwroot/api/pbl_blueprint_subobject_list.dspy new file mode 100644 index 0000000..92cf091 --- /dev/null +++ b/wwwroot/api/pbl_blueprint_subobject_list.dspy @@ -0,0 +1,13 @@ +# pbl_blueprint_subobject_list.dspy —— 子对象列表(subobject + blueprint_id) +debug('pbl_blueprint_subobject_list.dspy: START params_kw=%s' % dict(params_kw)) +try: + tenant_id = params_kw.get('tenant_id') or None + res = await pbl_blueprint_subobject_list(dict(params_kw), request=request, tenant_id=tenant_id) + return res +except PblBlueprintError as e: + debug('pbl_blueprint_subobject_list.dspy: BIZ_ERR %s' % e.to_dict()) + return e.to_dict() +except Exception as e: + exception('pbl_blueprint_subobject_list.dspy: %s' % format_exc()) + return {'status': 'error', 'code': 'PBL_INTERNAL', 'message': str(e), + 'total': 0, 'data': []} diff --git a/wwwroot/api/pbl_blueprint_subobject_save.dspy b/wwwroot/api/pbl_blueprint_subobject_save.dspy new file mode 100644 index 0000000..d38c0d0 --- /dev/null +++ b/wwwroot/api/pbl_blueprint_subobject_save.dspy @@ -0,0 +1,12 @@ +# pbl_blueprint_subobject_save.dspy —— 子对象新增/更新(subobject 指定类型,有 id 走更新) +debug('pbl_blueprint_subobject_save.dspy: START params_kw=%s' % dict(params_kw)) +try: + tenant_id = params_kw.get('tenant_id') or None + res = await pbl_blueprint_subobject_save(dict(params_kw), request=request, tenant_id=tenant_id) + return res +except PblBlueprintError as e: + debug('pbl_blueprint_subobject_save.dspy: BIZ_ERR %s' % e.to_dict()) + return e.to_dict() +except Exception as e: + exception('pbl_blueprint_subobject_save.dspy: %s' % format_exc()) + return {'status': 'error', 'code': 'PBL_INTERNAL', 'message': str(e)} diff --git a/wwwroot/api/pbl_blueprint_tree.dspy b/wwwroot/api/pbl_blueprint_tree.dspy new file mode 100644 index 0000000..59f2d46 --- /dev/null +++ b/wwwroot/api/pbl_blueprint_tree.dspy @@ -0,0 +1,12 @@ +# pbl_blueprint_tree.dspy —— 蓝图聚合树(蓝图→mission→task→artifact/evidence) +debug('pbl_blueprint_tree.dspy: START params_kw=%s' % dict(params_kw)) +try: + tenant_id = params_kw.get('tenant_id') or None + res = await pbl_blueprint_tree(dict(params_kw), request=request, tenant_id=tenant_id) + return res +except PblBlueprintError as e: + debug('pbl_blueprint_tree.dspy: BIZ_ERR %s' % e.to_dict()) + return e.to_dict() +except Exception as e: + exception('pbl_blueprint_tree.dspy: %s' % format_exc()) + return {'status': 'error', 'code': 'PBL_INTERNAL', 'message': str(e)} diff --git a/wwwroot/api/pbl_blueprint_update.dspy b/wwwroot/api/pbl_blueprint_update.dspy new file mode 100644 index 0000000..1a6a513 --- /dev/null +++ b/wwwroot/api/pbl_blueprint_update.dspy @@ -0,0 +1,12 @@ +# pbl_blueprint_update.dspy —— 更新蓝图主表(白名单字段,自动升版本) +debug('pbl_blueprint_update.dspy: START params_kw=%s' % dict(params_kw)) +try: + tenant_id = params_kw.get('tenant_id') or None + res = await pbl_blueprint_update(dict(params_kw), request=request, tenant_id=tenant_id) + return res +except PblBlueprintError as e: + debug('pbl_blueprint_update.dspy: BIZ_ERR %s' % e.to_dict()) + return e.to_dict() +except Exception as e: + exception('pbl_blueprint_update.dspy: %s' % format_exc()) + return {'status': 'error', 'code': 'PBL_INTERNAL', 'message': str(e)}