2026-09-16 16:25:33 +08:00

310 lines
13 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 -*-
"""M1b API 层:模板平台公共部分 / 子对象扩展 / 关联表。
约定
----
* 每个 handler 第一行取 tenant_id(来自登录上下文 params['tenant_id'] 或 session),
缺失直接 400 fail-closed,绝不做全租户扫描;
* ``is_platform_admin`` 只信 RBAC 判定结果(由应用层注入 params),不接受前端自述;
* 返回统一 {ok, ...} / 异常转 {ok: False, code, message}。
路由(挂在模块 api 前缀下)
GET /pbl/templates 列表(本租户 + 平台公共)
POST /pbl/templates 创建(scope=platform 需平台管理员)
GET /pbl/templates/{id} 详情
PUT /pbl/templates/{id} 更新
DEL /pbl/templates/{id} 软删(内置禁删)
POST /pbl/templates/{id}/publish 发布
POST /pbl/templates/{id}/deprecate 停用
POST /pbl/templates/{id}/fork 派生为租户私有
POST /pbl/templates/{id}/instantiate 实例化为蓝图
GET /pbl/ext-defs 扩展字段定义列表
POST /pbl/ext-defs 创建定义
PUT /pbl/ext-defs/{id} 更新定义
DEL /pbl/ext-defs/{id} 删除定义
GET /pbl/blueprints/{bp}/ext 蓝图扩展值(按子对象聚合)
PUT /pbl/blueprints/{bp}/ext 批量写扩展值
DEL /pbl/blueprints/{bp}/ext 删扩展值
GET /pbl/blueprints/{bp}/refs 关联边列表
POST /pbl/blueprints/{bp}/refs 新增/批量新增关联边
DEL /pbl/refs/{id} 删除关联边
POST /pbl/blueprints/{bp}/refs/resolve 引用有效性判定
POST /pbl/blueprints/{bp}/refs/sync 按 content 同步关联边
GET /pbl/refs/impact 反查影响面(供 world 删除前置校验)
"""
from .m1b_common import PblM1bError, require_tenant
from . import m1b_template as T
from . import m1b_subobject as S
from . import m1b_ref as R
__all__ = ["M1B_ROUTES", "dispatch", "register_m1b_routes"]
def _ctx(params):
params = dict(params or {})
tenant_id = params.get("tenant_id") or params.get("tenantId")
actor = params.get("actor") or params.get("user_id") or params.get("operator")
is_admin = params.get("is_platform_admin")
if isinstance(is_admin, str):
is_admin = is_admin.strip().lower() in ("1", "true", "yes")
return tenant_id, actor, bool(is_admin), params
def _ok(**kw):
out = {"ok": True}
out.update(kw)
return out
# --------------------------------------------------------------------------
# handlers
# --------------------------------------------------------------------------
def api_list_templates(db, params):
tenant_id, _actor, _adm, p = _ctx(params)
return T.list_templates(db, tenant_id,
include_platform=p.get("include_platform", True) not in (False, "false", 0),
status=p.get("status"), category=p.get("category"),
keyword=p.get("keyword"),
limit=int(p.get("limit") or 100), offset=int(p.get("offset") or 0))
def api_get_template(db, params):
tenant_id, _a, _adm, p = _ctx(params)
return _ok(template=T.get_template(db, tenant_id, p.get("template_id") or p.get("id")))
def api_create_template(db, params):
tenant_id, actor, adm, p = _ctx(params)
payload = dict(p.get("payload") or p.get("data") or p)
payload.pop("tenant_id", None)
return T.create_template(db, tenant_id, payload, actor=actor, is_platform_admin=adm)
def api_update_template(db, params):
tenant_id, actor, adm, p = _ctx(params)
payload = dict(p.get("payload") or p.get("data") or p)
for k in ("tenant_id", "template_id", "id", "actor", "is_platform_admin"):
payload.pop(k, None)
return T.update_template(db, tenant_id, p.get("template_id") or p.get("id"), payload,
actor=actor, is_platform_admin=adm)
def api_delete_template(db, params):
tenant_id, actor, adm, p = _ctx(params)
return T.delete_template(db, tenant_id, p.get("template_id") or p.get("id"),
actor=actor, is_platform_admin=adm)
def api_publish_template(db, params):
tenant_id, actor, adm, p = _ctx(params)
return T.publish_template(db, tenant_id, p.get("template_id") or p.get("id"),
actor=actor, is_platform_admin=adm)
def api_deprecate_template(db, params):
tenant_id, actor, adm, p = _ctx(params)
return T.deprecate_template(db, tenant_id, p.get("template_id") or p.get("id"),
actor=actor, is_platform_admin=adm)
def api_fork_template(db, params):
tenant_id, actor, _adm, p = _ctx(params)
return T.fork_template(db, tenant_id, p.get("template_id") or p.get("id"), actor=actor,
new_code=p.get("new_code"), new_name=p.get("new_name"))
def api_instantiate_template(db, params):
tenant_id, actor, _adm, p = _ctx(params)
create_blueprint = p.get("create_blueprint")
if not callable(create_blueprint):
try:
from .blueprint_crud import create_blueprint as _cb # type: ignore
create_blueprint = _cb
except Exception:
create_blueprint = None
return T.instantiate_template(db, tenant_id, p.get("template_id") or p.get("id"),
blueprint_payload=p.get("payload") or p.get("blueprint") or {},
actor=actor, create_blueprint=create_blueprint)
def api_list_ext_defs(db, params):
tenant_id, _a, _adm, p = _ctx(params)
return S.list_ext_field_defs(db, tenant_id, kind=p.get("kind") or p.get("subobject_kind"),
include_platform=p.get("include_platform", True) not in (False, "false", 0))
def api_create_ext_def(db, params):
tenant_id, actor, adm, p = _ctx(params)
payload = dict(p.get("payload") or p.get("data") or p)
payload.pop("tenant_id", None)
return S.create_ext_field_def(db, tenant_id, payload, actor=actor, is_platform_admin=adm)
def api_update_ext_def(db, params):
tenant_id, actor, adm, p = _ctx(params)
payload = dict(p.get("payload") or p.get("data") or p)
for k in ("tenant_id", "def_id", "id", "actor", "is_platform_admin"):
payload.pop(k, None)
return S.update_ext_field_def(db, tenant_id, p.get("def_id") or p.get("id"), payload,
actor=actor, is_platform_admin=adm)
def api_delete_ext_def(db, params):
tenant_id, actor, adm, p = _ctx(params)
return S.delete_ext_field_def(db, tenant_id, p.get("def_id") or p.get("id"),
actor=actor, is_platform_admin=adm)
def api_get_blueprint_ext(db, params):
tenant_id, _a, _adm, p = _ctx(params)
bp = p.get("blueprint_id") or p.get("bp_id")
if p.get("flat"):
return S.list_ext(db, tenant_id, blueprint_id=bp, kind=p.get("kind"),
subobject_id=p.get("subobject_id"), version_id=p.get("version_id"))
return S.subobject_tree_ext(db, tenant_id, bp, version_id=p.get("version_id"))
def api_set_blueprint_ext(db, params):
tenant_id, actor, _adm, p = _ctx(params)
bp = p.get("blueprint_id") or p.get("bp_id")
kind = p.get("kind") or p.get("subobject_kind")
sid = p.get("subobject_id")
values = p.get("values")
if isinstance(values, dict) and values:
return S.bulk_set_ext(db, tenant_id, bp, kind, sid, values, actor=actor,
source=p.get("source") or "manual", version_id=p.get("version_id"),
source_template_id=p.get("source_template_id"))
return S.set_ext(db, tenant_id, bp, kind, sid, p.get("ext_key"), p.get("value"),
actor=actor, source=p.get("source") or "manual",
version_id=p.get("version_id"),
source_template_id=p.get("source_template_id"))
def api_delete_blueprint_ext(db, params):
tenant_id, actor, _adm, p = _ctx(params)
return S.delete_ext(db, tenant_id, p.get("blueprint_id") or p.get("bp_id"),
p.get("kind") or p.get("subobject_kind"), p.get("subobject_id"),
ext_key=p.get("ext_key"), actor=actor)
def api_list_refs(db, params):
tenant_id, _a, _adm, p = _ctx(params)
return R.list_refs(db, tenant_id, blueprint_id=p.get("blueprint_id") or p.get("bp_id"),
ref_domain=p.get("ref_domain"), ref_table=p.get("ref_table"),
ref_id=p.get("ref_id"), src_kind=p.get("src_kind"),
src_id=p.get("src_id"), rel_type=p.get("rel_type"),
resolve_status=p.get("resolve_status"), version_id=p.get("version_id"),
limit=int(p.get("limit") or 500))
def api_add_refs(db, params):
tenant_id, actor, _adm, p = _ctx(params)
bp = p.get("blueprint_id") or p.get("bp_id")
refs = p.get("refs")
if isinstance(refs, list) and refs:
return R.bulk_add_refs(db, tenant_id, bp, refs, actor=actor)
payload = dict(p.get("ref") or p)
for k in ("tenant_id", "blueprint_id", "bp_id", "actor", "refs", "ref"):
payload.pop(k, None)
return R.add_ref(db, tenant_id, bp, payload, actor=actor)
def api_remove_ref(db, params):
tenant_id, actor, _adm, p = _ctx(params)
return R.remove_ref(db, tenant_id, p.get("ref_id") or p.get("id"), actor=actor)
def api_resolve_refs(db, params):
tenant_id, actor, _adm, p = _ctx(params)
return R.resolve_refs(db, tenant_id, blueprint_id=p.get("blueprint_id") or p.get("bp_id"),
actor=actor, reader=p.get("reader") if callable(p.get("reader")) else None)
def api_sync_refs(db, params):
tenant_id, actor, _adm, p = _ctx(params)
content = p.get("content")
if content is None:
content = (p.get("blueprint") or {}).get("content")
return R.sync_refs_from_content(db, tenant_id, p.get("blueprint_id") or p.get("bp_id"),
content, actor=actor, version_id=p.get("version_id"))
def api_ref_impact(db, params):
tenant_id, _a, _adm, p = _ctx(params)
return R.impact_of(db, tenant_id, p.get("ref_domain"), p.get("ref_table"), p.get("ref_id"))
# --------------------------------------------------------------------------
# 路由表 + 分发
# --------------------------------------------------------------------------
M1B_ROUTES = [
("GET", "/pbl/templates", api_list_templates),
("POST", "/pbl/templates", api_create_template),
("GET", "/pbl/templates/{template_id}", api_get_template),
("PUT", "/pbl/templates/{template_id}", api_update_template),
("DELETE", "/pbl/templates/{template_id}", api_delete_template),
("POST", "/pbl/templates/{template_id}/publish", api_publish_template),
("POST", "/pbl/templates/{template_id}/deprecate", api_deprecate_template),
("POST", "/pbl/templates/{template_id}/fork", api_fork_template),
("POST", "/pbl/templates/{template_id}/instantiate", api_instantiate_template),
("GET", "/pbl/ext-defs", api_list_ext_defs),
("POST", "/pbl/ext-defs", api_create_ext_def),
("PUT", "/pbl/ext-defs/{def_id}", api_update_ext_def),
("DELETE", "/pbl/ext-defs/{def_id}", api_delete_ext_def),
("GET", "/pbl/blueprints/{blueprint_id}/ext", api_get_blueprint_ext),
("PUT", "/pbl/blueprints/{blueprint_id}/ext", api_set_blueprint_ext),
("DELETE", "/pbl/blueprints/{blueprint_id}/ext", api_delete_blueprint_ext),
("GET", "/pbl/blueprints/{blueprint_id}/refs", api_list_refs),
("POST", "/pbl/blueprints/{blueprint_id}/refs", api_add_refs),
("POST", "/pbl/blueprints/{blueprint_id}/refs/resolve", api_resolve_refs),
("POST", "/pbl/blueprints/{blueprint_id}/refs/sync", api_sync_refs),
("DELETE", "/pbl/refs/{ref_id}", api_remove_ref),
("GET", "/pbl/refs/impact", api_ref_impact),
]
def dispatch(db, method, path, params=None):
"""轻量分发(供应用层/测试直接调用)。未命中 → 404 结构。"""
method = (method or "GET").upper()
path = (path or "").split("?")[0].rstrip("/") or "/"
for m, pat, fn in M1B_ROUTES:
if m != method:
continue
pseg, rseg = pat.rstrip("/").split("/")[1:], path.split("/")[1:]
if len(pseg) != len(rseg):
continue
args = {}
matched = True
for a, b in zip(pseg, rseg):
if a.startswith("{") and a.endswith("}"):
args[a[1:-1]] = b
elif a != b:
matched = False
break
if not matched:
continue
merged = dict(params or {})
merged.update(args)
try:
return fn(db, merged)
except PblM1bError as e:
return {"ok": False, "code": e.code, "message": e.message,
"detail": e.detail, "http_status": e.http_status}
return {"ok": False, "code": "PBL_ROUTE_NOT_FOUND", "message": "no route: %s %s" % (method, path),
"http_status": 404}
def register_m1b_routes(app=None, register=None):
"""把 M1B_ROUTES 注册到应用(init.py 调用)。register(method, path, handler) 由应用层提供。"""
if not callable(register):
return {"ok": False, "registered": 0, "reason": "register callback not provided"}
n = 0
for m, pat, fn in M1B_ROUTES:
try:
register(m, pat, fn)
n += 1
except Exception:
continue
return {"ok": True, "registered": n}