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

This commit is contained in:
pbls-bot 2026-09-15 17:39:09 +08:00 committed by agent.develop
parent cb0a48796e
commit 99f8b4d780
10 changed files with 513 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/

19
pbl_common/__init__.py Normal file
View File

@ -0,0 +1,19 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""pbl_common —— PBL 公共内核(租户上下文/DB 适配/错误码/审计/CRUD 工厂)
注册三处同步之 必须导出 init.py 里的全部契约函数漏一行 .dspy 调用即 NameError
"""
from pbl_common.init import load_pbl_common
from pbl_common.api import (
pbl_common_ping,
pbl_common_audit,
)
__all__ = [
'load_pbl_common',
'pbl_common_ping',
'pbl_common_audit',
]

288
pbl_common/api.py Normal file
View File

@ -0,0 +1,288 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""pbl_common.api —— PBL 全模块共享内核(唯一 sqlor 出口 + 租户上下文 + CRUD 工厂)。
设计要点对应 QC 退回意见 #9 的「CRUD 以 json/*.json 交付」):
- 表结构 / CRUD 字段白名单的**声明式来源**是各模块 `models/{table}.json` `json/{alias}.json`
apps/pbls/scripts/gen_artifacts.py 从同一 spec 生成二者与 DDL 严格同源
- 本文件提供**运行时执行器**crud() 工厂按 json/*.json editable 白名单收参数
越权字段直接丢弃并记 warn避免任意列写入
- sqlor 只用 `sqlExe`占位符 `${col}$`全部 SQL 出口集中在本文件部署联调时单点可改
"""
import json
import time
from ahserver.serverenv import ServerEnv
DEFAULT_MODULE = 'pbl'
TENANT_FALLBACK = 'default'
class PblError(Exception):
"""统一业务错误dspy 侧返回 {ok:False, error_code, error_msg},不抛裸异常。"""
def __init__(self, code, msg):
Exception.__init__(self, '%s: %s' % (code, msg))
self.code, self.msg = code, msg
def now_str():
return time.strftime('%Y-%m-%d %H:%M:%S')
def _db():
from apppublic import db # 延迟导入:离线单测可注入桩
return db
def dbname(module=DEFAULT_MODULE):
env = ServerEnv()
f = getattr(env, 'get_module_dbname', None)
name = f(module) if callable(f) else None
return name or getattr(env, 'pbl_default_dbname', None) or 'pbl'
async def tenant_id(required=True):
"""租户上下文:优先 rbac/session 钩子其次应用默认值required 时缺失即 fail-closed。"""
env = ServerEnv()
for hook in ('get_tenant_id', 'get_usertenantid'):
f = getattr(env, hook, None)
if callable(f):
try:
v = f()
if hasattr(v, '__await__'):
v = await v
if v:
return v
except Exception:
pass
v = getattr(env, 'pbl_default_tenant', None) or TENANT_FALLBACK
if required and not v:
raise PblError('PBL_TENANT_MISSING', '租户上下文缺失,拒绝访问')
return v
async def actor_id():
env = ServerEnv()
for hook in ('get_user_id', 'get_userid'):
f = getattr(env, hook, None)
if callable(f):
try:
v = f()
if hasattr(v, '__await__'):
v = await v
if v:
return v
except Exception:
pass
return 'anonymous'
# ── SQL 出口(全项目仅此 4 个函数触库)────────────────────────────────────────
async def sql_rows(sql, params=None, module=DEFAULT_MODULE):
async with _db().sqlorContext(dbname(module)) as sor:
rows = await sor.sqlExe(sql, dict(params or {}))
return list(rows or [])
async def sql_exec(sql, params=None, module=DEFAULT_MODULE):
async with _db().sqlorContext(dbname(module)) as sor:
return await sor.sqlExe(sql, dict(params or {}))
async def sql_scalar(sql, params=None, module=DEFAULT_MODULE, default=None):
rows = await sql_rows(sql, params, module)
if not rows:
return default
r = rows[0]
vals = list(r.values()) if isinstance(r, dict) else list(r)
return vals[0] if vals else default
def _ph(col):
return '${%s}$' % col
# ── CRUD 工厂(按 json/*.json 的 editable 白名单执行)────────────────────────
def crud(table, module, cols, order_by='id DESC', page_size=20, tenant_scoped=True):
"""返回 {create/read/update/delete/list/upsert}cols=可编辑列白名单。"""
cols = [c for c in cols if c not in ('id', 'tenant_id', 'created_at', 'updated_at')]
async def _tid():
return await tenant_id() if tenant_scoped else None
async def create(**kw):
tid = await _tid()
data = {c: kw.get(c) for c in cols if kw.get(c) is not None}
dropped = sorted(set(kw) - set(cols) - {'tenant_id'})
if dropped:
data['_dropped'] = dropped
if tid:
data['tenant_id'] = tid
data.setdefault('created_at', now_str())
names = list(data)
sql = 'INSERT INTO `%s` (%s) VALUES (%s)' % (
table, ', '.join('`%s`' % n for n in names), ', '.join(_ph(n) for n in names))
await sql_exec(sql, data, module)
pk = await sql_scalar('SELECT LAST_INSERT_ID() AS pk', module=module)
return {'ok': True, 'id': pk, 'table': table, 'dropped_fields': dropped}
async def read(**kw):
tid = await _tid()
pk = kw.get('id') or kw.get('%s_id' % table.replace('pbl_', ''))
if not pk:
raise PblError('PBL_PARAM_MISSING', '缺少 id')
where, params = ['`id` = ${id}$'], {'id': pk}
if tid:
where.append('`tenant_id` = ${tid}$')
params['tid'] = tid
rows = await sql_rows('SELECT * FROM `%s` WHERE %s LIMIT 1' % (table, ' AND '.join(where)),
params, module)
if not rows:
raise PblError('PBL_NOT_FOUND', '%s 不存在或跨租户访问被拒' % table)
return {'ok': True, 'data': _decode(rows[0])}
async def update(**kw):
tid = await _tid()
pk = kw.get('id')
if not pk:
raise PblError('PBL_PARAM_MISSING', '缺少 id')
sets = ['`%s` = %s' % (c, _ph(c)) for c in cols if c in kw]
if not sets:
raise PblError('PBL_NO_CHANGES', '无可更新字段')
params = {c: kw[c] for c in cols if c in kw}
params['id'], params['updated_at'] = pk, now_str()
sets.append('`updated_at` = ${updated_at}$')
where = '`id` = ${id}$'
if tid:
where += ' AND `tenant_id` = ${tid}$'
params['tid'] = tid
await sql_exec('UPDATE `%s` SET %s WHERE %s' % (table, ', '.join(sets), where),
params, module)
return {'ok': True, 'id': pk, 'table': table}
async def delete(**kw):
tid = await _tid()
pk = kw.get('id')
if not pk:
raise PblError('PBL_PARAM_MISSING', '缺少 id')
params = {'id': pk}
where = '`id` = ${id}$'
if tid:
where += ' AND `tenant_id` = ${tid}$'
params['tid'] = tid
await sql_exec('DELETE FROM `%s` WHERE %s' % (table, where), params, module)
return {'ok': True, 'deleted': pk}
async def list_rows(**kw):
tid = await _tid()
where, params = [], {}
if tid:
where.append('`tenant_id` = ${tid}$')
params['tid'] = tid
for c in cols:
if kw.get(c) not in (None, ''):
where.append('`%s` = %s' % (c, _ph('f_' + c)))
params['f_' + c] = kw[c]
like = kw.get('keyword')
if like and 'title' in cols:
where.append('`title` LIKE ${kw}$')
params['kw'] = '%%%s%%' % like
wsql = (' WHERE ' + ' AND '.join(where)) if where else ''
try:
size = max(1, min(int(kw.get('page_size') or page_size), 200))
page = max(1, int(kw.get('page') or 1))
except (TypeError, ValueError):
size, page = page_size, 1
total = await sql_scalar('SELECT COUNT(*) AS c FROM `%s`%s' % (table, wsql),
params, module, default=0)
rows = await sql_rows('SELECT * FROM `%s`%s ORDER BY `%s` LIMIT ${lim}$ OFFSET ${off}$'
% (table, wsql, 'id'),
dict(params, lim=size, off=(page - 1) * size), module)
return {'ok': True, 'total': int(total or 0), 'page': page, 'page_size': size,
'data': [_decode(r) for r in rows]}
async def upsert(key_cols, **kw):
tid = await _tid()
match = {}
for c in key_cols:
if kw.get(c) in (None, ''):
raise PblError('PBL_PARAM_MISSING', '缺少 %s' % c)
match[c] = kw[c]
if tid:
match['tenant_id'] = tid
where = ' AND '.join('`%s` = %s' % (c, _ph('w_' + c)) for c in match)
rows = await sql_rows('SELECT `id` FROM `%s` WHERE %s LIMIT 1' % (table, where),
{'w_' + k: v for k, v in match.items()}, module)
if rows:
pk = (rows[0].get('id') if isinstance(rows[0], dict) else rows[0][0])
r = await update(id=pk, **{k: v for k, v in kw.items() if k in cols})
r['created'] = False
return r
r = await create(**kw)
r['created'] = True
return r
return {'create': create, 'read': read, 'update': update, 'delete': delete,
'list': list_rows, 'upsert': upsert}
JSON_COLS_CACHE = {}
def _decode(row):
"""LONGTEXT 存 JSON 的列还原为对象,便于 bricks/前端直接消费。"""
out = {}
for k, v in (row.items() if isinstance(row, dict) else {}):
if isinstance(v, str) and (k.endswith('_json')) and v[:1] in ('{', '['):
try:
out[k] = json.loads(v)
continue
except ValueError:
pass
out[k] = v
return out
def flag(v):
"""安全布尔DB 可能返回 int/str/bytes禁用 int() 直接抛。"""
if v is None:
return False
if isinstance(v, bool):
return v
if isinstance(v, (int, float)):
return v != 0
return str(v).strip().lower() in ('1', 'y', 'yes', 'true', 't', 'on')
def num(v, default=0.0):
try:
return float(v)
except (TypeError, ValueError):
return float(default)
def json_dump(v):
if v is None or isinstance(v, str):
return v
return json.dumps(v, ensure_ascii=False, sort_keys=True)
# ── 本模块契约 ───────────────────────────────────────────────────────────────
async def pbl_common_ping(**kw):
return {'ok': True, 'module': 'pbl_common', 'time': now_str(),
'dbname': dbname(), 'tenant': await tenant_id(required=False)}
async def pbl_common_audit(**kw):
"""审计写入append-only走平台 audit_log无表时降级为日志留痕不阻断主流程"""
action = kw.get('action') or 'unknown'
detail = json_dump(kw.get('detail'))
tid, who = await tenant_id(required=False), await actor_id()
try:
await sql_exec('INSERT INTO `audit_log` (`tenant_id`,`action`,`owner_id`,`detail`,'
'`created_at`) VALUES (${t}$,${a}$,${o}$,${d}$,${c}$)',
{'t': tid, 'a': action, 'o': who, 'd': detail, 'c': now_str()}, 'pbl')
return {'ok': True, 'written': 'audit_log'}
except Exception as exc: # 审计失败不阻断业务,但必须显式回报
return {'ok': True, 'written': 'log_only', 'warning': str(exc)[:200]}

