From 99f8b4d780e0c2911c600c22c050ebcb110aa209 Mon Sep 17 00:00:00 2001 From: pbls-bot Date: Tue, 15 Sep 2026 17:39:09 +0800 Subject: [PATCH] =?UTF-8?q?feat(pbl=5Fcommon):=20=E5=88=9D=E5=A7=8B?= =?UTF-8?q?=E6=8F=90=E4=BA=A4=20-=20pbls=20=E9=A1=B9=E7=9B=AE=E6=A8=A1?= =?UTF-8?q?=E5=9D=97=E4=BB=A3=E7=A0=81=E5=85=A5=E5=BA=93?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 12 ++ pbl_common/__init__.py | 19 ++ pbl_common/api.py | 288 ++++++++++++++++++++++++++++++ pbl_common/init.py | 21 +++ pyproject.toml | 13 ++ scripts/load_path.py | 41 +++++ skill/SKILL.md | 20 +++ wwwroot/api/pbl_common_audit.dspy | 4 + wwwroot/api/pbl_common_ping.dspy | 4 + wwwroot/index.ui | 91 ++++++++++ 10 files changed, 513 insertions(+) create mode 100644 .gitignore create mode 100644 pbl_common/__init__.py create mode 100644 pbl_common/api.py create mode 100644 pbl_common/init.py create mode 100644 pyproject.toml create mode 100644 scripts/load_path.py create mode 100644 skill/SKILL.md create mode 100644 wwwroot/api/pbl_common_audit.dspy create mode 100644 wwwroot/api/pbl_common_ping.dspy create mode 100644 wwwroot/index.ui 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/pbl_common/__init__.py b/pbl_common/__init__.py new file mode 100644 index 0000000..1048272 --- /dev/null +++ b/pbl_common/__init__.py @@ -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', + +] diff --git a/pbl_common/api.py b/pbl_common/api.py new file mode 100644 index 0000000..47bf6ff --- /dev/null +++ b/pbl_common/api.py @@ -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]} diff --git a/pbl_common/init.py b/pbl_common/init.py new file mode 100644 index 0000000..8bd15da --- /dev/null +++ b/pbl_common/init.py @@ -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' diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..99b30b7 --- /dev/null +++ b/pyproject.toml @@ -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"] diff --git a/scripts/load_path.py b/scripts/load_path.py new file mode 100644 index 0000000..6da600f --- /dev/null +++ b/scripts/load_path.py @@ -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) diff --git a/skill/SKILL.md b/skill/SKILL.md new file mode 100644 index 0000000..1c2e66b --- /dev/null +++ b/skill/SKILL.md @@ -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/.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 路径。 diff --git a/wwwroot/api/pbl_common_audit.dspy b/wwwroot/api/pbl_common_audit.dspy new file mode 100644 index 0000000..35132cc --- /dev/null +++ b/wwwroot/api/pbl_common_audit.dspy @@ -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 diff --git a/wwwroot/api/pbl_common_ping.dspy b/wwwroot/api/pbl_common_ping.dspy new file mode 100644 index 0000000..f079024 --- /dev/null +++ b/wwwroot/api/pbl_common_ping.dspy @@ -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 diff --git a/wwwroot/index.ui b/wwwroot/index.ui new file mode 100644 index 0000000..fa94b2f --- /dev/null +++ b/wwwroot/index.ui @@ -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" + } + } + ] +}