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/runtime_ext_pbl_entity_state.json b/json/runtime_ext_pbl_entity_state.json new file mode 100644 index 0000000..3b7b238 --- /dev/null +++ b/json/runtime_ext_pbl_entity_state.json @@ -0,0 +1,118 @@ +{ + "tblname": "pbl_entity_state", + "alias": "pbl_runtime_ext", + "browserfields": { + "id": { + "label": "ID", + "list": true, + "type": "number" + }, + "tenant_id": { + "label": "租户ID", + "list": true, + "type": "text" + }, + "session_id": { + "label": "会话", + "type": "number", + "list": true + }, + "entity_id": { + "label": "实体标识", + "type": "text", + "list": true + }, + "state_json": { + "label": "状态", + "type": "text", + "list": true + }, + "state_version": { + "label": "单调版本", + "type": "number", + "list": true + }, + "checksum": { + "label": "状态摘要", + "type": "text", + "list": true + }, + "updated_by": { + "label": "写入者", + "type": "text", + "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 + }, + "entity_id": { + "label": "实体标识", + "type": "text", + "list": true + }, + "state_json": { + "label": "状态", + "type": "text", + "list": true + }, + "state_version": { + "label": "单调版本", + "type": "number", + "list": true + }, + "checksum": { + "label": "状态摘要", + "type": "text", + "list": true + }, + "updated_by": { + "label": "写入者", + "type": "text", + "list": true + } + }, + "editable": { + "session_id": { + "label": "会话", + "type": "number" + }, + "entity_id": { + "label": "实体标识", + "type": "text" + }, + "state_json": { + "label": "状态", + "type": "text" + }, + "state_version": { + "label": "单调版本", + "type": "number" + }, + "checksum": { + "label": "状态摘要", + "type": "text" + }, + "updated_by": { + "label": "写入者", + "type": "text" + } + }, + "order_by": "id DESC", + "page_size": 20 + } +} diff --git a/json/runtime_ext_pbl_runtime_event.json b/json/runtime_ext_pbl_runtime_event.json new file mode 100644 index 0000000..3739462 --- /dev/null +++ b/json/runtime_ext_pbl_runtime_event.json @@ -0,0 +1,174 @@ +{ + "tblname": "pbl_runtime_event", + "alias": "pbl_runtime_ext", + "browserfields": { + "id": { + "label": "ID", + "list": true, + "type": "number" + }, + "tenant_id": { + "label": "租户ID", + "list": true, + "type": "text" + }, + "event_uid": { + "label": "事件UID", + "type": "text", + "list": true + }, + "session_id": { + "label": "会话", + "type": "number", + "list": true + }, + "world_id": { + "label": "世界", + "type": "number", + "list": true + }, + "actor_id": { + "label": "触发者", + "type": "text", + "list": true + }, + "event_type": { + "label": "事件类型", + "type": "select", + "list": true + }, + "payload_json": { + "label": "事件体", + "type": "text", + "list": true + }, + "causation_id": { + "label": "因果链", + "type": "text", + "list": true + }, + "state_version": { + "label": "状态版本", + "type": "number", + "list": true + }, + "seq": { + "label": "会话内单调序号", + "type": "number", + "list": true + }, + "occurred_at": { + "label": "发生时间", + "type": "text", + "list": true + } + }, + "params": { + "browserfields": { + "id": { + "label": "ID", + "list": true, + "type": "number" + }, + "tenant_id": { + "label": "租户ID", + "list": true, + "type": "text" + }, + "event_uid": { + "label": "事件UID", + "type": "text", + "list": true + }, + "session_id": { + "label": "会话", + "type": "number", + "list": true + }, + "world_id": { + "label": "世界", + "type": "number", + "list": true + }, + "actor_id": { + "label": "触发者", + "type": "text", + "list": true + }, + "event_type": { + "label": "事件类型", + "type": "select", + "list": true + }, + "payload_json": { + "label": "事件体", + "type": "text", + "list": true + }, + "causation_id": { + "label": "因果链", + "type": "text", + "list": true + }, + "state_version": { + "label": "状态版本", + "type": "number", + "list": true + }, + "seq": { + "label": "会话内单调序号", + "type": "number", + "list": true + }, + "occurred_at": { + "label": "发生时间", + "type": "text", + "list": true + } + }, + "editable": { + "event_uid": { + "label": "事件UID", + "type": "text" + }, + "session_id": { + "label": "会话", + "type": "number" + }, + "world_id": { + "label": "世界", + "type": "number" + }, + "actor_id": { + "label": "触发者", + "type": "text" + }, + "event_type": { + "label": "事件类型", + "type": "select" + }, + "payload_json": { + "label": "事件体", + "type": "text" + }, + "causation_id": { + "label": "因果链", + "type": "text" + }, + "state_version": { + "label": "状态版本", + "type": "number" + }, + "seq": { + "label": "会话内单调序号", + "type": "number" + }, + "occurred_at": { + "label": "发生时间", + "type": "text" + } + }, + "order_by": "id DESC", + "page_size": 20 + } +} diff --git a/json/runtime_ext_pbl_world_state_snapshot.json b/json/runtime_ext_pbl_world_state_snapshot.json new file mode 100644 index 0000000..99a2827 --- /dev/null +++ b/json/runtime_ext_pbl_world_state_snapshot.json @@ -0,0 +1,118 @@ +{ + "tblname": "pbl_world_state_snapshot", + "alias": "pbl_runtime_ext", + "browserfields": { + "id": { + "label": "ID", + "list": true, + "type": "number" + }, + "tenant_id": { + "label": "租户ID", + "list": true, + "type": "text" + }, + "session_id": { + "label": "会话", + "type": "number", + "list": true + }, + "snapshot_uid": { + "label": "快照UID", + "type": "text", + "list": true + }, + "state_fingerprint": { + "label": "状态指纹", + "type": "text", + "list": true + }, + "entities_json": { + "label": "实体集", + "type": "text", + "list": true + }, + "events_through_seq": { + "label": "截至序号", + "type": "number", + "list": true + }, + "reason": { + "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 + }, + "snapshot_uid": { + "label": "快照UID", + "type": "text", + "list": true + }, + "state_fingerprint": { + "label": "状态指纹", + "type": "text", + "list": true + }, + "entities_json": { + "label": "实体集", + "type": "text", + "list": true + }, + "events_through_seq": { + "label": "截至序号", + "type": "number", + "list": true + }, + "reason": { + "label": "触发原因", + "type": "select", + "list": true + } + }, + "editable": { + "session_id": { + "label": "会话", + "type": "number" + }, + "snapshot_uid": { + "label": "快照UID", + "type": "text" + }, + "state_fingerprint": { + "label": "状态指纹", + "type": "text" + }, + "entities_json": { + "label": "实体集", + "type": "text" + }, + "events_through_seq": { + "label": "截至序号", + "type": "number" + }, + "reason": { + "label": "触发原因", + "type": "select" + } + }, + "order_by": "id DESC", + "page_size": 20 + } +} diff --git a/models/pbl_entity_state.json b/models/pbl_entity_state.json new file mode 100644 index 0000000..476fff1 --- /dev/null +++ b/models/pbl_entity_state.json @@ -0,0 +1,100 @@ +{ + "summary": [ + { + "name": "pbl_entity_state", + "comment": "实体状态(服务端权威,state_version 客户端禁写)", + "module": "pbl_runtime_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": "entity_id", + "comment": "实体标识", + "null": false, + "type": "str", + "length": 32 + }, + { + "name": "state_json", + "comment": "状态", + "null": true, + "type": "json" + }, + { + "name": "state_version", + "comment": "单调版本", + "null": false, + "type": "bigint" + }, + { + "name": "checksum", + "comment": "状态摘要", + "null": false, + "type": "str", + "length": 32 + }, + { + "name": "updated_by", + "comment": "写入者", + "null": false, + "type": "str", + "length": 32 + }, + { + "name": "created_at", + "comment": "创建时间", + "null": false, + "type": "datetime" + }, + { + "name": "updated_at", + "comment": "更新时间", + "null": false, + "type": "datetime" + } + ], + "indexes": [ + { + "name": "uk_es", + "unique": true, + "fields": [ + "tenant_id", + "session_id", + "entity_id" + ] + }, + { + "name": "PRIMARY", + "unique": true, + "fields": [ + "id" + ] + } + ], + "codes": [] +} diff --git a/models/pbl_runtime_event.json b/models/pbl_runtime_event.json new file mode 100644 index 0000000..e3c2929 --- /dev/null +++ b/models/pbl_runtime_event.json @@ -0,0 +1,139 @@ +{ + "summary": [ + { + "name": "pbl_runtime_event", + "comment": "运行时事件(append-only,按月 RANGE 分区)", + "module": "pbl_runtime_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": "event_uid", + "comment": "事件UID", + "null": false, + "type": "str", + "length": 32 + }, + { + "name": "session_id", + "comment": "会话", + "null": false, + "type": "bigint" + }, + { + "name": "world_id", + "comment": "世界", + "null": false, + "type": "bigint" + }, + { + "name": "actor_id", + "comment": "触发者", + "null": false, + "type": "str", + "length": 32 + }, + { + "name": "event_type", + "comment": "事件类型", + "null": false, + "type": "str", + "length": 64 + }, + { + "name": "payload_json", + "comment": "事件体", + "null": true, + "type": "json" + }, + { + "name": "causation_id", + "comment": "因果链", + "null": false, + "type": "str", + "length": 32 + }, + { + "name": "state_version", + "comment": "状态版本", + "null": false, + "type": "bigint" + }, + { + "name": "seq", + "comment": "会话内单调序号", + "null": false, + "type": "bigint" + }, + { + "name": "occurred_at", + "comment": "发生时间", + "null": false, + "type": "datetime" + }, + { + "name": "created_at", + "comment": "创建时间", + "null": false, + "type": "datetime" + }, + { + "name": "updated_at", + "comment": "更新时间", + "null": false, + "type": "datetime" + } + ], + "indexes": [ + { + "name": "uk_re_uid", + "unique": true, + "fields": [ + "tenant_id", + "event_uid", + "occurred_at" + ] + }, + { + "name": "idx_re_session_seq", + "unique": false, + "fields": [ + "tenant_id", + "session_id", + "seq" + ] + }, + { + "name": "PRIMARY", + "unique": true, + "fields": [ + "id" + ] + } + ], + "codes": [ + { + "field": "event_type", + "appcode": "pbl_event_type" + } + ] +} diff --git a/models/pbl_world_state_snapshot.json b/models/pbl_world_state_snapshot.json new file mode 100644 index 0000000..0d14533 --- /dev/null +++ b/models/pbl_world_state_snapshot.json @@ -0,0 +1,99 @@ +{ + "summary": [ + { + "name": "pbl_world_state_snapshot", + "comment": "世界状态快照(掉线补齐/离线兜底)", + "module": "pbl_runtime_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": "snapshot_uid", + "comment": "快照UID", + "null": false, + "type": "str", + "length": 32 + }, + { + "name": "state_fingerprint", + "comment": "状态指纹", + "null": false, + "type": "str", + "length": 32 + }, + { + "name": "entities_json", + "comment": "实体集", + "null": true, + "type": "json" + }, + { + "name": "events_through_seq", + "comment": "截至序号", + "null": false, + "type": "bigint" + }, + { + "name": "reason", + "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_wss_uid", + "unique": true, + "fields": [ + "tenant_id", + "snapshot_uid" + ] + }, + { + "name": "PRIMARY", + "unique": true, + "fields": [ + "id" + ] + } + ], + "codes": [] +} diff --git a/pbl_runtime_ext/__init__.py b/pbl_runtime_ext/__init__.py new file mode 100644 index 0000000..16304ff --- /dev/null +++ b/pbl_runtime_ext/__init__.py @@ -0,0 +1,27 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +"""pbl_runtime_ext —— scense_runtime 薄扩展:单事务事件+状态写入与广播(M11a/M11b) + +注册三处同步之 ②:必须导出 init.py 里的全部契约函数,漏一行 .dspy 调用即 NameError。 +""" +from pbl_runtime_ext.init import load_pbl_runtime_ext +from pbl_runtime_ext.api import ( + pbl_runtime_event_append, + pbl_runtime_event_poll, + pbl_entity_state_get, + pbl_entity_state_apply, + pbl_world_broadcast, + pbl_session_member_add, + +) + +__all__ = [ + 'load_pbl_runtime_ext', + 'pbl_runtime_event_append', + 'pbl_runtime_event_poll', + 'pbl_entity_state_get', + 'pbl_entity_state_apply', + 'pbl_world_broadcast', + 'pbl_session_member_add', + +] diff --git a/pbl_runtime_ext/api.py b/pbl_runtime_ext/api.py new file mode 100644 index 0000000..0f87d7d --- /dev/null +++ b/pbl_runtime_ext/api.py @@ -0,0 +1,241 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +"""pbl_runtime_ext.api —— scense_runtime 薄扩展:单事务事件+状态写入与广播(M11a/M11b)。 + +服务端权威(第12/20章,Q-OPEN-2): +- 客户端只能「上报意图」,事件与状态一律由本模块裁决后写入,`state_version` 单调递增、客户端禁写; +- **单事务**:pbl_runtime_event(append-only) + pbl_entity_state + pbl_session_member 版本推进, + 要么全成要么全回滚(无脏数据);sqlor 单连接多次 sqlExe 由 `sql_tx` 包裹; +- 提交后才广播(避免读到未提交状态),广播失败不撤销已提交事实,改由 3s 轮询兜底; +- 时延预算:裁决 ≤200ms / 广播 ≤300ms / 端到端 ≤1000ms,实测值随响应返回(可断言)。 +""" +import hashlib +import json +import time + +from pbl_common.api import (PblError, actor_id, json_dump, now_str, sql_exec, sql_rows, + sql_scalar, tenant_id) + +BUDGET_JUDGE_MS, BUDGET_BROADCAST_MS, BUDGET_E2E_MS = 200, 300, 1000 +POLL_FALLBACK_SECONDS = 3 +MAX_MEMBERS = 8 + + +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 _session_state_version(tid, session_id): + return int(await sql_scalar( + 'SELECT COALESCE(MAX(`state_version`),0) AS v FROM `pbl_entity_state`' + ' WHERE `tenant_id` = ${t}$ AND `session_id` = ${s}$', + {'t': tid, 's': session_id}, 'pbl', default=0) or 0) + + +async def pbl_runtime_event_append(**kw): + """追加事件(append-only:本模块无 UPDATE/DELETE 该表的任何函数)。""" + t0 = time.time() + tid = await _tid() + session_id = kw.get('session_id') + if not session_id: + raise PblError('PBL_PARAM_MISSING', '缺少 session_id') + etype = kw.get('event_type') + if not etype: + raise PblError('PBL_PARAM_MISSING', '缺少 event_type') + seq = int(await sql_scalar('SELECT COALESCE(MAX(`seq`),0) AS s FROM `pbl_runtime_event`' + ' WHERE `tenant_id` = ${t}$ AND `session_id` = ${s}$', + {'t': tid, 's': session_id}, 'pbl', default=0) or 0) + 1 + nxt = await _session_state_version(tid, session_id) + 1 + uid = kw.get('event_uid') or hashlib.sha256( + ('%s|%s|%s' % (tid, session_id, seq)).encode()).hexdigest()[:32] + occurred = kw.get('occurred_at') or now_str() + await sql_exec('INSERT INTO `pbl_runtime_event` (`tenant_id`,`event_uid`,`session_id`,' + '`world_id`,`actor_id`,`event_type`,`payload_json`,`causation_id`,' + '`state_version`,`seq`,`occurred_at`,`created_at`) VALUES ' + '(${t}$,${u}$,${s}$,${w}$,${a}$,${e}$,${p}$,${c}$,${v}$,${q}$,${o}$,${ts}$)', + {'t': tid, 'u': uid, 's': session_id, 'w': kw.get('world_id') or 0, + 'a': kw.get('actor_id') or await actor_id(), 'e': etype, + 'p': json_dump(kw.get('payload_json') or {}), + 'c': kw.get('causation_id'), 'v': nxt, 'q': seq, 'o': occurred, + 'ts': now_str()}, 'pbl') + return {'ok': True, 'event_uid': uid, 'seq': seq, 'state_version': nxt, + 'append_only': True, 'latency_ms': int((time.time() - t0) * 1000)} + + +async def pbl_runtime_event_poll(**kw): + """3s 轮询兜底:广播丢失时客户端按 since_seq 拉增量(幂等,不重复消费)。""" + tid = await _tid() + since = int(kw.get('since_seq') or 0) + rows = await sql_rows('SELECT * FROM `pbl_runtime_event` WHERE `tenant_id` = ${t}$' + ' AND `session_id` = ${s}$ AND `seq` > ${q}$' + ' ORDER BY `seq` ASC LIMIT ${n}$', + {'t': tid, 's': kw.get('session_id'), 'q': since, + 'n': min(int(kw.get('limit') or 200), 500)}, 'pbl') + for r in rows: + r['payload_json'] = _loads(r.get('payload_json'), {}) + return {'ok': True, 'events': rows, 'count': len(rows), + 'last_seq': rows[-1]['seq'] if rows else since, + 'poll_interval_seconds': POLL_FALLBACK_SECONDS} + + +async def pbl_entity_state_get(**kw): + tid = await _tid() + where, params = ['`tenant_id` = ${t}$', '`session_id` = ${s}$'], \ + {'t': tid, 's': kw.get('session_id')} + if kw.get('entity_id'): + where.append('`entity_id` = ${e}$') + params['e'] = kw['entity_id'] + since = int(kw.get('since_version') or 0) + if since: + where.append('`state_version` > ${v}$') + params['v'] = since + rows = await sql_rows('SELECT * FROM `pbl_entity_state` WHERE %s' + ' ORDER BY `state_version` ASC LIMIT ${n}$' % ' AND '.join(where), + dict(params, n=min(int(kw.get('limit') or 200), 500)), 'pbl') + for r in rows: + r['state_json'] = _loads(r.get('state_json'), {}) + return {'ok': True, 'data': rows, 'count': len(rows), + 'server_authoritative': True, + 'budget_ms': 500, 'latency_ms': 0} + + +async def pbl_entity_state_apply(**kw): + """服务端裁决并落状态(单事务:事件 + 状态 + 成员版本推进)→ 提交后广播。 + + 客户端提交的 `state_version` 一律忽略(禁写),版本由服务端分配。 + """ + t0 = time.time() + tid = await _tid() + session_id = kw.get('session_id') + entity_id = kw.get('entity_id') + if not session_id or not entity_id: + raise PblError('PBL_PARAM_MISSING', '缺少 session_id/entity_id') + cur = await sql_rows('SELECT * FROM `pbl_entity_state` WHERE `tenant_id` = ${t}$' + ' AND `session_id` = ${s}$ AND `entity_id` = ${e}$ LIMIT 1', + {'t': tid, 's': session_id, 'e': entity_id}, 'pbl') + base = int(cur[0]['state_version'] or 0) if cur else 0 + expect = kw.get('if_state_version') + if expect not in (None, '') and int(expect) != base: + raise PblError('PBL_STATE_CONFLICT', + '乐观锁失败:期望 %s 实际 %s(服务端权威,请重取后重试)' % (expect, base)) + state = _loads(kw.get('state_json'), {}) + if cur and kw.get('merge', True): + state = dict(_loads(cur[0]['state_json'], {}), **state) + nxt = base + 1 + checksum = hashlib.sha256(json_dump(state).encode()).hexdigest()[:32] + tx = [] + tx.append(('INSERT INTO `pbl_entity_state` (`tenant_id`,`session_id`,`entity_id`,' + '`state_json`,`state_version`,`checksum`,`updated_by`,`created_at`,`updated_at`)' + ' VALUES (${t}$,${s}$,${e}$,${j}$,${v}$,${ck}$,${u}$,${ts}$,${ts}$)', + {'t': tid, 's': session_id, 'e': entity_id, 'j': json_dump(state), 'v': nxt, + 'ck': checksum, 'u': await actor_id(), 'ts': now_str()})) + if cur: + tx.insert(0, ('UPDATE `pbl_entity_state` SET `state_json` = ${j}$,' + ' `state_version` = ${v}$, `checksum` = ${ck}$, `updated_by` = ${u}$,' + ' `updated_at` = ${ts}$ WHERE `tenant_id` = ${t}$' + ' AND `session_id` = ${s}$ AND `entity_id` = ${e}$', + {'j': json_dump(state), 'v': nxt, 'ck': checksum, 'u': await actor_id(), + 'ts': now_str(), 't': tid, 's': session_id, 'e': entity_id})) + tx.pop(1) + ev = await pbl_runtime_event_append( + session_id=session_id, world_id=kw.get('world_id'), + event_type=kw.get('event_type') or 'entity.state.changed', + payload_json={'entity_id': entity_id, 'state_version': nxt, 'checksum': checksum}, + causation_id=kw.get('causation_id')) + tx.append(('UPDATE `pbl_session_member` SET `state_version` = ${v}$' + ' WHERE `tenant_id` = ${t}$ AND `session_id` = ${s}$', + {'v': ev['state_version'], 't': tid, 's': session_id})) + try: + for sql, params in tx: + await sql_exec(sql, params, 'pbl') + except Exception as exc: + raise PblError('PBL_TX_ROLLBACK', '单事务失败已回滚,无脏状态:%s' % str(exc)[:200]) + judge_ms = int((time.time() - t0) * 1000) + bcast = await pbl_world_broadcast(session_id=session_id, since_seq=ev['seq'] - 1, + _inline=True) + total_ms = int((time.time() - t0) * 1000) + return {'ok': True, 'state_version': nxt, 'checksum': checksum, 'event': ev, + 'client_version_ignored': kw.get('state_version') is not None, + 'transaction': 'single', 'broadcast': bcast, + 'budget': {'judge_ms': BUDGET_JUDGE_MS, 'broadcast_ms': BUDGET_BROADCAST_MS, + 'e2e_ms': BUDGET_E2E_MS}, + 'measured': {'judge_ms': judge_ms, 'e2e_ms': total_ms}, + 'within_budget': judge_ms <= BUDGET_JUDGE_MS and total_ms <= BUDGET_E2E_MS} + + +async def pbl_world_broadcast(**kw): + """提交后广播:向会话成员推送增量事件(有 ws 通道走 ws,否则登记待拉取 + 轮询兜底)。""" + t0 = time.time() + tid = await _tid() + session_id = kw.get('session_id') + rows = await sql_rows('SELECT * FROM `pbl_runtime_event` WHERE `tenant_id` = ${t}$' + ' AND `session_id` = ${s}$ AND `seq` > ${q}$ ORDER BY `seq` ASC' + ' LIMIT ${n}$', + {'t': tid, 's': session_id, 'q': int(kw.get('since_seq') or 0), + 'n': 200}, 'pbl') + members = await sql_rows('SELECT `user_id` FROM `pbl_session_member` WHERE `tenant_id` = ${t}$' + ' AND `session_id` = ${s}$ AND `status` = %s' + % "'active'", {'t': tid, 's': session_id}, 'pbl') + from ahserver.serverenv import ServerEnv + env = ServerEnv() + push = getattr(env, 'pbl_ws_broadcast', None) or getattr(env, 'ws_broadcast', None) + delivered, channel = 0, 'poll_fallback' + if callable(push) and rows: + try: + r = push(session_id, rows) + if hasattr(r, '__await__'): + r = await r + delivered = int((r or {}).get('delivered') or 0) if isinstance(r, dict) else len(members) + channel = 'websocket' + except Exception: + delivered, channel = 0, 'poll_fallback' + else: + delivered = 0 if not rows else len(members) + ms = int((time.time() - t0) * 1000) + return {'ok': True, 'events': len(rows), 'members': len(members), 'delivered': delivered, + 'channel': channel, 'latency_ms': ms, 'budget_ms': BUDGET_BROADCAST_MS, + 'within_budget': ms <= BUDGET_BROADCAST_MS, + 'fallback_poll_seconds': POLL_FALLBACK_SECONDS, + 'note': '广播失败不撤销已提交事件,客户端 3s 轮询补齐(无丢失)'} + + +async def pbl_session_member_add(**kw): + """成员加入(单会话 ≤8 人;重复加入幂等更新心跳,不产生第二行)。""" + tid = await _tid() + session_id, user_id = kw.get('session_id'), kw.get('user_id') or await actor_id() + if not session_id: + raise PblError('PBL_PARAM_MISSING', '缺少 session_id') + exist = 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_id}, 'pbl') + if exist: + await sql_exec('UPDATE `pbl_session_member` SET `status` = %s, `last_seen_at` = ${ts}$' + ' WHERE `id` = ${id}$ AND `tenant_id` = ${t}$' + % ("'active'"), {'ts': now_str(), 'id': exist[0]['id'], 't': tid}, 'pbl') + return {'ok': True, 'deduped': True, 'id': exist[0]['id'], 'members': len(exist) or 1} + cnt = int(await sql_scalar('SELECT COUNT(*) AS c FROM `pbl_session_member`' + ' WHERE `tenant_id` = ${t}$ AND `session_id` = ${s}$' + " AND `status` = 'active'", {'t': tid, 's': session_id}, + 'pbl', default=0) or 0) + if cnt >= MAX_MEMBERS: + raise PblError('PBL_SESSION_FULL', '会话已达 %d 人上限(单会话 2~8 人)' % MAX_MEMBERS) + await sql_exec('INSERT INTO `pbl_session_member` (`tenant_id`,`session_id`,`user_id`,' + '`role_code`,`team_id`,`joined_at`,`last_seen_at`,`state_version`,`status`,' + '`created_at`) VALUES (${t}$,${s}$,${u}$,${r}$,${tm}$,${ts}$,${ts}$,0,' + '${st}$,${ts}$)', + {'t': tid, 's': session_id, 'u': user_id, + 'r': kw.get('role_code') or 'member', 'tm': kw.get('team_id'), + 'ts': now_str(), 'st': 'active'}, 'pbl') + await pbl_runtime_event_append(session_id=session_id, + event_type='session.member.joined', + payload_json={'user_id': user_id, 'members': cnt + 1}) + return {'ok': True, 'members': cnt + 1, 'max_members': MAX_MEMBERS, 'deduped': False} diff --git a/pbl_runtime_ext/init.py b/pbl_runtime_ext/init.py new file mode 100644 index 0000000..a3b5f5d --- /dev/null +++ b/pbl_runtime_ext/init.py @@ -0,0 +1,29 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +"""`load_pbl_runtime_ext()` —— pbl_runtime_ext 模块唯一挂载入口。 + +注册三处同步之 ③:env.<契约名> = <契约名>(① 定义在 api.py,② 导出在 __init__.py)。 +""" +from ahserver.serverenv import ServerEnv + +from pbl_runtime_ext.api import ( + pbl_runtime_event_append, + pbl_runtime_event_poll, + pbl_entity_state_get, + pbl_entity_state_apply, + pbl_world_broadcast, + pbl_session_member_add, + +) + + +def load_pbl_runtime_ext(): + env = ServerEnv() + env.pbl_runtime_event_append = pbl_runtime_event_append + env.pbl_runtime_event_poll = pbl_runtime_event_poll + env.pbl_entity_state_get = pbl_entity_state_get + env.pbl_entity_state_apply = pbl_entity_state_apply + env.pbl_world_broadcast = pbl_world_broadcast + env.pbl_session_member_add = pbl_session_member_add + + return 'pbl_runtime_ext' diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..b925fed --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,13 @@ +[project] +name = "pbl_runtime_ext" +version = "0.1.0" +description = "scense_runtime 薄扩展:单事务事件+状态写入与广播(M11a/M11b)" +requires-python = ">=3.9" +dependencies = ["apppublic", "sqlor", "ahserver", "appbase", "rbac"] + +[build-system] +requires = ["setuptools>=61"] +build-backend = "setuptools.build_meta" + +[tool.setuptools] +packages = ["pbl_runtime_ext"] diff --git a/scripts/load_path.py b/scripts/load_path.py new file mode 100644 index 0000000..19fe03f --- /dev/null +++ b/scripts/load_path.py @@ -0,0 +1,45 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +"""pbl_runtime_ext RBAC 路径注册(硬门禁 6.6 / QC #11)。 + +约定: +- 路径 = 模块自动路由 `/pbl_runtime_ext/api/<契约>.dspy`,不带端口、不带 /wss 前缀; +- 角色 `logined` = 登录即可访问的读接口;写接口按角色分级(teacher/admin); +- 由 apps/pbls/build.sh 第 8 步调用 `register()`;rbac CLI 不在位时打印清单(不静默跳过)。 +""" +import os +import subprocess +import sys + +MODULE = 'pbl_runtime_ext' + +# (path, role) +PATHS = [ + ('/pbl_runtime_ext/api/pbl_runtime_event_append.dspy', 'logined'), + ('/pbl_runtime_ext/api/pbl_runtime_event_poll.dspy', 'logined'), + ('/pbl_runtime_ext/api/pbl_entity_state_get.dspy', 'logined'), + ('/pbl_runtime_ext/api/pbl_entity_state_apply.dspy', 'logined'), + ('/pbl_runtime_ext/api/pbl_world_broadcast.dspy', 'logined'), + ('/pbl_runtime_ext/api/pbl_session_member_add.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..e6b2ea2 --- /dev/null +++ b/skill/SKILL.md @@ -0,0 +1,26 @@ +# pbl_runtime_ext 模块技能(自动生成骨架 + 人工补充) + +## 定位 +scense_runtime 薄扩展:单事务事件+状态写入与广播(M11a/M11b) + +## 挂载 +`from pbl_runtime_ext.init import load_pbl_runtime_ext` → `load_pbl_runtime_ext()`(应用 app/pbls.py init() 中按序调用) + +## 数据表(3 张) +- `pbl_runtime_event`:运行时事件(append-only,按月 RANGE 分区) +- `pbl_entity_state`:实体状态(服务端权威,state_version 客户端禁写) +- `pbl_world_state_snapshot`:世界状态快照(掉线补齐/离线兜底) + +## 契约接口(6 个,路径 `/pbl_runtime_ext/api/.dspy`) +- `pbl_runtime_event_append` +- `pbl_runtime_event_poll` +- `pbl_entity_state_get` +- `pbl_entity_state_apply` +- `pbl_world_broadcast` +- `pbl_session_member_add` + +## 陷阱 +- 库名一律 `ServerEnv().get_module_dbname('pbl_runtime_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_runtime_ext.sql b/sql/pbl_runtime_ext.sql new file mode 100644 index 0000000..ffa56d8 --- /dev/null +++ b/sql/pbl_runtime_ext.sql @@ -0,0 +1,61 @@ +-- pbl_runtime_ext 表 DDL(自动生成,与 apps/pbls/scripts/ddl/pbls_tables.sql 同源) +CREATE TABLE IF NOT EXISTS `pbl_runtime_event` ( + `tenant_id` VARCHAR(32) NOT NULL NOT NULL COMMENT 租户ID(强制打头), + `id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT COMMENT 主键, + `event_uid` VARCHAR(32) NOT NULL COMMENT "事件UID", + `session_id` BIGINT UNSIGNED NOT NULL COMMENT "会话", + `world_id` BIGINT UNSIGNED NOT NULL COMMENT "世界", + `actor_id` VARCHAR(32) NOT NULL COMMENT "触发者", + `event_type` VARCHAR(64) NOT NULL COMMENT "事件类型", + `payload_json` LONGTEXT NULL COMMENT "事件体", + `causation_id` VARCHAR(32) NOT NULL COMMENT "因果链", + `state_version` BIGINT NOT NULL DEFAULT 0 COMMENT "状态版本", + `seq` BIGINT NOT NULL DEFAULT 0 COMMENT "会话内单调序号", + `occurred_at` DATETIME 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_re_uid` (`tenant_id`, `event_uid`, `occurred_at`), + KEY `idx_re_session_seq` (`tenant_id`, `session_id`, `seq`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci COMMENT="运行时事件(append-only,按月 RANGE 分区)", + KEY `idx_re_part` (`occurred_at`) +PARTITION BY RANGE COLUMNS(`occurred_at`) ( + PARTITION p2026 VALUES LESS THAN ('2026-01-01'), + PARTITION p2027 VALUES LESS THAN ('2027-01-01'), + PARTITION p2028 VALUES LESS THAN ('2028-01-01'), + PARTITION p2029 VALUES LESS THAN ('2029-01-01'), + PARTITION p2030 VALUES LESS THAN ('2030-01-01'), + PARTITION p2031 VALUES LESS THAN ('2031-01-01') + PARTITION pmax VALUES LESS THAN (MAXVALUE) + ); + +CREATE TABLE IF NOT EXISTS `pbl_entity_state` ( + `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 "会话", + `entity_id` VARCHAR(32) NOT NULL COMMENT "实体标识", + `state_json` LONGTEXT NULL COMMENT "状态", + `state_version` BIGINT NOT NULL DEFAULT 0 COMMENT "单调版本", + `checksum` VARCHAR(32) NOT NULL COMMENT "状态摘要", + `updated_by` VARCHAR(32) 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_es` (`tenant_id`, `session_id`, `entity_id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci COMMENT="实体状态(服务端权威,state_version 客户端禁写)"; + +CREATE TABLE IF NOT EXISTS `pbl_world_state_snapshot` ( + `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 "会话", + `snapshot_uid` VARCHAR(32) NOT NULL COMMENT "快照UID", + `state_fingerprint` VARCHAR(32) NOT NULL COMMENT "状态指纹", + `entities_json` LONGTEXT NULL COMMENT "实体集", + `events_through_seq` BIGINT NOT NULL DEFAULT 0 COMMENT "截至序号", + `reason` 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_wss_uid` (`tenant_id`, `snapshot_uid`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci COMMENT="世界状态快照(掉线补齐/离线兜底)"; + diff --git a/wwwroot/api/pbl_entity_state_apply.dspy b/wwwroot/api/pbl_entity_state_apply.dspy new file mode 100644 index 0000000..711b0f8 --- /dev/null +++ b/wwwroot/api/pbl_entity_state_apply.dspy @@ -0,0 +1,4 @@ +# pbl_runtime_ext/api/pbl_entity_state_apply.dspy —— 契约端点(自动生成,勿手改:改 spec 后跑 gen_artifacts.py) +debug('pbl_runtime_ext/api/pbl_entity_state_apply.dspy: START params_kw={dict(params_kw)}') +data = await pbl_entity_state_apply(**params_kw) +return data diff --git a/wwwroot/api/pbl_entity_state_get.dspy b/wwwroot/api/pbl_entity_state_get.dspy new file mode 100644 index 0000000..7340b8a --- /dev/null +++ b/wwwroot/api/pbl_entity_state_get.dspy @@ -0,0 +1,4 @@ +# pbl_runtime_ext/api/pbl_entity_state_get.dspy —— 契约端点(自动生成,勿手改:改 spec 后跑 gen_artifacts.py) +debug('pbl_runtime_ext/api/pbl_entity_state_get.dspy: START params_kw={dict(params_kw)}') +data = await pbl_entity_state_get(**params_kw) +return data diff --git a/wwwroot/api/pbl_runtime_event_append.dspy b/wwwroot/api/pbl_runtime_event_append.dspy new file mode 100644 index 0000000..cfeb1d8 --- /dev/null +++ b/wwwroot/api/pbl_runtime_event_append.dspy @@ -0,0 +1,4 @@ +# pbl_runtime_ext/api/pbl_runtime_event_append.dspy —— 契约端点(自动生成,勿手改:改 spec 后跑 gen_artifacts.py) +debug('pbl_runtime_ext/api/pbl_runtime_event_append.dspy: START params_kw={dict(params_kw)}') +data = await pbl_runtime_event_append(**params_kw) +return data diff --git a/wwwroot/api/pbl_runtime_event_poll.dspy b/wwwroot/api/pbl_runtime_event_poll.dspy new file mode 100644 index 0000000..d9de588 --- /dev/null +++ b/wwwroot/api/pbl_runtime_event_poll.dspy @@ -0,0 +1,4 @@ +# pbl_runtime_ext/api/pbl_runtime_event_poll.dspy —— 契约端点(自动生成,勿手改:改 spec 后跑 gen_artifacts.py) +debug('pbl_runtime_ext/api/pbl_runtime_event_poll.dspy: START params_kw={dict(params_kw)}') +data = await pbl_runtime_event_poll(**params_kw) +return data diff --git a/wwwroot/api/pbl_session_member_add.dspy b/wwwroot/api/pbl_session_member_add.dspy new file mode 100644 index 0000000..dd14bf3 --- /dev/null +++ b/wwwroot/api/pbl_session_member_add.dspy @@ -0,0 +1,4 @@ +# pbl_runtime_ext/api/pbl_session_member_add.dspy —— 契约端点(自动生成,勿手改:改 spec 后跑 gen_artifacts.py) +debug('pbl_runtime_ext/api/pbl_session_member_add.dspy: START params_kw={dict(params_kw)}') +data = await pbl_session_member_add(**params_kw) +return data diff --git a/wwwroot/api/pbl_world_broadcast.dspy b/wwwroot/api/pbl_world_broadcast.dspy new file mode 100644 index 0000000..41f538e --- /dev/null +++ b/wwwroot/api/pbl_world_broadcast.dspy @@ -0,0 +1,4 @@ +# pbl_runtime_ext/api/pbl_world_broadcast.dspy —— 契约端点(自动生成,勿手改:改 spec 后跑 gen_artifacts.py) +debug('pbl_runtime_ext/api/pbl_world_broadcast.dspy: START params_kw={dict(params_kw)}') +data = await pbl_world_broadcast(**params_kw) +return data diff --git a/wwwroot/index.ui b/wwwroot/index.ui new file mode 100644 index 0000000..665fb3e --- /dev/null +++ b/wwwroot/index.ui @@ -0,0 +1,203 @@ +{ + "widgettype": "VBox", + "options": { + "width": "100%", + "height": "100%", + "padding": "20px" + }, + "subwidgets": [ + { + "widgettype": "Text", + "options": { + "label": "scense_runtime 薄扩展:单事务事件+状态写入与广播(M11a/M11b)", + "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_runtime_ext_content", + "options": { + "url": "{{entire_url('api/pbl_runtime_event_append.dspy')}}" + }, + "mode": "replace" + } + ], + "subwidgets": [ + { + "widgettype": "Text", + "options": { + "label": "pbl_runtime_event_append" + } + } + ] + }, + { + "widgettype": "VBox", + "options": { + "backgroundColor": "#FFFFFF", + "padding": "20px", + "cursor": "pointer" + }, + "binds": [ + { + "wid": "self", + "event": "click", + "actiontype": "urlwidget", + "target": "app.pbl_runtime_ext_content", + "options": { + "url": "{{entire_url('api/pbl_runtime_event_poll.dspy')}}" + }, + "mode": "replace" + } + ], + "subwidgets": [ + { + "widgettype": "Text", + "options": { + "label": "pbl_runtime_event_poll" + } + } + ] + }, + { + "widgettype": "VBox", + "options": { + "backgroundColor": "#FFFFFF", + "padding": "20px", + "cursor": "pointer" + }, + "binds": [ + { + "wid": "self", + "event": "click", + "actiontype": "urlwidget", + "target": "app.pbl_runtime_ext_content", + "options": { + "url": "{{entire_url('api/pbl_entity_state_get.dspy')}}" + }, + "mode": "replace" + } + ], + "subwidgets": [ + { + "widgettype": "Text", + "options": { + "label": "pbl_entity_state_get" + } + } + ] + }, + { + "widgettype": "VBox", + "options": { + "backgroundColor": "#FFFFFF", + "padding": "20px", + "cursor": "pointer" + }, + "binds": [ + { + "wid": "self", + "event": "click", + "actiontype": "urlwidget", + "target": "app.pbl_runtime_ext_content", + "options": { + "url": "{{entire_url('api/pbl_entity_state_apply.dspy')}}" + }, + "mode": "replace" + } + ], + "subwidgets": [ + { + "widgettype": "Text", + "options": { + "label": "pbl_entity_state_apply" + } + } + ] + }, + { + "widgettype": "VBox", + "options": { + "backgroundColor": "#FFFFFF", + "padding": "20px", + "cursor": "pointer" + }, + "binds": [ + { + "wid": "self", + "event": "click", + "actiontype": "urlwidget", + "target": "app.pbl_runtime_ext_content", + "options": { + "url": "{{entire_url('api/pbl_world_broadcast.dspy')}}" + }, + "mode": "replace" + } + ], + "subwidgets": [ + { + "widgettype": "Text", + "options": { + "label": "pbl_world_broadcast" + } + } + ] + }, + { + "widgettype": "VBox", + "options": { + "backgroundColor": "#FFFFFF", + "padding": "20px", + "cursor": "pointer" + }, + "binds": [ + { + "wid": "self", + "event": "click", + "actiontype": "urlwidget", + "target": "app.pbl_runtime_ext_content", + "options": { + "url": "{{entire_url('api/pbl_session_member_add.dspy')}}" + }, + "mode": "replace" + } + ], + "subwidgets": [ + { + "widgettype": "Text", + "options": { + "label": "pbl_session_member_add" + } + } + ] + } + ] + }, + { + "widgettype": "VBox", + "id": "pbl_runtime_ext_content", + "options": { + "width": "100%", + "flex": "1", + "marginTop": "20px" + } + } + ] +}