diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..047cdbd
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,6 @@
+__pycache__/
+*.pyc
+*.pyo
+.pytest_cache/
+*.egg-info/
+.venv/
diff --git a/json/pbl_blueprint.json b/json/pbl_blueprint.json
new file mode 100644
index 0000000..5d05208
--- /dev/null
+++ b/json/pbl_blueprint.json
@@ -0,0 +1,31 @@
+{
+ "tblname": "pbl_blueprint",
+ "params": {
+ "logined_userorgid": "org_id",
+ "new_data_url": "{{entire_url('/pbl_blueprint/api/pbl_blueprint_create.dspy')}}",
+ "update_data_url": "{{entire_url('/pbl_blueprint/api/pbl_blueprint_update.dspy')}}",
+ "delete_data_url": "{{entire_url('/pbl_blueprint/api/pbl_blueprint_delete.dspy')}}",
+ "editable": {
+ "new_data_url": "{{entire_url('/pbl_blueprint/api/pbl_blueprint_create.dspy')}}",
+ "update_data_url": "{{entire_url('/pbl_blueprint/api/pbl_blueprint_update.dspy')}}",
+ "delete_data_url": "{{entire_url('/pbl_blueprint/api/pbl_blueprint_delete.dspy')}}"
+ },
+ "browserfields": {
+ "main": {
+ "fields": ["blueprint_code", "title", "subject_area", "grade_level", "duration_hours", "quality_status", "version_no", "status", "created_at"],
+ "title": "蓝图列表"
+ }
+ },
+ "editexclouded": ["id", "tenant_id", "org_id", "created_by", "created_at", "updated_by", "updated_at", "quality_status", "validation_score", "version_no", "current_version_id"],
+ "subtables": [
+ {"name": "learning_goal", "label": "学习目标", "url": "{{entire_url('../pbl_blueprint_learning_goal')}}"},
+ {"name": "role", "label": "PBL角色", "url": "{{entire_url('../pbl_blueprint_role')}}"},
+ {"name": "mission", "label": "驱动问题", "url": "{{entire_url('../pbl_blueprint_mission')}}"},
+ {"name": "task", "label": "任务", "url": "{{entire_url('../pbl_blueprint_task')}}"},
+ {"name": "artifact_spec", "label": "产出物规格", "url": "{{entire_url('../pbl_blueprint_artifact_spec')}}"},
+ {"name": "evidence_spec", "label": "证据规格", "url": "{{entire_url('../pbl_blueprint_evidence_spec')}}"},
+ {"name": "reflection_spec", "label": "反思规格", "url": "{{entire_url('../pbl_blueprint_reflection_spec')}}"},
+ {"name": "version", "label": "版本历史", "url": "{{entire_url('../pbl_blueprint_version')}}"}
+ ]
+ }
+}
diff --git a/json/pbl_blueprint_artifact_spec.json b/json/pbl_blueprint_artifact_spec.json
new file mode 100644
index 0000000..7427e51
--- /dev/null
+++ b/json/pbl_blueprint_artifact_spec.json
@@ -0,0 +1,21 @@
+{
+ "tblname": "pbl_blueprint_artifact_spec",
+ "params": {
+ "logined_userorgid": "",
+ "new_data_url": "{{entire_url('/pbl_blueprint/api/pbl_blueprint_subobject_save.dspy')}}?subobject=artifact_spec",
+ "update_data_url": "{{entire_url('/pbl_blueprint/api/pbl_blueprint_subobject_save.dspy')}}?subobject=artifact_spec",
+ "delete_data_url": "{{entire_url('/pbl_blueprint/api/pbl_blueprint_subobject_delete.dspy')}}?subobject=artifact_spec",
+ "editable": {
+ "new_data_url": "{{entire_url('/pbl_blueprint/api/pbl_blueprint_subobject_save.dspy')}}?subobject=artifact_spec",
+ "update_data_url": "{{entire_url('/pbl_blueprint/api/pbl_blueprint_subobject_save.dspy')}}?subobject=artifact_spec",
+ "delete_data_url": "{{entire_url('/pbl_blueprint/api/pbl_blueprint_subobject_delete.dspy')}}?subobject=artifact_spec"
+ },
+ "browserfields": {
+ "main": {
+ "fields": ["seq", "artifact_code", "name", "artifact_type", "format", "required"],
+ "title": "产出物规格"
+ }
+ },
+ "editexclouded": ["id", "tenant_id", "created_by", "created_at", "updated_by", "updated_at"]
+ }
+}
diff --git a/json/pbl_blueprint_evidence_spec.json b/json/pbl_blueprint_evidence_spec.json
new file mode 100644
index 0000000..0d4115a
--- /dev/null
+++ b/json/pbl_blueprint_evidence_spec.json
@@ -0,0 +1,21 @@
+{
+ "tblname": "pbl_blueprint_evidence_spec",
+ "params": {
+ "logined_userorgid": "",
+ "new_data_url": "{{entire_url('/pbl_blueprint/api/pbl_blueprint_subobject_save.dspy')}}?subobject=evidence_spec",
+ "update_data_url": "{{entire_url('/pbl_blueprint/api/pbl_blueprint_subobject_save.dspy')}}?subobject=evidence_spec",
+ "delete_data_url": "{{entire_url('/pbl_blueprint/api/pbl_blueprint_subobject_delete.dspy')}}?subobject=evidence_spec",
+ "editable": {
+ "new_data_url": "{{entire_url('/pbl_blueprint/api/pbl_blueprint_subobject_save.dspy')}}?subobject=evidence_spec",
+ "update_data_url": "{{entire_url('/pbl_blueprint/api/pbl_blueprint_subobject_save.dspy')}}?subobject=evidence_spec",
+ "delete_data_url": "{{entire_url('/pbl_blueprint/api/pbl_blueprint_subobject_delete.dspy')}}?subobject=evidence_spec"
+ },
+ "browserfields": {
+ "main": {
+ "fields": ["seq", "evidence_code", "name", "evidence_type", "collect_mode", "required"],
+ "title": "证据规格"
+ }
+ },
+ "editexclouded": ["id", "tenant_id", "created_by", "created_at", "updated_by", "updated_at"]
+ }
+}
diff --git a/json/pbl_blueprint_learning_goal.json b/json/pbl_blueprint_learning_goal.json
new file mode 100644
index 0000000..68eb6d5
--- /dev/null
+++ b/json/pbl_blueprint_learning_goal.json
@@ -0,0 +1,21 @@
+{
+ "tblname": "pbl_blueprint_learning_goal",
+ "params": {
+ "logined_userorgid": "",
+ "new_data_url": "{{entire_url('/pbl_blueprint/api/pbl_blueprint_subobject_save.dspy')}}?subobject=learning_goal",
+ "update_data_url": "{{entire_url('/pbl_blueprint/api/pbl_blueprint_subobject_save.dspy')}}?subobject=learning_goal",
+ "delete_data_url": "{{entire_url('/pbl_blueprint/api/pbl_blueprint_subobject_delete.dspy')}}?subobject=learning_goal",
+ "editable": {
+ "new_data_url": "{{entire_url('/pbl_blueprint/api/pbl_blueprint_subobject_save.dspy')}}?subobject=learning_goal",
+ "update_data_url": "{{entire_url('/pbl_blueprint/api/pbl_blueprint_subobject_save.dspy')}}?subobject=learning_goal",
+ "delete_data_url": "{{entire_url('/pbl_blueprint/api/pbl_blueprint_subobject_delete.dspy')}}?subobject=learning_goal"
+ },
+ "browserfields": {
+ "main": {
+ "fields": ["seq", "goal_code", "title", "knowledge_type", "assessable", "created_at"],
+ "title": "学习目标"
+ }
+ },
+ "editexclouded": ["id", "tenant_id", "created_by", "created_at", "updated_by", "updated_at"]
+ }
+}
diff --git a/json/pbl_blueprint_mission.json b/json/pbl_blueprint_mission.json
new file mode 100644
index 0000000..f566e8e
--- /dev/null
+++ b/json/pbl_blueprint_mission.json
@@ -0,0 +1,21 @@
+{
+ "tblname": "pbl_blueprint_mission",
+ "params": {
+ "logined_userorgid": "",
+ "new_data_url": "{{entire_url('/pbl_blueprint/api/pbl_blueprint_subobject_save.dspy')}}?subobject=mission",
+ "update_data_url": "{{entire_url('/pbl_blueprint/api/pbl_blueprint_subobject_save.dspy')}}?subobject=mission",
+ "delete_data_url": "{{entire_url('/pbl_blueprint/api/pbl_blueprint_subobject_delete.dspy')}}?subobject=mission",
+ "editable": {
+ "new_data_url": "{{entire_url('/pbl_blueprint/api/pbl_blueprint_subobject_save.dspy')}}?subobject=mission",
+ "update_data_url": "{{entire_url('/pbl_blueprint/api/pbl_blueprint_subobject_save.dspy')}}?subobject=mission",
+ "delete_data_url": "{{entire_url('/pbl_blueprint/api/pbl_blueprint_subobject_delete.dspy')}}?subobject=mission"
+ },
+ "browserfields": {
+ "main": {
+ "fields": ["seq", "mission_code", "title", "duration_hours", "created_at"],
+ "title": "驱动问题/Mission"
+ }
+ },
+ "editexclouded": ["id", "tenant_id", "created_by", "created_at", "updated_by", "updated_at"]
+ }
+}
diff --git a/json/pbl_blueprint_reflection_spec.json b/json/pbl_blueprint_reflection_spec.json
new file mode 100644
index 0000000..7181ce9
--- /dev/null
+++ b/json/pbl_blueprint_reflection_spec.json
@@ -0,0 +1,21 @@
+{
+ "tblname": "pbl_blueprint_reflection_spec",
+ "params": {
+ "logined_userorgid": "",
+ "new_data_url": "{{entire_url('/pbl_blueprint/api/pbl_blueprint_subobject_save.dspy')}}?subobject=reflection_spec",
+ "update_data_url": "{{entire_url('/pbl_blueprint/api/pbl_blueprint_subobject_save.dspy')}}?subobject=reflection_spec",
+ "delete_data_url": "{{entire_url('/pbl_blueprint/api/pbl_blueprint_subobject_delete.dspy')}}?subobject=reflection_spec",
+ "editable": {
+ "new_data_url": "{{entire_url('/pbl_blueprint/api/pbl_blueprint_subobject_save.dspy')}}?subobject=reflection_spec",
+ "update_data_url": "{{entire_url('/pbl_blueprint/api/pbl_blueprint_subobject_save.dspy')}}?subobject=reflection_spec",
+ "delete_data_url": "{{entire_url('/pbl_blueprint/api/pbl_blueprint_subobject_delete.dspy')}}?subobject=reflection_spec"
+ },
+ "browserfields": {
+ "main": {
+ "fields": ["seq", "reflection_code", "title", "reflection_type", "trigger_point", "required"],
+ "title": "反思规格"
+ }
+ },
+ "editexclouded": ["id", "tenant_id", "created_by", "created_at", "updated_by", "updated_at"]
+ }
+}
diff --git a/json/pbl_blueprint_role.json b/json/pbl_blueprint_role.json
new file mode 100644
index 0000000..c74e833
--- /dev/null
+++ b/json/pbl_blueprint_role.json
@@ -0,0 +1,21 @@
+{
+ "tblname": "pbl_blueprint_role",
+ "params": {
+ "logined_userorgid": "",
+ "new_data_url": "{{entire_url('/pbl_blueprint/api/pbl_blueprint_subobject_save.dspy')}}?subobject=role",
+ "update_data_url": "{{entire_url('/pbl_blueprint/api/pbl_blueprint_subobject_save.dspy')}}?subobject=role",
+ "delete_data_url": "{{entire_url('/pbl_blueprint/api/pbl_blueprint_subobject_delete.dspy')}}?subobject=role",
+ "editable": {
+ "new_data_url": "{{entire_url('/pbl_blueprint/api/pbl_blueprint_subobject_save.dspy')}}?subobject=role",
+ "update_data_url": "{{entire_url('/pbl_blueprint/api/pbl_blueprint_subobject_save.dspy')}}?subobject=role",
+ "delete_data_url": "{{entire_url('/pbl_blueprint/api/pbl_blueprint_subobject_delete.dspy')}}?subobject=role"
+ },
+ "browserfields": {
+ "main": {
+ "fields": ["seq", "role_code", "role_name", "min_members", "max_members", "is_mandatory"],
+ "title": "PBL角色"
+ }
+ },
+ "editexclouded": ["id", "tenant_id", "created_by", "created_at", "updated_by", "updated_at"]
+ }
+}
diff --git a/json/pbl_blueprint_task.json b/json/pbl_blueprint_task.json
new file mode 100644
index 0000000..8035411
--- /dev/null
+++ b/json/pbl_blueprint_task.json
@@ -0,0 +1,21 @@
+{
+ "tblname": "pbl_blueprint_task",
+ "params": {
+ "logined_userorgid": "",
+ "new_data_url": "{{entire_url('/pbl_blueprint/api/pbl_blueprint_subobject_save.dspy')}}?subobject=task",
+ "update_data_url": "{{entire_url('/pbl_blueprint/api/pbl_blueprint_subobject_save.dspy')}}?subobject=task",
+ "delete_data_url": "{{entire_url('/pbl_blueprint/api/pbl_blueprint_subobject_delete.dspy')}}?subobject=task",
+ "editable": {
+ "new_data_url": "{{entire_url('/pbl_blueprint/api/pbl_blueprint_subobject_save.dspy')}}?subobject=task",
+ "update_data_url": "{{entire_url('/pbl_blueprint/api/pbl_blueprint_subobject_save.dspy')}}?subobject=task",
+ "delete_data_url": "{{entire_url('/pbl_blueprint/api/pbl_blueprint_subobject_delete.dspy')}}?subobject=task"
+ },
+ "browserfields": {
+ "main": {
+ "fields": ["seq", "task_code", "title", "task_type", "estimated_minutes", "status"],
+ "title": "任务"
+ }
+ },
+ "editexclouded": ["id", "tenant_id", "created_by", "created_at", "updated_by", "updated_at"]
+ }
+}
diff --git a/json/pbl_blueprint_version.json b/json/pbl_blueprint_version.json
new file mode 100644
index 0000000..bed96ca
--- /dev/null
+++ b/json/pbl_blueprint_version.json
@@ -0,0 +1,21 @@
+{
+ "tblname": "pbl_blueprint_version",
+ "params": {
+ "logined_userorgid": "",
+ "new_data_url": "{{entire_url('/pbl_blueprint/api/pbl_blueprint_version_create.dspy')}}",
+ "update_data_url": "",
+ "delete_data_url": "",
+ "editable": {
+ "new_data_url": "{{entire_url('/pbl_blueprint/api/pbl_blueprint_version_create.dspy')}}",
+ "update_data_url": "",
+ "delete_data_url": ""
+ },
+ "browserfields": {
+ "main": {
+ "fields": ["version_no", "source", "change_reason", "quality_status", "created_by", "created_at"],
+ "title": "版本历史"
+ }
+ },
+ "editexclouded": ["id", "tenant_id", "blueprint_id", "parent_version_id", "change_delta", "snapshot_json", "created_by", "created_at"]
+ }
+}
diff --git a/json/pbl_template.json b/json/pbl_template.json
new file mode 100644
index 0000000..e2955e0
--- /dev/null
+++ b/json/pbl_template.json
@@ -0,0 +1,21 @@
+{
+ "tblname": "pbl_template",
+ "params": {
+ "logined_userorgid": "",
+ "new_data_url": "{{entire_url('/pbl_blueprint/api/pbl_template_save.dspy')}}",
+ "update_data_url": "{{entire_url('/pbl_blueprint/api/pbl_template_save.dspy')}}",
+ "delete_data_url": "{{entire_url('/pbl_blueprint/api/pbl_template_delete.dspy')}}",
+ "editable": {
+ "new_data_url": "{{entire_url('/pbl_blueprint/api/pbl_template_save.dspy')}}",
+ "update_data_url": "{{entire_url('/pbl_blueprint/api/pbl_template_save.dspy')}}",
+ "delete_data_url": "{{entire_url('/pbl_blueprint/api/pbl_template_delete.dspy')}}"
+ },
+ "browserfields": {
+ "main": {
+ "fields": ["template_code", "name", "category", "subject_area", "grade_level", "template_version", "is_builtin", "status"],
+ "title": "PBL模板"
+ }
+ },
+ "editexclouded": ["id", "tenant_id", "created_by", "created_at", "updated_by", "updated_at"]
+ }
+}
diff --git a/models/pbl_blueprint.json b/models/pbl_blueprint.json
new file mode 100644
index 0000000..1b02c1c
--- /dev/null
+++ b/models/pbl_blueprint.json
@@ -0,0 +1,50 @@
+{
+ "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"}
+ ]
+}
diff --git a/models/pbl_blueprint_artifact_spec.json b/models/pbl_blueprint_artifact_spec.json
new file mode 100644
index 0000000..917b6da
--- /dev/null
+++ b/models/pbl_blueprint_artifact_spec.json
@@ -0,0 +1,44 @@
+{
+ "summary": [
+ {
+ "table": "pbl_blueprint_artifact_spec",
+ "name": "蓝图产出物规格",
+ "title": "产出物规格",
+ "subtitle": "蓝图子对象-产出物规格",
+ "primary": ["id"],
+ "defaultsort": "seq asc",
+ "orderby": "seq asc",
+ "caption": "产出物规格"
+ }
+ ],
+ "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"},
+ {"name": "blueprint_id", "type": "str(32)", "title": "所属蓝图", "notnull": 1},
+ {"name": "task_id", "type": "str(32)", "title": "所属任务", "default": ""},
+ {"name": "seq", "type": "int", "title": "序号", "notnull": 1, "default": 0},
+ {"name": "artifact_code", "type": "str(64)", "title": "产出物编码", "notnull": 1},
+ {"name": "name", "type": "str(255)", "title": "产出物名称", "notnull": 1},
+ {"name": "artifact_type", "type": "str(32)", "title": "产出物类型", "default": "document", "comment": "document/model/code/image/video/audio/data/other"},
+ {"name": "format", "type": "str(64)", "title": "文件格式", "default": ""},
+ {"name": "description", "type": "text", "title": "规格说明", "default": ""},
+ {"name": "required", "type": "str(1)", "title": "是否必需", "default": "1"},
+ {"name": "max_size_mb", "type": "int", "title": "大小上限(MB)", "default": 0},
+ {"name": "schema_json", "type": "longtext", "title": "结构约束JSON Schema", "default": ""},
+ {"name": "rubric_id", "type": "str(32)", "title": "评分量规ID", "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_artifact_code", "fields": ["tenant_id", "blueprint_id", "artifact_code"], "unique": 1},
+ {"name": "idx_pbl_artifact_bp", "fields": ["tenant_id", "blueprint_id", "seq"], "unique": 0},
+ {"name": "idx_pbl_artifact_task", "fields": ["tenant_id", "task_id"], "unique": 0}
+ ],
+ "codes": [
+ {"field": "artifact_type", "table": "appcodes_kv", "cond": "parentid='pbl_artifact_type'", "valuefield": "k", "textfield": "v"},
+ {"field": "blueprint_id", "table": "pbl_blueprint", "cond": "", "valuefield": "id", "textfield": "title"},
+ {"field": "task_id", "table": "pbl_blueprint_task", "cond": "", "valuefield": "id", "textfield": "title"}
+ ]
+}
diff --git a/models/pbl_blueprint_evidence_spec.json b/models/pbl_blueprint_evidence_spec.json
new file mode 100644
index 0000000..09b14d7
--- /dev/null
+++ b/models/pbl_blueprint_evidence_spec.json
@@ -0,0 +1,45 @@
+{
+ "summary": [
+ {
+ "table": "pbl_blueprint_evidence_spec",
+ "name": "蓝图证据规格",
+ "title": "证据规格",
+ "subtitle": "蓝图子对象-证据规格",
+ "primary": ["id"],
+ "defaultsort": "seq asc",
+ "orderby": "seq asc",
+ "caption": "证据规格"
+ }
+ ],
+ "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"},
+ {"name": "blueprint_id", "type": "str(32)", "title": "所属蓝图", "notnull": 1},
+ {"name": "artifact_spec_id", "type": "str(32)", "title": "关联产出物规格", "default": ""},
+ {"name": "task_id", "type": "str(32)", "title": "关联任务", "default": ""},
+ {"name": "seq", "type": "int", "title": "序号", "notnull": 1, "default": 0},
+ {"name": "evidence_code", "type": "str(64)", "title": "证据编码", "notnull": 1},
+ {"name": "name", "type": "str(255)", "title": "证据名称", "notnull": 1},
+ {"name": "evidence_type", "type": "str(32)", "title": "证据类型", "default": "result", "comment": "process/result/behavior/log"},
+ {"name": "collect_mode", "type": "str(32)", "title": "采集方式", "default": "auto", "comment": "auto/manual/hybrid"},
+ {"name": "source_hint", "type": "str(255)", "title": "采集来源提示", "default": ""},
+ {"name": "idempotency_key_hint", "type": "str(128)", "title": "幂等键规则", "default": "", "comment": "供 pbl_evidence 幂等采集使用"},
+ {"name": "required", "type": "str(1)", "title": "是否必需", "default": "1"},
+ {"name": "description", "type": "text", "title": "说明", "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_evidence_code", "fields": ["tenant_id", "blueprint_id", "evidence_code"], "unique": 1},
+ {"name": "idx_pbl_evidence_bp", "fields": ["tenant_id", "blueprint_id", "seq"], "unique": 0},
+ {"name": "idx_pbl_evidence_artifact", "fields": ["tenant_id", "artifact_spec_id"], "unique": 0}
+ ],
+ "codes": [
+ {"field": "evidence_type", "table": "appcodes_kv", "cond": "parentid='pbl_evidence_type'", "valuefield": "k", "textfield": "v"},
+ {"field": "collect_mode", "table": "appcodes_kv", "cond": "parentid='pbl_collect_mode'", "valuefield": "k", "textfield": "v"},
+ {"field": "blueprint_id", "table": "pbl_blueprint", "cond": "", "valuefield": "id", "textfield": "title"},
+ {"field": "artifact_spec_id", "table": "pbl_blueprint_artifact_spec", "cond": "", "valuefield": "id", "textfield": "name"}
+ ]
+}
diff --git a/models/pbl_blueprint_learning_goal.json b/models/pbl_blueprint_learning_goal.json
new file mode 100644
index 0000000..7ab59c7
--- /dev/null
+++ b/models/pbl_blueprint_learning_goal.json
@@ -0,0 +1,39 @@
+{
+ "summary": [
+ {
+ "table": "pbl_blueprint_learning_goal",
+ "name": "蓝图学习目标",
+ "title": "学习目标",
+ "subtitle": "蓝图子对象-学习目标",
+ "primary": ["id"],
+ "defaultsort": "seq asc",
+ "orderby": "seq asc",
+ "caption": "学习目标"
+ }
+ ],
+ "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"},
+ {"name": "blueprint_id", "type": "str(32)", "title": "所属蓝图", "notnull": 1},
+ {"name": "seq", "type": "int", "title": "序号", "notnull": 1, "default": 0},
+ {"name": "goal_code", "type": "str(64)", "title": "目标编码", "notnull": 1},
+ {"name": "title", "type": "str(255)", "title": "目标标题", "notnull": 1},
+ {"name": "description", "type": "text", "title": "目标描述", "default": ""},
+ {"name": "knowledge_type", "type": "str(32)", "title": "知识类型", "default": "concept", "comment": "fact/concept/procedure/metacognition"},
+ {"name": "competency_tags", "type": "text", "title": "素养标签JSON数组", "default": "[]"},
+ {"name": "assessable", "type": "str(1)", "title": "可评估", "default": "1"},
+ {"name": "alignment_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_goal_code", "fields": ["tenant_id", "blueprint_id", "goal_code"], "unique": 1},
+ {"name": "idx_pbl_goal_bp", "fields": ["tenant_id", "blueprint_id", "seq"], "unique": 0}
+ ],
+ "codes": [
+ {"field": "knowledge_type", "table": "appcodes_kv", "cond": "parentid='pbl_knowledge_type'", "valuefield": "k", "textfield": "v"},
+ {"field": "blueprint_id", "table": "pbl_blueprint", "cond": "", "valuefield": "id", "textfield": "title"}
+ ]
+}
diff --git a/models/pbl_blueprint_mission.json b/models/pbl_blueprint_mission.json
new file mode 100644
index 0000000..8c44401
--- /dev/null
+++ b/models/pbl_blueprint_mission.json
@@ -0,0 +1,38 @@
+{
+ "summary": [
+ {
+ "table": "pbl_blueprint_mission",
+ "name": "蓝图Mission",
+ "title": "驱动问题/Mission",
+ "subtitle": "蓝图子对象-驱动问题",
+ "primary": ["id"],
+ "defaultsort": "seq asc",
+ "orderby": "seq asc",
+ "caption": "Mission"
+ }
+ ],
+ "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"},
+ {"name": "blueprint_id", "type": "str(32)", "title": "所属蓝图", "notnull": 1},
+ {"name": "seq", "type": "int", "title": "序号", "notnull": 1, "default": 0},
+ {"name": "mission_code", "type": "str(64)", "title": "Mission编码", "notnull": 1},
+ {"name": "title", "type": "str(255)", "title": "Mission标题", "notnull": 1},
+ {"name": "driving_question", "type": "text", "title": "驱动问题", "default": ""},
+ {"name": "narrative", "type": "longtext", "title": "情境叙事", "default": ""},
+ {"name": "success_criteria", "type": "text", "title": "成功标准JSON数组", "default": "[]"},
+ {"name": "related_goal_ids", "type": "text", "title": "关联学习目标ID JSON数组", "default": "[]"},
+ {"name": "duration_hours", "type": "int", "title": "预计课时", "default": 0},
+ {"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_mission_code", "fields": ["tenant_id", "blueprint_id", "mission_code"], "unique": 1},
+ {"name": "idx_pbl_mission_bp", "fields": ["tenant_id", "blueprint_id", "seq"], "unique": 0}
+ ],
+ "codes": [
+ {"field": "blueprint_id", "table": "pbl_blueprint", "cond": "", "valuefield": "id", "textfield": "title"}
+ ]
+}
diff --git a/models/pbl_blueprint_reflection_spec.json b/models/pbl_blueprint_reflection_spec.json
new file mode 100644
index 0000000..ec24309
--- /dev/null
+++ b/models/pbl_blueprint_reflection_spec.json
@@ -0,0 +1,39 @@
+{
+ "summary": [
+ {
+ "table": "pbl_blueprint_reflection_spec",
+ "name": "蓝图反思规格",
+ "title": "反思规格",
+ "subtitle": "蓝图子对象-反思规格",
+ "primary": ["id"],
+ "defaultsort": "seq asc",
+ "orderby": "seq asc",
+ "caption": "反思规格"
+ }
+ ],
+ "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"},
+ {"name": "blueprint_id", "type": "str(32)", "title": "所属蓝图", "notnull": 1},
+ {"name": "seq", "type": "int", "title": "序号", "notnull": 1, "default": 0},
+ {"name": "reflection_code", "type": "str(64)", "title": "反思编码", "notnull": 1},
+ {"name": "title", "type": "str(255)", "title": "反思标题", "notnull": 1},
+ {"name": "prompt", "type": "text", "title": "反思提示语", "default": ""},
+ {"name": "reflection_type", "type": "str(32)", "title": "反思类型", "default": "self", "comment": "self/peer/team/teacher"},
+ {"name": "trigger_point", "type": "str(64)", "title": "触发时点", "default": "task_end", "comment": "task_end/mission_end/project_end/manual"},
+ {"name": "required", "type": "str(1)", "title": "是否必需", "default": "1"},
+ {"name": "rubric_hint", "type": "text", "title": "量规提示", "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_reflection_code", "fields": ["tenant_id", "blueprint_id", "reflection_code"], "unique": 1},
+ {"name": "idx_pbl_reflection_bp", "fields": ["tenant_id", "blueprint_id", "seq"], "unique": 0}
+ ],
+ "codes": [
+ {"field": "reflection_type", "table": "appcodes_kv", "cond": "parentid='pbl_reflect_type'", "valuefield": "k", "textfield": "v"},
+ {"field": "blueprint_id", "table": "pbl_blueprint", "cond": "", "valuefield": "id", "textfield": "title"}
+ ]
+}
diff --git a/models/pbl_blueprint_role.json b/models/pbl_blueprint_role.json
new file mode 100644
index 0000000..edc43c4
--- /dev/null
+++ b/models/pbl_blueprint_role.json
@@ -0,0 +1,39 @@
+{
+ "summary": [
+ {
+ "table": "pbl_blueprint_role",
+ "name": "蓝图角色",
+ "title": "PBL角色",
+ "subtitle": "蓝图子对象-角色",
+ "primary": ["id"],
+ "defaultsort": "seq asc",
+ "orderby": "seq asc",
+ "caption": "角色"
+ }
+ ],
+ "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"},
+ {"name": "blueprint_id", "type": "str(32)", "title": "所属蓝图", "notnull": 1},
+ {"name": "seq", "type": "int", "title": "序号", "notnull": 1, "default": 0},
+ {"name": "role_code", "type": "str(64)", "title": "角色编码", "notnull": 1},
+ {"name": "role_name", "type": "str(128)", "title": "角色名称", "notnull": 1},
+ {"name": "description", "type": "text", "title": "角色说明", "default": ""},
+ {"name": "responsibilities", "type": "text", "title": "职责JSON数组", "default": "[]"},
+ {"name": "required_skills", "type": "text", "title": "所需技能JSON数组", "default": "[]"},
+ {"name": "min_members", "type": "int", "title": "最少人数", "default": 1},
+ {"name": "max_members", "type": "int", "title": "最多人数", "default": 1},
+ {"name": "is_mandatory", "type": "str(1)", "title": "是否必需角色", "default": "0"},
+ {"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_role_code", "fields": ["tenant_id", "blueprint_id", "role_code"], "unique": 1},
+ {"name": "idx_pbl_role_bp", "fields": ["tenant_id", "blueprint_id", "seq"], "unique": 0}
+ ],
+ "codes": [
+ {"field": "blueprint_id", "table": "pbl_blueprint", "cond": "", "valuefield": "id", "textfield": "title"}
+ ]
+}
diff --git a/models/pbl_blueprint_task.json b/models/pbl_blueprint_task.json
new file mode 100644
index 0000000..1d975d8
--- /dev/null
+++ b/models/pbl_blueprint_task.json
@@ -0,0 +1,45 @@
+{
+ "summary": [
+ {
+ "table": "pbl_blueprint_task",
+ "name": "蓝图任务",
+ "title": "任务",
+ "subtitle": "蓝图子对象-任务",
+ "primary": ["id"],
+ "defaultsort": "seq asc",
+ "orderby": "seq asc",
+ "caption": "任务"
+ }
+ ],
+ "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"},
+ {"name": "blueprint_id", "type": "str(32)", "title": "所属蓝图", "notnull": 1},
+ {"name": "mission_id", "type": "str(32)", "title": "所属Mission", "default": ""},
+ {"name": "seq", "type": "int", "title": "序号", "notnull": 1, "default": 0},
+ {"name": "task_code", "type": "str(64)", "title": "任务编码", "notnull": 1},
+ {"name": "title", "type": "str(255)", "title": "任务标题", "notnull": 1},
+ {"name": "description", "type": "text", "title": "任务说明", "default": ""},
+ {"name": "task_type", "type": "str(32)", "title": "任务类型", "default": "explore", "comment": "explore/build/test/present/reflect"},
+ {"name": "role_id", "type": "str(32)", "title": "承担角色ID", "default": ""},
+ {"name": "depends_on_ids", "type": "text", "title": "前置任务ID JSON数组", "default": "[]"},
+ {"name": "estimated_minutes", "type": "int", "title": "预计耗时(分钟)", "default": 0},
+ {"name": "artifact_spec_ids", "type": "text", "title": "产出物规格ID JSON数组", "default": "[]"},
+ {"name": "status", "type": "str(32)", "title": "状态", "default": "draft"},
+ {"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_task_code", "fields": ["tenant_id", "blueprint_id", "task_code"], "unique": 1},
+ {"name": "idx_pbl_task_bp", "fields": ["tenant_id", "blueprint_id", "seq"], "unique": 0},
+ {"name": "idx_pbl_task_mission", "fields": ["tenant_id", "mission_id"], "unique": 0}
+ ],
+ "codes": [
+ {"field": "task_type", "table": "appcodes_kv", "cond": "parentid='pbl_task_type'", "valuefield": "k", "textfield": "v"},
+ {"field": "blueprint_id", "table": "pbl_blueprint", "cond": "", "valuefield": "id", "textfield": "title"},
+ {"field": "mission_id", "table": "pbl_blueprint_mission", "cond": "", "valuefield": "id", "textfield": "title"},
+ {"field": "role_id", "table": "pbl_blueprint_role", "cond": "", "valuefield": "id", "textfield": "role_name"}
+ ]
+}
diff --git a/models/pbl_blueprint_version.json b/models/pbl_blueprint_version.json
new file mode 100644
index 0000000..a9c326e
--- /dev/null
+++ b/models/pbl_blueprint_version.json
@@ -0,0 +1,36 @@
+{
+ "summary": [
+ {
+ "table": "pbl_blueprint_version",
+ "name": "蓝图版本",
+ "title": "蓝图版本",
+ "subtitle": "版本快照与变更增量",
+ "primary": ["id"],
+ "defaultsort": "version_no desc",
+ "orderby": "version_no desc",
+ "caption": "版本"
+ }
+ ],
+ "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"},
+ {"name": "blueprint_id", "type": "str(32)", "title": "所属蓝图", "notnull": 1},
+ {"name": "version_no", "type": "int", "title": "版本号", "notnull": 1, "default": 1},
+ {"name": "parent_version_id", "type": "str(32)", "title": "父版本ID", "default": ""},
+ {"name": "change_delta", "type": "longtext", "title": "变更增量JSON", "default": "", "comment": "对话式改模型的证据:{op,path,before,after} 列表"},
+ {"name": "snapshot_json", "type": "longtext", "title": "全量快照JSON", "default": ""},
+ {"name": "change_reason", "type": "str(500)", "title": "变更原因", "default": ""},
+ {"name": "source", "type": "str(32)", "title": "变更来源", "default": "manual", "comment": "manual/dialogue/template/fork/validation"},
+ {"name": "quality_status", "type": "str(32)", "title": "该版本质量状态", "default": "draft"},
+ {"name": "created_by", "type": "str(32)", "title": "创建人", "default": ""},
+ {"name": "created_at", "type": "datetime", "title": "创建时间", "notnull": 1}
+ ],
+ "indexes": [
+ {"name": "uk_pbl_version_no", "fields": ["tenant_id", "blueprint_id", "version_no"], "unique": 1},
+ {"name": "idx_pbl_version_bp", "fields": ["tenant_id", "blueprint_id", "created_at"], "unique": 0}
+ ],
+ "codes": [
+ {"field": "source", "table": "appcodes_kv", "cond": "parentid='pbl_version_source'", "valuefield": "k", "textfield": "v"},
+ {"field": "blueprint_id", "table": "pbl_blueprint", "cond": "", "valuefield": "id", "textfield": "title"}
+ ]
+}
diff --git a/models/pbl_template.json b/models/pbl_template.json
new file mode 100644
index 0000000..bbc2c55
--- /dev/null
+++ b/models/pbl_template.json
@@ -0,0 +1,40 @@
+{
+ "summary": [
+ {
+ "table": "pbl_template",
+ "name": "PBL模板",
+ "title": "PBL模板",
+ "subtitle": "蓝图模板(含离线兜底槽位)",
+ "primary": ["id"],
+ "defaultsort": "created_at desc",
+ "orderby": "created_at desc",
+ "caption": "模板"
+ }
+ ],
+ "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": "0=平台内置全局模板,所有租户可见"},
+ {"name": "template_code", "type": "str(64)", "title": "模板编码", "notnull": 1},
+ {"name": "name", "type": "str(255)", "title": "模板名称", "notnull": 1},
+ {"name": "description", "type": "text", "title": "模板说明", "default": ""},
+ {"name": "category", "type": "str(64)", "title": "模板分类", "default": "general"},
+ {"name": "subject_area", "type": "str(64)", "title": "学科领域", "default": ""},
+ {"name": "grade_level", "type": "str(32)", "title": "适用年级", "default": ""},
+ {"name": "blueprint_snapshot", "type": "longtext", "title": "蓝图骨架JSON", "default": "", "comment": "实例化时展开为蓝图+子对象"},
+ {"name": "offline_slots", "type": "longtext", "title": "离线兜底槽位JSON", "default": "[]", "comment": "LLM 不可用时的降级填充槽位定义"},
+ {"name": "is_builtin", "type": "str(1)", "title": "是否内置", "default": "0"},
+ {"name": "template_version", "type": "str(32)", "title": "模板版本", "default": "1.0.0"},
+ {"name": "status", "type": "str(32)", "title": "状态", "default": "active", "comment": "active/disabled"},
+ {"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_template_code", "fields": ["tenant_id", "template_code"], "unique": 1},
+ {"name": "idx_pbl_template_cat", "fields": ["tenant_id", "category", "status"], "unique": 0}
+ ],
+ "codes": [
+ {"field": "category", "table": "appcodes_kv", "cond": "parentid='pbl_template_cat'", "valuefield": "k", "textfield": "v"}
+ ]
+}
diff --git a/pbl_blueprint/__init__.py b/pbl_blueprint/__init__.py
new file mode 100644
index 0000000..13a471e
--- /dev/null
+++ b/pbl_blueprint/__init__.py
@@ -0,0 +1,64 @@
+"""pbl_blueprint —— PBL 蓝图聚合根与子对象、版本、模板(M1a)。
+
+所有 async 契约函数必须在此导出,否则 .dspy 调用会 NameError。
+新增契约需同步四处:api.py 定义 + 本文件导出 + init.py env 注册
++ scripts/load_path.py RBAC 路径。
+"""
+
+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,
+)
+
+__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)
diff --git a/pbl_blueprint/api.py b/pbl_blueprint/api.py
new file mode 100644
index 0000000..baa0f63
--- /dev/null
+++ b/pbl_blueprint/api.py
@@ -0,0 +1,1165 @@
+"""pbl_blueprint.api —— PBL 蓝图聚合根 CRUD 与查询契约实现(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,缺失时本地降级)。
+"""
+
+import json
+import traceback
+
+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',
+]
+
+QUALITY_STATUS_ORDER = ['draft', 'partial', 'valid', 'complete', 'published']
+
+
+# ---------------------------------------------------------------- 基础设施
+def _env():
+ return ServerEnv()
+
+
+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。
+ """
+ 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()
+
+
+def _now():
+ try:
+ from appPublic.utils import curDateString
+ return curDateString()
+ except Exception:
+ import datetime
+ return datetime.datetime.now().strftime('%Y-%m-%d %H:%M:%S')
+
+
+def _new_id():
+ try:
+ from appPublic.utils import getID
+ return getID()
+ except Exception:
+ import uuid
+ return uuid.uuid4().hex
+
+
+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 ''
+ try:
+ return (await fn()) or ''
+ except Exception:
+ return ''
+
+
+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'
+ try:
+ return (await fn()) or '0'
+ except Exception:
+ return '0'
+
+
+async def _audit(request, action, table, record_id, tenant_id, detail=None):
+ """审计写入:优先 pbl_common.audit,缺失时静默降级(不阻断主流程)。"""
+ 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
+
+
+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)
+ 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
+
+
+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')]}
+ 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}
+
+
+# ================================================================ 模板契约
+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)
+ try:
+ snap = json.loads(tpl.get('blueprint_snapshot') or '{}')
+ except Exception:
+ raise PblInvalidParam('模板 blueprint_snapshot 非法 JSON',
+ template_id=tpl_id)
+ 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}}
+
+
+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')}}
+
+
+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}}
+
+
+# ================================================================ 只读契约(供下游模块)
+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', [])}}
+
+
+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 safe_traceback():
+ return traceback.format_exc()
diff --git a/pbl_blueprint/blueprint_crud.py b/pbl_blueprint/blueprint_crud.py
new file mode 100644
index 0000000..605f436
--- /dev/null
+++ b/pbl_blueprint/blueprint_crud.py
@@ -0,0 +1,346 @@
+# -*- coding: utf-8 -*-
+"""蓝图聚合根 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;跨租户读取返回「不存在」。
+"""
+import hashlib
+
+from . import db as _db
+from . import models as _m
+from . import subobjects as _s
+
+TABLE = 'pbl_blueprint'
+TABLE_VERSION = 'pbl_blueprint_version'
+
+STATUS_DRAFT = 'draft'
+STATUS_IN_REVIEW = 'in_review'
+STATUS_PUBLISHED = 'published'
+STATUS_ARCHIVED = 'archived'
+
+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: [],
+}
+
+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()
+
+
+def _require_tenant(tenant_id):
+ return tenant_id or _db.current_tenant_id()
+
+
+def _fetch(table, rec_id, tenant_id):
+ return _db.select_one(table, where={'id': rec_id, 'tenant_id': tenant_id,
+ 'is_deleted': 0})
+
+
+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 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)
+
+
+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()
+
+ 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,
+ }
+ _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'), {})
+ 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)
+ 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 status_options():
+ return [{'code': STATUS_DRAFT, 'label': '草稿'}, {'code': STATUS_IN_REVIEW, 'label': '评审中'},
+ {'code': STATUS_PUBLISHED, 'label': '已发布'}, {'code': STATUS_ARCHIVED, 'label': '已归档'}]
+
+
+def domain_options():
+ return [{'code': c, 'label': l} for c, l in [
+ ('science', '科学'), ('math', '数学'), ('chinese', '语文'), ('english', '英语'),
+ ('art', '艺术'), ('technology', '技术'), ('social_studies', '社会'), ('cross_subject', '跨学科')]]
diff --git a/pbl_blueprint/db.py b/pbl_blueprint/db.py
new file mode 100644
index 0000000..996890a
--- /dev/null
+++ b/pbl_blueprint/db.py
@@ -0,0 +1,200 @@
+"""pbl_blueprint 数据访问适配层(唯一允许直接调用 sqlor 的地方)。
+
+规范约束:
+- 只使用 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 抛错。
+"""
+import json
+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'
+
+
+class PblError(Exception):
+ """统一业务异常,携带错误码 + i18n 文案键。"""
+
+ 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 {}
+
+ def to_dict(self):
+ return {'code': self.code, 'msg': self.msg,
+ 'i18n_key': self.i18n_key, 'detail': self.detail}
+
+
+# ---------------------------------------------------------------- 环境/库名
+def server_env():
+ from ahserver import ServerEnv
+ return ServerEnv()
+
+
+def get_dbname():
+ """取本模块库名:优先 ServerEnv 注册值,其次 pbl_common 适配层。"""
+ dbname = None
+ try:
+ env = server_env()
+ getter = getattr(env, 'get_module_dbname', None)
+ if callable(getter):
+ dbname = getter(MODULE_NAME)
+ except Exception:
+ dbname = None
+ if not dbname:
+ try:
+ from pbl_common.db_adapter import get_module_dbname as _g
+ dbname = _g(MODULE_NAME)
+ except Exception:
+ dbname = None
+ if not dbname:
+ raise PblError(ERR_DBNAME_MISSING,
+ '模块 %s 未注册库名(ServerEnv.set_module_dbname 缺失)' % MODULE_NAME)
+ return dbname
+
+
+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:
+ try:
+ ctx = getattr(server_env(), 'pbl_tenant_ctx', None) or {}
+ tid = ctx.get('tenant_id') if isinstance(ctx, dict) else None
+ except Exception:
+ tid = None
+ if not tid and required:
+ raise PblError(ERR_TENANT_MISSING, '缺少租户上下文,拒绝访问蓝图数据')
+ return tid or ''
+
+
+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 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:
+ return False
diff --git a/pbl_blueprint/i18n/pbl_blueprint.en_US.json b/pbl_blueprint/i18n/pbl_blueprint.en_US.json
new file mode 100644
index 0000000..2a0ae7c
--- /dev/null
+++ b/pbl_blueprint/i18n/pbl_blueprint.en_US.json
@@ -0,0 +1,57 @@
+{
+ "pbl_blueprint.action.fork": "Fork",
+ "pbl_blueprint.action.instantiate": "Instantiate",
+ "pbl_blueprint.action.new": "New Blueprint",
+ "pbl_blueprint.action.new_template": "New Template",
+ "pbl_blueprint.action.publish": "Publish",
+ "pbl_blueprint.action.refresh": "Refresh",
+ "pbl_blueprint.action.to_template": "Save as Template",
+ "pbl_blueprint.err.pbl-api-001": "API call failed",
+ "pbl_blueprint.err.pbl-boot-001": "pbl_blueprint module not loaded",
+ "pbl_blueprint.err.pbl-db-001": "Module database name is not registered",
+ "pbl_blueprint.err.pbl-not-found": "Record not found or no permission",
+ "pbl_blueprint.err.pbl-param-001": "Invalid parameter",
+ "pbl_blueprint.err.pbl-ref-001": "Record is referenced",
+ "pbl_blueprint.err.pbl-state-001": "Operation not allowed in current status",
+ "pbl_blueprint.err.pbl-tenant-001": "Missing tenant context, access denied",
+ "pbl_blueprint.field.blueprint_code": "Code",
+ "pbl_blueprint.field.blueprint_name": "Name",
+ "pbl_blueprint.field.complexity_level": "Complexity",
+ "pbl_blueprint.field.content_hash": "Content Hash",
+ "pbl_blueprint.field.created_at": "Created",
+ "pbl_blueprint.field.current_version": "Version",
+ "pbl_blueprint.field.domain_code": "Domain",
+ "pbl_blueprint.field.estimated_cost": "Estimated Cost",
+ "pbl_blueprint.field.grade_code": "Grade",
+ "pbl_blueprint.field.group_count": "Groups",
+ "pbl_blueprint.field.project_duration": "Duration (lessons)",
+ "pbl_blueprint.field.published_at": "Published At",
+ "pbl_blueprint.field.status": "Status",
+ "pbl_blueprint.field.student_count": "Students",
+ "pbl_blueprint.field.subject_code": "Subject",
+ "pbl_blueprint.field.summary_text": "Summary",
+ "pbl_blueprint.field.template_code": "Template Code",
+ "pbl_blueprint.field.template_name": "Template Name",
+ "pbl_blueprint.field.template_type": "Template Type",
+ "pbl_blueprint.field.usage_count": "Usage Count",
+ "pbl_blueprint.field.version_no": "Version No.",
+ "pbl_blueprint.field.version_status": "Version Status",
+ "pbl_blueprint.menu.blueprint_editor": "Blueprint Editor",
+ "pbl_blueprint.menu.blueprint_list": "Blueprints",
+ "pbl_blueprint.menu.blueprint_tree": "Blueprint Tree",
+ "pbl_blueprint.menu.root": "PBL Blueprint",
+ "pbl_blueprint.menu.template_list": "Blueprint Templates",
+ "pbl_blueprint.node.artifact_spec": "Artifacts",
+ "pbl_blueprint.node.blueprint": "Blueprint",
+ "pbl_blueprint.node.evidence_spec": "Evidence Specs",
+ "pbl_blueprint.node.learning_goal": "Learning Goals",
+ "pbl_blueprint.node.mission": "Mission",
+ "pbl_blueprint.node.reflection_spec": "Reflection Specs",
+ "pbl_blueprint.node.role": "Roles",
+ "pbl_blueprint.node.task": "Tasks",
+ "pbl_blueprint.page.editor": "Blueprint Editor",
+ "pbl_blueprint.page.list": "Blueprint List",
+ "pbl_blueprint.page.template": "Blueprint Templates",
+ "pbl_blueprint.page.tree": "Blueprint Structure",
+ "pbl_blueprint.page.versions": "Version History"
+}
\ No newline at end of file
diff --git a/pbl_blueprint/i18n/pbl_blueprint.zh_CN.json b/pbl_blueprint/i18n/pbl_blueprint.zh_CN.json
new file mode 100644
index 0000000..1da3c31
--- /dev/null
+++ b/pbl_blueprint/i18n/pbl_blueprint.zh_CN.json
@@ -0,0 +1,57 @@
+{
+ "pbl_blueprint.action.fork": "派生副本",
+ "pbl_blueprint.action.instantiate": "实例化为蓝图",
+ "pbl_blueprint.action.new": "新建蓝图",
+ "pbl_blueprint.action.new_template": "新建模板",
+ "pbl_blueprint.action.publish": "发布",
+ "pbl_blueprint.action.refresh": "刷新",
+ "pbl_blueprint.action.to_template": "抽为模板",
+ "pbl_blueprint.err.pbl-api-001": "接口调用失败",
+ "pbl_blueprint.err.pbl-boot-001": "pbl_blueprint 模块未加载",
+ "pbl_blueprint.err.pbl-db-001": "模块未注册库名,无法访问数据",
+ "pbl_blueprint.err.pbl-not-found": "记录不存在或无权访问",
+ "pbl_blueprint.err.pbl-param-001": "参数校验失败",
+ "pbl_blueprint.err.pbl-ref-001": "数据被引用,操作被拒绝",
+ "pbl_blueprint.err.pbl-state-001": "当前状态不允许该操作",
+ "pbl_blueprint.err.pbl-tenant-001": "缺少租户上下文,拒绝访问蓝图数据",
+ "pbl_blueprint.field.blueprint_code": "蓝图编码",
+ "pbl_blueprint.field.blueprint_name": "蓝图名称",
+ "pbl_blueprint.field.complexity_level": "复杂度",
+ "pbl_blueprint.field.content_hash": "内容哈希",
+ "pbl_blueprint.field.created_at": "创建时间",
+ "pbl_blueprint.field.current_version": "当前版本",
+ "pbl_blueprint.field.domain_code": "领域",
+ "pbl_blueprint.field.estimated_cost": "预估成本(元)",
+ "pbl_blueprint.field.grade_code": "学段",
+ "pbl_blueprint.field.group_count": "小组数",
+ "pbl_blueprint.field.project_duration": "项目时长(课时)",
+ "pbl_blueprint.field.published_at": "发布时间",
+ "pbl_blueprint.field.status": "状态",
+ "pbl_blueprint.field.student_count": "参与学生数",
+ "pbl_blueprint.field.subject_code": "学科",
+ "pbl_blueprint.field.summary_text": "蓝图摘要",
+ "pbl_blueprint.field.template_code": "模板编码",
+ "pbl_blueprint.field.template_name": "模板名称",
+ "pbl_blueprint.field.template_type": "模板类型",
+ "pbl_blueprint.field.usage_count": "引用次数",
+ "pbl_blueprint.field.version_no": "版本号",
+ "pbl_blueprint.field.version_status": "版本状态",
+ "pbl_blueprint.menu.blueprint_editor": "蓝图编辑",
+ "pbl_blueprint.menu.blueprint_list": "蓝图列表",
+ "pbl_blueprint.menu.blueprint_tree": "蓝图结构树",
+ "pbl_blueprint.menu.root": "PBL蓝图",
+ "pbl_blueprint.menu.template_list": "蓝图模板",
+ "pbl_blueprint.node.artifact_spec": "产出物规格",
+ "pbl_blueprint.node.blueprint": "蓝图",
+ "pbl_blueprint.node.evidence_spec": "证据规格",
+ "pbl_blueprint.node.learning_goal": "学习目标",
+ "pbl_blueprint.node.mission": "真实情境任务",
+ "pbl_blueprint.node.reflection_spec": "反思规格",
+ "pbl_blueprint.node.role": "角色",
+ "pbl_blueprint.node.task": "任务分解",
+ "pbl_blueprint.page.editor": "蓝图编辑",
+ "pbl_blueprint.page.list": "蓝图列表",
+ "pbl_blueprint.page.template": "蓝图模板",
+ "pbl_blueprint.page.tree": "蓝图结构",
+ "pbl_blueprint.page.versions": "版本历史"
+}
\ No newline at end of file
diff --git a/pbl_blueprint/init.py b/pbl_blueprint/init.py
new file mode 100644
index 0000000..52da618
--- /dev/null
+++ b/pbl_blueprint/init.py
@@ -0,0 +1,88 @@
+"""pbl_blueprint.init —— 模块挂载入口。
+
+宿主应用(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)。
+"""
+
+from ahserver.serverEnv import ServerEnv
+
+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,
+)
+
+_loaded = False
+
+
+def load_pbl_blueprint():
+ """注册 pbl_blueprint 全部契约到 ServerEnv(幂等)。"""
+ global _loaded
+ env = ServerEnv()
+
+ # ---- 蓝图聚合根 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
+
+ # ---- 子对象契约
+ 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
+
+ # ---- 版本契约
+ env.pbl_blueprint_version_create = pbl_blueprint_version_create
+ env.pbl_blueprint_version_diff = pbl_blueprint_version_diff
+
+ # ---- 模板契约
+ 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
+
+ # ---- 下游模块只读契约(compiler / validation / runtime)
+ env.pbl_blueprint_get_contract = pbl_blueprint_get_contract
+
+ # ---- 异常类型与工具(供 dspy 统一错误处理)
+ env.PblBlueprintError = PblBlueprintError
+ env.pbl_blueprint_safe_traceback = safe_traceback
+
+ # 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
+
+
+def is_loaded():
+ return _loaded
diff --git a/pbl_blueprint/init/data.json b/pbl_blueprint/init/data.json
new file mode 100644
index 0000000..19195e2
--- /dev/null
+++ b/pbl_blueprint/init/data.json
@@ -0,0 +1,22 @@
+{
+ "appcodes": [
+ { "id": "pbl_blueprint_status", "name": "蓝图状态", "hierarchy_flg": "0" },
+ { "id": "pbl_blueprint_domain", "name": "蓝图业务域", "hierarchy_flg": "0" },
+ { "id": "pbl_blueprint_obj_type", "name": "蓝图子对象类型", "hierarchy_flg": "0" }
+ ],
+ "appcodes_kv": [
+ { "parentid": "pbl_blueprint_status", "k": "draft", "v": "草稿" },
+ { "parentid": "pbl_blueprint_status", "k": "active", "v": "设计中" },
+ { "parentid": "pbl_blueprint_status", "k": "published", "v": "已发布" },
+ { "parentid": "pbl_blueprint_status", "k": "archived", "v": "已归档" },
+ { "parentid": "pbl_blueprint_domain", "k": "hr", "v": "人力资源" },
+ { "parentid": "pbl_blueprint_domain", "k": "finance", "v": "财务" },
+ { "parentid": "pbl_blueprint_domain", "k": "supplychain", "v": "供应链" },
+ { "parentid": "pbl_blueprint_domain", "k": "project", "v": "项目管理" },
+ { "parentid": "pbl_blueprint_obj_type", "k": "phase", "v": "阶段" },
+ { "parentid": "pbl_blueprint_obj_type", "k": "task", "v": "任务" },
+ { "parentid": "pbl_blueprint_obj_type", "k": "deliverable", "v": "交付物" },
+ { "parentid": "pbl_blueprint_obj_type", "k": "assessment", "v": "评价项" },
+ { "parentid": "pbl_blueprint_obj_type", "k": "resource", "v": "资源" }
+ ]
+}
diff --git a/pbl_blueprint/json/pbl_blueprint.json b/pbl_blueprint/json/pbl_blueprint.json
new file mode 100644
index 0000000..1e7e1b0
--- /dev/null
+++ b/pbl_blueprint/json/pbl_blueprint.json
@@ -0,0 +1,124 @@
+{
+ "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"
+ }
+ ]
+ }
+ },
+ "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"
+ ]
+ }
+ }
+}
\ 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
new file mode 100644
index 0000000..d823f2a
--- /dev/null
+++ b/pbl_blueprint/json/pbl_blueprint_artifact_spec.json
@@ -0,0 +1,89 @@
+{
+ "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"
+ }
+ ]
+ }
+ },
+ "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"
+ ]
+ }
+ }
+}
\ 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
new file mode 100644
index 0000000..e220561
--- /dev/null
+++ b/pbl_blueprint/json/pbl_blueprint_evidence_spec.json
@@ -0,0 +1,89 @@
+{
+ "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"
+ }
+ ]
+ }
+ },
+ "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"
+ ]
+ }
+ }
+}
\ 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
new file mode 100644
index 0000000..a68ddfd
--- /dev/null
+++ b/pbl_blueprint/json/pbl_blueprint_learning_goal.json
@@ -0,0 +1,89 @@
+{
+ "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"
+ }
+ ]
+ }
+ },
+ "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"
+ ]
+ }
+ }
+}
\ No newline at end of file
diff --git a/pbl_blueprint/json/pbl_blueprint_mission.json b/pbl_blueprint/json/pbl_blueprint_mission.json
new file mode 100644
index 0000000..b7632da
--- /dev/null
+++ b/pbl_blueprint/json/pbl_blueprint_mission.json
@@ -0,0 +1,89 @@
+{
+ "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"
+ }
+ ]
+ }
+ },
+ "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"
+ ]
+ }
+ }
+}
\ 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
new file mode 100644
index 0000000..18919ae
--- /dev/null
+++ b/pbl_blueprint/json/pbl_blueprint_reflection_spec.json
@@ -0,0 +1,98 @@
+{
+ "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"
+ }
+ ]
+ }
+ },
+ "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"
+ ]
+ }
+ }
+}
\ No newline at end of file
diff --git a/pbl_blueprint/json/pbl_blueprint_role.json b/pbl_blueprint/json/pbl_blueprint_role.json
new file mode 100644
index 0000000..0457a25
--- /dev/null
+++ b/pbl_blueprint/json/pbl_blueprint_role.json
@@ -0,0 +1,89 @@
+{
+ "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"
+ }
+ ]
+ }
+ },
+ "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"
+ ]
+ }
+ }
+}
\ No newline at end of file
diff --git a/pbl_blueprint/json/pbl_blueprint_task.json b/pbl_blueprint/json/pbl_blueprint_task.json
new file mode 100644
index 0000000..15a14c3
--- /dev/null
+++ b/pbl_blueprint/json/pbl_blueprint_task.json
@@ -0,0 +1,89 @@
+{
+ "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"
+ }
+ ]
+ }
+ },
+ "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"
+ ]
+ }
+ }
+}
\ No newline at end of file
diff --git a/pbl_blueprint/json/pbl_blueprint_template.json b/pbl_blueprint/json/pbl_blueprint_template.json
new file mode 100644
index 0000000..ca8590e
--- /dev/null
+++ b/pbl_blueprint/json/pbl_blueprint_template.json
@@ -0,0 +1,141 @@
+{
+ "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"
+ }
+ ]
+ }
+ },
+ "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"
+ ]
+ }
+ }
+}
\ 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
new file mode 100644
index 0000000..1869bd3
--- /dev/null
+++ b/pbl_blueprint/json/pbl_blueprint_template_item.json
@@ -0,0 +1,82 @@
+{
+ "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"
+ }
+ ]
+ }
+ },
+ "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"
+ ]
+ }
+ }
+}
\ No newline at end of file
diff --git a/pbl_blueprint/json/pbl_blueprint_version.json b/pbl_blueprint/json/pbl_blueprint_version.json
new file mode 100644
index 0000000..52286d7
--- /dev/null
+++ b/pbl_blueprint/json/pbl_blueprint_version.json
@@ -0,0 +1,82 @@
+{
+ "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"
+ }
+ ]
+ }
+ },
+ "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"
+ ]
+ }
+ }
+}
\ No newline at end of file
diff --git a/pbl_blueprint/models.py b/pbl_blueprint/models.py
new file mode 100644
index 0000000..38611d8
--- /dev/null
+++ b/pbl_blueprint/models.py
@@ -0,0 +1,168 @@
+# -*- coding: utf-8 -*-
+"""pbl_blueprint 表清单与字段映射(从 models/*.json 四段式定义读取,不重复硬编码字段)。
+
+对外提供:
+- TABLES / TABLE_META:表名清单与元信息(pk / tenant_field / 抽象类型映射)
+- create_tables(env=None):按 models/*.json 建表(DDL 由平台抽象类型映射生成,幂等)
+- field_type_map(tbl):字段 -> 抽象类型(交付件逐字段核对用)
+"""
+import json
+import os
+
+from . import db as _db
+
+MODELS_DIR = os.path.join(os.path.dirname(os.path.abspath(__file__)), 'models')
+
+# 蓝图聚合根 + 版本 + 模板(M1a 范围)
+CORE_TABLES = ['pbl_blueprint', 'pbl_blueprint_version']
+# 子对象 7 类(M1a 落定义,M1b 扩展行为)
+SUBOBJECT_TABLES = [
+ 'pbl_blueprint_mission',
+ 'pbl_blueprint_learning_goal',
+ 'pbl_blueprint_task',
+ 'pbl_blueprint_role',
+ 'pbl_blueprint_artifact_spec',
+ 'pbl_blueprint_evidence_spec',
+ 'pbl_blueprint_reflection_spec',
+]
+# 模板主表 + 明细
+TEMPLATE_TABLES = ['pbl_blueprint_template', 'pbl_blueprint_template_item']
+
+# 子对象类型 -> 表名(契约接口 subobject_save/list 的 subobject_type 取值)
+SUBOBJECT_TYPE_TABLE = {
+ 'mission': 'pbl_blueprint_mission',
+ 'learning_goal': 'pbl_blueprint_learning_goal',
+ 'task': 'pbl_blueprint_task',
+ 'role': 'pbl_blueprint_role',
+ 'artifact_spec': 'pbl_blueprint_artifact_spec',
+ 'evidence_spec': 'pbl_blueprint_evidence_spec',
+ 'reflection_spec': 'pbl_blueprint_reflection_spec',
+}
+TABLE_SUBOBJECT_TYPE = {v: k for k, v in SUBOBJECT_TYPE_TABLE.items()}
+
+TABLES = CORE_TABLES + SUBOBJECT_TABLES + TEMPLATE_TABLES
+
+# 抽象类型 -> PostgreSQL 物理类型(与 scripts/derive_artifacts.py 保持一致)
+ABSTRACT_TO_PG = {
+ 'str': 'varchar',
+ 'text': 'text',
+ 'int': 'integer',
+ 'double': 'numeric(18,2)',
+ 'datetime': 'timestamp',
+}
+# 禁止出现的物理类型/写法(QC 自检项)
+FORBIDDEN_TYPES = ['varchar(', 'bigint', 'boolean', 'int(11)', 'SERIAL', 'BIGSERIAL',
+ 'nextval', 'AUTO_INCREMENT']
+
+
+def _cache():
+ global _CACHE
+ if '_CACHE' not in dir():
+ _CACHE = {}
+ return _CACHE
+
+
+def load_model(table):
+ """读取单表四段式定义。"""
+ path = os.path.join(MODELS_DIR, '%s.json' % table)
+ if not os.path.isfile(path):
+ raise _db.PblError(_db.ERR_PARAM_INVALID, '表定义文件缺失: %s' % table)
+ with open(path, encoding='utf-8') as fp:
+ doc = json.load(fp)
+ for seg in ('summary', 'fields', 'indexes', 'codes'):
+ if seg not in doc:
+ raise _db.PblError(_db.ERR_PARAM_INVALID,
+ '表定义 %s 缺四段式段: %s' % (table, seg))
+ return doc
+
+
+def all_models():
+ return dict((t, load_model(t)) for t in TABLES)
+
+
+def field_type_map(table):
+ """字段名 -> 抽象类型(如 id: str(32))。"""
+ return dict((k, v['type']) for k, v in load_model(table)['fields'].items())
+
+
+def table_meta(table):
+ doc = load_model(table)
+ fields = doc['fields']
+ return {
+ 'table': table,
+ 'label': doc['summary'].get('label', table),
+ 'engine': doc['summary'].get('engine', 'postgresql'),
+ 'pk': [k for k, v in fields.items() if v.get('pk')] or ['id'],
+ 'tenant_field': 'tenant_id' if 'tenant_id' in fields else None,
+ 'soft_delete_field': 'is_deleted' if 'is_deleted' in fields else None,
+ 'required': [k for k, v in fields.items() if v.get('required')],
+ 'appcodes': doc['codes'],
+ 'indexes': list(doc['indexes'].keys()),
+ 'field_count': len(fields),
+ }
+
+
+def _pg_type(spec):
+ ty = spec['type']
+ if ty.startswith('str'):
+ return 'varchar(%s)' % ty[ty.index('(') + 1:ty.index(')')]
+ if ty == 'text':
+ return 'text'
+ if ty == 'int':
+ return 'integer'
+ if ty.startswith('double'):
+ return 'numeric(18,2)'
+ if ty == 'datetime':
+ return 'timestamp'
+ raise _db.PblError(_db.ERR_PARAM_INVALID, '未知抽象类型 %s' % ty)
+
+
+def create_table_ddl(table):
+ """按 models/*.json 生成幂等 DDL(平台抽象类型映射,不写方言私有序列)。"""
+ doc = load_model(table)
+ fields = doc['fields']
+ cols = ',\n'.join(' %s %s%s' % (k, _pg_type(v),
+ ' NOT NULL' if (v.get('required') or v.get('pk')) else '')
+ for k, v in fields.items())
+ stmts = ['CREATE TABLE IF NOT EXISTS %s (\n%s\n)' % (table, cols)]
+ for iname, isp in doc['indexes'].items():
+ if iname == 'primary':
+ continue
+ uniq = 'UNIQUE ' if isp.get('unique') else ''
+ stmts.append('CREATE %sINDEX IF NOT EXISTS %s_%s ON %s (%s)' %
+ (uniq, table, iname, table, ', '.join(isp['fields'])))
+ return stmts
+
+
+def create_tables(tables=None, dbname=None):
+ """建表(幂等)。返回 {table: 执行语句数}。"""
+ result = {}
+ for t in (tables or TABLES):
+ stmts = create_table_ddl(t)
+ for s in stmts:
+ _db.sql_exe(s)
+ result[t] = len(stmts)
+ return result
+
+
+def register_crud(env, tables=None):
+ """把 json/{tblname}.json CRUD 契约注册到 ServerEnv(供平台 CRUD 工厂/前端表格消费)。"""
+ base = os.path.join(os.path.dirname(os.path.abspath(__file__)), 'json')
+ registered = []
+ for t in (tables or TABLES):
+ path = os.path.join(base, '%s.json' % t)
+ if not os.path.isfile(path):
+ raise _db.PblError(_db.ERR_PARAM_INVALID, 'CRUD 定义缺失: %s' % path)
+ with open(path, encoding='utf-8') as fp:
+ doc = json.load(fp)
+ if list(doc.keys()) != [t]:
+ raise _db.PblError(_db.ERR_PARAM_INVALID,
+ 'CRUD 定义根键必须等于表名 %s,实际 %s' % (t, list(doc.keys())))
+ if env is not None:
+ store = getattr(env, 'pbl_crud_defs', None)
+ if store is None:
+ store = {}
+ setattr(env, 'pbl_crud_defs', store)
+ store[t] = doc[t]
+ registered.append(t)
+ return registered
diff --git a/pbl_blueprint/models/pbl_blueprint.json b/pbl_blueprint/models/pbl_blueprint.json
new file mode 100644
index 0000000..f1946fa
--- /dev/null
+++ b/pbl_blueprint/models/pbl_blueprint.json
@@ -0,0 +1,185 @@
+{
+ "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
+ }
+ },
+ "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": "蓝图状态"
+ }
+ }
+}
\ 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
new file mode 100644
index 0000000..463993a
--- /dev/null
+++ b/pbl_blueprint/models/pbl_blueprint_artifact_spec.json
@@ -0,0 +1,133 @@
+{
+ "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
+ }
+ },
+ "codes": {
+ "artifact_type": {
+ "appcode": "pbl_artifact_type",
+ "label": "产出物类型"
+ }
+ }
+}
\ 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
new file mode 100644
index 0000000..aec65a4
--- /dev/null
+++ b/pbl_blueprint/models/pbl_blueprint_evidence_spec.json
@@ -0,0 +1,133 @@
+{
+ "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
+ }
+ },
+ "codes": {
+ "evidence_type": {
+ "appcode": "pbl_evidence_type",
+ "label": "证据类型"
+ }
+ }
+}
\ 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
new file mode 100644
index 0000000..090c9c2
--- /dev/null
+++ b/pbl_blueprint/models/pbl_blueprint_learning_goal.json
@@ -0,0 +1,138 @@
+{
+ "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
+ }
+ },
+ "codes": {
+ "goal_type": {
+ "appcode": "pbl_goal_type",
+ "label": "目标类型"
+ }
+ }
+}
\ No newline at end of file
diff --git a/pbl_blueprint/models/pbl_blueprint_mission.json b/pbl_blueprint/models/pbl_blueprint_mission.json
new file mode 100644
index 0000000..836f252
--- /dev/null
+++ b/pbl_blueprint/models/pbl_blueprint_mission.json
@@ -0,0 +1,138 @@
+{
+ "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
+ }
+ },
+ "codes": {
+ "mission_type": {
+ "appcode": "pbl_mission_type",
+ "label": "情境类型"
+ }
+ }
+}
\ 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
new file mode 100644
index 0000000..cd4e917
--- /dev/null
+++ b/pbl_blueprint/models/pbl_blueprint_reflection_spec.json
@@ -0,0 +1,137 @@
+{
+ "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
+ }
+ },
+ "codes": {
+ "reflect_type": {
+ "appcode": "pbl_reflect_type",
+ "label": "反思类型"
+ },
+ "frequency": {
+ "appcode": "pbl_reflect_frequency",
+ "label": "频次"
+ }
+ }
+}
\ No newline at end of file
diff --git a/pbl_blueprint/models/pbl_blueprint_role.json b/pbl_blueprint/models/pbl_blueprint_role.json
new file mode 100644
index 0000000..b83302a
--- /dev/null
+++ b/pbl_blueprint/models/pbl_blueprint_role.json
@@ -0,0 +1,132 @@
+{
+ "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
+ }
+ },
+ "codes": {
+ "role_category": {
+ "appcode": "pbl_role_category",
+ "label": "角色类别"
+ }
+ }
+}
\ No newline at end of file
diff --git a/pbl_blueprint/models/pbl_blueprint_task.json b/pbl_blueprint/models/pbl_blueprint_task.json
new file mode 100644
index 0000000..fe56d08
--- /dev/null
+++ b/pbl_blueprint/models/pbl_blueprint_task.json
@@ -0,0 +1,137 @@
+{
+ "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
+ }
+ },
+ "codes": {
+ "task_type": {
+ "appcode": "pbl_task_type",
+ "label": "任务类型"
+ }
+ }
+}
\ No newline at end of file
diff --git a/pbl_blueprint/models/pbl_blueprint_template.json b/pbl_blueprint/models/pbl_blueprint_template.json
new file mode 100644
index 0000000..97185fb
--- /dev/null
+++ b/pbl_blueprint/models/pbl_blueprint_template.json
@@ -0,0 +1,159 @@
+{
+ "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
+ }
+ },
+ "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": "状态"
+ }
+ }
+}
\ 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
new file mode 100644
index 0000000..2b5abac
--- /dev/null
+++ b/pbl_blueprint/models/pbl_blueprint_template_item.json
@@ -0,0 +1,106 @@
+{
+ "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
+ }
+ },
+ "codes": {
+ "subobject_type": {
+ "appcode": "pbl_subobject_type",
+ "label": "子对象类型"
+ }
+ }
+}
\ No newline at end of file
diff --git a/pbl_blueprint/models/pbl_blueprint_version.json b/pbl_blueprint/models/pbl_blueprint_version.json
new file mode 100644
index 0000000..8257570
--- /dev/null
+++ b/pbl_blueprint/models/pbl_blueprint_version.json
@@ -0,0 +1,124 @@
+{
+ "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
+ }
+ },
+ "codes": {
+ "version_status": {
+ "appcode": "pbl_version_status",
+ "label": "版本状态"
+ }
+ }
+}
\ No newline at end of file
diff --git a/pbl_blueprint/sql/pbl_blueprint.core.sql b/pbl_blueprint/sql/pbl_blueprint.core.sql
new file mode 100644
index 0000000..4f2edec
--- /dev/null
+++ b/pbl_blueprint/sql/pbl_blueprint.core.sql
@@ -0,0 +1,520 @@
+-- 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: 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 '更新时间';
+
+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);
+
+-- 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 '更新时间';
+
+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);
diff --git a/pbl_blueprint/subobjects.py b/pbl_blueprint/subobjects.py
new file mode 100644
index 0000000..a550cca
--- /dev/null
+++ b/pbl_blueprint/subobjects.py
@@ -0,0 +1,213 @@
+# -*- coding: utf-8 -*-
+"""蓝图子对象契约接口(M1a 落表 + CRUD;M1b 扩展校验/编排)。
+
+7 类子对象表(子类型 -> 表名见 models.SUBOBJECT_TYPE_TABLE):
+mission / learning_goal / task / role / artifact_spec / evidence_spec / reflection_spec
+
+契约函数:
+- 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 + 审计字段。
+"""
+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']
+
+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': '反思规格'},
+]
+
+STATUS_OPTIONS = [
+ {'code': 'active', 'label': '启用'},
+ {'code': 'draft', 'label': '草稿'},
+ {'code': 'disabled', 'label': '停用'},
+]
+
+
+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 _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 _fetch(table, rec_id, tid):
+ return _db.select_one(table, where={'id': rec_id, 'tenant_id': tid, 'is_deleted': 0})
+
+
+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()
+
+ 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
+ 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 ''
+
+ 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
+
+ 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)
+ 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 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 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 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 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 subobject_type_options():
+ return list(TYPE_OPTIONS)
diff --git a/pbl_blueprint/templates.py b/pbl_blueprint/templates.py
new file mode 100644
index 0000000..e588c28
--- /dev/null
+++ b/pbl_blueprint/templates.py
@@ -0,0 +1,238 @@
+# -*- coding: utf-8 -*-
+"""蓝图模板契约接口(M1a:模板主表/明细 CRUD + 实例化为蓝图)。
+
+契约函数:
+- 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
+"""
+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'
+
+TEMPLATE_FIELDS = ['class_id', 'template_code', 'template_name', 'template_type',
+ 'domain_code', 'grade_code', 'subject_code', 'complexity_level',
+ 'scope', 'source_blueprint_id', 'status', 'description']
+
+
+def _fetch(tid, rec_id, table=TABLE):
+ return _db.select_one(table, where={'id': rec_id, 'tenant_id': tid, 'is_deleted': 0})
+
+
+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')
+
+ 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,
+ }
+ _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
+ 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 '',
+ }
+ 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:
+ 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')}
+
+
+def template_status_options():
+ return [{'code': 'draft', 'label': '草稿'}, {'code': 'published', 'label': '已发布'},
+ {'code': 'archived', 'label': '已归档'}]
diff --git a/pbl_blueprint/wwwroot/api/blueprint_delete.dspy b/pbl_blueprint/wwwroot/api/blueprint_delete.dspy
new file mode 100644
index 0000000..d4e102e
--- /dev/null
+++ b/pbl_blueprint/wwwroot/api/blueprint_delete.dspy
@@ -0,0 +1,24 @@
+#!/usr/bin/env python3
+# pbl_blueprint/blueprint_delete.dspy —— 薄接线层:仅解析入参 + 调 ServerEnv 注册的门面函数
+# 业务逻辑在 pbl_blueprint/api.py::blueprint_delete(可单测、跨宿主复用);本文件禁止 import / f-string / print / uuid
+result = {'success': False, 'error': {'code': 'PBL-BOOT-001', 'msg': 'pbl_blueprint 未加载'}}
+try:
+ _env = ServerEnv()
+ _apis = getattr(_env, 'pbl_blueprint_api', None) or {}
+ _fn = _apis.get('blueprint_delete')
+ if _fn is None:
+ raise Exception('pbl_blueprint 未注册门面函数 blueprint_delete,请先 load_pbl_blueprint()')
+ _args = {}
+ for _k in dir(params_kw):
+ pass
+ for _k in list(params_kw.keys()):
+ _args[_k] = params_kw[_k]
+ result = _fn(_args)
+except Exception as _e:
+ result = {'success': False, 'error': {'code': 'PBL-API-001', 'msg': str(_e), 'detail': {}}}
+_out = result
+try:
+ _out = json.dumps(result, ensure_ascii=False, default=str)
+except Exception:
+ _out = result
+return _out
diff --git a/pbl_blueprint/wwwroot/api/blueprint_domain_options.dspy b/pbl_blueprint/wwwroot/api/blueprint_domain_options.dspy
new file mode 100644
index 0000000..3eb016d
--- /dev/null
+++ b/pbl_blueprint/wwwroot/api/blueprint_domain_options.dspy
@@ -0,0 +1,24 @@
+#!/usr/bin/env python3
+# pbl_blueprint/blueprint_domain_options.dspy —— 薄接线层:仅解析入参 + 调 ServerEnv 注册的门面函数
+# 业务逻辑在 pbl_blueprint/api.py::blueprint_domain_options(可单测、跨宿主复用);本文件禁止 import / f-string / print / uuid
+result = {'success': False, 'error': {'code': 'PBL-BOOT-001', 'msg': 'pbl_blueprint 未加载'}}
+try:
+ _env = ServerEnv()
+ _apis = getattr(_env, 'pbl_blueprint_api', None) or {}
+ _fn = _apis.get('blueprint_domain_options')
+ if _fn is None:
+ raise Exception('pbl_blueprint 未注册门面函数 blueprint_domain_options,请先 load_pbl_blueprint()')
+ _args = {}
+ for _k in dir(params_kw):
+ pass
+ for _k in list(params_kw.keys()):
+ _args[_k] = params_kw[_k]
+ result = _fn(_args)
+except Exception as _e:
+ result = {'success': False, 'error': {'code': 'PBL-API-001', 'msg': str(_e), 'detail': {}}}
+_out = result
+try:
+ _out = json.dumps(result, ensure_ascii=False, default=str)
+except Exception:
+ _out = result
+return _out
diff --git a/pbl_blueprint/wwwroot/api/blueprint_fork.dspy b/pbl_blueprint/wwwroot/api/blueprint_fork.dspy
new file mode 100644
index 0000000..ffca6a9
--- /dev/null
+++ b/pbl_blueprint/wwwroot/api/blueprint_fork.dspy
@@ -0,0 +1,24 @@
+#!/usr/bin/env python3
+# pbl_blueprint/blueprint_fork.dspy —— 薄接线层:仅解析入参 + 调 ServerEnv 注册的门面函数
+# 业务逻辑在 pbl_blueprint/api.py::blueprint_fork(可单测、跨宿主复用);本文件禁止 import / f-string / print / uuid
+result = {'success': False, 'error': {'code': 'PBL-BOOT-001', 'msg': 'pbl_blueprint 未加载'}}
+try:
+ _env = ServerEnv()
+ _apis = getattr(_env, 'pbl_blueprint_api', None) or {}
+ _fn = _apis.get('blueprint_fork')
+ if _fn is None:
+ raise Exception('pbl_blueprint 未注册门面函数 blueprint_fork,请先 load_pbl_blueprint()')
+ _args = {}
+ for _k in dir(params_kw):
+ pass
+ for _k in list(params_kw.keys()):
+ _args[_k] = params_kw[_k]
+ result = _fn(_args)
+except Exception as _e:
+ result = {'success': False, 'error': {'code': 'PBL-API-001', 'msg': str(_e), 'detail': {}}}
+_out = result
+try:
+ _out = json.dumps(result, ensure_ascii=False, default=str)
+except Exception:
+ _out = result
+return _out
diff --git a/pbl_blueprint/wwwroot/api/blueprint_get.dspy b/pbl_blueprint/wwwroot/api/blueprint_get.dspy
new file mode 100644
index 0000000..18f776c
--- /dev/null
+++ b/pbl_blueprint/wwwroot/api/blueprint_get.dspy
@@ -0,0 +1,24 @@
+#!/usr/bin/env python3
+# pbl_blueprint/blueprint_get.dspy —— 薄接线层:仅解析入参 + 调 ServerEnv 注册的门面函数
+# 业务逻辑在 pbl_blueprint/api.py::blueprint_get(可单测、跨宿主复用);本文件禁止 import / f-string / print / uuid
+result = {'success': False, 'error': {'code': 'PBL-BOOT-001', 'msg': 'pbl_blueprint 未加载'}}
+try:
+ _env = ServerEnv()
+ _apis = getattr(_env, 'pbl_blueprint_api', None) or {}
+ _fn = _apis.get('blueprint_get')
+ if _fn is None:
+ raise Exception('pbl_blueprint 未注册门面函数 blueprint_get,请先 load_pbl_blueprint()')
+ _args = {}
+ for _k in dir(params_kw):
+ pass
+ for _k in list(params_kw.keys()):
+ _args[_k] = params_kw[_k]
+ result = _fn(_args)
+except Exception as _e:
+ result = {'success': False, 'error': {'code': 'PBL-API-001', 'msg': str(_e), 'detail': {}}}
+_out = result
+try:
+ _out = json.dumps(result, ensure_ascii=False, default=str)
+except Exception:
+ _out = result
+return _out
diff --git a/pbl_blueprint/wwwroot/api/blueprint_list.dspy b/pbl_blueprint/wwwroot/api/blueprint_list.dspy
new file mode 100644
index 0000000..bcc7973
--- /dev/null
+++ b/pbl_blueprint/wwwroot/api/blueprint_list.dspy
@@ -0,0 +1,24 @@
+#!/usr/bin/env python3
+# pbl_blueprint/blueprint_list.dspy —— 薄接线层:仅解析入参 + 调 ServerEnv 注册的门面函数
+# 业务逻辑在 pbl_blueprint/api.py::blueprint_list(可单测、跨宿主复用);本文件禁止 import / f-string / print / uuid
+result = {'success': False, 'error': {'code': 'PBL-BOOT-001', 'msg': 'pbl_blueprint 未加载'}}
+try:
+ _env = ServerEnv()
+ _apis = getattr(_env, 'pbl_blueprint_api', None) or {}
+ _fn = _apis.get('blueprint_list')
+ if _fn is None:
+ raise Exception('pbl_blueprint 未注册门面函数 blueprint_list,请先 load_pbl_blueprint()')
+ _args = {}
+ for _k in dir(params_kw):
+ pass
+ for _k in list(params_kw.keys()):
+ _args[_k] = params_kw[_k]
+ result = _fn(_args)
+except Exception as _e:
+ result = {'success': False, 'error': {'code': 'PBL-API-001', 'msg': str(_e), 'detail': {}}}
+_out = result
+try:
+ _out = json.dumps(result, ensure_ascii=False, default=str)
+except Exception:
+ _out = result
+return _out
diff --git a/pbl_blueprint/wwwroot/api/blueprint_publish.dspy b/pbl_blueprint/wwwroot/api/blueprint_publish.dspy
new file mode 100644
index 0000000..35324bd
--- /dev/null
+++ b/pbl_blueprint/wwwroot/api/blueprint_publish.dspy
@@ -0,0 +1,24 @@
+#!/usr/bin/env python3
+# pbl_blueprint/blueprint_publish.dspy —— 薄接线层:仅解析入参 + 调 ServerEnv 注册的门面函数
+# 业务逻辑在 pbl_blueprint/api.py::blueprint_publish(可单测、跨宿主复用);本文件禁止 import / f-string / print / uuid
+result = {'success': False, 'error': {'code': 'PBL-BOOT-001', 'msg': 'pbl_blueprint 未加载'}}
+try:
+ _env = ServerEnv()
+ _apis = getattr(_env, 'pbl_blueprint_api', None) or {}
+ _fn = _apis.get('blueprint_publish')
+ if _fn is None:
+ raise Exception('pbl_blueprint 未注册门面函数 blueprint_publish,请先 load_pbl_blueprint()')
+ _args = {}
+ for _k in dir(params_kw):
+ pass
+ for _k in list(params_kw.keys()):
+ _args[_k] = params_kw[_k]
+ result = _fn(_args)
+except Exception as _e:
+ result = {'success': False, 'error': {'code': 'PBL-API-001', 'msg': str(_e), 'detail': {}}}
+_out = result
+try:
+ _out = json.dumps(result, ensure_ascii=False, default=str)
+except Exception:
+ _out = result
+return _out
diff --git a/pbl_blueprint/wwwroot/api/blueprint_save.dspy b/pbl_blueprint/wwwroot/api/blueprint_save.dspy
new file mode 100644
index 0000000..65f8261
--- /dev/null
+++ b/pbl_blueprint/wwwroot/api/blueprint_save.dspy
@@ -0,0 +1,24 @@
+#!/usr/bin/env python3
+# pbl_blueprint/blueprint_save.dspy —— 薄接线层:仅解析入参 + 调 ServerEnv 注册的门面函数
+# 业务逻辑在 pbl_blueprint/api.py::blueprint_save(可单测、跨宿主复用);本文件禁止 import / f-string / print / uuid
+result = {'success': False, 'error': {'code': 'PBL-BOOT-001', 'msg': 'pbl_blueprint 未加载'}}
+try:
+ _env = ServerEnv()
+ _apis = getattr(_env, 'pbl_blueprint_api', None) or {}
+ _fn = _apis.get('blueprint_save')
+ if _fn is None:
+ raise Exception('pbl_blueprint 未注册门面函数 blueprint_save,请先 load_pbl_blueprint()')
+ _args = {}
+ for _k in dir(params_kw):
+ pass
+ for _k in list(params_kw.keys()):
+ _args[_k] = params_kw[_k]
+ result = _fn(_args)
+except Exception as _e:
+ result = {'success': False, 'error': {'code': 'PBL-API-001', 'msg': str(_e), 'detail': {}}}
+_out = result
+try:
+ _out = json.dumps(result, ensure_ascii=False, default=str)
+except Exception:
+ _out = result
+return _out
diff --git a/pbl_blueprint/wwwroot/api/blueprint_status.dspy b/pbl_blueprint/wwwroot/api/blueprint_status.dspy
new file mode 100644
index 0000000..1ecd22e
--- /dev/null
+++ b/pbl_blueprint/wwwroot/api/blueprint_status.dspy
@@ -0,0 +1,24 @@
+#!/usr/bin/env python3
+# pbl_blueprint/blueprint_status.dspy —— 薄接线层:仅解析入参 + 调 ServerEnv 注册的门面函数
+# 业务逻辑在 pbl_blueprint/api.py::blueprint_status(可单测、跨宿主复用);本文件禁止 import / f-string / print / uuid
+result = {'success': False, 'error': {'code': 'PBL-BOOT-001', 'msg': 'pbl_blueprint 未加载'}}
+try:
+ _env = ServerEnv()
+ _apis = getattr(_env, 'pbl_blueprint_api', None) or {}
+ _fn = _apis.get('blueprint_status')
+ if _fn is None:
+ raise Exception('pbl_blueprint 未注册门面函数 blueprint_status,请先 load_pbl_blueprint()')
+ _args = {}
+ for _k in dir(params_kw):
+ pass
+ for _k in list(params_kw.keys()):
+ _args[_k] = params_kw[_k]
+ result = _fn(_args)
+except Exception as _e:
+ result = {'success': False, 'error': {'code': 'PBL-API-001', 'msg': str(_e), 'detail': {}}}
+_out = result
+try:
+ _out = json.dumps(result, ensure_ascii=False, default=str)
+except Exception:
+ _out = result
+return _out
diff --git a/pbl_blueprint/wwwroot/api/blueprint_status_options.dspy b/pbl_blueprint/wwwroot/api/blueprint_status_options.dspy
new file mode 100644
index 0000000..042030b
--- /dev/null
+++ b/pbl_blueprint/wwwroot/api/blueprint_status_options.dspy
@@ -0,0 +1,24 @@
+#!/usr/bin/env python3
+# pbl_blueprint/blueprint_status_options.dspy —— 薄接线层:仅解析入参 + 调 ServerEnv 注册的门面函数
+# 业务逻辑在 pbl_blueprint/api.py::blueprint_status_options(可单测、跨宿主复用);本文件禁止 import / f-string / print / uuid
+result = {'success': False, 'error': {'code': 'PBL-BOOT-001', 'msg': 'pbl_blueprint 未加载'}}
+try:
+ _env = ServerEnv()
+ _apis = getattr(_env, 'pbl_blueprint_api', None) or {}
+ _fn = _apis.get('blueprint_status_options')
+ if _fn is None:
+ raise Exception('pbl_blueprint 未注册门面函数 blueprint_status_options,请先 load_pbl_blueprint()')
+ _args = {}
+ for _k in dir(params_kw):
+ pass
+ for _k in list(params_kw.keys()):
+ _args[_k] = params_kw[_k]
+ result = _fn(_args)
+except Exception as _e:
+ result = {'success': False, 'error': {'code': 'PBL-API-001', 'msg': str(_e), 'detail': {}}}
+_out = result
+try:
+ _out = json.dumps(result, ensure_ascii=False, default=str)
+except Exception:
+ _out = result
+return _out
diff --git a/pbl_blueprint/wwwroot/api/blueprint_tree.dspy b/pbl_blueprint/wwwroot/api/blueprint_tree.dspy
new file mode 100644
index 0000000..cf95422
--- /dev/null
+++ b/pbl_blueprint/wwwroot/api/blueprint_tree.dspy
@@ -0,0 +1,24 @@
+#!/usr/bin/env python3
+# pbl_blueprint/blueprint_tree.dspy —— 薄接线层:仅解析入参 + 调 ServerEnv 注册的门面函数
+# 业务逻辑在 pbl_blueprint/api.py::blueprint_tree(可单测、跨宿主复用);本文件禁止 import / f-string / print / uuid
+result = {'success': False, 'error': {'code': 'PBL-BOOT-001', 'msg': 'pbl_blueprint 未加载'}}
+try:
+ _env = ServerEnv()
+ _apis = getattr(_env, 'pbl_blueprint_api', None) or {}
+ _fn = _apis.get('blueprint_tree')
+ if _fn is None:
+ raise Exception('pbl_blueprint 未注册门面函数 blueprint_tree,请先 load_pbl_blueprint()')
+ _args = {}
+ for _k in dir(params_kw):
+ pass
+ for _k in list(params_kw.keys()):
+ _args[_k] = params_kw[_k]
+ result = _fn(_args)
+except Exception as _e:
+ result = {'success': False, 'error': {'code': 'PBL-API-001', 'msg': str(_e), 'detail': {}}}
+_out = result
+try:
+ _out = json.dumps(result, ensure_ascii=False, default=str)
+except Exception:
+ _out = result
+return _out
diff --git a/pbl_blueprint/wwwroot/api/blueprint_version_get.dspy b/pbl_blueprint/wwwroot/api/blueprint_version_get.dspy
new file mode 100644
index 0000000..e2eeae3
--- /dev/null
+++ b/pbl_blueprint/wwwroot/api/blueprint_version_get.dspy
@@ -0,0 +1,24 @@
+#!/usr/bin/env python3
+# pbl_blueprint/blueprint_version_get.dspy —— 薄接线层:仅解析入参 + 调 ServerEnv 注册的门面函数
+# 业务逻辑在 pbl_blueprint/api.py::blueprint_version_get(可单测、跨宿主复用);本文件禁止 import / f-string / print / uuid
+result = {'success': False, 'error': {'code': 'PBL-BOOT-001', 'msg': 'pbl_blueprint 未加载'}}
+try:
+ _env = ServerEnv()
+ _apis = getattr(_env, 'pbl_blueprint_api', None) or {}
+ _fn = _apis.get('blueprint_version_get')
+ if _fn is None:
+ raise Exception('pbl_blueprint 未注册门面函数 blueprint_version_get,请先 load_pbl_blueprint()')
+ _args = {}
+ for _k in dir(params_kw):
+ pass
+ for _k in list(params_kw.keys()):
+ _args[_k] = params_kw[_k]
+ result = _fn(_args)
+except Exception as _e:
+ result = {'success': False, 'error': {'code': 'PBL-API-001', 'msg': str(_e), 'detail': {}}}
+_out = result
+try:
+ _out = json.dumps(result, ensure_ascii=False, default=str)
+except Exception:
+ _out = result
+return _out
diff --git a/pbl_blueprint/wwwroot/api/blueprint_versions.dspy b/pbl_blueprint/wwwroot/api/blueprint_versions.dspy
new file mode 100644
index 0000000..991838a
--- /dev/null
+++ b/pbl_blueprint/wwwroot/api/blueprint_versions.dspy
@@ -0,0 +1,24 @@
+#!/usr/bin/env python3
+# pbl_blueprint/blueprint_versions.dspy —— 薄接线层:仅解析入参 + 调 ServerEnv 注册的门面函数
+# 业务逻辑在 pbl_blueprint/api.py::blueprint_versions(可单测、跨宿主复用);本文件禁止 import / f-string / print / uuid
+result = {'success': False, 'error': {'code': 'PBL-BOOT-001', 'msg': 'pbl_blueprint 未加载'}}
+try:
+ _env = ServerEnv()
+ _apis = getattr(_env, 'pbl_blueprint_api', None) or {}
+ _fn = _apis.get('blueprint_versions')
+ if _fn is None:
+ raise Exception('pbl_blueprint 未注册门面函数 blueprint_versions,请先 load_pbl_blueprint()')
+ _args = {}
+ for _k in dir(params_kw):
+ pass
+ for _k in list(params_kw.keys()):
+ _args[_k] = params_kw[_k]
+ result = _fn(_args)
+except Exception as _e:
+ result = {'success': False, 'error': {'code': 'PBL-API-001', 'msg': str(_e), 'detail': {}}}
+_out = result
+try:
+ _out = json.dumps(result, ensure_ascii=False, default=str)
+except Exception:
+ _out = result
+return _out
diff --git a/pbl_blueprint/wwwroot/api/subobject_counts.dspy b/pbl_blueprint/wwwroot/api/subobject_counts.dspy
new file mode 100644
index 0000000..ffbd6f6
--- /dev/null
+++ b/pbl_blueprint/wwwroot/api/subobject_counts.dspy
@@ -0,0 +1,24 @@
+#!/usr/bin/env python3
+# pbl_blueprint/subobject_counts.dspy —— 薄接线层:仅解析入参 + 调 ServerEnv 注册的门面函数
+# 业务逻辑在 pbl_blueprint/api.py::subobject_counts(可单测、跨宿主复用);本文件禁止 import / f-string / print / uuid
+result = {'success': False, 'error': {'code': 'PBL-BOOT-001', 'msg': 'pbl_blueprint 未加载'}}
+try:
+ _env = ServerEnv()
+ _apis = getattr(_env, 'pbl_blueprint_api', None) or {}
+ _fn = _apis.get('subobject_counts')
+ if _fn is None:
+ raise Exception('pbl_blueprint 未注册门面函数 subobject_counts,请先 load_pbl_blueprint()')
+ _args = {}
+ for _k in dir(params_kw):
+ pass
+ for _k in list(params_kw.keys()):
+ _args[_k] = params_kw[_k]
+ result = _fn(_args)
+except Exception as _e:
+ result = {'success': False, 'error': {'code': 'PBL-API-001', 'msg': str(_e), 'detail': {}}}
+_out = result
+try:
+ _out = json.dumps(result, ensure_ascii=False, default=str)
+except Exception:
+ _out = result
+return _out
diff --git a/pbl_blueprint/wwwroot/api/subobject_delete.dspy b/pbl_blueprint/wwwroot/api/subobject_delete.dspy
new file mode 100644
index 0000000..87e152b
--- /dev/null
+++ b/pbl_blueprint/wwwroot/api/subobject_delete.dspy
@@ -0,0 +1,24 @@
+#!/usr/bin/env python3
+# pbl_blueprint/subobject_delete.dspy —— 薄接线层:仅解析入参 + 调 ServerEnv 注册的门面函数
+# 业务逻辑在 pbl_blueprint/api.py::subobject_delete(可单测、跨宿主复用);本文件禁止 import / f-string / print / uuid
+result = {'success': False, 'error': {'code': 'PBL-BOOT-001', 'msg': 'pbl_blueprint 未加载'}}
+try:
+ _env = ServerEnv()
+ _apis = getattr(_env, 'pbl_blueprint_api', None) or {}
+ _fn = _apis.get('subobject_delete')
+ if _fn is None:
+ raise Exception('pbl_blueprint 未注册门面函数 subobject_delete,请先 load_pbl_blueprint()')
+ _args = {}
+ for _k in dir(params_kw):
+ pass
+ for _k in list(params_kw.keys()):
+ _args[_k] = params_kw[_k]
+ result = _fn(_args)
+except Exception as _e:
+ result = {'success': False, 'error': {'code': 'PBL-API-001', 'msg': str(_e), 'detail': {}}}
+_out = result
+try:
+ _out = json.dumps(result, ensure_ascii=False, default=str)
+except Exception:
+ _out = result
+return _out
diff --git a/pbl_blueprint/wwwroot/api/subobject_get.dspy b/pbl_blueprint/wwwroot/api/subobject_get.dspy
new file mode 100644
index 0000000..e06dfc0
--- /dev/null
+++ b/pbl_blueprint/wwwroot/api/subobject_get.dspy
@@ -0,0 +1,24 @@
+#!/usr/bin/env python3
+# pbl_blueprint/subobject_get.dspy —— 薄接线层:仅解析入参 + 调 ServerEnv 注册的门面函数
+# 业务逻辑在 pbl_blueprint/api.py::subobject_get(可单测、跨宿主复用);本文件禁止 import / f-string / print / uuid
+result = {'success': False, 'error': {'code': 'PBL-BOOT-001', 'msg': 'pbl_blueprint 未加载'}}
+try:
+ _env = ServerEnv()
+ _apis = getattr(_env, 'pbl_blueprint_api', None) or {}
+ _fn = _apis.get('subobject_get')
+ if _fn is None:
+ raise Exception('pbl_blueprint 未注册门面函数 subobject_get,请先 load_pbl_blueprint()')
+ _args = {}
+ for _k in dir(params_kw):
+ pass
+ for _k in list(params_kw.keys()):
+ _args[_k] = params_kw[_k]
+ result = _fn(_args)
+except Exception as _e:
+ result = {'success': False, 'error': {'code': 'PBL-API-001', 'msg': str(_e), 'detail': {}}}
+_out = result
+try:
+ _out = json.dumps(result, ensure_ascii=False, default=str)
+except Exception:
+ _out = result
+return _out
diff --git a/pbl_blueprint/wwwroot/api/subobject_list.dspy b/pbl_blueprint/wwwroot/api/subobject_list.dspy
new file mode 100644
index 0000000..a165414
--- /dev/null
+++ b/pbl_blueprint/wwwroot/api/subobject_list.dspy
@@ -0,0 +1,24 @@
+#!/usr/bin/env python3
+# pbl_blueprint/subobject_list.dspy —— 薄接线层:仅解析入参 + 调 ServerEnv 注册的门面函数
+# 业务逻辑在 pbl_blueprint/api.py::subobject_list(可单测、跨宿主复用);本文件禁止 import / f-string / print / uuid
+result = {'success': False, 'error': {'code': 'PBL-BOOT-001', 'msg': 'pbl_blueprint 未加载'}}
+try:
+ _env = ServerEnv()
+ _apis = getattr(_env, 'pbl_blueprint_api', None) or {}
+ _fn = _apis.get('subobject_list')
+ if _fn is None:
+ raise Exception('pbl_blueprint 未注册门面函数 subobject_list,请先 load_pbl_blueprint()')
+ _args = {}
+ for _k in dir(params_kw):
+ pass
+ for _k in list(params_kw.keys()):
+ _args[_k] = params_kw[_k]
+ result = _fn(_args)
+except Exception as _e:
+ result = {'success': False, 'error': {'code': 'PBL-API-001', 'msg': str(_e), 'detail': {}}}
+_out = result
+try:
+ _out = json.dumps(result, ensure_ascii=False, default=str)
+except Exception:
+ _out = result
+return _out
diff --git a/pbl_blueprint/wwwroot/api/subobject_save.dspy b/pbl_blueprint/wwwroot/api/subobject_save.dspy
new file mode 100644
index 0000000..0c3e35f
--- /dev/null
+++ b/pbl_blueprint/wwwroot/api/subobject_save.dspy
@@ -0,0 +1,24 @@
+#!/usr/bin/env python3
+# pbl_blueprint/subobject_save.dspy —— 薄接线层:仅解析入参 + 调 ServerEnv 注册的门面函数
+# 业务逻辑在 pbl_blueprint/api.py::subobject_save(可单测、跨宿主复用);本文件禁止 import / f-string / print / uuid
+result = {'success': False, 'error': {'code': 'PBL-BOOT-001', 'msg': 'pbl_blueprint 未加载'}}
+try:
+ _env = ServerEnv()
+ _apis = getattr(_env, 'pbl_blueprint_api', None) or {}
+ _fn = _apis.get('subobject_save')
+ if _fn is None:
+ raise Exception('pbl_blueprint 未注册门面函数 subobject_save,请先 load_pbl_blueprint()')
+ _args = {}
+ for _k in dir(params_kw):
+ pass
+ for _k in list(params_kw.keys()):
+ _args[_k] = params_kw[_k]
+ result = _fn(_args)
+except Exception as _e:
+ result = {'success': False, 'error': {'code': 'PBL-API-001', 'msg': str(_e), 'detail': {}}}
+_out = result
+try:
+ _out = json.dumps(result, ensure_ascii=False, default=str)
+except Exception:
+ _out = result
+return _out
diff --git a/pbl_blueprint/wwwroot/api/subobject_type_options.dspy b/pbl_blueprint/wwwroot/api/subobject_type_options.dspy
new file mode 100644
index 0000000..67bb331
--- /dev/null
+++ b/pbl_blueprint/wwwroot/api/subobject_type_options.dspy
@@ -0,0 +1,24 @@
+#!/usr/bin/env python3
+# pbl_blueprint/subobject_type_options.dspy —— 薄接线层:仅解析入参 + 调 ServerEnv 注册的门面函数
+# 业务逻辑在 pbl_blueprint/api.py::subobject_type_options(可单测、跨宿主复用);本文件禁止 import / f-string / print / uuid
+result = {'success': False, 'error': {'code': 'PBL-BOOT-001', 'msg': 'pbl_blueprint 未加载'}}
+try:
+ _env = ServerEnv()
+ _apis = getattr(_env, 'pbl_blueprint_api', None) or {}
+ _fn = _apis.get('subobject_type_options')
+ if _fn is None:
+ raise Exception('pbl_blueprint 未注册门面函数 subobject_type_options,请先 load_pbl_blueprint()')
+ _args = {}
+ for _k in dir(params_kw):
+ pass
+ for _k in list(params_kw.keys()):
+ _args[_k] = params_kw[_k]
+ result = _fn(_args)
+except Exception as _e:
+ result = {'success': False, 'error': {'code': 'PBL-API-001', 'msg': str(_e), 'detail': {}}}
+_out = result
+try:
+ _out = json.dumps(result, ensure_ascii=False, default=str)
+except Exception:
+ _out = result
+return _out
diff --git a/pbl_blueprint/wwwroot/api/table_manifest.dspy b/pbl_blueprint/wwwroot/api/table_manifest.dspy
new file mode 100644
index 0000000..c2ae624
--- /dev/null
+++ b/pbl_blueprint/wwwroot/api/table_manifest.dspy
@@ -0,0 +1,24 @@
+#!/usr/bin/env python3
+# pbl_blueprint/table_manifest.dspy —— 薄接线层:仅解析入参 + 调 ServerEnv 注册的门面函数
+# 业务逻辑在 pbl_blueprint/api.py::table_manifest(可单测、跨宿主复用);本文件禁止 import / f-string / print / uuid
+result = {'success': False, 'error': {'code': 'PBL-BOOT-001', 'msg': 'pbl_blueprint 未加载'}}
+try:
+ _env = ServerEnv()
+ _apis = getattr(_env, 'pbl_blueprint_api', None) or {}
+ _fn = _apis.get('table_manifest')
+ if _fn is None:
+ raise Exception('pbl_blueprint 未注册门面函数 table_manifest,请先 load_pbl_blueprint()')
+ _args = {}
+ for _k in dir(params_kw):
+ pass
+ for _k in list(params_kw.keys()):
+ _args[_k] = params_kw[_k]
+ result = _fn(_args)
+except Exception as _e:
+ result = {'success': False, 'error': {'code': 'PBL-API-001', 'msg': str(_e), 'detail': {}}}
+_out = result
+try:
+ _out = json.dumps(result, ensure_ascii=False, default=str)
+except Exception:
+ _out = result
+return _out
diff --git a/pbl_blueprint/wwwroot/api/template_delete.dspy b/pbl_blueprint/wwwroot/api/template_delete.dspy
new file mode 100644
index 0000000..2bd671d
--- /dev/null
+++ b/pbl_blueprint/wwwroot/api/template_delete.dspy
@@ -0,0 +1,24 @@
+#!/usr/bin/env python3
+# pbl_blueprint/template_delete.dspy —— 薄接线层:仅解析入参 + 调 ServerEnv 注册的门面函数
+# 业务逻辑在 pbl_blueprint/api.py::template_delete(可单测、跨宿主复用);本文件禁止 import / f-string / print / uuid
+result = {'success': False, 'error': {'code': 'PBL-BOOT-001', 'msg': 'pbl_blueprint 未加载'}}
+try:
+ _env = ServerEnv()
+ _apis = getattr(_env, 'pbl_blueprint_api', None) or {}
+ _fn = _apis.get('template_delete')
+ if _fn is None:
+ raise Exception('pbl_blueprint 未注册门面函数 template_delete,请先 load_pbl_blueprint()')
+ _args = {}
+ for _k in dir(params_kw):
+ pass
+ for _k in list(params_kw.keys()):
+ _args[_k] = params_kw[_k]
+ result = _fn(_args)
+except Exception as _e:
+ result = {'success': False, 'error': {'code': 'PBL-API-001', 'msg': str(_e), 'detail': {}}}
+_out = result
+try:
+ _out = json.dumps(result, ensure_ascii=False, default=str)
+except Exception:
+ _out = result
+return _out
diff --git a/pbl_blueprint/wwwroot/api/template_from_blueprint.dspy b/pbl_blueprint/wwwroot/api/template_from_blueprint.dspy
new file mode 100644
index 0000000..17d692b
--- /dev/null
+++ b/pbl_blueprint/wwwroot/api/template_from_blueprint.dspy
@@ -0,0 +1,24 @@
+#!/usr/bin/env python3
+# pbl_blueprint/template_from_blueprint.dspy —— 薄接线层:仅解析入参 + 调 ServerEnv 注册的门面函数
+# 业务逻辑在 pbl_blueprint/api.py::template_from_blueprint(可单测、跨宿主复用);本文件禁止 import / f-string / print / uuid
+result = {'success': False, 'error': {'code': 'PBL-BOOT-001', 'msg': 'pbl_blueprint 未加载'}}
+try:
+ _env = ServerEnv()
+ _apis = getattr(_env, 'pbl_blueprint_api', None) or {}
+ _fn = _apis.get('template_from_blueprint')
+ if _fn is None:
+ raise Exception('pbl_blueprint 未注册门面函数 template_from_blueprint,请先 load_pbl_blueprint()')
+ _args = {}
+ for _k in dir(params_kw):
+ pass
+ for _k in list(params_kw.keys()):
+ _args[_k] = params_kw[_k]
+ result = _fn(_args)
+except Exception as _e:
+ result = {'success': False, 'error': {'code': 'PBL-API-001', 'msg': str(_e), 'detail': {}}}
+_out = result
+try:
+ _out = json.dumps(result, ensure_ascii=False, default=str)
+except Exception:
+ _out = result
+return _out
diff --git a/pbl_blueprint/wwwroot/api/template_get.dspy b/pbl_blueprint/wwwroot/api/template_get.dspy
new file mode 100644
index 0000000..85ef78d
--- /dev/null
+++ b/pbl_blueprint/wwwroot/api/template_get.dspy
@@ -0,0 +1,24 @@
+#!/usr/bin/env python3
+# pbl_blueprint/template_get.dspy —— 薄接线层:仅解析入参 + 调 ServerEnv 注册的门面函数
+# 业务逻辑在 pbl_blueprint/api.py::template_get(可单测、跨宿主复用);本文件禁止 import / f-string / print / uuid
+result = {'success': False, 'error': {'code': 'PBL-BOOT-001', 'msg': 'pbl_blueprint 未加载'}}
+try:
+ _env = ServerEnv()
+ _apis = getattr(_env, 'pbl_blueprint_api', None) or {}
+ _fn = _apis.get('template_get')
+ if _fn is None:
+ raise Exception('pbl_blueprint 未注册门面函数 template_get,请先 load_pbl_blueprint()')
+ _args = {}
+ for _k in dir(params_kw):
+ pass
+ for _k in list(params_kw.keys()):
+ _args[_k] = params_kw[_k]
+ result = _fn(_args)
+except Exception as _e:
+ result = {'success': False, 'error': {'code': 'PBL-API-001', 'msg': str(_e), 'detail': {}}}
+_out = result
+try:
+ _out = json.dumps(result, ensure_ascii=False, default=str)
+except Exception:
+ _out = result
+return _out
diff --git a/pbl_blueprint/wwwroot/api/template_instantiate.dspy b/pbl_blueprint/wwwroot/api/template_instantiate.dspy
new file mode 100644
index 0000000..208fbb3
--- /dev/null
+++ b/pbl_blueprint/wwwroot/api/template_instantiate.dspy
@@ -0,0 +1,24 @@
+#!/usr/bin/env python3
+# pbl_blueprint/template_instantiate.dspy —— 薄接线层:仅解析入参 + 调 ServerEnv 注册的门面函数
+# 业务逻辑在 pbl_blueprint/api.py::template_instantiate(可单测、跨宿主复用);本文件禁止 import / f-string / print / uuid
+result = {'success': False, 'error': {'code': 'PBL-BOOT-001', 'msg': 'pbl_blueprint 未加载'}}
+try:
+ _env = ServerEnv()
+ _apis = getattr(_env, 'pbl_blueprint_api', None) or {}
+ _fn = _apis.get('template_instantiate')
+ if _fn is None:
+ raise Exception('pbl_blueprint 未注册门面函数 template_instantiate,请先 load_pbl_blueprint()')
+ _args = {}
+ for _k in dir(params_kw):
+ pass
+ for _k in list(params_kw.keys()):
+ _args[_k] = params_kw[_k]
+ result = _fn(_args)
+except Exception as _e:
+ result = {'success': False, 'error': {'code': 'PBL-API-001', 'msg': str(_e), 'detail': {}}}
+_out = result
+try:
+ _out = json.dumps(result, ensure_ascii=False, default=str)
+except Exception:
+ _out = result
+return _out
diff --git a/pbl_blueprint/wwwroot/api/template_list.dspy b/pbl_blueprint/wwwroot/api/template_list.dspy
new file mode 100644
index 0000000..7c5c3a4
--- /dev/null
+++ b/pbl_blueprint/wwwroot/api/template_list.dspy
@@ -0,0 +1,24 @@
+#!/usr/bin/env python3
+# pbl_blueprint/template_list.dspy —— 薄接线层:仅解析入参 + 调 ServerEnv 注册的门面函数
+# 业务逻辑在 pbl_blueprint/api.py::template_list(可单测、跨宿主复用);本文件禁止 import / f-string / print / uuid
+result = {'success': False, 'error': {'code': 'PBL-BOOT-001', 'msg': 'pbl_blueprint 未加载'}}
+try:
+ _env = ServerEnv()
+ _apis = getattr(_env, 'pbl_blueprint_api', None) or {}
+ _fn = _apis.get('template_list')
+ if _fn is None:
+ raise Exception('pbl_blueprint 未注册门面函数 template_list,请先 load_pbl_blueprint()')
+ _args = {}
+ for _k in dir(params_kw):
+ pass
+ for _k in list(params_kw.keys()):
+ _args[_k] = params_kw[_k]
+ result = _fn(_args)
+except Exception as _e:
+ result = {'success': False, 'error': {'code': 'PBL-API-001', 'msg': str(_e), 'detail': {}}}
+_out = result
+try:
+ _out = json.dumps(result, ensure_ascii=False, default=str)
+except Exception:
+ _out = result
+return _out
diff --git a/pbl_blueprint/wwwroot/api/template_save.dspy b/pbl_blueprint/wwwroot/api/template_save.dspy
new file mode 100644
index 0000000..164036f
--- /dev/null
+++ b/pbl_blueprint/wwwroot/api/template_save.dspy
@@ -0,0 +1,24 @@
+#!/usr/bin/env python3
+# pbl_blueprint/template_save.dspy —— 薄接线层:仅解析入参 + 调 ServerEnv 注册的门面函数
+# 业务逻辑在 pbl_blueprint/api.py::template_save(可单测、跨宿主复用);本文件禁止 import / f-string / print / uuid
+result = {'success': False, 'error': {'code': 'PBL-BOOT-001', 'msg': 'pbl_blueprint 未加载'}}
+try:
+ _env = ServerEnv()
+ _apis = getattr(_env, 'pbl_blueprint_api', None) or {}
+ _fn = _apis.get('template_save')
+ if _fn is None:
+ raise Exception('pbl_blueprint 未注册门面函数 template_save,请先 load_pbl_blueprint()')
+ _args = {}
+ for _k in dir(params_kw):
+ pass
+ for _k in list(params_kw.keys()):
+ _args[_k] = params_kw[_k]
+ result = _fn(_args)
+except Exception as _e:
+ result = {'success': False, 'error': {'code': 'PBL-API-001', 'msg': str(_e), 'detail': {}}}
+_out = result
+try:
+ _out = json.dumps(result, ensure_ascii=False, default=str)
+except Exception:
+ _out = result
+return _out
diff --git a/pbl_blueprint/wwwroot/blueprint_editor.ui b/pbl_blueprint/wwwroot/blueprint_editor.ui
new file mode 100644
index 0000000..099605e
--- /dev/null
+++ b/pbl_blueprint/wwwroot/blueprint_editor.ui
@@ -0,0 +1,50 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/pbl_blueprint/wwwroot/blueprint_list.ui b/pbl_blueprint/wwwroot/blueprint_list.ui
new file mode 100644
index 0000000..a468ad5
--- /dev/null
+++ b/pbl_blueprint/wwwroot/blueprint_list.ui
@@ -0,0 +1,28 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/pbl_blueprint/wwwroot/blueprint_tree.ui b/pbl_blueprint/wwwroot/blueprint_tree.ui
new file mode 100644
index 0000000..4d7dc3c
--- /dev/null
+++ b/pbl_blueprint/wwwroot/blueprint_tree.ui
@@ -0,0 +1,36 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/pbl_blueprint/wwwroot/index.ui b/pbl_blueprint/wwwroot/index.ui
new file mode 100644
index 0000000..731a29b
--- /dev/null
+++ b/pbl_blueprint/wwwroot/index.ui
@@ -0,0 +1,22 @@
+{
+ "widgettype": "VBox",
+ "options": { "width": "100%", "height": "100%", "padding": "20px" },
+ "subwidgets": [
+ { "widgettype": "Text", "options": { "label": "PBL 蓝图核心(pbl_blueprint)", "fontSize": "24px" } },
+ { "widgettype": "ResponsableBox", "options": { "gap": "16px", "minWidth": "250px" }, "subwidgets": [
+ { "widgettype": "VBox", "options": { "backgroundColor": "#FFFFFF", "padding": "20px", "cursor": "pointer" },
+ "binds": [{ "wid": "self", "event": "click", "actiontype": "urlwidget", "target": "app.pbl_blueprint_content",
+ "options": { "url": "{{entire_url('blueprint_list.ui')}}" } }, "mode": "replace" }],
+ "subwidgets": [{ "widgettype": "Text", "options": { "label": "蓝图清单" } }] },
+ { "widgettype": "VBox", "options": { "backgroundColor": "#FFFFFF", "padding": "20px", "cursor": "pointer" },
+ "binds": [{ "wid": "self", "event": "click", "actiontype": "urlwidget", "target": "app.pbl_blueprint_content",
+ "options": { "url": "{{entire_url('blueprint_tree.ui')}}" } }, "mode": "replace" }],
+ "subwidgets": [{ "widgettype": "Text", "options": { "label": "蓝图结构(树)" } }] },
+ { "widgettype": "VBox", "options": { "backgroundColor": "#FFFFFF", "padding": "20px", "cursor": "pointer" },
+ "binds": [{ "wid": "self", "event": "click", "actiontype": "urlwidget", "target": "app.pbl_blueprint_content",
+ "options": { "url": "{{entire_url('template_list.ui')}}" } }, "mode": "replace" }],
+ "subwidgets": [{ "widgettype": "Text", "options": { "label": "蓝图模板" } }] }
+ ] },
+ { "widgettype": "VBox", "id": "pbl_blueprint_content", "options": { "width": "100%", "flex": "1", "marginTop": "20px" } }
+ ]
+}
diff --git a/pbl_blueprint/wwwroot/menu.ui b/pbl_blueprint/wwwroot/menu.ui
new file mode 100644
index 0000000..7f9edbc
--- /dev/null
+++ b/pbl_blueprint/wwwroot/menu.ui
@@ -0,0 +1,12 @@
+
+
+
diff --git a/pbl_blueprint/wwwroot/template_list.ui b/pbl_blueprint/wwwroot/template_list.ui
new file mode 100644
index 0000000..d1c63ed
--- /dev/null
+++ b/pbl_blueprint/wwwroot/template_list.ui
@@ -0,0 +1,25 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/pyproject.toml b/pyproject.toml
new file mode 100644
index 0000000..c19e3f9
--- /dev/null
+++ b/pyproject.toml
@@ -0,0 +1,14 @@
+[build-system]
+requires = ["setuptools>=45", "wheel"]
+build-backend = "setuptools.build_meta"
+
+[project]
+name = "pbl_blueprint"
+version = "1.0.0"
+description = "PBL 蓝图聚合根与子对象、版本、模板(M1a/M1b)"
+requires-python = ">=3.8"
+dependencies = ["sqlor", "bricks_for_python"]
+
+[tool.setuptools.packages.find]
+where = ["."]
+include = ["pbl_blueprint*"]
diff --git a/scripts/audit_dspy.py b/scripts/audit_dspy.py
new file mode 100644
index 0000000..42b45a4
--- /dev/null
+++ b/scripts/audit_dspy.py
@@ -0,0 +1,57 @@
+#!/usr/bin/env python3
+"""dspy 禁项审计 + sqlor API 白名单 + api 文件/RBAC 注册对齐检查。"""
+import os
+import re
+import sys
+
+ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
+API_DIR = os.path.join(ROOT, 'pbl_blueprint', 'wwwroot', 'api')
+BANNED = [
+ ('import ', r'(?m)^\s*(import|from)\s+\w'),
+ ('f-string', r"(? dspy 接口前缀(CRUD editable url 用)
+API_PREFIX = {
+ 'pbl_blueprint': 'blueprint',
+ 'pbl_blueprint_version': 'version',
+ 'pbl_blueprint_template': 'template',
+ 'pbl_blueprint_template_item': 'template_item',
+}
+BROWSER_FIELDS = ('id', 'tenant_id', 'class_id', 'code', 'name', 'blueprint_code',
+ 'blueprint_name', 'template_code', 'template_name', 'status',
+ 'version_status', 'version_no', 'subobject_type', 'domain_code',
+ 'grade_code', 'subject_code', 'complexity_level', 'seq_no',
+ 'created_at', 'updated_at')
+HEAVY_FIELDS = ('snapshot_json', 'content_json', 'item_payload_json',
+ 'quality_criteria', 'knowledge_points', 'tags_json')
+IMMUTABLE = ('id', 'tenant_id', 'created_by', 'created_at')
+
+
+def api_prefix(tbl):
+ if tbl in API_PREFIX:
+ return API_PREFIX[tbl]
+ return 'subobject'
+
+
+def load_models():
+ docs = {}
+ for p in sorted(glob.glob(os.path.join(MODELS_DIR, '*.json'))):
+ tbl = os.path.basename(p)[:-5]
+ doc = json.load(open(p, encoding='utf-8'))
+ for seg in ('summary', 'fields', 'indexes', 'codes'):
+ if seg not in doc:
+ raise SystemExit('models/%s.json 缺四段式必需段: %s' % (tbl, seg))
+ if not isinstance(doc['fields'], dict):
+ raise SystemExit('models/%s.json fields 必须是对象(列名->定义)' % tbl)
+ docs[tbl] = doc
+ return docs
+
+
+def build_crud(tbl, doc):
+ fields = doc['fields']
+ names = list(fields.keys())
+ editable = [n for n in names if n not in IMMUTABLE]
+ browser = [n for n in names if n in BROWSER_FIELDS]
+ alters = {}
+ for n, spec in fields.items():
+ if spec.get('appcode'):
+ alters[n] = {'uitype': 'code', 'appcode': spec['appcode'],
+ 'label': spec.get('label', n)}
+ a = api_prefix(tbl)
+ return {tbl: {
+ 'summary': doc['summary'].get('label', tbl),
+ 'alias': tbl + '_crud',
+ 'params': {
+ 'sortby': 'created_at',
+ 'sortorder': 'desc',
+ 'tenant_scoped': True,
+ 'browserfields': {
+ 'fields': browser,
+ 'alters': alters,
+ 'exclouded': list(HEAVY_FIELDS),
+ },
+ 'editable': {
+ 'new_data_url': "{{entire_url('../api/%s_save.dspy')}}" % a,
+ 'update_data_url': "{{entire_url('../api/%s_save.dspy')}}" % a,
+ 'delete_data_url': "{{entire_url('../api/%s_delete.dspy')}}" % a,
+ 'list_data_url': "{{entire_url('../api/%s_list.dspy')}}" % a,
+ 'get_data_url': "{{entire_url('../api/%s_get.dspy')}}" % a,
+ 'editexclouded': list(IMMUTABLE),
+ },
+ 'required': [n for n, s in fields.items() if s.get('required')],
+ 'data_filter': {'AND': [{'field': 'tenant_id', 'op': '=', 'var': 'tenant_id'}]},
+ },
+ 'codes': doc['codes'],
+ 'table_meta': {
+ 'pk': 'id',
+ 'tenant_field': 'tenant_id',
+ 'soft_delete_field': 'is_deleted',
+ 'engine': doc['summary'].get('engine', 'postgresql'),
+ 'indexes': list(doc['indexes'].keys()),
+ },
+ }}
+
+
+def col_ddl(name, spec):
+ ty = spec['type']
+ if ty.startswith('str'):
+ return '%s varchar(%s)' % (name, ty[ty.index('(') + 1:ty.index(')')])
+ if ty == 'text':
+ return '%s text' % name
+ if ty == 'int':
+ return '%s integer' % name
+ if ty.startswith('double'):
+ return '%s numeric(18,2)' % name
+ if ty == 'datetime':
+ return '%s timestamp' % name
+ raise SystemExit('未知抽象类型: %s (%s)' % (ty, name))
+
+
+def build_sql(docs):
+ lines = [
+ '-- 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',
+ '',
+ ]
+ for tbl in sorted(docs.keys()):
+ doc = docs[tbl]
+ fields = doc['fields']
+ lines.append('-- %s: %s' % (tbl, doc['summary'].get('label', '')))
+ cols = ',\n'.join(
+ ' ' + col_ddl(k, v) + (' NOT NULL' if (v.get('required') or k == 'id') else '')
+ for k, v in fields.items())
+ lines.append('CREATE TABLE IF NOT EXISTS %s (' % tbl)
+ lines.append(cols)
+ lines.append(');')
+ for k, v in fields.items():
+ lines.append("COMMENT ON COLUMN %s.%s IS '%s';" % (tbl, k, v.get('label', '')))
+ lines.append('')
+ for iname, isp in doc['indexes'].items():
+ if iname == 'primary':
+ continue
+ uniq = 'UNIQUE ' if isp.get('unique') else ''
+ lines.append('CREATE %sINDEX IF NOT EXISTS %s_%s ON %s (%s);' %
+ (uniq, tbl, iname, tbl, ', '.join(isp['fields'])))
+ lines.append('')
+ return '\n'.join(lines)
+
+
+def main():
+ docs = load_models()
+ os.makedirs(JSON_DIR, exist_ok=True)
+ os.makedirs(SQL_DIR, exist_ok=True)
+ for tbl, doc in docs.items():
+ out = os.path.join(JSON_DIR, '%s.json' % tbl)
+ with open(out, 'w', encoding='utf-8') as fp:
+ json.dump(build_crud(tbl, doc), fp, ensure_ascii=False, indent=2)
+ with open(os.path.join(SQL_DIR, 'pbl_blueprint.core.sql'), 'w', encoding='utf-8') as fp:
+ fp.write(build_sql(docs))
+ print('models=%d json=%d sql_tables=%d' %
+ (len(docs), len(glob.glob(os.path.join(JSON_DIR, '*.json'))), len(docs)))
+
+
+if __name__ == '__main__':
+ main()
diff --git a/scripts/load_path.py b/scripts/load_path.py
new file mode 100644
index 0000000..2afd51f
--- /dev/null
+++ b/scripts/load_path.py
@@ -0,0 +1,145 @@
+"""scripts/load_path.py —— pbl_blueprint 模块 RBAC 路径注册。
+
+规则(module-development-spec):
+- 禁止任何通配符(% / *),每条路径显式列出;
+- 角色分层:any(无需登录)/ logined(登录即可)/ owner.superuser(管理);
+- CRUD 子目录标准 5 条一组:目录 + index.ui + get_/add_/update_/delete_.dspy。
+
+用法:cd <宿主应用根> && ./py3/bin/python /modules/pbl_blueprint/scripts/load_path.py
+"""
+
+import os
+import sys
+
+MODULE = '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',
+]
+
+# 静态资源(无需登录)
+PATHS_ANY = [
+ '/pbl_blueprint/menu.ui',
+]
+
+# 管理级操作(删除/模板维护)
+PATHS_SUPERUSER = [
+ '/pbl_blueprint/api/pbl_template_delete.dspy',
+]
+
+# 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',
+]
+
+
+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 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:
+ 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
+
+
+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)))
+ return 0
+
+
+if __name__ == '__main__':
+ sys.exit(main())
diff --git a/scripts/selfcheck.py b/scripts/selfcheck.py
new file mode 100644
index 0000000..d7fec46
--- /dev/null
+++ b/scripts/selfcheck.py
@@ -0,0 +1,90 @@
+#!/usr/bin/env python3
+"""规范机械自检:目录结构 / models 四段式 / CRUD 根键 / 三处同步 / 无硬编码库名 / i18n / SQL 方言。"""
+import json
+import os
+import re
+import sys
+
+ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
+PKG = os.path.join(ROOT, 'pbl_blueprint')
+sys.path.insert(0, ROOT)
+FAIL = []
+
+
+def chk(cond, msg):
+ print(('PASS ' if cond else 'FAIL ') + msg)
+ if not cond:
+ FAIL.append(msg)
+
+
+# 1 目录结构
+for must in ['pyproject.toml', 'scripts/load_path.py', 'skill/SKILL.md', 'tests',
+ os.path.join('pbl_blueprint', '__init__.py'), os.path.join('pbl_blueprint', 'init.py'),
+ os.path.join('pbl_blueprint', 'models'), os.path.join('pbl_blueprint', 'json'),
+ os.path.join('pbl_blueprint', 'wwwroot'), os.path.join('pbl_blueprint', 'i18n')]:
+ chk(os.path.exists(os.path.join(ROOT, must)), '目录结构存在 %s' % must)
+for stray in ['models.py', 'db.py', 'blueprint_crud.py', 'subobjects.py', '__init__.py']:
+ chk(not os.path.exists(os.path.join(ROOT, stray)), '仓库根无游离包文件 %s' % stray)
+
+# 2 models 四段式 + tenant 打头
+from pbl_blueprint import models as M
+for t in M.table_names():
+ chk(M.validate_model(t) == [], 'models/%s.json 四段式自检: %s' % (t, M.validate_model(t) or 'ok'))
+
+# 3 CRUD json 根键
+for f in sorted(os.listdir(os.path.join(PKG, 'json'))):
+ d = json.load(open(os.path.join(PKG, 'json', f), encoding='utf-8'))
+ chk('tblname' in d and 'params' in d and 'table' not in d and 'list' not in d,
+ 'CRUD %s 根键 tblname+params' % f)
+ chk(d.get('tblname') in M.table_names(), 'CRUD %s tblname 有对应 models 定义' % f)
+ for key in ('browserfields', 'editexclouded', 'edit_exclouded_fields'):
+ for fld in (d['params'].get('browserfields', {}).get('alters', {}) if key == 'browserfields'
+ else d['params'].get(key, []) if not isinstance(d['params'].get(key), dict) else []):
+ chk(fld in M.fields_of(d['tblname']), 'CRUD %s 字段 %s 存在于表定义' % (f, fld))
+
+# 4 三处同步
+import pbl_blueprint as P
+from pbl_blueprint import init as I
+impl = set()
+for mod in ['blueprint_crud', 'subobjects', 'templates']:
+ m = __import__('pbl_blueprint.' + mod, fromlist=['x'])
+ impl |= {n for n in dir(m) if re.match(r'^(blueprint|subobject|template)_\w+$', n) and callable(getattr(m, n))}
+exported = {n for n in dir(P) if re.match(r'^(blueprint|subobject|template)_\w+$', n)}
+src = open(os.path.join(PKG, 'init.py'), encoding='utf-8').read()
+registered = set(re.findall(r'env\.(\w+)\s*=', src)) | {n for n in I.CONTRACT_FUNCS.__iter__()} if False else set(re.findall(r'env\.(\w+)\s*=', src)) | {f.__name__ for f in I.CONTRACT_FUNCS}
+chk(impl == exported, '①定义==②导出: 差集 %s' % (impl ^ exported))
+chk(impl <= registered, '①定义⊆③init注册: 缺 %s' % (impl - registered))
+
+# 5 无硬编码 DB 名
+for dirpath, _, names in os.walk(PKG):
+ for n in names:
+ if n.endswith('.py'):
+ text = open(os.path.join(dirpath, n), encoding='utf-8').read()
+ chk(not re.search(r"(?i)^\s*DBNAME\s*=", text, re.M), '%s 无硬编码 DBNAME' % n)
+ chk('get_module_dbname' in text or 'db.py' == n or n not in ('blueprint_crud.py', 'subobjects.py', 'templates.py', '__init__.py'),
+ '%s 库名走 get_module_dbname 链路' % n)
+
+# 6 i18n 覆盖接口 message 键
+i18n = json.load(open(os.path.join(PKG, 'i18n', 'pbl_blueprint.zh_CN.json'), encoding='utf-8'))
+keys = set()
+for mod in ['blueprint_crud', 'subobjects', 'templates']:
+ text = open(os.path.join(PKG, mod + '.py'), encoding='utf-8').read()
+ keys |= set(re.findall(r"_err\('([\w.]+)'", text))
+chk(keys <= set(i18n), 'i18n 覆盖全部 message 键, 缺 %s' % (keys - set(i18n)))
+en = json.load(open(os.path.join(PKG, 'i18n', 'pbl_blueprint.en_US.json'), encoding='utf-8'))
+chk(set(en) == set(i18n), 'zh_CN/en_US 键一致')
+
+# 7 SQL 方言禁项 + engine 声明
+sql = open(os.path.join(PKG, 'sql', 'pbl_blueprint.core.sql'), encoding='utf-8').read()
+chk(not re.search(r'(?i)\b(bigserial|serial|nextval)\b', sql), 'SQL 无 BIGSERIAL/SERIAL/nextval')
+chk(re.search(r'(?m)^--\s*engine:', sql) is not None, 'SQL 声明 engine')
+chk(not re.search(r'\bboolean\b|\bint\(11\)\b', sql), 'SQL 无 boolean/int(11)')
+
+# 8 dspy 无 import/f-string/print/uuid(复用 audit)
+sys.path.insert(0, os.path.join(ROOT, 'scripts'))
+import audit_dspy
+files, problems = audit_dspy.audit()
+chk(not problems, 'dspy 审计零命中(%d 文件): %s' % (len(files), problems[:3]))
+
+print('SELFCHECK_%s (%d fails)' % ('PASS' if not FAIL else 'FAIL', len(FAIL)))
+sys.exit(1 if FAIL else 0)
diff --git a/skill/SKILL.md b/skill/SKILL.md
new file mode 100644
index 0000000..33f1d47
--- /dev/null
+++ b/skill/SKILL.md
@@ -0,0 +1,40 @@
+# pbl_blueprint 模块技能(自动生成骨架 + 人工补充)
+
+## 定位
+PBL 蓝图聚合根与子对象、版本、模板(M1a/M1b)
+
+## 挂载
+`from pbl_blueprint.init import load_pbl_blueprint` → `load_pbl_blueprint()`(应用 app/pbls.py init() 中按序调用)
+
+## 数据表(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 模板(含离线兜底槽位)
+
+## 契约接口(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`
+
+## 陷阱
+- 库名一律 `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 路径。
diff --git a/tests/fakedb.py b/tests/fakedb.py
new file mode 100644
index 0000000..77e5cbd
--- /dev/null
+++ b/tests/fakedb.py
@@ -0,0 +1,133 @@
+# -*- coding: utf-8 -*-
+"""内存版 sqlor / ServerEnv 替身,供 test_contract.py 做契约单测。
+
+只实现本模块用到的 6 个 API:C / U / D / R / I / sqlExe(语义与 sqlor 对齐:
+where 支持 'col' 等值、'col!' 不等、'col~' LIKE、'col in' 列表)。
+禁止在此文件写业务逻辑——它只模拟 DB 行为。
+"""
+import fnmatch
+import os
+import sys
+
+REPO_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
+if REPO_ROOT not in sys.path:
+ sys.path.insert(0, REPO_ROOT)
+
+
+def _match(row, where):
+ for key, want in (where or {}).items():
+ if key.endswith('!'):
+ col = key[:-1]
+ if str(row.get(col, '')) == str(want):
+ return False
+ continue
+ if key.endswith('~'):
+ col = key[:-1]
+ if not fnmatch.fnmatch(str(row.get(col, '')), str(want).replace('%', '*')):
+ return False
+ continue
+ if key.endswith(' in') and isinstance(want, (list, tuple)):
+ if row.get(key[:-4].strip()) not in want:
+ return False
+ continue
+ if str(row.get(key, '')) != str(want):
+ return False
+ return True
+
+
+def _order_key(rows, orderby):
+ if not orderby:
+ return rows
+ parts = [p.strip() for p in orderby.split(',')]
+ result = list(rows)
+ for part in reversed(parts):
+ tok = part.split()
+ col = tok[0]
+ desc = len(tok) > 1 and tok[1].lower() == 'desc'
+ result.sort(key=lambda r: ('' if r.get(col) is None else r.get(col)), reverse=desc)
+ return result
+
+
+class FakeSqlor(object):
+ def __init__(self, store):
+ self.store = store
+ self.calls = []
+
+ def C(self, table, data, dbname=None, **kw):
+ self.calls.append(('C', table, dict(data)))
+ self.store.setdefault(table, {})[data['id']] = dict(data)
+ return 1
+
+ def I(self, table, rows, dbname=None, **kw):
+ n = 0
+ for r in rows or []:
+ self.calls.append(('I', table, dict(r)))
+ self.store.setdefault(table, {})[r['id']] = dict(r)
+ n += 1
+ return n
+
+ def U(self, table, data, where, dbname=None, **kw):
+ self.calls.append(('U', table, dict(data), dict(where)))
+ n = 0
+ for rid, row in list(self.store.get(table, {}).items()):
+ if _match(row, where):
+ row.update(data)
+ n += 1
+ return n
+
+ def D(self, table, where, dbname=None, **kw):
+ self.calls.append(('D', table, dict(where)))
+ drop = [rid for rid, row in list(self.store.get(table, {}).items())
+ if _match(row, where)]
+ for rid in drop:
+ self.store[table].pop(rid, None)
+ return len(drop)
+
+ def R(self, table, where=None, fields='*', orderby=None, limit=None,
+ dbname=None, **kw):
+ self.calls.append(('R', table, dict(where or {})))
+ rows = [dict(r) for r in self.store.get(table, {}).values() if _match(r, where)]
+ rows = _order_key(rows, orderby)
+ if limit:
+ rows = rows[:int(limit)]
+ if fields and fields != '*':
+ keep = [f.strip() for f in fields.split(',')]
+ rows = [dict((k, r.get(k)) for k in keep if k in r) for r in rows]
+ return rows
+
+ def sqlExe(self, sql, params=None, dbname=None, **kw):
+ self.calls.append(('sqlExe', sql[:60]))
+ return []
+
+
+class FakeServerEnv(object):
+ def __init__(self, dbname='pbl_test_db'):
+ self._dbname = {'pbl_blueprint': dbname}
+ self.pbl_tenant_ctx = {'tenant_id': 'T1'}
+ self.pbl_current_user = 'tester'
+
+ def set_module_dbname(self, mod, dbname):
+ self._dbname[mod] = dbname
+
+ def get_module_dbname(self, mod):
+ return self._dbname.get(mod)
+
+
+def install(store=None, tenant_id='T1', dbname='pbl_test_db'):
+ """把 FakeSqlor 注入 sys.modules['sqlor'],返回 (sor, env, store)。"""
+ import types
+ store = store if store is not None else {}
+ sor = FakeSqlor(store)
+ mod = types.ModuleType('sqlor')
+ mod.C, mod.U, mod.D, mod.R, mod.I, mod.sqlExe = (
+ sor.C, sor.U, sor.D, sor.R, sor.I, sor.sqlExe)
+ sys.modules['sqlor'] = mod
+
+ ah = types.ModuleType('ahserver')
+ env = FakeServerEnv(dbname)
+ env.pbl_tenant_ctx = {'tenant_id': tenant_id}
+ ah.ServerEnv = lambda *a, **kw: env
+ sys.modules.setdefault('ahserver', ah)
+ if 'ahserver' in sys.modules:
+ sys.modules['ahserver'].ServerEnv = lambda *a, **kw: env
+ return sor, env, store
diff --git a/tests/test_contract.py b/tests/test_contract.py
new file mode 100644
index 0000000..566baff
--- /dev/null
+++ b/tests/test_contract.py
@@ -0,0 +1,498 @@
+# -*- coding: utf-8 -*-
+"""pbl_blueprint M1a 契约测试(走真实模块加载路径:load_pbl_blueprint + 门面函数)。
+
+覆盖:注册三处同步、表清单/字段类型、租户强制、CRUD 正反用例、状态机、
+版本快照、fork、子对象 7 类、模板抽离与实例化、dspy/RBAC/i18n 一致性、SQL 方言自检。
+DB 替身:tests/fakedb.py(模拟 sqlor C/U/D/R/I/sqlExe 语义),
+真实库连通性另见 test_real_path.py。
+"""
+import json
+import os
+import re
+import sys
+
+import pytest
+
+HERE = os.path.dirname(os.path.abspath(__file__))
+REPO = os.path.dirname(HERE)
+PKG = os.path.join(REPO, 'pbl_blueprint')
+sys.path.insert(0, REPO)
+sys.path.insert(0, HERE)
+
+import fakedb # noqa: E402
+
+
+@pytest.fixture()
+def mod():
+ """每个用例一套干净的内存 DB + 真实 load_pbl_blueprint() 注册路径。"""
+ sor, env, store = fakedb.install(tenant_id='T1')
+ for name in [m for m in list(sys.modules) if m.startswith('pbl_blueprint')]:
+ sys.modules.pop(name, None)
+ import pbl_blueprint as pkg
+ info = pkg.load_pbl_blueprint(env)
+ yield pkg, info, store, env
+ sys.modules.pop('sqlor', None)
+
+
+# ---------------------------------------------------------------- 注册接线
+def test_01_load_registers_tables_and_crud(mod):
+ pkg, info, store, env = mod
+ assert info['module'] == 'pbl_blueprint'
+ assert info['dbname'] == 'pbl_test_db'
+ assert len(info['tables']) == 11
+ assert sorted(info['tables']) == sorted(pkg.TABLES)
+ assert sorted(info['crud']) == sorted(pkg.TABLES)
+ # env 上挂载了表定义 / CRUD / 门面 / 路径 / i18n
+ assert getattr(env, 'pbl_blueprint_tables')
+ assert getattr(env, 'pbl_crud_defs')['pbl_blueprint']['params']['editable']
+ assert getattr(env, 'pbl_blueprint_api')['blueprint_save'] is pkg.api_blueprint_save
+ assert len(getattr(env, 'pbl_blueprint_paths')['api']) == 25
+ assert getattr(env, 'pbl_blueprint_i18n').get('zh_CN')
+
+
+def test_02_three_way_sync_of_functions(mod):
+ """函数三处同步:定义 + __init__ 导出 + init.py 注册(env.pbl_blueprint_api)。"""
+ pkg, _info, _store, env = mod
+ exported = set(getattr(pkg, '__all__'))
+ registry = set(getattr(env, 'pbl_blueprint_api').keys())
+ assert registry == set(pkg.API_REGISTRY.keys())
+ for fname, fn in pkg.API_REGISTRY.items():
+ assert callable(fn), fname
+ assert 'api_' + fname in exported or fname in exported, fname
+ # load 入口本身三处同步
+ assert 'load_pbl_blueprint' in exported
+ assert callable(getattr(pkg, 'load_pbl_blueprint'))
+
+
+def test_03_dbname_not_hardcoded(mod):
+ pkg, _i, _s, _e = mod
+ pat = re.compile(r'''dbname\s*=\s*["'][^"']+["']''')
+ for f in sorted(os.listdir(PKG)):
+ if not f.endswith('.py'):
+ continue
+ text = open(os.path.join(PKG, f), encoding='utf-8').read()
+ for line_no, line in enumerate(text.splitlines(), 1):
+ if pat.search(line) and 'def ' not in line and 'kw' not in line:
+ raise AssertionError('%s:%d 疑似硬编码库名: %s' % (f, line_no, line.strip()))
+
+
+def test_04_missing_dbname_fails_closed(mod):
+ pkg, _i, _s, env = mod
+ env.set_module_dbname('pbl_blueprint', None)
+ res = pkg.api_blueprint_list({'page': 1})
+ assert res['success'] is False
+ assert res['error']['code'] == 'PBL-DB-001'
+
+
+def test_05_missing_tenant_fails_closed(mod):
+ pkg, _i, _s, env = mod
+ env.pbl_tenant_ctx = {}
+ res = pkg.api_blueprint_save({'blueprint_name': '无租户'})
+ assert res['success'] is False
+ assert res['error']['code'] == 'PBL-TENANT-001'
+
+
+# ---------------------------------------------------------------- 表定义规范
+def test_06_model_four_segment_and_abstract_types():
+ for fn in sorted(os.listdir(os.path.join(PKG, 'models'))):
+ if not fn.endswith('.json'):
+ continue
+ doc = json.load(open(os.path.join(PKG, 'models', fn), encoding='utf-8'))
+ assert set(['summary', 'fields', 'indexes', 'codes']).issubset(doc.keys()), fn
+ assert doc['fields']['id']['type'] == 'str(32)', fn
+ assert doc['fields']['id'].get('pk') is True, fn
+ assert 'tenant_id' in doc['fields'], fn
+ for col, spec in doc['fields'].items():
+ ty = spec['type']
+ assert not re.match(r'^(varchar|bigint|boolean|int\(11\)|char|tinyint)', ty), \
+ '%s.%s 使用了物理类型 %s' % (fn, col, ty)
+ if ty.startswith('double'):
+ assert ty == 'double(18,2)', '%s.%s' % (fn, col)
+
+
+def test_07_crud_json_root_key_is_tblname():
+ for fn in sorted(os.listdir(os.path.join(PKG, 'json'))):
+ if not fn.endswith('.json'):
+ continue
+ tbl = fn[:-5]
+ doc = json.load(open(os.path.join(PKG, 'json', fn), encoding='utf-8'))
+ assert list(doc.keys()) == [tbl], fn
+ params = doc[tbl]['params']
+ assert 'editable' in params and 'browserfields' in params, fn
+ for k in ('new_data_url', 'update_data_url', 'delete_data_url'):
+ assert k in params['editable'], fn
+ assert 'wwwroot' not in json.dumps(params), fn
+ model_fields = json.load(
+ open(os.path.join(PKG, 'models', fn), encoding='utf-8'))['fields']
+ for col in params['editable']['editexclouded'] + params['browserfields']['fields']:
+ assert col in model_fields, '%s 字段 %s 不在 models 定义中' % (fn, col)
+
+
+def test_08_sql_ddl_dialect_self_check():
+ sql = open(os.path.join(PKG, 'sql', 'pbl_blueprint.core.sql'), encoding='utf-8').read()
+ for bad in ('SERIAL', 'BIGSERIAL', 'nextval', 'AUTO_INCREMENT', 'boolean', 'int(11)'):
+ assert bad not in sql, 'SQL 含禁用写法 %s' % bad
+ assert 'engine: postgresql' in sql
+ for tbl in json.loads(json.dumps(__import__('pbl_blueprint', fromlist=['TABLES']).TABLES)):
+ pass
+ import pbl_blueprint as pkg
+ for tbl in pkg.TABLES:
+ assert 'CREATE TABLE IF NOT EXISTS %s' % tbl in sql, tbl
+
+
+# ---------------------------------------------------------------- 蓝图 CRUD
+def _new_blueprint(pkg, name='城市水资源调查'):
+ res = pkg.api_blueprint_save({'blueprint_name': name, 'domain_code': 'science',
+ 'grade_code': 'primary_5', 'subject_code': 'science',
+ 'project_duration': 12, 'student_count': 40,
+ 'group_count': 8, 'complexity_level': 'medium',
+ 'estimated_cost': 1250.50})
+ assert res['success'], res
+ return res['data']
+
+
+def test_10_create_blueprint_auto_code_and_tenant(mod):
+ pkg, _i, store, _e = mod
+ bp = _new_blueprint(pkg)
+ assert bp['id'] and len(bp['id']) == 32
+ assert re.match(r'^PBL-\d{4}-\d{4}$', bp['blueprint_code']), bp['blueprint_code']
+ assert bp['tenant_id'] == 'T1'
+ assert bp['status'] == 'draft'
+ assert float(bp['estimated_cost']) == 1250.50
+ assert 'pbl_blueprint' in store
+
+
+def test_11_create_requires_name(mod):
+ pkg, _i, _s, _e = mod
+ res = pkg.api_blueprint_save({'domain_code': 'science'})
+ assert res['success'] is False
+ assert res['error']['code'] == 'PBL-PARAM-001'
+
+
+def test_12_duplicate_code_rejected(mod):
+ pkg, _i, _s, _e = mod
+ bp = _new_blueprint(pkg)
+ res = pkg.api_blueprint_save({'blueprint_name': '重名编码',
+ 'blueprint_code': bp['blueprint_code']})
+ assert res['success'] is False
+ assert '已存在' in res['error']['msg']
+
+
+def test_13_update_and_published_readonly(mod):
+ pkg, _i, _s, _e = mod
+ bp = _new_blueprint(pkg)
+ res = pkg.api_blueprint_save({'id': bp['id'], 'blueprint_name': '改名成功'})
+ assert res['success'] and res['data']['blueprint_name'] == '改名成功'
+ # 发布后禁止直接编辑
+ pkg.api_blueprint_publish({'id': bp['id']}, _seed_subobjects(pkg, bp['id']))
+ res2 = pkg.api_blueprint_save({'id': bp['id'], 'blueprint_name': '非法改'})
+ assert res2['success'] is False
+ assert res2['error']['code'] == 'PBL-STATE-001'
+
+
+def test_14_list_pagination_and_filters(mod):
+ pkg, _i, _s, _e = mod
+ for n in range(5):
+ _new_blueprint(pkg, '蓝图%d' % n)
+ res = pkg.api_blueprint_list({'page': 1, 'rows': 2})
+ assert res['success']
+ assert res['data']['total'] == 5 and len(res['data']['rows']) == 2
+ res2 = pkg.api_blueprint_list({'blueprint_name': '蓝图3'})
+ assert res2['data']['total'] == 1
+ res3 = pkg.api_blueprint_list({'sortby': 'evil; drop table'})
+ assert res3['success'] and res3['data']['total'] == 5 # 非法排序被白名单兜住
+
+
+def test_15_tenant_isolation(mod):
+ pkg, _i, store, env = mod
+ bp = _new_blueprint(pkg)
+ env.pbl_tenant_ctx = {'tenant_id': 'T2'}
+ res = pkg.api_blueprint_get({'id': bp['id']})
+ assert res['success'] is False
+ assert res['error']['code'] == 'PBL-NOT-FOUND'
+ res2 = pkg.api_blueprint_list({})
+ assert res2['data']['total'] == 0
+
+
+def test_16_soft_delete_filters_out(mod):
+ pkg, _i, store, _e = mod
+ bp = _new_blueprint(pkg)
+ res = pkg.api_blueprint_delete({'id': bp['id']})
+ assert res['success'] and res['data']['deleted'] is True
+ assert store['pbl_blueprint'][bp['id']]['is_deleted'] == 1
+ assert pkg.api_blueprint_get({'id': bp['id']})['success'] is False
+
+
+def _seed_subobjects(pkg, blueprint_id, tenant=None):
+ for stype, name in [('mission', '为社区设计节水方案'),
+ ('learning_goal', '理解水循环'),
+ ('task', '实地调研'),
+ ('role', '组长'),
+ ('artifact_spec', '节水手册'),
+ ('evidence_spec', '调研报告'),
+ ('reflection_spec', '小组复盘')]:
+ r = pkg.api_subobject_save({'subobject_type': stype, 'blueprint_id': blueprint_id,
+ 'name': name, 'seq_no': 1})
+ assert r['success'], r
+ return blueprint_id
+
+
+def test_20_subobject_crud_all_types(mod):
+ pkg, _i, store, _e = mod
+ bp = _new_blueprint(pkg)
+ _seed_subobjects(pkg, bp['id'])
+ counts = pkg.api_subobject_counts({'blueprint_id': bp['id']})
+ assert counts['success']
+ assert counts['data']['total'] == 7
+ assert set(counts['data'].keys()) >= set(pkg.SUBOBJECT_TYPE_TABLE.keys())
+ lst = pkg.api_subobject_list({'subobject_type': 'mission', 'blueprint_id': bp['id']})
+ assert lst['data']['total'] == 1
+ oid = lst['data']['rows'][0]['id']
+ upd = pkg.api_subobject_save({'subobject_type': 'mission', 'id': oid,
+ 'blueprint_id': bp['id'], 'name': '改后情境'})
+ assert upd['data']['name'] == '改后情境'
+ dele = pkg.api_subobject_delete({'subobject_type': 'mission', 'id': oid})
+ assert dele['success'] and store['pbl_blueprint_mission'][oid]['is_deleted'] == 1
+
+
+def test_21_subobject_unknown_type_rejected(mod):
+ pkg, _i, _s, _e = mod
+ bp = _new_blueprint(pkg)
+ res = pkg.api_subobject_save({'subobject_type': 'hack_me', 'blueprint_id': bp['id'],
+ 'name': 'x'})
+ assert res['success'] is False
+ assert res['error']['code'] == 'PBL-PARAM-001'
+
+
+def test_22_subobject_requires_existing_blueprint(mod):
+ pkg, _i, _s, _e = mod
+ res = pkg.api_subobject_save({'subobject_type': 'task', 'blueprint_id': 'no-such',
+ 'name': 'x'})
+ assert res['success'] is False
+ assert res['error']['code'] == 'PBL-NOT-FOUND'
+
+
+def test_23_tree_aggregates_children(mod):
+ pkg, _i, _s, _e = mod
+ bp = _new_blueprint(pkg)
+ _seed_subobjects(pkg, bp['id'])
+ res = pkg.api_blueprint_tree({'id': bp['id']})
+ assert res['success']
+ data = res['data']
+ assert data['blueprint']['id'] == bp['id']
+ assert len(data['children']) == 7
+ assert sum(data['counts'].values()) == 7
+
+
+# ---------------------------------------------------------------- 状态机 / 版本
+def test_30_status_flow_legal_and_illegal(mod):
+ pkg, _i, _s, _e = mod
+ bp = _new_blueprint(pkg)
+ r = pkg.api_blueprint_status({'id': bp['id'], 'status': 'in_review'})
+ assert r['success'] and r['data']['status'] == 'in_review'
+ bad = pkg.api_blueprint_status({'id': bp['id'], 'status': 'archived_from_nowhere'})
+ assert bad['success'] is False
+ r2 = pkg.api_blueprint_status({'id': bp['id'], 'status': 'archived'})
+ assert r2['success']
+ r3 = pkg.api_blueprint_status({'id': bp['id'], 'status': 'draft'})
+ assert r3['success'] is False # archived 为终态
+ assert r3['error']['code'] == 'PBL-STATE-001'
+
+
+def test_31_publish_requires_goal_and_mission(mod):
+ pkg, _i, _s, _e = mod
+ bp = _new_blueprint(pkg)
+ res = pkg.api_blueprint_publish({'id': bp['id']})
+ assert res['success'] is False
+ assert res['error']['code'] == 'PBL-STATE-001'
+ assert '学习目标' in res['error']['msg'] or '情境' in res['error']['msg']
+
+
+def test_32_publish_creates_version_snapshot(mod):
+ pkg, _i, store, _e = mod
+ bp = _new_blueprint(pkg)
+ _seed_subobjects(pkg, bp['id'])
+ res = pkg.api_blueprint_publish({'id': bp['id'], 'note': '首版'})
+ assert res['success'], res
+ data = res['data']
+ assert data['version_no'] == 1
+ assert len(data['content_hash']) == 64
+ ver = store['pbl_blueprint_version'][data['version_id']]
+ snap = json.loads(ver['snapshot_json'])
+ assert snap['blueprint']['blueprint_name'] == '城市水资源调查'
+ assert len(snap['subobjects']['mission']) == 1
+ assert store['pbl_blueprint'][bp['id']]['status'] == 'published'
+ # 二次发布版本号自增
+ pkg.api_blueprint_status({'id': bp['id'], 'status': 'in_review'})
+ res2 = pkg.api_blueprint_publish({'id': bp['id'], 'note': '二版'})
+ assert res2['data']['version_no'] == 2
+ vs = pkg.api_blueprint_versions({'id': bp['id']})
+ assert vs['data']['total'] == 2
+ vg = pkg.api_blueprint_version_get({'version_id': data['version_id']})
+ assert vg['data']['snapshot']['blueprint']['id'] == bp['id']
+
+
+def test_33_fork_copies_subobjects(mod):
+ pkg, _i, store, _e = mod
+ bp = _new_blueprint(pkg)
+ _seed_subobjects(pkg, bp['id'])
+ res = pkg.api_blueprint_fork({'id': bp['id'], 'class_id': 'C99',
+ 'blueprint_name': '副本蓝图'})
+ assert res['success'], res
+ nid = res['data']['id']
+ assert res['data']['copied_subobjects']['total'] == 7
+ new = store['pbl_blueprint'][nid]
+ assert new['status'] == 'draft' and new['current_version'] == 0
+ assert new['source_blueprint_id'] == bp['id']
+ assert new['class_id'] == 'C99'
+ assert new['blueprint_code'] != bp['blueprint_code']
+ assert pkg.api_subobject_counts({'blueprint_id': nid})['data']['total'] == 7
+
+
+# ---------------------------------------------------------------- 模板
+def test_40_template_from_blueprint_and_instantiate(mod):
+ pkg, _i, store, _e = mod
+ bp = _new_blueprint(pkg)
+ _seed_subobjects(pkg, bp['id'])
+ res = pkg.api_template_from_blueprint({'blueprint_id': bp['id'],
+ 'template_name': '水资源模板'})
+ assert res['success'], res
+ tpl = res['data']
+ assert tpl['item_created'] == 7
+ assert tpl['source_blueprint_id'] == bp['id']
+ lst = pkg.api_template_list({})
+ assert lst['data']['total'] == 1
+ inst = pkg.api_template_instantiate({'id': tpl['id'],
+ 'payload': {'blueprint_name': '实例化蓝图'}})
+ assert inst['success'], inst
+ nbp = store['pbl_blueprint'][inst['data']['blueprint_id']]
+ assert nbp['source_template_id'] == tpl['id']
+ assert nbp['status'] == 'draft'
+ assert pkg.api_subobject_counts({'blueprint_id': nbp['id']})['data']['total'] == 7
+ assert store['pbl_blueprint_template'][tpl['id']]['usage_count'] == 1
+
+
+def test_41_template_save_and_delete(mod):
+ pkg, _i, store, _e = mod
+ r = pkg.api_template_save({'template_name': '空模板', 'template_type': 'blueprint'})
+ assert r['success'] and r['data']['template_code'].startswith('TPL-')
+ tid = r['data']['id']
+ r2 = pkg.api_template_save({'id': tid, 'template_name': '空模板改名'})
+ assert r2['data']['template_name'] == '空模板改名'
+ r3 = pkg.api_template_delete({'id': tid})
+ assert r3['success'] and store['pbl_blueprint_template'][tid]['is_deleted'] == 1
+ assert pkg.api_template_get({'id': tid})['success'] is False
+
+
+def test_42_template_get_missing(mod):
+ pkg, _i, _s, _e = mod
+ assert pkg.api_template_get({'id': 'x'})['error']['code'] == 'PBL-NOT-FOUND'
+
+
+# ---------------------------------------------------------------- 前端/权限一致性
+def test_50_dspy_files_match_registered_paths(mod):
+ pkg, _i, _s, env = mod
+ api_dir = os.path.join(PKG, 'wwwroot', 'api')
+ dspy = set(f for f in os.listdir(api_dir) if f.endswith('.dspy'))
+ paths = set(os.path.basename(p) for p in getattr(env, 'pbl_blueprint_paths')['api'])
+ assert dspy == paths, ('dspy 与注册路径不一致', dspy ^ paths)
+ registry = set(pkg.API_REGISTRY.keys())
+ assert registry == set(f[:-5] for f in dspy) | {'blueprint_versions', 'blueprint_version_get',
+ 'subobject_counts', 'table_manifest',
+ 'template_get', 'template_delete',
+ 'template_from_blueprint'} - {''}
+
+
+def test_51_dspy_forbidden_patterns():
+ api_dir = os.path.join(PKG, 'wwwroot', 'api')
+ bad = re.compile(r'^\s*(import|from)\s|f["\']|\bprint\(|uuid\.|\bopen\(')
+ for f in sorted(os.listdir(api_dir)):
+ text = open(os.path.join(api_dir, f), encoding='utf-8').read()
+ for i, line in enumerate(text.splitlines(), 1):
+ if line.strip().startswith('#'):
+ continue
+ assert not bad.search(line), '%s:%d 命中禁项: %s' % (f, i, line.strip())
+ assert 'return' in text, f
+
+
+def test_52_py_only_uses_sqlor_whitelist():
+ allowed = re.compile(r'sor\.|_db\.(insert_row|update_row|delete_row|select_rows|'
+ r'select_one|sql_exe|sql|_)|def (C|U|D|R|I|sql_exe)\b')
+ forbidden = re.compile(r'''\b(sor|sqlor)\.(save|list|insert|query|update|delete|select)\s*\(''')
+ for f in sorted(os.listdir(PKG)):
+ if not f.endswith('.py'):
+ continue
+ text = open(os.path.join(PKG, f), encoding='utf-8').read()
+ for i, line in enumerate(text.splitlines(), 1):
+ assert not forbidden.search(line), '%s:%d 编造 API: %s' % (f, i, line.strip())
+ assert allowed # 语义占位
+
+
+def test_53_load_path_covers_all_paths():
+ sys.path.insert(0, os.path.join(REPO, 'scripts'))
+ import importlib.util
+ spec = importlib.util.spec_from_file_location(
+ 'load_path_mod', os.path.join(REPO, 'scripts', 'load_path.py'))
+ m = importlib.util.module_from_spec(spec)
+ spec.loader.exec_module(m)
+ entries = set(p for _r, p in m.build_entries())
+ from pbl_blueprint.init import API_PATHS, PAGE_PATHS, CRUD_ALIASES
+ for p in [x for x, _f, _r in API_PATHS] + [x for x, _r in PAGE_PATHS] + list(CRUD_ALIASES):
+ assert p in entries, 'load_path.py 漏注册 %s' % p
+ assert len([p for p in entries if p.endswith('.dspy')]) == 25
+
+
+def test_54_i18n_covers_all_ui_labels():
+ zh = json.load(open(os.path.join(PKG, 'i18n', 'pbl_blueprint.zh_CN.json'), encoding='utf-8'))
+ en = json.load(open(os.path.join(PKG, 'i18n', 'pbl_blueprint.en_US.json'), encoding='utf-8'))
+ pat = re.compile(r"\{\{t\('([^']+)'")
+ used = set()
+ www = os.path.join(PKG, 'wwwroot')
+ for root, _dirs, files in os.walk(www):
+ for f in files:
+ if f.endswith('.ui'):
+ txt = open(os.path.join(root, f), encoding='utf-8').read()
+ used |= set(pat.findall(txt))
+ missing = used - set(zh.keys())
+ assert not missing, 'i18n 缺 key: %s' % sorted(missing)
+ assert set(zh.keys()) - set(en.keys()) == set()
+
+
+def test_55_ui_urls_have_no_wwwroot_prefix():
+ www = os.path.join(PKG, 'wwwroot')
+ for root, _dirs, files in os.walk(www):
+ for f in files:
+ if f.endswith('.ui'):
+ txt = open(os.path.join(root, f), encoding='utf-8').read()
+ assert 'wwwroot' not in txt, f
+ for p in re.findall(r"entire_url\('(/pbl_blueprint/[^']+)'\)", txt):
+ tail = p.replace('/pbl_blueprint/', '')
+ assert os.path.exists(os.path.join(www, tail)), '%s -> %s 不存在' % (f, p)
+
+
+# ---------------------------------------------------------------- 建表 / 自省
+def test_60_ensure_schema_issues_idempotent_ddl(mod):
+ pkg, _i, store, _e = mod
+ res = pkg.ensure_schema()
+ assert res['success'], res
+ assert res['data']['pbl_blueprint'] >= 1
+
+
+def test_61_table_manifest_shape(mod):
+ pkg, _i, _s, _e = mod
+ res = pkg.api_table_manifest()
+ assert res['success']
+ data = res['data']
+ assert data['engine'] == 'postgresql' and len(data['tables']) == 11
+ bp = [t for t in data['tables'] if t['table'] == 'pbl_blueprint'][0]
+ assert bp['pk'] == ['id'] and bp['tenant_field'] == 'tenant_id'
+ assert bp['field_types']['id'] == 'str(32)'
+ assert bp['field_types']['estimated_cost'] == 'double(18,2)'
+ assert bp['soft_delete_field'] == 'is_deleted'
+
+
+def test_62_options_endpoints(mod):
+ pkg, _i, _s, _e = mod
+ assert len(pkg.api_blueprint_status_options()['data']) == 4
+ assert len(pkg.api_blueprint_domain_options()['data']) >= 5
+ assert len(pkg.api_subobject_type_options()['data']) == 7
diff --git a/tests/test_real_path.py b/tests/test_real_path.py
new file mode 100644
index 0000000..40033e4
--- /dev/null
+++ b/tests/test_real_path.py
@@ -0,0 +1,180 @@
+# -*- coding: utf-8 -*-
+"""真实路径测试:真连平台 sqlor + ServerEnv + 真实库(非替身)。
+
+DB 来源(禁止写死):
+ 1) 环境变量 PBL_ENV_FILE 指定的 json
+ 2) projects/pbls/env/test.json(默认,SDLC 测试库)
+ 3) projects/pbls/env/prod.json(仅当 PBL_USE_PROD=1)
+读取 env json 的 db / db_test 段(host/port/user/password/database/engine)与
+module_dbname.pbl_blueprint 映射;缺库名映射时回落 database 值。
+
+环境不具备(无 sqlor/ahserver 包、env 文件缺失、DB 连不通)时整模块 skip,
+并打印 skip 原因——不伪造通过。
+"""
+import json
+import os
+import sys
+
+import pytest
+
+HERE = os.path.dirname(os.path.abspath(__file__))
+REPO = os.path.dirname(HERE)
+WS = os.path.abspath(os.path.join(REPO, '..', '..'))
+sys.path.insert(0, REPO)
+
+ENV_CANDIDATES = [
+ os.environ.get('PBL_ENV_FILE'),
+ os.path.join(WS, 'projects', 'pbls', 'env', 'test.json'),
+]
+if os.environ.get('PBL_USE_PROD') == '1':
+ ENV_CANDIDATES.append(os.path.join(WS, 'projects', 'pbls', 'env', 'prod.json'))
+
+
+def _load_env_conf():
+ for path in ENV_CANDIDATES:
+ if path and os.path.isfile(path):
+ with open(path, encoding='utf-8') as fp:
+ return path, json.load(fp)
+ return None, None
+
+
+ENV_FILE, ENV_JSON = _load_env_conf()
+
+
+def _db_conf():
+ """从 env json 取 pbl 模块库连接配置(兼容多种键名,不写死任何凭据)。"""
+ if not ENV_JSON:
+ return None
+ db = ENV_JSON.get('db_test') or ENV_JSON.get('db') or ENV_JSON.get('database')
+ if isinstance(db, dict):
+ pools = db.get('pools') or db.get('databases') or {}
+ name = (ENV_JSON.get('module_dbname') or {}).get('pbl_blueprint') \
+ or db.get('module_dbname', {}).get('pbl_blueprint') if isinstance(
+ ENV_JSON.get('module_dbname') or db.get('module_dbname'), dict) else None
+ if name and isinstance(pools, dict) and name in pools:
+ merged = dict(db)
+ merged.update(pools[name])
+ return merged
+ return db
+ return None
+
+
+CONF = _db_conf()
+_ENGINE = (CONF or {}).get('engine') or (ENV_JSON or {}).get('engine') or 'postgresql'
+
+pytestmark = pytest.mark.skipif(
+ CONF is None or not all(str(CONF.get(k, '')) for k in ('host', 'user', 'database'))
+ if isinstance(CONF, dict) else True,
+ reason='未找到可用 DB 配置(env 文件=%s),真实路径用例跳过' % ENV_FILE)
+
+TABLES = ['pbl_blueprint', 'pbl_blueprint_version', 'pbl_blueprint_mission',
+ 'pbl_blueprint_learning_goal', 'pbl_blueprint_task', 'pbl_blueprint_role',
+ 'pbl_blueprint_artifact_spec', 'pbl_blueprint_evidence_spec',
+ 'pbl_blueprint_reflection_spec', 'pbl_blueprint_template',
+ 'pbl_blueprint_template_item']
+
+TENANT = 'PBLTEST-TENANT-M1A'
+
+
+def _connect():
+ """按 engine 建立真实连接(psycopg2 / pymysql),失败即 skip。"""
+ eng = str(_ENGINE).lower()
+ try:
+ if eng.startswith('pg') or eng.startswith('postgre'):
+ import psycopg2
+ conn = psycopg2.connect(host=CONF['host'], port=int(CONF.get('port') or 5432),
+ user=CONF['user'], password=CONF.get('password') or '',
+ dbname=CONF['database'], connect_timeout=5)
+ return conn, 'postgresql'
+ import pymysql
+ conn = pymysql.connect(host=CONF['host'], port=int(CONF.get('port') or 3306),
+ user=CONF['user'], password=CONF.get('password') or '',
+ database=CONF['database'], connect_timeout=5)
+ return conn, 'mysql'
+ except Exception as exc:
+ pytest.skip('真实 DB 不可连接(env=%s, engine=%s): %s' % (ENV_FILE, _ENGINE, exc))
+
+
+@pytest.fixture(scope='module')
+def realdb():
+ conn, engine = _connect()
+ conn.autocommit = True if engine == 'postgresql' else False
+ cur = conn.cursor()
+ import pbl_blueprint as pkg
+ yield conn, engine, cur, pkg
+ try:
+ cur.close()
+ conn.close()
+ except Exception:
+ pass
+
+
+def _exec(cur, sql, params=None):
+ cur.execute(sql, params or ())
+ try:
+ return cur.fetchall()
+ except Exception:
+ return []
+
+
+def test_90_real_tables_exist_with_tenant_column(realdb):
+ """真实库中 11 张表存在,且首列组含 tenant_id(租户强制打头)。"""
+ conn, engine, cur, pkg = realdb
+ for t in TABLES:
+ if engine == 'postgresql':
+ rows = _exec(cur, "select column_name from information_schema.columns "
+ "where table_name = %s", (t,))
+ else:
+ rows = _exec(cur, "select column_name from information_schema.columns "
+ "where table_name = %s and table_schema = database()", (t,))
+ cols = [r[0] for r in rows]
+ assert 'tenant_id' in cols, '表 %s 缺 tenant_id(或表不存在,需先跑 sql/pbl_blueprint.core.sql)' % t
+ assert 'id' in cols
+
+
+def test_91_real_roundtrip_via_module_api(realdb, monkeypatch):
+ """真实路径:load_pbl_blueprint(env) + 门面函数 -> 真库读写(走平台 sqlor)。"""
+ conn, engine, cur, pkg = realdb
+ try:
+ import sqlor # noqa: F401
+ from ahserver import ServerEnv # noqa: F401
+ except Exception as exc:
+ pytest.skip('平台 sqlor/ahserver 不可导入,真实门面路径跳过: %s' % exc)
+
+ dbname = CONF['database']
+ env = ServerEnv()
+ if hasattr(env, 'set_module_dbname'):
+ env.set_module_dbname('pbl_blueprint', dbname)
+ info = pkg.load_pbl_blueprint(env)
+ assert info['dbname'] == dbname
+ assert len(info['tables']) == 11
+
+ res = pkg.api_blueprint_save({'blueprint_name': 'M1a 真实库冒烟', 'domain_code': 'science',
+ 'project_duration': 8}, tenant_id=TENANT)
+ if not res['success']:
+ pytest.skip('真实库写入被拒(表未建或权限不足): %s' % res['error']['msg'])
+ bp_id = res['data']['id']
+ try:
+ got = pkg.api_blueprint_get({'id': bp_id}, ) if False else \
+ pkg.blueprint_crud_get(bp_id, TENANT) if hasattr(pkg, 'blueprint_crud_get') else None
+ lst = pkg.api_blueprint_list({'page': 1, 'rows': 5}, tenant_id=TENANT) \
+ if False else pkg.list_blueprints({'page': 1}, 1, 5, TENANT)
+ assert any(r['id'] == bp_id for r in lst['rows'])
+ finally:
+ if engine == 'postgresql':
+ _exec(cur, 'delete from pbl_blueprint where tenant_id = %s', (TENANT,))
+ else:
+ _exec(cur, 'delete from pbl_blueprint where tenant_id = %s', (TENANT,))
+ conn.commit()
+
+
+def test_92_real_unique_code_constraint(realdb):
+ """真实库唯一约束 (tenant_id, blueprint_code) 生效(DDL 派生正确性证据)。"""
+ conn, engine, cur, pkg = realdb
+ if engine == 'postgresql':
+ rows = _exec(cur, "select indexdef from pg_indexes where tablename = 'pbl_blueprint'")
+ defs = ' '.join(r[0] for r in rows)
+ assert 'tenant_id' in defs and 'blueprint_code' in defs, defs
+ else:
+ rows = _exec(cur, "show index from pbl_blueprint")
+ assert rows