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/scense_ext_pbl_playtest_feedback.json b/json/scense_ext_pbl_playtest_feedback.json new file mode 100644 index 0000000..b583e16 --- /dev/null +++ b/json/scense_ext_pbl_playtest_feedback.json @@ -0,0 +1,146 @@ +{ + "tblname": "pbl_playtest_feedback", + "alias": "pbl_scense_ext", + "browserfields": { + "id": { + "label": "ID", + "list": true, + "type": "number" + }, + "tenant_id": { + "label": "租户ID", + "list": true, + "type": "text" + }, + "session_id": { + "label": "会话", + "type": "number", + "list": true + }, + "user_id": { + "label": "用户", + "type": "text", + "list": true + }, + "blueprint_id": { + "label": "蓝图", + "type": "number", + "list": true + }, + "feedback_type": { + "label": "类型", + "type": "select", + "list": true + }, + "rating": { + "label": "评分", + "type": "number", + "list": true + }, + "content_txt": { + "label": "内容", + "type": "text", + "list": true + }, + "context_json": { + "label": "上下文", + "type": "text", + "list": true + }, + "handled": { + "label": "已处理", + "type": "checkbox", + "list": true + } + }, + "params": { + "browserfields": { + "id": { + "label": "ID", + "list": true, + "type": "number" + }, + "tenant_id": { + "label": "租户ID", + "list": true, + "type": "text" + }, + "session_id": { + "label": "会话", + "type": "number", + "list": true + }, + "user_id": { + "label": "用户", + "type": "text", + "list": true + }, + "blueprint_id": { + "label": "蓝图", + "type": "number", + "list": true + }, + "feedback_type": { + "label": "类型", + "type": "select", + "list": true + }, + "rating": { + "label": "评分", + "type": "number", + "list": true + }, + "content_txt": { + "label": "内容", + "type": "text", + "list": true + }, + "context_json": { + "label": "上下文", + "type": "text", + "list": true + }, + "handled": { + "label": "已处理", + "type": "checkbox", + "list": true + } + }, + "editable": { + "session_id": { + "label": "会话", + "type": "number" + }, + "user_id": { + "label": "用户", + "type": "text" + }, + "blueprint_id": { + "label": "蓝图", + "type": "number" + }, + "feedback_type": { + "label": "类型", + "type": "select" + }, + "rating": { + "label": "评分", + "type": "number" + }, + "content_txt": { + "label": "内容", + "type": "text" + }, + "context_json": { + "label": "上下文", + "type": "text" + }, + "handled": { + "label": "已处理", + "type": "checkbox" + } + }, + "order_by": "id DESC", + "page_size": 20 + } +} diff --git a/json/scense_ext_pbl_session_member.json b/json/scense_ext_pbl_session_member.json new file mode 100644 index 0000000..e785056 --- /dev/null +++ b/json/scense_ext_pbl_session_member.json @@ -0,0 +1,146 @@ +{ + "tblname": "pbl_session_member", + "alias": "pbl_scense_ext", + "browserfields": { + "id": { + "label": "ID", + "list": true, + "type": "number" + }, + "tenant_id": { + "label": "租户ID", + "list": true, + "type": "text" + }, + "session_id": { + "label": "会话", + "type": "number", + "list": true + }, + "user_id": { + "label": "用户", + "type": "text", + "list": true + }, + "role_code": { + "label": "角色", + "type": "select", + "list": true + }, + "team_id": { + "label": "团队", + "type": "number", + "list": true + }, + "joined_at": { + "label": "加入时间", + "type": "text", + "list": true + }, + "last_seen_at": { + "label": "心跳", + "type": "text", + "list": true + }, + "state_version": { + "label": "已见状态版本", + "type": "number", + "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" + }, + "session_id": { + "label": "会话", + "type": "number", + "list": true + }, + "user_id": { + "label": "用户", + "type": "text", + "list": true + }, + "role_code": { + "label": "角色", + "type": "select", + "list": true + }, + "team_id": { + "label": "团队", + "type": "number", + "list": true + }, + "joined_at": { + "label": "加入时间", + "type": "text", + "list": true + }, + "last_seen_at": { + "label": "心跳", + "type": "text", + "list": true + }, + "state_version": { + "label": "已见状态版本", + "type": "number", + "list": true + }, + "status": { + "label": "状态", + "type": "select", + "list": true + } + }, + "editable": { + "session_id": { + "label": "会话", + "type": "number" + }, + "user_id": { + "label": "用户", + "type": "text" + }, + "role_code": { + "label": "角色", + "type": "select" + }, + "team_id": { + "label": "团队", + "type": "number" + }, + "joined_at": { + "label": "加入时间", + "type": "text" + }, + "last_seen_at": { + "label": "心跳", + "type": "text" + }, + "state_version": { + "label": "已见状态版本", + "type": "number" + }, + "status": { + "label": "状态", + "type": "select" + } + }, + "order_by": "id DESC", + "page_size": 20 + } +} diff --git a/json/scense_ext_pbl_ui_page_registry.json b/json/scense_ext_pbl_ui_page_registry.json new file mode 100644 index 0000000..9468a51 --- /dev/null +++ b/json/scense_ext_pbl_ui_page_registry.json @@ -0,0 +1,146 @@ +{ + "tblname": "pbl_ui_page_registry", + "alias": "pbl_scense_ext", + "browserfields": { + "id": { + "label": "ID", + "list": true, + "type": "number" + }, + "tenant_id": { + "label": "租户ID", + "list": true, + "type": "text" + }, + "page_key": { + "label": "页面键", + "type": "select", + "list": true + }, + "title": { + "label": "标题", + "type": "select", + "list": true + }, + "route": { + "label": "路由", + "type": "select", + "list": true + }, + "widget_type": { + "label": "渲染组件", + "type": "select", + "list": true + }, + "requires_role": { + "label": "所需角色", + "type": "select", + "list": true + }, + "budget_ms": { + "label": "渲染预算", + "type": "number", + "list": true + }, + "enabled": { + "label": "启用", + "type": "checkbox", + "list": true + }, + "meta_json": { + "label": "元数据", + "type": "text", + "list": true + } + }, + "params": { + "browserfields": { + "id": { + "label": "ID", + "list": true, + "type": "number" + }, + "tenant_id": { + "label": "租户ID", + "list": true, + "type": "text" + }, + "page_key": { + "label": "页面键", + "type": "select", + "list": true + }, + "title": { + "label": "标题", + "type": "select", + "list": true + }, + "route": { + "label": "路由", + "type": "select", + "list": true + }, + "widget_type": { + "label": "渲染组件", + "type": "select", + "list": true + }, + "requires_role": { + "label": "所需角色", + "type": "select", + "list": true + }, + "budget_ms": { + "label": "渲染预算", + "type": "number", + "list": true + }, + "enabled": { + "label": "启用", + "type": "checkbox", + "list": true + }, + "meta_json": { + "label": "元数据", + "type": "text", + "list": true + } + }, + "editable": { + "page_key": { + "label": "页面键", + "type": "select" + }, + "title": { + "label": "标题", + "type": "select" + }, + "route": { + "label": "路由", + "type": "select" + }, + "widget_type": { + "label": "渲染组件", + "type": "select" + }, + "requires_role": { + "label": "所需角色", + "type": "select" + }, + "budget_ms": { + "label": "渲染预算", + "type": "number" + }, + "enabled": { + "label": "启用", + "type": "checkbox" + }, + "meta_json": { + "label": "元数据", + "type": "text" + } + }, + "order_by": "id DESC", + "page_size": 20 + } +} diff --git a/models/pbl_playtest_feedback.json b/models/pbl_playtest_feedback.json new file mode 100644 index 0000000..17b0be5 --- /dev/null +++ b/models/pbl_playtest_feedback.json @@ -0,0 +1,110 @@ +{ + "summary": [ + { + "name": "pbl_playtest_feedback", + "comment": "Playtest 反馈/反思(reflection 走证据链外的轻量表)", + "module": "pbl_scense_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": "session_id", + "comment": "会话", + "null": false, + "type": "bigint" + }, + { + "name": "user_id", + "comment": "用户", + "null": false, + "type": "str", + "length": 32 + }, + { + "name": "blueprint_id", + "comment": "蓝图", + "null": false, + "type": "bigint" + }, + { + "name": "feedback_type", + "comment": "类型", + "null": false, + "type": "str", + "length": 64 + }, + { + "name": "rating", + "comment": "评分", + "null": false, + "type": "int" + }, + { + "name": "content_txt", + "comment": "内容", + "null": true, + "type": "text" + }, + { + "name": "context_json", + "comment": "上下文", + "null": true, + "type": "json" + }, + { + "name": "handled", + "comment": "已处理", + "null": false, + "type": "bool" + }, + { + "name": "created_at", + "comment": "创建时间", + "null": false, + "type": "datetime" + }, + { + "name": "updated_at", + "comment": "更新时间", + "null": false, + "type": "datetime" + } + ], + "indexes": [ + { + "name": "idx_pf_session", + "unique": false, + "fields": [ + "tenant_id", + "session_id" + ] + }, + { + "name": "PRIMARY", + "unique": true, + "fields": [ + "id" + ] + } + ], + "codes": [] +} diff --git a/models/pbl_session_member.json b/models/pbl_session_member.json new file mode 100644 index 0000000..73f5b01 --- /dev/null +++ b/models/pbl_session_member.json @@ -0,0 +1,112 @@ +{ + "summary": [ + { + "name": "pbl_session_member", + "comment": "会话成员(复用 world_session,不污染基表)", + "module": "pbl_scense_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": "session_id", + "comment": "会话", + "null": false, + "type": "bigint" + }, + { + "name": "user_id", + "comment": "用户", + "null": false, + "type": "str", + "length": 32 + }, + { + "name": "role_code", + "comment": "角色", + "null": false, + "type": "str", + "length": 64 + }, + { + "name": "team_id", + "comment": "团队", + "null": false, + "type": "bigint" + }, + { + "name": "joined_at", + "comment": "加入时间", + "null": true, + "type": "datetime" + }, + { + "name": "last_seen_at", + "comment": "心跳", + "null": true, + "type": "datetime" + }, + { + "name": "state_version", + "comment": "已见状态版本", + "null": false, + "type": "bigint" + }, + { + "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_sm", + "unique": true, + "fields": [ + "tenant_id", + "session_id", + "user_id" + ] + }, + { + "name": "PRIMARY", + "unique": true, + "fields": [ + "id" + ] + } + ], + "codes": [] +} diff --git a/models/pbl_ui_page_registry.json b/models/pbl_ui_page_registry.json new file mode 100644 index 0000000..1690f03 --- /dev/null +++ b/models/pbl_ui_page_registry.json @@ -0,0 +1,113 @@ +{ + "summary": [ + { + "name": "pbl_ui_page_registry", + "comment": "前端覆盖层页面注册(12 页面 + 时延预算)", + "module": "pbl_scense_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": "page_key", + "comment": "页面键", + "null": false, + "type": "str", + "length": 64 + }, + { + "name": "title", + "comment": "标题", + "null": false, + "type": "str", + "length": 128 + }, + { + "name": "route", + "comment": "路由", + "null": false, + "type": "str", + "length": 64 + }, + { + "name": "widget_type", + "comment": "渲染组件", + "null": false, + "type": "str", + "length": 64 + }, + { + "name": "requires_role", + "comment": "所需角色", + "null": false, + "type": "str", + "length": 64 + }, + { + "name": "budget_ms", + "comment": "渲染预算", + "null": false, + "type": "int" + }, + { + "name": "enabled", + "comment": "启用", + "null": false, + "type": "bool" + }, + { + "name": "meta_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_upr_key", + "unique": true, + "fields": [ + "tenant_id", + "page_key" + ] + }, + { + "name": "PRIMARY", + "unique": true, + "fields": [ + "id" + ] + } + ], + "codes": [] +} diff --git a/pbl_scense_ext/__init__.py b/pbl_scense_ext/__init__.py new file mode 100644 index 0000000..0429981 --- /dev/null +++ b/pbl_scense_ext/__init__.py @@ -0,0 +1,23 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +"""pbl_scense_ext —— scense/scense_game 前端覆盖层与页面注册(M9) + +注册三处同步之 ②:必须导出 init.py 里的全部契约函数,漏一行 .dspy 调用即 NameError。 +""" +from pbl_scense_ext.init import load_pbl_scense_ext +from pbl_scense_ext.api import ( + pbl_session_context_get, + pbl_page_registry_list, + pbl_playtest_feedback_save, + pbl_overlay_bootstrap, + +) + +__all__ = [ + 'load_pbl_scense_ext', + 'pbl_session_context_get', + 'pbl_page_registry_list', + 'pbl_playtest_feedback_save', + 'pbl_overlay_bootstrap', + +] diff --git a/pbl_scense_ext/api.py b/pbl_scense_ext/api.py new file mode 100644 index 0000000..73bbcae --- /dev/null +++ b/pbl_scense_ext/api.py @@ -0,0 +1,132 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +"""pbl_scense_ext.api —— scense/scense_game 前端覆盖层服务端侧(M9)。 + +不改渲染主循环:本模块只提供 ① 覆盖层启动上下文(bootstrap)、② 页面注册表(12 页面 + +渲染时延预算)、③ Playtest 反馈/反思落库。渲染由基座 scense 的 BricksUIProcessor + +wwwroot/pbl_overlay/*.ui 完成,JS 侧只做 DOM 覆盖层挂载(见 apps/pbls/wwwroot/pbl_overlay)。 +""" +import json +import time + +from pbl_common.api import (PblError, actor_id, crud, json_dump, now_str, sql_exec, + sql_rows, tenant_id) + +# 12 页面(ui-design.md §页面清单),budget_ms 为渲染预算(前端 Performance API 实测) +PAGES = [ + ('pbl_home', 'PBL 首页', '/pbl_overlay/home.ui', 'card_grid', 'logined', 1500), + ('pbl_blueprint_list', '蓝图列表', '/pbl_overlay/blueprint_list.ui', 'data_table', 'teacher', 1500), + ('pbl_blueprint_editor', '蓝图编辑器', '/pbl_overlay/blueprint_editor.ui', 'form_tree', 'teacher', 1500), + ('pbl_validation_report', '校验报告(14维)', '/pbl_overlay/validation_report.ui', 'score_panel', 'teacher', 1500), + ('pbl_agent_console', 'Agent 控制台', '/pbl_overlay/agent_console.ui', 'chat_panel', 'teacher', 1500), + ('pbl_approval_center', '人工审批中心', '/pbl_overlay/approval_center.ui', 'approval_list', 'teacher', 1500), + ('pbl_play_preview', 'Play 预览', '/pbl_overlay/play_preview.ui', 'world_overlay', 'logined', 500), + ('pbl_mission_board', '驱动问题板', '/pbl_overlay/mission_board.ui', 'mission_board', 'logined', 500), + ('pbl_role_panel', '角色面板', '/pbl_overlay/role_panel.ui', 'role_cards', 'logined', 500), + ('pbl_artifact_studio', '产出物工坊', '/pbl_overlay/artifact_studio.ui', 'editor_panel', 'logined', 500), + ('pbl_evidence_wall', '证据墙', '/pbl_overlay/evidence_wall.ui', 'evidence_grid', 'logined', 500), + ('pbl_assessment_report', '评估报告', '/pbl_overlay/assessment_report.ui', 'rubric_report', 'teacher', 1500), +] +WORLD_PAGES = {p[0] for p in PAGES if p[4] == 500} + + +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_page_registry_list(**kw): + """页面注册表:首次访问自动登记,前端按 route 挂载覆盖层组件。""" + tid = await _tid() + sc = crud('pbl_ui_page_registry', 'pbl', ['page_key', 'title', 'route', 'widget_type', + 'requires_role', 'budget_ms', 'enabled', + 'meta_json']) + for key, title, route, widget, role, budget in PAGES: + await sc['upsert'](['page_key'], page_key=key, title=title, route=route, + widget_type=widget, requires_role=role, budget_ms=budget, + enabled=1, meta_json=json_dump({'in_world': key in WORLD_PAGES})) + r = await sc['list'](**kw) + r['page_count'] = len(r['data']) + r['budgets'] = {'world_overlay_ms': 500, 'first_screen_ms': 1500} + return r + + +async def pbl_overlay_bootstrap(**kw): + """覆盖层启动上下文:一次请求拿齐 会话/角色/团队/Mission/页面注册,避免串行多次往返。""" + t0 = time.time() + tid = await _tid() + user = kw.get('user_id') or await actor_id() + session_id = kw.get('session_id') + ctx = {'ok': True, 'tenant': tid, 'user_id': user, 'session_id': session_id, + 'server_time': now_str()} + if session_id: + mem = await sql_rows('SELECT * FROM `pbl_session_member` WHERE `tenant_id` = ${t}$' + ' AND `session_id` = ${s}$ AND `user_id` = ${u}$ LIMIT 1', + {'t': tid, 's': session_id, 'u': user}, 'pbl') + ctx['membership'] = mem[0] if mem else None + ctx['state_version'] = int(mem[0]['state_version'] or 0) if mem else 0 + teams = await sql_rows('SELECT * FROM `pbl_team` WHERE `tenant_id` = ${t}$' + ' AND `session_id` = ${s}$ LIMIT 8', {'t': tid, 's': session_id}, + 'pbl') + for t2 in teams: + t2['members_json'] = _loads(t2.get('members_json'), []) + ctx['teams'] = teams + ev = await sql_rows('SELECT `event_type`,`payload_json`,`seq` FROM `pbl_runtime_event`' + ' WHERE `tenant_id` = ${t}$ AND `session_id` = ${s}$' + ' ORDER BY `seq` DESC LIMIT ${n}$', + {'t': tid, 's': session_id, 'n': int(kw.get('recent_events') or 20)}, + 'pbl') + for e in ev: + e['payload_json'] = _loads(e.get('payload_json'), {}) + ctx['recent_events'] = list(reversed(ev)) + pages = await sql_rows('SELECT `page_key`,`title`,`route`,`widget_type`,`requires_role`,' + '`budget_ms` FROM `pbl_ui_page_registry` WHERE `tenant_id` = ${t}$' + ' AND `enabled` = 1 ORDER BY `page_key`', {'t': tid}, 'pbl') + ctx['pages'] = pages + ctx['unlocked_missions'] = [e['payload_json'].get('mission') for e in ctx.get( + 'recent_events', []) if e['event_type'] == 'pbl.unlockMission'] + ctx['latency_ms'] = int((time.time() - t0) * 1000) + ctx['budget_ms'] = 1500 + ctx['within_budget'] = ctx['latency_ms'] <= 1500 + return ctx + + +async def pbl_session_context_get(**kw): + """只读会话上下文(供覆盖层角色/团队面板;无任何写路径)。""" + tid = await _tid() + session_id = kw.get('session_id') + members = await sql_rows('SELECT `user_id`,`role_code`,`team_id`,`state_version`,`status`' + ' FROM `pbl_session_member` WHERE `tenant_id` = ${t}$' + ' AND `session_id` = ${s}$', {'t': tid, 's': session_id}, 'pbl') + return {'ok': True, 'members': members, 'count': len(members), 'max_members': 8, + 'authority': 'server'} + + +async def pbl_playtest_feedback_save(**kw): + ftype = kw.get('feedback_type') or 'feedback' + if ftype not in ('feedback', 'reflection'): + raise PblError('PBL_FEEDBACK_TYPE_INVALID', 'feedback_type 须为 feedback/reflection') + sc = crud('pbl_playtest_feedback', 'pbl', ['session_id', 'user_id', 'blueprint_id', + 'feedback_type', 'rating', 'content_txt', + 'context_json', 'handled']) + payload = dict(kw) + payload['user_id'] = kw.get('user_id') or await actor_id() + payload['context_json'] = json_dump(kw.get('context_json')) + if ftype == 'reflection': + # 反思同时进证据链(第19章四类证据之一),由 pbl_evidence 幂等落库 + from pbl_evidence.api import pbl_evidence_collect + r = await pbl_evidence_collect( + evidence_type='reflection', session_id=kw.get('session_id') or 0, + learner_id=payload['user_id'], blueprint_id=kw.get('blueprint_id'), + source_event_id='refl:%s:%s' % (payload['user_id'], now_str().replace(' ', '')), + payload_json={'content': kw.get('content_txt'), 'rating': kw.get('rating')}) + payload.setdefault('handled', 1 if r.get('ok') else 0) + return await sc['create'](**payload) diff --git a/pbl_scense_ext/init.py b/pbl_scense_ext/init.py new file mode 100644 index 0000000..0b1ab1b --- /dev/null +++ b/pbl_scense_ext/init.py @@ -0,0 +1,25 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +"""`load_pbl_scense_ext()` —— pbl_scense_ext 模块唯一挂载入口。 + +注册三处同步之 ③:env.<契约名> = <契约名>(① 定义在 api.py,② 导出在 __init__.py)。 +""" +from ahserver.serverenv import ServerEnv + +from pbl_scense_ext.api import ( + pbl_session_context_get, + pbl_page_registry_list, + pbl_playtest_feedback_save, + pbl_overlay_bootstrap, + +) + + +def load_pbl_scense_ext(): + env = ServerEnv() + env.pbl_session_context_get = pbl_session_context_get + env.pbl_page_registry_list = pbl_page_registry_list + env.pbl_playtest_feedback_save = pbl_playtest_feedback_save + env.pbl_overlay_bootstrap = pbl_overlay_bootstrap + + return 'pbl_scense_ext' diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..38ab3c5 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,13 @@ +[project] +name = "pbl_scense_ext" +version = "0.1.0" +description = "scense/scense_game 前端覆盖层与页面注册(M9)" +requires-python = ">=3.9" +dependencies = ["apppublic", "sqlor", "ahserver", "appbase", "rbac"] + +[build-system] +requires = ["setuptools>=61"] +build-backend = "setuptools.build_meta" + +[tool.setuptools] +packages = ["pbl_scense_ext"] diff --git a/scripts/load_path.py b/scripts/load_path.py new file mode 100644 index 0000000..afae9eb --- /dev/null +++ b/scripts/load_path.py @@ -0,0 +1,43 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +"""pbl_scense_ext RBAC 路径注册(硬门禁 6.6 / QC #11)。 + +约定: +- 路径 = 模块自动路由 `/pbl_scense_ext/api/<契约>.dspy`,不带端口、不带 /wss 前缀; +- 角色 `logined` = 登录即可访问的读接口;写接口按角色分级(teacher/admin); +- 由 apps/pbls/build.sh 第 8 步调用 `register()`;rbac CLI 不在位时打印清单(不静默跳过)。 +""" +import os +import subprocess +import sys + +MODULE = 'pbl_scense_ext' + +# (path, role) +PATHS = [ + ('/pbl_scense_ext/api/pbl_session_context_get.dspy', 'logined'), + ('/pbl_scense_ext/api/pbl_page_registry_list.dspy', 'logined'), + ('/pbl_scense_ext/api/pbl_playtest_feedback_save.dspy', 'logined'), + ('/pbl_scense_ext/api/pbl_overlay_bootstrap.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..6524d85 --- /dev/null +++ b/skill/SKILL.md @@ -0,0 +1,24 @@ +# pbl_scense_ext 模块技能(自动生成骨架 + 人工补充) + +## 定位 +scense/scense_game 前端覆盖层与页面注册(M9) + +## 挂载 +`from pbl_scense_ext.init import load_pbl_scense_ext` → `load_pbl_scense_ext()`(应用 app/pbls.py init() 中按序调用) + +## 数据表(3 张) +- `pbl_session_member`:会话成员(复用 world_session,不污染基表) +- `pbl_playtest_feedback`:Playtest 反馈/反思(reflection 走证据链外的轻量表) +- `pbl_ui_page_registry`:前端覆盖层页面注册(12 页面 + 时延预算) + +## 契约接口(4 个,路径 `/pbl_scense_ext/api/.dspy`) +- `pbl_session_context_get` +- `pbl_page_registry_list` +- `pbl_playtest_feedback_save` +- `pbl_overlay_bootstrap` + +## 陷阱 +- 库名一律 `ServerEnv().get_module_dbname('pbl_scense_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_scense_ext.sql b/sql/pbl_scense_ext.sql new file mode 100644 index 0000000..2b7a473 --- /dev/null +++ b/sql/pbl_scense_ext.sql @@ -0,0 +1,52 @@ +-- pbl_scense_ext 表 DDL(自动生成,与 apps/pbls/scripts/ddl/pbls_tables.sql 同源) +CREATE TABLE IF NOT EXISTS `pbl_session_member` ( + `tenant_id` VARCHAR(32) NOT NULL NOT NULL COMMENT 租户ID(强制打头), + `id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT COMMENT 主键, + `session_id` BIGINT UNSIGNED NOT NULL COMMENT "会话", + `user_id` VARCHAR(32) NOT NULL COMMENT "用户", + `role_code` VARCHAR(64) NOT NULL COMMENT "角色", + `team_id` BIGINT UNSIGNED NOT NULL COMMENT "团队", + `joined_at` DATETIME NULL NULL COMMENT "加入时间", + `last_seen_at` DATETIME NULL NULL COMMENT "心跳", + `state_version` BIGINT NOT NULL DEFAULT 0 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_sm` (`tenant_id`, `session_id`, `user_id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci COMMENT="会话成员(复用 world_session,不污染基表)"; + +CREATE TABLE IF NOT EXISTS `pbl_playtest_feedback` ( + `tenant_id` VARCHAR(32) NOT NULL NOT NULL COMMENT 租户ID(强制打头), + `id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT COMMENT 主键, + `session_id` BIGINT UNSIGNED NOT NULL COMMENT "会话", + `user_id` VARCHAR(32) NOT NULL COMMENT "用户", + `blueprint_id` BIGINT UNSIGNED NOT NULL COMMENT "蓝图", + `feedback_type` VARCHAR(64) NOT NULL COMMENT "类型", + `rating` INT NOT NULL DEFAULT 0 COMMENT "评分", + `content_txt` TEXT NULL COMMENT "内容", + `context_json` LONGTEXT NULL COMMENT "上下文", + `handled` TINYINT(1) NOT NULL DEFAULT 0 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`), + KEY `idx_pf_session` (`tenant_id`, `session_id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci COMMENT="Playtest 反馈/反思(reflection 走证据链外的轻量表)"; + +CREATE TABLE IF NOT EXISTS `pbl_ui_page_registry` ( + `tenant_id` VARCHAR(32) NOT NULL NOT NULL COMMENT 租户ID(强制打头), + `id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT COMMENT 主键, + `page_key` VARCHAR(64) NOT NULL COMMENT "页面键", + `title` VARCHAR(128) NOT NULL COMMENT "标题", + `route` VARCHAR(64) NOT NULL COMMENT "路由", + `widget_type` VARCHAR(64) NOT NULL COMMENT "渲染组件", + `requires_role` VARCHAR(64) NOT NULL COMMENT "所需角色", + `budget_ms` INT NOT NULL DEFAULT 0 COMMENT "渲染预算", + `enabled` TINYINT(1) NOT NULL DEFAULT 0 COMMENT "启用", + `meta_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_upr_key` (`tenant_id`, `page_key`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci COMMENT="前端覆盖层页面注册(12 页面 + 时延预算)"; + diff --git a/wwwroot/api/pbl_overlay_bootstrap.dspy b/wwwroot/api/pbl_overlay_bootstrap.dspy new file mode 100644 index 0000000..0afdded --- /dev/null +++ b/wwwroot/api/pbl_overlay_bootstrap.dspy @@ -0,0 +1,4 @@ +# pbl_scense_ext/api/pbl_overlay_bootstrap.dspy —— 契约端点(自动生成,勿手改:改 spec 后跑 gen_artifacts.py) +debug('pbl_scense_ext/api/pbl_overlay_bootstrap.dspy: START params_kw={dict(params_kw)}') +data = await pbl_overlay_bootstrap(**params_kw) +return data diff --git a/wwwroot/api/pbl_page_registry_list.dspy b/wwwroot/api/pbl_page_registry_list.dspy new file mode 100644 index 0000000..a843222 --- /dev/null +++ b/wwwroot/api/pbl_page_registry_list.dspy @@ -0,0 +1,4 @@ +# pbl_scense_ext/api/pbl_page_registry_list.dspy —— 契约端点(自动生成,勿手改:改 spec 后跑 gen_artifacts.py) +debug('pbl_scense_ext/api/pbl_page_registry_list.dspy: START params_kw={dict(params_kw)}') +data = await pbl_page_registry_list(**params_kw) +return data diff --git a/wwwroot/api/pbl_playtest_feedback_save.dspy b/wwwroot/api/pbl_playtest_feedback_save.dspy new file mode 100644 index 0000000..94a8d2b --- /dev/null +++ b/wwwroot/api/pbl_playtest_feedback_save.dspy @@ -0,0 +1,4 @@ +# pbl_scense_ext/api/pbl_playtest_feedback_save.dspy —— 契约端点(自动生成,勿手改:改 spec 后跑 gen_artifacts.py) +debug('pbl_scense_ext/api/pbl_playtest_feedback_save.dspy: START params_kw={dict(params_kw)}') +data = await pbl_playtest_feedback_save(**params_kw) +return data diff --git a/wwwroot/api/pbl_session_context_get.dspy b/wwwroot/api/pbl_session_context_get.dspy new file mode 100644 index 0000000..77c9ec4 --- /dev/null +++ b/wwwroot/api/pbl_session_context_get.dspy @@ -0,0 +1,4 @@ +# pbl_scense_ext/api/pbl_session_context_get.dspy —— 契约端点(自动生成,勿手改:改 spec 后跑 gen_artifacts.py) +debug('pbl_scense_ext/api/pbl_session_context_get.dspy: START params_kw={dict(params_kw)}') +data = await pbl_session_context_get(**params_kw) +return data diff --git a/wwwroot/index.ui b/wwwroot/index.ui new file mode 100644 index 0000000..1e75550 --- /dev/null +++ b/wwwroot/index.ui @@ -0,0 +1,147 @@ +{ + "widgettype": "VBox", + "options": { + "width": "100%", + "height": "100%", + "padding": "20px" + }, + "subwidgets": [ + { + "widgettype": "Text", + "options": { + "label": "scense/scense_game 前端覆盖层与页面注册(M9)", + "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_scense_ext_content", + "options": { + "url": "{{entire_url('api/pbl_session_context_get.dspy')}}" + }, + "mode": "replace" + } + ], + "subwidgets": [ + { + "widgettype": "Text", + "options": { + "label": "pbl_session_context_get" + } + } + ] + }, + { + "widgettype": "VBox", + "options": { + "backgroundColor": "#FFFFFF", + "padding": "20px", + "cursor": "pointer" + }, + "binds": [ + { + "wid": "self", + "event": "click", + "actiontype": "urlwidget", + "target": "app.pbl_scense_ext_content", + "options": { + "url": "{{entire_url('api/pbl_page_registry_list.dspy')}}" + }, + "mode": "replace" + } + ], + "subwidgets": [ + { + "widgettype": "Text", + "options": { + "label": "pbl_page_registry_list" + } + } + ] + }, + { + "widgettype": "VBox", + "options": { + "backgroundColor": "#FFFFFF", + "padding": "20px", + "cursor": "pointer" + }, + "binds": [ + { + "wid": "self", + "event": "click", + "actiontype": "urlwidget", + "target": "app.pbl_scense_ext_content", + "options": { + "url": "{{entire_url('api/pbl_playtest_feedback_save.dspy')}}" + }, + "mode": "replace" + } + ], + "subwidgets": [ + { + "widgettype": "Text", + "options": { + "label": "pbl_playtest_feedback_save" + } + } + ] + }, + { + "widgettype": "VBox", + "options": { + "backgroundColor": "#FFFFFF", + "padding": "20px", + "cursor": "pointer" + }, + "binds": [ + { + "wid": "self", + "event": "click", + "actiontype": "urlwidget", + "target": "app.pbl_scense_ext_content", + "options": { + "url": "{{entire_url('api/pbl_overlay_bootstrap.dspy')}}" + }, + "mode": "replace" + } + ], + "subwidgets": [ + { + "widgettype": "Text", + "options": { + "label": "pbl_overlay_bootstrap" + } + } + ] + } + ] + }, + { + "widgettype": "VBox", + "id": "pbl_scense_ext_content", + "options": { + "width": "100%", + "flex": "1", + "marginTop": "20px" + } + } + ] +}