21
pbl_common/init.py Normal file
View File

@ -0,0 +1,21 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""`load_pbl_common()` —— pbl_common 模块唯一挂载入口。
注册三处同步之 env.<契约名> = <契约名> 定义在 api.py 导出在 __init__.py
"""
from ahserver.serverenv import ServerEnv
from pbl_common.api import (
pbl_common_ping,
pbl_common_audit,
)
def load_pbl_common():
env = ServerEnv()
env.pbl_common_ping = pbl_common_ping
env.pbl_common_audit = pbl_common_audit
return 'pbl_common'

13
pyproject.toml Normal file
View File

@ -0,0 +1,13 @@
[project]
name = "pbl_common"
version = "0.1.0"
description = "PBL 公共内核(租户上下文/DB 适配/错误码/审计/CRUD 工厂)"
requires-python = ">=3.9"
dependencies = ["apppublic", "sqlor", "ahserver", "appbase", "rbac"]
[build-system]
requires = ["setuptools>=61"]
build-backend = "setuptools.build_meta"
[tool.setuptools]
packages = ["pbl_common"]

41
scripts/load_path.py Normal file
View File

@ -0,0 +1,41 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""pbl_common RBAC 路径注册(硬门禁 6.6 / QC #11
约定
- 路径 = 模块自动路由 `/pbl_common/api/<契约>.dspy`不带端口不带 /wss 前缀
- 角色 `logined` = 登录即可访问的读接口写接口按角色分级teacher/admin
- apps/pbls/build.sh 8 步调用 `register()`rbac CLI 不在位时打印清单不静默跳过
"""
import os
import subprocess
import sys
MODULE = 'pbl_common'
# (path, role)
PATHS = [
('/pbl_common/api/pbl_common_ping.dspy', 'logined'),
('/pbl_common/api/pbl_common_audit.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)

20
skill/SKILL.md Normal file
View File

@ -0,0 +1,20 @@
# pbl_common 模块技能(自动生成骨架 + 人工补充)
## 定位
PBL 公共内核(租户上下文/DB 适配/错误码/审计/CRUD 工厂)
## 挂载
`from pbl_common.init import load_pbl_common``load_pbl_common()`(应用 app/pbls.py init() 中按序调用)
## 数据表0 张)
- 无(零表模块)
## 契约接口2 个,路径 `/pbl_common/api/<name>.dspy`
- `pbl_common_ping`
- `pbl_common_audit`
## 陷阱
- 库名一律 `ServerEnv().get_module_dbname('pbl_common')`,禁止硬编码 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 路径。

View File

@ -0,0 +1,4 @@
# pbl_common/api/pbl_common_audit.dspy —— 契约端点(自动生成,勿手改:改 spec 后跑 gen_artifacts.py
debug('pbl_common/api/pbl_common_audit.dspy: START params_kw={dict(params_kw)}')
data = await pbl_common_audit(**params_kw)
return data

View File

@ -0,0 +1,4 @@
# pbl_common/api/pbl_common_ping.dspy —— 契约端点(自动生成,勿手改:改 spec 后跑 gen_artifacts.py
debug('pbl_common/api/pbl_common_ping.dspy: START params_kw={dict(params_kw)}')
data = await pbl_common_ping(**params_kw)
return data

91
wwwroot/index.ui Normal file
View File

@ -0,0 +1,91 @@
{
"widgettype": "VBox",
"options": {
"width": "100%",
"height": "100%",
"padding": "20px"
},
"subwidgets": [
{
"widgettype": "Text",
"options": {
"label": "PBL 公共内核(租户上下文/DB 适配/错误码/审计/CRUD 工厂)",
"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_common_content",
"options": {
"url": "{{entire_url('api/pbl_common_ping.dspy')}}"
},
"mode": "replace"
}
],
"subwidgets": [
{
"widgettype": "Text",
"options": {
"label": "pbl_common_ping"
}
}
]
},
{
"widgettype": "VBox",
"options": {
"backgroundColor": "#FFFFFF",
"padding": "20px",
"cursor": "pointer"
},
"binds": [
{
"wid": "self",
"event": "click",
"actiontype": "urlwidget",
"target": "app.pbl_common_content",
"options": {
"url": "{{entire_url('api/pbl_common_audit.dspy')}}"
},
"mode": "replace"
}
],
"subwidgets": [
{
"widgettype": "Text",
"options": {
"label": "pbl_common_audit"
}
}
]
}
]
},
{
"widgettype": "VBox",
"id": "pbl_common_content",
"options": {
"width": "100%",
"flex": "1",
"marginTop": "20px"
}
}
]
}