pbl_blueprint/pbl_blueprint/template_platform.py
2026-09-16 16:25:33 +08:00

246 lines
9.8 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# -*- coding: utf-8 -*-
"""
pbl_blueprint · M1b 模板「平台公共部分」(tenant_id NULL)读写与解析
规则(本文件是 M1b 平台公共模板的唯一实现口径)
1. `pbl_template.tenant_id IS NULL` => 平台公共模板(platform scope):
- 对**全部租户只读可见**(list/get 自动并入);
- 仅平台管理员可写/归档(`is_platform_admin(ctx)` 门禁,非管理员写 -> PBL_TPL_PLATFORM_FORBIDDEN);
- 租户**不得**修改/删除平台公共行,只能 fork 成租户私有模板(新 template_code 或同 code 的租户行)。
2. 解析优先级:租户私有 > 平台公共(同 template_code + template_version 时租户行覆盖平台行)。
3. 唯一性:MySQL 8 函数索引 uk((IFNULL(tenant_id,'__PLATFORM__')), template_code, template_version),
保证平台公共行同样受唯一约束(NULL 不参与 MySQL 唯一索引比较的坑由函数索引消除)。
4. 实例化平台公共模板时:产出的蓝图/子对象/实例化日志 **tenant_id 一律为发起租户**(平台公共只存在于模板层,
不产生跨租户数据);`pbl_template_instance_log.template_scope='platform'` 留痕来源。
5. 离线兜底模板(offline_flag='Y')由 build.sh 种子落库为**平台公共行**,确保 test 环境冷启动即可用。
所有查询 tenant 维度打头;无 FOREIGN KEY;不直写 M1a 表。
"""
from __future__ import annotations
from .models.pbl_template import (
PLATFORM_TENANT_KEY,
is_platform_scope,
normalize_tenant_key,
)
#: 平台公共模板可见性范围标识
SCOPE_PLATFORM = "platform"
SCOPE_TENANT = "tenant"
#: 解析顺序:租户私有优先,平台公共兜底
RESOLVE_ORDER = (SCOPE_TENANT, SCOPE_PLATFORM)
class TemplateError(Exception):
"""模板域业务异常基类(错误码与 M1a errors.py 同口径)。"""
code = "PBL_TPL_ERROR"
def __init__(self, message, **detail):
super(TemplateError, self).__init__(message)
self.message = message
self.detail = detail
class TemplateNotFound(TemplateError):
code = "PBL_TEMPLATE_NOT_FOUND"
class TemplatePlatformForbidden(TemplateError):
code = "PBL_TPL_PLATFORM_FORBIDDEN"
class TemplateSchemaInvalid(TemplateError):
code = "PBL_TPL_SCHEMA_INVALID"
# ---------------------------------------------------------------------------
# 租户上下文(与 M1a tenant.py 同口径:tenant_id 强制打头,缺失即 fail-closed)
# ---------------------------------------------------------------------------
def require_tenant(tenant_ctx):
"""
取 tenant_id;缺失抛 PBL_TENANT_MISSING(fail-closed,不静默、不默认全局)。
平台管理员操作平台公共模板时同样必须携带 tenant_ctx(可为平台租户)。
"""
tenant_id = None
if isinstance(tenant_ctx, dict):
tenant_id = tenant_ctx.get("tenant_id")
else:
tenant_id = getattr(tenant_ctx, "tenant_id", None)
if tenant_id in (None, ""):
err = TemplateError("tenant_id 缺失")
err.code = "PBL_TENANT_MISSING"
raise err
return str(tenant_id)
def is_platform_admin(tenant_ctx):
"""
是否平台管理员(可写平台公共模板)。
兼容三种上下文表达:is_platform_admin / platform_admin / role 列表含 'platform_admin'。
"""
if isinstance(tenant_ctx, dict):
ctx = tenant_ctx
else:
ctx = getattr(tenant_ctx, "__dict__", {}) or {}
if ctx.get("is_platform_admin") or ctx.get("platform_admin"):
return True
roles = ctx.get("roles") or []
if isinstance(roles, str):
roles = [roles]
return any(str(r) in ("platform_admin", "owner.platform", "admin.platform") for r in roles)
# ---------------------------------------------------------------------------
# SQL 片段(tenant 维度打头;平台公共 = tenant_id IS NULL)
# ---------------------------------------------------------------------------
def scope_where(tenant_id, include_platform=True, alias=""):
"""
可见范围 WHERE 片段:租户私有 + 平台公共。
:return: (sql_fragment, params)
"""
col = "%stenant_id" % alias
if include_platform:
return "(%s = %%s OR %s IS NULL)" % (col, col), [tenant_id]
return "%s = %%s" % col, [tenant_id]
def build_list_sql(tenant_id, filters=None, include_platform=True):
"""
模板库浏览与筛选(F-CP-0x):租户私有优先排序,平台公共兜底。
filters: {subject, grade, tpl_status, offline_flag, template_name(like), scope}
"""
filters = filters or {}
include_platform = include_platform and filters.get("scope") != SCOPE_TENANT
where, params = scope_where(tenant_id, include_platform=include_platform)
conds = [where]
if filters.get("scope") == SCOPE_PLATFORM:
conds = ["tenant_id IS NULL"]
params = []
for key, col in (("subject", "subject"), ("grade", "grade"),
("tpl_status", "tpl_status"), ("offline_flag", "offline_flag")):
if filters.get(key) not in (None, ""):
conds.append("%s = %%s" % col)
params.append(filters[key])
if filters.get("template_name"):
conds.append("template_name LIKE %s")
params.append("%%%s%%" % filters["template_name"])
if not filters.get("tpl_status"):
conds.append("tpl_status <> 'archived'")
sql = (
"SELECT id, tenant_id, template_code, template_version, template_name, subject, grade, "
"tpl_hash, offline_flag, tpl_status, create_time, update_time, "
"CASE WHEN tenant_id IS NULL THEN '%s' ELSE '%s' END AS template_scope "
"FROM pbl_template WHERE %s "
"ORDER BY (tenant_id IS NULL) ASC, template_code ASC, template_version DESC"
% (SCOPE_PLATFORM, SCOPE_TENANT, " AND ".join(conds))
)
return sql, params
def build_resolve_sql(tenant_id, template_code, template_version=None):
"""
模板解析(租户私有 > 平台公共):一次查询取回候选行,由 pick_template 选定。
template_version 为 None 时取各 scope 的最新 active 版本。
"""
sql = (
"SELECT id, tenant_id, template_code, template_version, template_name, subject, grade, "
"tpl_json, tpl_hash, offline_flag, tpl_status, create_time, update_time "
"FROM pbl_template "
"WHERE (tenant_id = %s OR tenant_id IS NULL) AND template_code = %s "
"AND tpl_status = 'active' "
)
params = [tenant_id, template_code]
if template_version is not None:
sql += "AND template_version = %s "
params.append(int(template_version))
sql += ("ORDER BY (tenant_id IS NULL) ASC, template_version DESC LIMIT 2")
return sql, params
def pick_template(rows):
"""
从候选行按 RESOLVE_ORDER 选定模板行;无候选抛 PBL_TEMPLATE_NOT_FOUND。
:param rows: build_resolve_sql 的结果(已按 租户优先 + 版本倒序 排序)
:return: (row, scope) scope ∈ {'tenant','platform'}
"""
if not rows:
raise TemplateNotFound("模板不存在或已归档")
tenant_rows = [r for r in rows if not is_platform_scope(_get(r, "tenant_id"))]
platform_rows = [r for r in rows if is_platform_scope(_get(r, "tenant_id"))]
buckets = {SCOPE_TENANT: tenant_rows, SCOPE_PLATFORM: platform_rows}
for scope in RESOLVE_ORDER:
if buckets.get(scope):
return buckets[scope][0], scope
raise TemplateNotFound("模板不存在或已归档")
def build_offline_sql(tenant_id, subject=None, grade=None):
"""
离线兜底模板选取(M1b-annex §4):offline_flag='Y' 且 active,租户私有优先、平台公共兜底。
"""
sql = (
"SELECT id, tenant_id, template_code, template_version, template_name, subject, grade, "
"tpl_json, tpl_hash, offline_flag, tpl_status "
"FROM pbl_template "
"WHERE (tenant_id = %s OR tenant_id IS NULL) AND offline_flag = 'Y' "
"AND tpl_status = 'active' "
)
params = [tenant_id]
if subject:
sql += "AND (subject = %s OR subject IS NULL) "
params.append(subject)
if grade:
sql += "AND (grade = %s OR grade IS NULL) "
params.append(grade)
sql += "ORDER BY (tenant_id IS NULL) ASC, template_version DESC LIMIT 1"
return sql, params
def build_platform_write_guard(tenant_ctx, target_tenant_id):
"""
写入门禁:写平台公共行(target_tenant_id 为 None/'')必须平台管理员。
:return: scope('platform'/'tenant')
:raises TemplatePlatformForbidden: 非平台管理员写平台公共模板
"""
if is_platform_scope(target_tenant_id):
if not is_platform_admin(tenant_ctx):
raise TemplatePlatformForbidden(
"平台公共模板(tenant_id NULL)仅平台管理员可写/归档")
return SCOPE_PLATFORM
# 租户行:tenant_ctx.tenant_id 必须与目标一致(禁止跨租户写)
ctx_tenant = require_tenant(tenant_ctx)
if target_tenant_id and str(target_tenant_id) != ctx_tenant:
raise TemplatePlatformForbidden(
"跨租户写模板被拒绝: ctx=%s target=%s" % (ctx_tenant, target_tenant_id))
return SCOPE_TENANT
def tenant_key_for_insert(target_tenant_id):
"""
插入时的归一键(MySQL 5.7 回退列 tenant_key 用;MySQL 8 函数索引下无需写该列)。
"""
return normalize_tenant_key(target_tenant_id)
def _get(row, key, default=None):
if isinstance(row, dict):
return row.get(key, default)
return getattr(row, key, default)
__all__ = [
"PLATFORM_TENANT_KEY", "SCOPE_PLATFORM", "SCOPE_TENANT", "RESOLVE_ORDER",
"TemplateError", "TemplateNotFound", "TemplatePlatformForbidden", "TemplateSchemaInvalid",
"require_tenant", "is_platform_admin", "scope_where", "build_list_sql",
"build_resolve_sql", "pick_template", "build_offline_sql",
"build_platform_write_guard", "tenant_key_for_insert",
]