feat(pbl_runtime_ext): 初始提交 - pbls 项目模块代码入库

This commit is contained in:
pbls-bot 2026-09-15 17:39:27 +08:00 committed by agent.develop
parent 33706399dd
commit e7cffb940d
21 changed files with 1429 additions and 0 deletions

12
.gitignore vendored Normal file
View File

@ -0,0 +1,12 @@
__pycache__/
*.pyc
*.pyo
.pytest_cache/
.coverage
.DS_Store
*.log
node_modules/
dist/
build/
.venv/
venv/

View File

@ -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
}
}

View File

@ -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
}
}

View File

@ -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
}
}

View File

@ -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": []
}

View File

@ -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"
}
]
}

View File

@ -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": []
}

View File

@ -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',
]

241
pbl_runtime_ext/api.py Normal file
View File

@ -0,0 +1,241 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""pbl_runtime_ext.api —— scense_runtime 薄扩展:单事务事件+状态写入与广播M11a/M11b
服务端权威第12/20Q-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}

29
pbl_runtime_ext/init.py Normal file
View File

@ -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'

13
pyproject.toml Normal file
View File

@ -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"]

45
scripts/load_path.py Normal file
View File

@ -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)

26
skill/SKILL.md Normal file
View File

@ -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/<name>.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 路径。

61
sql/pbl_runtime_ext.sql Normal file
View File

@ -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="世界状态快照(掉线补齐/离线兜底)";

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

203
wwwroot/index.ui Normal file
View File

@ -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"
}
}
]
}