From 4f7bd7394ce04197caff98554d31e7b94e70dcaf Mon Sep 17 00:00:00 2001 From: pbls-bot Date: Tue, 15 Sep 2026 17:39:23 +0800 Subject: [PATCH] =?UTF-8?q?feat(pbl=5Fdomain=5Fext):=20=E5=88=9D=E5=A7=8B?= =?UTF-8?q?=E6=8F=90=E4=BA=A4=20-=20pbls=20=E9=A1=B9=E7=9B=AE=E6=A8=A1?= =?UTF-8?q?=E5=9D=97=E4=BB=A3=E7=A0=81=E5=85=A5=E5=BA=93?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 12 ++ json/domain_ext_pbl_class.json | 132 ++++++++++++ json/domain_ext_pbl_team.json | 146 +++++++++++++ json/domain_ext_pbl_tenant.json | 132 ++++++++++++ models/pbl_class.json | 107 +++++++++ models/pbl_team.json | 119 ++++++++++ models/pbl_tenant.json | 107 +++++++++ pbl_domain_ext/__init__.py | 27 +++ pbl_domain_ext/api.py | 139 ++++++++++++ pbl_domain_ext/init.py | 29 +++ pyproject.toml | 13 ++ scripts/load_path.py | 45 ++++ skill/SKILL.md | 26 +++ sql/pbl_domain_ext.sql | 51 +++++ wwwroot/api/pbl_class_list.dspy | 4 + wwwroot/api/pbl_class_save.dspy | 4 + ...bl_domain_materialize_game_definition.dspy | 4 + wwwroot/api/pbl_team_list.dspy | 4 + wwwroot/api/pbl_team_save.dspy | 4 + wwwroot/api/pbl_tenant_upsert.dspy | 4 + wwwroot/index.ui | 203 ++++++++++++++++++ 21 files changed, 1312 insertions(+) create mode 100644 .gitignore create mode 100644 json/domain_ext_pbl_class.json create mode 100644 json/domain_ext_pbl_team.json create mode 100644 json/domain_ext_pbl_tenant.json create mode 100644 models/pbl_class.json create mode 100644 models/pbl_team.json create mode 100644 models/pbl_tenant.json create mode 100644 pbl_domain_ext/__init__.py create mode 100644 pbl_domain_ext/api.py create mode 100644 pbl_domain_ext/init.py create mode 100644 pyproject.toml create mode 100644 scripts/load_path.py create mode 100644 skill/SKILL.md create mode 100644 sql/pbl_domain_ext.sql create mode 100644 wwwroot/api/pbl_class_list.dspy create mode 100644 wwwroot/api/pbl_class_save.dspy create mode 100644 wwwroot/api/pbl_domain_materialize_game_definition.dspy create mode 100644 wwwroot/api/pbl_team_list.dspy create mode 100644 wwwroot/api/pbl_team_save.dspy create mode 100644 wwwroot/api/pbl_tenant_upsert.dspy create mode 100644 wwwroot/index.ui diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..6579d96 --- /dev/null +++ b/.gitignore @@ -0,0 +1,12 @@ +__pycache__/ +*.pyc +*.pyo +.pytest_cache/ +.coverage +.DS_Store +*.log +node_modules/ +dist/ +build/ +.venv/ +venv/ diff --git a/json/domain_ext_pbl_class.json b/json/domain_ext_pbl_class.json new file mode 100644 index 0000000..4aa971c --- /dev/null +++ b/json/domain_ext_pbl_class.json @@ -0,0 +1,132 @@ +{ + "tblname": "pbl_class", + "alias": "pbl_domain_ext", + "browserfields": { + "id": { + "label": "ID", + "list": true, + "type": "number" + }, + "tenant_id": { + "label": "租户ID", + "list": true, + "type": "text" + }, + "class_uid": { + "label": "班级UID", + "type": "text", + "list": true + }, + "name": { + "label": "名称", + "type": "select", + "list": true + }, + "grade": { + "label": "年级", + "type": "select", + "list": true + }, + "teacher_id": { + "label": "教师", + "type": "text", + "list": true + }, + "student_count": { + "label": "学生数", + "type": "number", + "list": true + }, + "roster_json": { + "label": "花名册", + "type": "text", + "list": true + }, + "status": { + "label": "状态", + "type": "select", + "list": true + } + }, + "params": { + "browserfields": { + "id": { + "label": "ID", + "list": true, + "type": "number" + }, + "tenant_id": { + "label": "租户ID", + "list": true, + "type": "text" + }, + "class_uid": { + "label": "班级UID", + "type": "text", + "list": true + }, + "name": { + "label": "名称", + "type": "select", + "list": true + }, + "grade": { + "label": "年级", + "type": "select", + "list": true + }, + "teacher_id": { + "label": "教师", + "type": "text", + "list": true + }, + "student_count": { + "label": "学生数", + "type": "number", + "list": true + }, + "roster_json": { + "label": "花名册", + "type": "text", + "list": true + }, + "status": { + "label": "状态", + "type": "select", + "list": true + } + }, + "editable": { + "class_uid": { + "label": "班级UID", + "type": "text" + }, + "name": { + "label": "名称", + "type": "select" + }, + "grade": { + "label": "年级", + "type": "select" + }, + "teacher_id": { + "label": "教师", + "type": "text" + }, + "student_count": { + "label": "学生数", + "type": "number" + }, + "roster_json": { + "label": "花名册", + "type": "text" + }, + "status": { + "label": "状态", + "type": "select" + } + }, + "order_by": "id DESC", + "page_size": 20 + } +} diff --git a/json/domain_ext_pbl_team.json b/json/domain_ext_pbl_team.json new file mode 100644 index 0000000..3209aaa --- /dev/null +++ b/json/domain_ext_pbl_team.json @@ -0,0 +1,146 @@ +{ + "tblname": "pbl_team", + "alias": "pbl_domain_ext", + "browserfields": { + "id": { + "label": "ID", + "list": true, + "type": "number" + }, + "tenant_id": { + "label": "租户ID", + "list": true, + "type": "text" + }, + "team_uid": { + "label": "团队UID", + "type": "text", + "list": true + }, + "class_id": { + "label": "班级", + "type": "number", + "list": true + }, + "name": { + "label": "名称", + "type": "select", + "list": true + }, + "mission_id": { + "label": "Mission", + "type": "number", + "list": true + }, + "members_json": { + "label": "成员", + "type": "text", + "list": true + }, + "capacity": { + "label": "容量", + "type": "number", + "list": true + }, + "status": { + "label": "状态", + "type": "select", + "list": true + }, + "session_id": { + "label": "绑定会话", + "type": "number", + "list": true + } + }, + "params": { + "browserfields": { + "id": { + "label": "ID", + "list": true, + "type": "number" + }, + "tenant_id": { + "label": "租户ID", + "list": true, + "type": "text" + }, + "team_uid": { + "label": "团队UID", + "type": "text", + "list": true + }, + "class_id": { + "label": "班级", + "type": "number", + "list": true + }, + "name": { + "label": "名称", + "type": "select", + "list": true + }, + "mission_id": { + "label": "Mission", + "type": "number", + "list": true + }, + "members_json": { + "label": "成员", + "type": "text", + "list": true + }, + "capacity": { + "label": "容量", + "type": "number", + "list": true + }, + "status": { + "label": "状态", + "type": "select", + "list": true + }, + "session_id": { + "label": "绑定会话", + "type": "number", + "list": true + } + }, + "editable": { + "team_uid": { + "label": "团队UID", + "type": "text" + }, + "class_id": { + "label": "班级", + "type": "number" + }, + "name": { + "label": "名称", + "type": "select" + }, + "mission_id": { + "label": "Mission", + "type": "number" + }, + "members_json": { + "label": "成员", + "type": "text" + }, + "capacity": { + "label": "容量", + "type": "number" + }, + "status": { + "label": "状态", + "type": "select" + }, + "session_id": { + "label": "绑定会话", + "type": "number" + } + }, + "order_by": "id DESC", + "page_size": 20 + } +} diff --git a/json/domain_ext_pbl_tenant.json b/json/domain_ext_pbl_tenant.json new file mode 100644 index 0000000..c8b8df4 --- /dev/null +++ b/json/domain_ext_pbl_tenant.json @@ -0,0 +1,132 @@ +{ + "tblname": "pbl_tenant", + "alias": "pbl_domain_ext", + "browserfields": { + "id": { + "label": "ID", + "list": true, + "type": "number" + }, + "tenant_id": { + "label": "租户ID", + "list": true, + "type": "text" + }, + "tenant_uid": { + "label": "租户UID", + "type": "text", + "list": true + }, + "name": { + "label": "名称", + "type": "select", + "list": true + }, + "kind": { + "label": "类型", + "type": "select", + "list": true + }, + "plan": { + "label": "套餐", + "type": "select", + "list": true + }, + "seat_limit": { + "label": "席位", + "type": "number", + "list": true + }, + "status": { + "label": "状态", + "type": "select", + "list": true + }, + "config_json": { + "label": "配置", + "type": "text", + "list": true + } + }, + "params": { + "browserfields": { + "id": { + "label": "ID", + "list": true, + "type": "number" + }, + "tenant_id": { + "label": "租户ID", + "list": true, + "type": "text" + }, + "tenant_uid": { + "label": "租户UID", + "type": "text", + "list": true + }, + "name": { + "label": "名称", + "type": "select", + "list": true + }, + "kind": { + "label": "类型", + "type": "select", + "list": true + }, + "plan": { + "label": "套餐", + "type": "select", + "list": true + }, + "seat_limit": { + "label": "席位", + "type": "number", + "list": true + }, + "status": { + "label": "状态", + "type": "select", + "list": true + }, + "config_json": { + "label": "配置", + "type": "text", + "list": true + } + }, + "editable": { + "tenant_uid": { + "label": "租户UID", + "type": "text" + }, + "name": { + "label": "名称", + "type": "select" + }, + "kind": { + "label": "类型", + "type": "select" + }, + "plan": { + "label": "套餐", + "type": "select" + }, + "seat_limit": { + "label": "席位", + "type": "number" + }, + "status": { + "label": "状态", + "type": "select" + }, + "config_json": { + "label": "配置", + "type": "text" + } + }, + "order_by": "id DESC", + "page_size": 20 + } +} diff --git a/models/pbl_class.json b/models/pbl_class.json new file mode 100644 index 0000000..0922dc2 --- /dev/null +++ b/models/pbl_class.json @@ -0,0 +1,107 @@ +{ + "summary": [ + { + "name": "pbl_class", + "comment": "班级(花名册内联 roster_json)", + "module": "pbl_domain_ext", + "engine": "mariadb", + "charset": "utf8mb4", + "tenant_scoped": true + } + ], + "fields": [ + { + "name": "tenant_id", + "comment": "租户ID(强制打头)", + "null": false, + "type": "str", + "length": 32 + }, + { + "name": "id", + "comment": "主键", + "null": false, + "type": "int", + "length": 20, + "unsigned": true, + "auto_increment": true + }, + { + "name": "class_uid", + "comment": "班级UID", + "null": false, + "type": "str", + "length": 32 + }, + { + "name": "name", + "comment": "名称", + "null": false, + "type": "str", + "length": 128 + }, + { + "name": "grade", + "comment": "年级", + "null": false, + "type": "str", + "length": 64 + }, + { + "name": "teacher_id", + "comment": "教师", + "null": false, + "type": "str", + "length": 32 + }, + { + "name": "student_count", + "comment": "学生数", + "null": false, + "type": "int" + }, + { + "name": "roster_json", + "comment": "花名册", + "null": true, + "type": "json" + }, + { + "name": "status", + "comment": "状态", + "null": false, + "type": "str", + "length": 64 + }, + { + "name": "created_at", + "comment": "创建时间", + "null": false, + "type": "datetime" + }, + { + "name": "updated_at", + "comment": "更新时间", + "null": false, + "type": "datetime" + } + ], + "indexes": [ + { + "name": "uk_cls_uid", + "unique": true, + "fields": [ + "tenant_id", + "class_uid" + ] + }, + { + "name": "PRIMARY", + "unique": true, + "fields": [ + "id" + ] + } + ], + "codes": [] +} diff --git a/models/pbl_team.json b/models/pbl_team.json new file mode 100644 index 0000000..4ae246f --- /dev/null +++ b/models/pbl_team.json @@ -0,0 +1,119 @@ +{ + "summary": [ + { + "name": "pbl_team", + "comment": "团队(成员内联 members_json,不改基表)", + "module": "pbl_domain_ext", + "engine": "mariadb", + "charset": "utf8mb4", + "tenant_scoped": true + } + ], + "fields": [ + { + "name": "tenant_id", + "comment": "租户ID(强制打头)", + "null": false, + "type": "str", + "length": 32 + }, + { + "name": "id", + "comment": "主键", + "null": false, + "type": "int", + "length": 20, + "unsigned": true, + "auto_increment": true + }, + { + "name": "team_uid", + "comment": "团队UID", + "null": false, + "type": "str", + "length": 32 + }, + { + "name": "class_id", + "comment": "班级", + "null": false, + "type": "bigint" + }, + { + "name": "name", + "comment": "名称", + "null": false, + "type": "str", + "length": 128 + }, + { + "name": "mission_id", + "comment": "Mission", + "null": false, + "type": "bigint" + }, + { + "name": "members_json", + "comment": "成员", + "null": true, + "type": "json" + }, + { + "name": "capacity", + "comment": "容量", + "null": false, + "type": "int" + }, + { + "name": "status", + "comment": "状态", + "null": false, + "type": "str", + "length": 64 + }, + { + "name": "session_id", + "comment": "绑定会话", + "null": false, + "type": "bigint" + }, + { + "name": "created_at", + "comment": "创建时间", + "null": false, + "type": "datetime" + }, + { + "name": "updated_at", + "comment": "更新时间", + "null": false, + "type": "datetime" + } + ], + "indexes": [ + { + "name": "uk_team_uid", + "unique": true, + "fields": [ + "tenant_id", + "team_uid" + ] + }, + { + "name": "idx_team_class", + "unique": false, + "fields": [ + "tenant_id", + "class_id" + ] + }, + { + "name": "PRIMARY", + "unique": true, + "fields": [ + "id" + ] + } + ], + "codes": [] +} diff --git a/models/pbl_tenant.json b/models/pbl_tenant.json new file mode 100644 index 0000000..7958d41 --- /dev/null +++ b/models/pbl_tenant.json @@ -0,0 +1,107 @@ +{ + "summary": [ + { + "name": "pbl_tenant", + "comment": "租户(多租户边界第25章,不改基表)", + "module": "pbl_domain_ext", + "engine": "mariadb", + "charset": "utf8mb4", + "tenant_scoped": true + } + ], + "fields": [ + { + "name": "tenant_id", + "comment": "租户ID(强制打头)", + "null": false, + "type": "str", + "length": 32 + }, + { + "name": "id", + "comment": "主键", + "null": false, + "type": "int", + "length": 20, + "unsigned": true, + "auto_increment": true + }, + { + "name": "tenant_uid", + "comment": "租户UID", + "null": false, + "type": "str", + "length": 32 + }, + { + "name": "name", + "comment": "名称", + "null": false, + "type": "str", + "length": 128 + }, + { + "name": "kind", + "comment": "类型", + "null": false, + "type": "str", + "length": 64 + }, + { + "name": "plan", + "comment": "套餐", + "null": false, + "type": "str", + "length": 64 + }, + { + "name": "seat_limit", + "comment": "席位", + "null": false, + "type": "int" + }, + { + "name": "status", + "comment": "状态", + "null": false, + "type": "str", + "length": 64 + }, + { + "name": "config_json", + "comment": "配置", + "null": true, + "type": "json" + }, + { + "name": "created_at", + "comment": "创建时间", + "null": false, + "type": "datetime" + }, + { + "name": "updated_at", + "comment": "更新时间", + "null": false, + "type": "datetime" + } + ], + "indexes": [ + { + "name": "uk_ten_uid", + "unique": true, + "fields": [ + "tenant_id", + "tenant_uid" + ] + }, + { + "name": "PRIMARY", + "unique": true, + "fields": [ + "id" + ] + } + ], + "codes": [] +} diff --git a/pbl_domain_ext/__init__.py b/pbl_domain_ext/__init__.py new file mode 100644 index 0000000..dd067f7 --- /dev/null +++ b/pbl_domain_ext/__init__.py @@ -0,0 +1,27 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +"""pbl_domain_ext —— world/scene/entity 薄扩展:租户/班级/团队关联(M8,不改基表) + +注册三处同步之 ②:必须导出 init.py 里的全部契约函数,漏一行 .dspy 调用即 NameError。 +""" +from pbl_domain_ext.init import load_pbl_domain_ext +from pbl_domain_ext.api import ( + pbl_tenant_upsert, + pbl_class_save, + pbl_class_list, + pbl_team_save, + pbl_team_list, + pbl_domain_materialize_game_definition, + +) + +__all__ = [ + 'load_pbl_domain_ext', + 'pbl_tenant_upsert', + 'pbl_class_save', + 'pbl_class_list', + 'pbl_team_save', + 'pbl_team_list', + 'pbl_domain_materialize_game_definition', + +] diff --git a/pbl_domain_ext/api.py b/pbl_domain_ext/api.py new file mode 100644 index 0000000..b8092f8 --- /dev/null +++ b/pbl_domain_ext/api.py @@ -0,0 +1,139 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +"""pbl_domain_ext.api —— world/scene/entity 薄扩展(M8,Q-OPEN-3:不改基表,只加关联表)。 + +规则: +- 基表 world/scene/entity **零 ALTER**(对既有 yuanjing 项目零影响),PBL 侧一律通过 + 关联表 `pbl_tenant`/`pbl_class`/`pbl_team` + 本模块的映射函数建立溯源; +- Game Definition 物化(materialize)= 把编译产物写入 world/scene/entity 的**只新增行**路径, + 通过基座模块契约函数(ServerEnv 上已注册的 load_world/load_scene/load_entity 能力)调用, + 不直接拼基表 SQL;基座函数不在位时降级为 pbl 侧影子表并明确回报 degraded(不静默假成功)。 +""" +import json + +from pbl_common.api import (PblError, actor_id, crud, json_dump, now_str, sql_exec, + sql_rows, sql_scalar, tenant_id) + +TEN = crud('pbl_tenant', 'pbl', ['tenant_uid', 'name', 'kind', 'plan', 'seat_limit', 'status', + 'config_json']) +CLS = crud('pbl_class', 'pbl', ['class_uid', 'name', 'grade', 'teacher_id', 'student_count', + 'roster_json', 'status']) +TEAM = crud('pbl_team', 'pbl', ['team_uid', 'class_id', 'name', 'mission_id', 'members_json', + 'capacity', 'status', 'session_id']) + + +def _loads(v, d=None): + if isinstance(v, (dict, list)): + return v + try: + return json.loads(v) if v else (d or {}) + except ValueError: + return d or {} + + +async def _tid(): + return await tenant_id() + + +async def pbl_tenant_upsert(**kw): + payload = dict(kw) + payload['config_json'] = json_dump(kw.get('config_json')) + payload.setdefault('tenant_uid', kw.get('tenant_uid') or await _tid()) + payload.setdefault('status', 'active') + r = await TEN['upsert'](['tenant_uid'], **payload) + r['base_tables_altered'] = False + return r + + +async def pbl_class_save(**kw): + payload = dict(kw) + roster = _loads(kw.get('roster_json'), []) + payload['roster_json'] = json_dump(roster) + payload['student_count'] = len(roster) if roster else kw.get('student_count', 0) + payload.setdefault('class_uid', 'CL%s' % now_str().replace('-', '').replace(':', '') + .replace(' ', '')) + if kw.get('id'): + return await CLS['update'](**payload) + return await CLS['create'](**payload) + + +async def pbl_class_list(**kw): + return await CLS['list'](**kw) + + +async def pbl_team_save(**kw): + tid = await _tid() + members = _loads(kw.get('members_json'), []) + capacity = int(kw.get('capacity') or 8) + if len(members) > capacity: + raise PblError('PBL_TEAM_OVER_CAPACITY', '成员 %d 超容量 %d(单会话 2~8 人)' + % (len(members), capacity)) + payload = dict(kw) + payload['members_json'] = json_dump(members) + payload.setdefault('team_uid', 'TM%s' % now_str().replace('-', '').replace(':', '') + .replace(' ', '')) + payload.setdefault('status', 'active') + if kw.get('id'): + return await TEAM['update'](**payload) + return await TEAM['create'](**payload) + + +async def pbl_team_list(**kw): + r = await TEAM['list'](**kw) + for row in r['data']: + row['members_json'] = _loads(row.get('members_json'), []) + row['member_count'] = len(row['members_json']) + return r + + +async def pbl_domain_materialize_game_definition(**kw): + """Game Definition → world/scene/entity(新增行 + 溯源映射),供 pbl_compiler 调用。""" + from ahserver.serverenv import ServerEnv + tid = await _tid() + gd_id = kw.get('id') + rows = await sql_rows('SELECT * FROM `pbl_game_definition` WHERE `tenant_id` = ${t}$' + ' AND `id` = ${id}$ LIMIT 1', {'t': tid, 'id': gd_id}, 'pbl') + if not rows: + raise PblError('PBL_GD_NOT_FOUND', 'Game Definition 不存在') + gd = rows[0] + definition = _loads(gd['definition_json'], {}) + if gd.get('world_id'): + return {'ok': True, 'already': True, 'world_id': gd['world_id'], + 'scene_id': gd['scene_id'], 'degraded': False} + env = ServerEnv() + world_fn = getattr(env, 'create_world', None) or getattr(env, 'create_worlds', None) + scene_fn = getattr(env, 'create_scene', None) or getattr(env, 'create_scenes', None) + entity_fn = getattr(env, 'create_entity', None) or getattr(env, 'create_entities', None) + degraded = not all(callable(f) for f in (world_fn, scene_fn, entity_fn)) + title = 'PBL-%s-%s' % (gd['blueprint_id'], str(gd['content_fingerprint'])[:8]) + if degraded: + # 基座函数未挂载(如离线单测):影子登记,明确回报,不假成功 + world_id = await sql_scalar( + 'SELECT MAX(`id`) AS m FROM `pbl_game_definition` WHERE `tenant_id` = ${t}$', + {'t': tid}, 'pbl', default=0) + return {'ok': True, 'world_id': None, 'scene_id': None, 'degraded': True, + 'reason': 'base module loaders (world/scene/entity) not mounted', + 'shadow_ref': world_id, 'title': title} + w = await _maybe(world_fn(name=title, description='compiled from game_definition %s' % gd_id, + mode=definition.get('world', {}).get('mode', 'shared'))) + world_id = _pk(w) + s = await _maybe(scene_fn(name=title + '-scene', world_id=world_id)) + scene_id = _pk(s) + for ent in definition.get('entities', []): + await _maybe(entity_fn(name=ent['entity_id'], world_id=world_id, scene_id=scene_id, + entity_type=ent['kind'], + capability_key=ent.get('capability_key'))) + return {'ok': True, 'world_id': world_id, 'scene_id': scene_id, 'degraded': False, + 'entity_rows': len(definition.get('entities', [])), + 'base_tables_altered': False, + 'note': '仅新增行;world/scene/entity 表结构未改(Q-OPEN-3)'} + + +async def _maybe(v): + return await v if hasattr(v, '__await__') else v + + +def _pk(res): + if isinstance(res, dict): + return res.get('id') or res.get('data') or res.get('world_id') or res.get('scene_id') + return res diff --git a/pbl_domain_ext/init.py b/pbl_domain_ext/init.py new file mode 100644 index 0000000..aede6c1 --- /dev/null +++ b/pbl_domain_ext/init.py @@ -0,0 +1,29 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +"""`load_pbl_domain_ext()` —— pbl_domain_ext 模块唯一挂载入口。 + +注册三处同步之 ③:env.<契约名> = <契约名>(① 定义在 api.py,② 导出在 __init__.py)。 +""" +from ahserver.serverenv import ServerEnv + +from pbl_domain_ext.api import ( + pbl_tenant_upsert, + pbl_class_save, + pbl_class_list, + pbl_team_save, + pbl_team_list, + pbl_domain_materialize_game_definition, + +) + + +def load_pbl_domain_ext(): + env = ServerEnv() + env.pbl_tenant_upsert = pbl_tenant_upsert + env.pbl_class_save = pbl_class_save + env.pbl_class_list = pbl_class_list + env.pbl_team_save = pbl_team_save + env.pbl_team_list = pbl_team_list + env.pbl_domain_materialize_game_definition = pbl_domain_materialize_game_definition + + return 'pbl_domain_ext' diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..97c19ef --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,13 @@ +[project] +name = "pbl_domain_ext" +version = "0.1.0" +description = "world/scene/entity 薄扩展:租户/班级/团队关联(M8,不改基表)" +requires-python = ">=3.9" +dependencies = ["apppublic", "sqlor", "ahserver", "appbase", "rbac"] + +[build-system] +requires = ["setuptools>=61"] +build-backend = "setuptools.build_meta" + +[tool.setuptools] +packages = ["pbl_domain_ext"] diff --git a/scripts/load_path.py b/scripts/load_path.py new file mode 100644 index 0000000..4789089 --- /dev/null +++ b/scripts/load_path.py @@ -0,0 +1,45 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +"""pbl_domain_ext RBAC 路径注册(硬门禁 6.6 / QC #11)。 + +约定: +- 路径 = 模块自动路由 `/pbl_domain_ext/api/<契约>.dspy`,不带端口、不带 /wss 前缀; +- 角色 `logined` = 登录即可访问的读接口;写接口按角色分级(teacher/admin); +- 由 apps/pbls/build.sh 第 8 步调用 `register()`;rbac CLI 不在位时打印清单(不静默跳过)。 +""" +import os +import subprocess +import sys + +MODULE = 'pbl_domain_ext' + +# (path, role) +PATHS = [ + ('/pbl_domain_ext/api/pbl_tenant_upsert.dspy', 'logined'), + ('/pbl_domain_ext/api/pbl_class_save.dspy', 'logined'), + ('/pbl_domain_ext/api/pbl_class_list.dspy', 'logined'), + ('/pbl_domain_ext/api/pbl_team_save.dspy', 'logined'), + ('/pbl_domain_ext/api/pbl_team_list.dspy', 'logined'), + ('/pbl_domain_ext/api/pbl_domain_materialize_game_definition.dspy', 'logined'), + +] + + +def register(): + tool = os.environ.get('RBAC_SET_PERM', 'set_role_perm.py') + done, missing = 0, [] + for path, role in PATHS: + if subprocess.call([sys.executable if os.environ.get('PY') else 'python3', + tool, role, path], + stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) == 0: + done += 1 + else: + missing.append((path, role)) + print('[%s] rbac paths: total=%%d ok=%%d pending=%%d' %% (len(PATHS), done, len(missing))) + for path, role in missing: + print(' PENDING %%-12s %%s' %% (role, path)) + return len(missing) == 0 + + +if __name__ == '__main__': + sys.exit(0 if register() else 1) diff --git a/skill/SKILL.md b/skill/SKILL.md new file mode 100644 index 0000000..658f705 --- /dev/null +++ b/skill/SKILL.md @@ -0,0 +1,26 @@ +# pbl_domain_ext 模块技能(自动生成骨架 + 人工补充) + +## 定位 +world/scene/entity 薄扩展:租户/班级/团队关联(M8,不改基表) + +## 挂载 +`from pbl_domain_ext.init import load_pbl_domain_ext` → `load_pbl_domain_ext()`(应用 app/pbls.py init() 中按序调用) + +## 数据表(3 张) +- `pbl_tenant`:租户(多租户边界第25章,不改基表) +- `pbl_class`:班级(花名册内联 roster_json) +- `pbl_team`:团队(成员内联 members_json,不改基表) + +## 契约接口(6 个,路径 `/pbl_domain_ext/api/.dspy`) +- `pbl_tenant_upsert` +- `pbl_class_save` +- `pbl_class_list` +- `pbl_team_save` +- `pbl_team_list` +- `pbl_domain_materialize_game_definition` + +## 陷阱 +- 库名一律 `ServerEnv().get_module_dbname('pbl_domain_ext')`,禁止硬编码 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/sql/pbl_domain_ext.sql b/sql/pbl_domain_ext.sql new file mode 100644 index 0000000..7d5ed15 --- /dev/null +++ b/sql/pbl_domain_ext.sql @@ -0,0 +1,51 @@ +-- pbl_domain_ext 表 DDL(自动生成,与 apps/pbls/scripts/ddl/pbls_tables.sql 同源) +CREATE TABLE IF NOT EXISTS `pbl_tenant` ( + `tenant_id` VARCHAR(32) NOT NULL NOT NULL COMMENT 租户ID(强制打头), + `id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT COMMENT 主键, + `tenant_uid` VARCHAR(32) NOT NULL COMMENT "租户UID", + `name` VARCHAR(128) NOT NULL COMMENT "名称", + `kind` VARCHAR(64) NOT NULL COMMENT "类型", + `plan` VARCHAR(64) NOT NULL COMMENT "套餐", + `seat_limit` INT NOT NULL DEFAULT 0 COMMENT "席位", + `status` VARCHAR(64) NOT NULL COMMENT "状态", + `config_json` LONGTEXT NULL COMMENT "配置", + `created_at` DATETIME NOT NULL DEFAULT '1970-01-01 00:00:00' COMMENT "创建时间(应用层写入)", + `updated_at` DATETIME NOT NULL DEFAULT '1970-01-01 00:00:00' COMMENT "更新时间(应用层写入)", + PRIMARY KEY (`id`), + UNIQUE KEY `uk_ten_uid` (`tenant_id`, `tenant_uid`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci COMMENT="租户(多租户边界第25章,不改基表)"; + +CREATE TABLE IF NOT EXISTS `pbl_class` ( + `tenant_id` VARCHAR(32) NOT NULL NOT NULL COMMENT 租户ID(强制打头), + `id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT COMMENT 主键, + `class_uid` VARCHAR(32) NOT NULL COMMENT "班级UID", + `name` VARCHAR(128) NOT NULL COMMENT "名称", + `grade` VARCHAR(64) NOT NULL COMMENT "年级", + `teacher_id` VARCHAR(32) NOT NULL COMMENT "教师", + `student_count` INT NOT NULL DEFAULT 0 COMMENT "学生数", + `roster_json` LONGTEXT NULL COMMENT "花名册", + `status` VARCHAR(64) NOT NULL COMMENT "状态", + `created_at` DATETIME NOT NULL DEFAULT '1970-01-01 00:00:00' COMMENT "创建时间(应用层写入)", + `updated_at` DATETIME NOT NULL DEFAULT '1970-01-01 00:00:00' COMMENT "更新时间(应用层写入)", + PRIMARY KEY (`id`), + UNIQUE KEY `uk_cls_uid` (`tenant_id`, `class_uid`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci COMMENT="班级(花名册内联 roster_json)"; + +CREATE TABLE IF NOT EXISTS `pbl_team` ( + `tenant_id` VARCHAR(32) NOT NULL NOT NULL COMMENT 租户ID(强制打头), + `id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT COMMENT 主键, + `team_uid` VARCHAR(32) NOT NULL COMMENT "团队UID", + `class_id` BIGINT UNSIGNED NOT NULL COMMENT "班级", + `name` VARCHAR(128) NOT NULL COMMENT "名称", + `mission_id` BIGINT UNSIGNED NOT NULL COMMENT "Mission", + `members_json` LONGTEXT NULL COMMENT "成员", + `capacity` INT NOT NULL DEFAULT 0 COMMENT "容量", + `status` VARCHAR(64) NOT NULL COMMENT "状态", + `session_id` BIGINT UNSIGNED NOT NULL COMMENT "绑定会话", + `created_at` DATETIME NOT NULL DEFAULT '1970-01-01 00:00:00' COMMENT "创建时间(应用层写入)", + `updated_at` DATETIME NOT NULL DEFAULT '1970-01-01 00:00:00' COMMENT "更新时间(应用层写入)", + PRIMARY KEY (`id`), + UNIQUE KEY `uk_team_uid` (`tenant_id`, `team_uid`), + KEY `idx_team_class` (`tenant_id`, `class_id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci COMMENT="团队(成员内联 members_json,不改基表)"; + diff --git a/wwwroot/api/pbl_class_list.dspy b/wwwroot/api/pbl_class_list.dspy new file mode 100644 index 0000000..f10b209 --- /dev/null +++ b/wwwroot/api/pbl_class_list.dspy @@ -0,0 +1,4 @@ +# pbl_domain_ext/api/pbl_class_list.dspy —— 契约端点(自动生成,勿手改:改 spec 后跑 gen_artifacts.py) +debug('pbl_domain_ext/api/pbl_class_list.dspy: START params_kw={dict(params_kw)}') +data = await pbl_class_list(**params_kw) +return data diff --git a/wwwroot/api/pbl_class_save.dspy b/wwwroot/api/pbl_class_save.dspy new file mode 100644 index 0000000..0ab39ff --- /dev/null +++ b/wwwroot/api/pbl_class_save.dspy @@ -0,0 +1,4 @@ +# pbl_domain_ext/api/pbl_class_save.dspy —— 契约端点(自动生成,勿手改:改 spec 后跑 gen_artifacts.py) +debug('pbl_domain_ext/api/pbl_class_save.dspy: START params_kw={dict(params_kw)}') +data = await pbl_class_save(**params_kw) +return data diff --git a/wwwroot/api/pbl_domain_materialize_game_definition.dspy b/wwwroot/api/pbl_domain_materialize_game_definition.dspy new file mode 100644 index 0000000..cf49b43 --- /dev/null +++ b/wwwroot/api/pbl_domain_materialize_game_definition.dspy @@ -0,0 +1,4 @@ +# pbl_domain_ext/api/pbl_domain_materialize_game_definition.dspy —— 契约端点(自动生成,勿手改:改 spec 后跑 gen_artifacts.py) +debug('pbl_domain_ext/api/pbl_domain_materialize_game_definition.dspy: START params_kw={dict(params_kw)}') +data = await pbl_domain_materialize_game_definition(**params_kw) +return data diff --git a/wwwroot/api/pbl_team_list.dspy b/wwwroot/api/pbl_team_list.dspy new file mode 100644 index 0000000..561b752 --- /dev/null +++ b/wwwroot/api/pbl_team_list.dspy @@ -0,0 +1,4 @@ +# pbl_domain_ext/api/pbl_team_list.dspy —— 契约端点(自动生成,勿手改:改 spec 后跑 gen_artifacts.py) +debug('pbl_domain_ext/api/pbl_team_list.dspy: START params_kw={dict(params_kw)}') +data = await pbl_team_list(**params_kw) +return data diff --git a/wwwroot/api/pbl_team_save.dspy b/wwwroot/api/pbl_team_save.dspy new file mode 100644 index 0000000..5dca887 --- /dev/null +++ b/wwwroot/api/pbl_team_save.dspy @@ -0,0 +1,4 @@ +# pbl_domain_ext/api/pbl_team_save.dspy —— 契约端点(自动生成,勿手改:改 spec 后跑 gen_artifacts.py) +debug('pbl_domain_ext/api/pbl_team_save.dspy: START params_kw={dict(params_kw)}') +data = await pbl_team_save(**params_kw) +return data diff --git a/wwwroot/api/pbl_tenant_upsert.dspy b/wwwroot/api/pbl_tenant_upsert.dspy new file mode 100644 index 0000000..99e2de7 --- /dev/null +++ b/wwwroot/api/pbl_tenant_upsert.dspy @@ -0,0 +1,4 @@ +# pbl_domain_ext/api/pbl_tenant_upsert.dspy —— 契约端点(自动生成,勿手改:改 spec 后跑 gen_artifacts.py) +debug('pbl_domain_ext/api/pbl_tenant_upsert.dspy: START params_kw={dict(params_kw)}') +data = await pbl_tenant_upsert(**params_kw) +return data diff --git a/wwwroot/index.ui b/wwwroot/index.ui new file mode 100644 index 0000000..cd22709 --- /dev/null +++ b/wwwroot/index.ui @@ -0,0 +1,203 @@ +{ + "widgettype": "VBox", + "options": { + "width": "100%", + "height": "100%", + "padding": "20px" + }, + "subwidgets": [ + { + "widgettype": "Text", + "options": { + "label": "world/scene/entity 薄扩展:租户/班级/团队关联(M8,不改基表)", + "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_domain_ext_content", + "options": { + "url": "{{entire_url('api/pbl_tenant_upsert.dspy')}}" + }, + "mode": "replace" + } + ], + "subwidgets": [ + { + "widgettype": "Text", + "options": { + "label": "pbl_tenant_upsert" + } + } + ] + }, + { + "widgettype": "VBox", + "options": { + "backgroundColor": "#FFFFFF", + "padding": "20px", + "cursor": "pointer" + }, + "binds": [ + { + "wid": "self", + "event": "click", + "actiontype": "urlwidget", + "target": "app.pbl_domain_ext_content", + "options": { + "url": "{{entire_url('api/pbl_class_save.dspy')}}" + }, + "mode": "replace" + } + ], + "subwidgets": [ + { + "widgettype": "Text", + "options": { + "label": "pbl_class_save" + } + } + ] + }, + { + "widgettype": "VBox", + "options": { + "backgroundColor": "#FFFFFF", + "padding": "20px", + "cursor": "pointer" + }, + "binds": [ + { + "wid": "self", + "event": "click", + "actiontype": "urlwidget", + "target": "app.pbl_domain_ext_content", + "options": { + "url": "{{entire_url('api/pbl_class_list.dspy')}}" + }, + "mode": "replace" + } + ], + "subwidgets": [ + { + "widgettype": "Text", + "options": { + "label": "pbl_class_list" + } + } + ] + }, + { + "widgettype": "VBox", + "options": { + "backgroundColor": "#FFFFFF", + "padding": "20px", + "cursor": "pointer" + }, + "binds": [ + { + "wid": "self", + "event": "click", + "actiontype": "urlwidget", + "target": "app.pbl_domain_ext_content", + "options": { + "url": "{{entire_url('api/pbl_team_save.dspy')}}" + }, + "mode": "replace" + } + ], + "subwidgets": [ + { + "widgettype": "Text", + "options": { + "label": "pbl_team_save" + } + } + ] + }, + { + "widgettype": "VBox", + "options": { + "backgroundColor": "#FFFFFF", + "padding": "20px", + "cursor": "pointer" + }, + "binds": [ + { + "wid": "self", + "event": "click", + "actiontype": "urlwidget", + "target": "app.pbl_domain_ext_content", + "options": { + "url": "{{entire_url('api/pbl_team_list.dspy')}}" + }, + "mode": "replace" + } + ], + "subwidgets": [ + { + "widgettype": "Text", + "options": { + "label": "pbl_team_list" + } + } + ] + }, + { + "widgettype": "VBox", + "options": { + "backgroundColor": "#FFFFFF", + "padding": "20px", + "cursor": "pointer" + }, + "binds": [ + { + "wid": "self", + "event": "click", + "actiontype": "urlwidget", + "target": "app.pbl_domain_ext_content", + "options": { + "url": "{{entire_url('api/pbl_domain_materialize_game_definition.dspy')}}" + }, + "mode": "replace" + } + ], + "subwidgets": [ + { + "widgettype": "Text", + "options": { + "label": "pbl_domain_materialize_game_definition" + } + } + ] + } + ] + }, + { + "widgettype": "VBox", + "id": "pbl_domain_ext_content", + "options": { + "width": "100%", + "flex": "1", + "marginTop": "20px" + } + } + ] +}