feat(pbl_common): 初始提交 - pbls 项目模块代码入库
This commit is contained in:
parent
cb0a48796e
commit
99f8b4d780
12
.gitignore
vendored
Normal file
12
.gitignore
vendored
Normal 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
19
pbl_common/__init__.py
Normal 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
288
pbl_common/api.py
Normal 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
21
pbl_common/init.py
Normal 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
13
pyproject.toml
Normal 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
41
scripts/load_path.py
Normal 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
20
skill/SKILL.md
Normal 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 路径。
|
||||
4
wwwroot/api/pbl_common_audit.dspy
Normal file
4
wwwroot/api/pbl_common_audit.dspy
Normal 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
|
||||
4
wwwroot/api/pbl_common_ping.dspy
Normal file
4
wwwroot/api/pbl_common_ping.dspy
Normal 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
91
wwwroot/index.ui
Normal 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"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user