labull-framework 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- labull_framework/__init__.py +8 -0
- labull_framework/admin.py +63 -0
- labull_framework/apps.py +33 -0
- labull_framework/auth/__init__.py +4 -0
- labull_framework/auth/backend.py +144 -0
- labull_framework/auth/bootstrap.py +47 -0
- labull_framework/auth/sso.py +259 -0
- labull_framework/auth/views.py +184 -0
- labull_framework/canvas/__init__.py +19 -0
- labull_framework/canvas/data.py +523 -0
- labull_framework/canvas/gitinfo.py +155 -0
- labull_framework/canvas/static/canvas.css +487 -0
- labull_framework/canvas/static/canvas.js +1036 -0
- labull_framework/canvas/static/page.html +52 -0
- labull_framework/canvas/views.py +121 -0
- labull_framework/env.py +187 -0
- labull_framework/jsonio.py +57 -0
- labull_framework/magic_block/__init__.py +2 -0
- labull_framework/magic_block/devkit.py +428 -0
- labull_framework/magic_block/dispatch.py +314 -0
- labull_framework/magic_block/export.py +773 -0
- labull_framework/magic_block/health.py +220 -0
- labull_framework/magic_block/registry.py +157 -0
- labull_framework/magic_block/sdk.py +245 -0
- labull_framework/magic_block/service.py +405 -0
- labull_framework/magic_block/validate.py +558 -0
- labull_framework/magic_block/views.py +407 -0
- labull_framework/management/__init__.py +0 -0
- labull_framework/management/commands/__init__.py +0 -0
- labull_framework/management/commands/labull_contract.py +52 -0
- labull_framework/migrations/0001_initial.py +71 -0
- labull_framework/migrations/__init__.py +0 -0
- labull_framework/models.py +76 -0
- labull_framework/settings.py +223 -0
- labull_framework/urls.py +93 -0
- labull_framework-0.1.0.dist-info/METADATA +140 -0
- labull_framework-0.1.0.dist-info/RECORD +40 -0
- labull_framework-0.1.0.dist-info/WHEEL +5 -0
- labull_framework-0.1.0.dist-info/licenses/LICENSE +28 -0
- labull_framework-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,220 @@
|
|
|
1
|
+
"""
|
|
2
|
+
体检:把库里**存着的块代码**还算不算数算出来,不合格的**整批停用**,理由落库给管理员看。
|
|
3
|
+
|
|
4
|
+
🔴 判据**完全复用保存那一道**(`validate.check_block`):一行校验逻辑都不另写 ——
|
|
5
|
+
保存说合法、体检说非法(或者反过来)是最难查的一类事故。
|
|
6
|
+
🔴 `enabled` / `disabled_reason` / `health_issues` / `health_checked_at` 四列**只有这里写**
|
|
7
|
+
(没有"手动启用"按钮)。幂等:改好代码再体检一次就自动恢复;**永不抛**:一个块失败不拖累别的。
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import logging
|
|
13
|
+
|
|
14
|
+
from django.db.models.signals import post_migrate
|
|
15
|
+
from django.utils import timezone
|
|
16
|
+
|
|
17
|
+
from . import validate
|
|
18
|
+
|
|
19
|
+
logger = logging.getLogger('labull_framework.magic_block')
|
|
20
|
+
|
|
21
|
+
#: 一句话原因的最大长度(逐条原文在 `health_issues` 里)。
|
|
22
|
+
REASON_MAX = 200
|
|
23
|
+
|
|
24
|
+
#: 一条 issue 的 `kind`:`syntax` = 语法 / 结构错,`usage` = 用法 / 引用问题。两者都算不通过。
|
|
25
|
+
_KINDS: dict[str, str] = {
|
|
26
|
+
'files': 'usage',
|
|
27
|
+
'backend': 'usage',
|
|
28
|
+
'syntax': 'syntax',
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
# ──────────────────────────── 算结论(纯函数式,不碰数据库) ────────────────────────────
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def evaluate(name: str, files: dict[str, str]) -> dict:
|
|
36
|
+
"""算一个块的体检结论:`{'enabled', 'disabled_reason', 'issues'}`。
|
|
37
|
+
|
|
38
|
+
🔴 判据**完全复用保存那一道**(`validate.check_block`)—— 体检不另写一套。
|
|
39
|
+
"""
|
|
40
|
+
issues: list[dict] = []
|
|
41
|
+
if name and not validate.NAME_PATTERN.match(str(name)):
|
|
42
|
+
issues.append(
|
|
43
|
+
{
|
|
44
|
+
'stage': 'files',
|
|
45
|
+
'kind': 'usage',
|
|
46
|
+
'message': f'name 不合法({name!r}):不能超过 {validate.NAME_MAX} 个字符、不能含控制字符',
|
|
47
|
+
}
|
|
48
|
+
)
|
|
49
|
+
try:
|
|
50
|
+
for stage, message in validate.check_block(files or {}):
|
|
51
|
+
issues.append({'stage': stage, 'kind': _KINDS.get(stage, 'usage'), 'message': message})
|
|
52
|
+
except Exception as exc: # noqa: BLE001 —— 判据自己炸了也只算"这个块不通过"
|
|
53
|
+
issues.append(
|
|
54
|
+
{
|
|
55
|
+
'stage': 'backend',
|
|
56
|
+
'kind': 'usage',
|
|
57
|
+
'message': f'判据本身失败(不是这个块的问题):{type(exc).__name__}: {exc}',
|
|
58
|
+
}
|
|
59
|
+
)
|
|
60
|
+
if not issues:
|
|
61
|
+
return {'enabled': True, 'disabled_reason': None, 'issues': []}
|
|
62
|
+
return {'enabled': False, 'disabled_reason': reason_of(issues), 'issues': issues}
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def reason_of(issues: list[dict]) -> str:
|
|
66
|
+
"""一句话原因:第一条问题 + 还有几条(截 `REASON_MAX` 个字符)。"""
|
|
67
|
+
if not issues:
|
|
68
|
+
return '体检不通过'
|
|
69
|
+
head = str(issues[0].get('message') or '')
|
|
70
|
+
head = f'{head[:REASON_MAX]}…' if len(head) > REASON_MAX else head
|
|
71
|
+
return f'{head}(还有 {len(issues) - 1} 条,见明细)' if len(issues) > 1 else head
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
# ──────────────────────────── 唯一的写库入口 ────────────────────────────
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def write_verdict(block_id: str, verdict: dict) -> None:
|
|
78
|
+
"""把一个结论写进那四列(一个块一次 `UPDATE`,只更这四列)。
|
|
79
|
+
|
|
80
|
+
🔴 用 `.update()` 而不是 `row.save()`:`save()` 会把整行写回去(`files` 那一包可能很大),
|
|
81
|
+
并发保存时还会把别人刚写的那一版覆盖掉。
|
|
82
|
+
🔴 它**不碰 `updated_at`**(那是 `auto_now`):体检改的是"能不能用",不是"作者改了代码" ——
|
|
83
|
+
动了它会让编译缓存失效、控制台显示成"刚被人改过"。
|
|
84
|
+
"""
|
|
85
|
+
from ..models import MagicBlock
|
|
86
|
+
|
|
87
|
+
MagicBlock.objects.filter(id=block_id).update(
|
|
88
|
+
enabled=bool(verdict.get('enabled')),
|
|
89
|
+
disabled_reason=verdict.get('disabled_reason'),
|
|
90
|
+
health_issues=verdict.get('issues') or [],
|
|
91
|
+
health_checked_at=timezone.now(),
|
|
92
|
+
)
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def check_all_blocks(ids: list[str] | None = None) -> dict:
|
|
96
|
+
"""全量(或点名的几个)体检并把结论写库,返回一份汇总。
|
|
97
|
+
|
|
98
|
+
:param ids: 只体检这几个 uuid;省略 / 空 = **全量**。
|
|
99
|
+
|
|
100
|
+
🔴 **没有"浏览器那一半"这个输入**:块前端的代码不由我们校验(作者在前端自己负责),
|
|
101
|
+
所以体检的输入只有库里存着的包。
|
|
102
|
+
"""
|
|
103
|
+
from ..models import MagicBlock
|
|
104
|
+
|
|
105
|
+
rows = list(MagicBlock.objects.all())
|
|
106
|
+
if ids:
|
|
107
|
+
wanted = {str(item) for item in ids}
|
|
108
|
+
rows = [row for row in rows if row.id in wanted]
|
|
109
|
+
|
|
110
|
+
results: list[dict] = []
|
|
111
|
+
disabled = 0
|
|
112
|
+
for row in rows:
|
|
113
|
+
was_enabled = row.enabled is not False
|
|
114
|
+
try:
|
|
115
|
+
verdict = evaluate(row.name or '', row.files or {})
|
|
116
|
+
if verdict['issues']:
|
|
117
|
+
verdict['enabled'] = False
|
|
118
|
+
verdict['disabled_reason'] = reason_of(verdict['issues'])
|
|
119
|
+
except Exception as exc: # noqa: BLE001 —— 连算结论都炸了也只算这一个块不通过
|
|
120
|
+
verdict = {
|
|
121
|
+
'enabled': False,
|
|
122
|
+
'disabled_reason': f'体检判据自己失败(不是这个块的问题):{type(exc).__name__}: {exc}',
|
|
123
|
+
'issues': [],
|
|
124
|
+
}
|
|
125
|
+
try:
|
|
126
|
+
write_verdict(row.id, verdict)
|
|
127
|
+
except Exception as exc: # noqa: BLE001 —— 写不进去也只记一条,不带走整轮
|
|
128
|
+
logger.error('块 %s 的体检结论写不进库:%s: %s', row.id, type(exc).__name__, exc)
|
|
129
|
+
if not verdict['enabled']:
|
|
130
|
+
disabled += 1
|
|
131
|
+
results.append(
|
|
132
|
+
{
|
|
133
|
+
'id': row.id,
|
|
134
|
+
'name': row.name,
|
|
135
|
+
'enabled': bool(verdict['enabled']),
|
|
136
|
+
'disabledReason': verdict['disabled_reason'],
|
|
137
|
+
'issues': verdict['issues'],
|
|
138
|
+
# 这一条**本来**是启用的吗(控制台用它报"这次新停用了哪几个")。
|
|
139
|
+
'wasEnabled': was_enabled,
|
|
140
|
+
}
|
|
141
|
+
)
|
|
142
|
+
summary = f'体检完成:查了 {len(rows)} 个块,通过 {len(rows) - disabled},停用 {disabled}'
|
|
143
|
+
logger.info(summary)
|
|
144
|
+
return {
|
|
145
|
+
'checked': len(rows),
|
|
146
|
+
'passed': len(rows) - disabled,
|
|
147
|
+
'disabled': disabled,
|
|
148
|
+
'empty': not rows,
|
|
149
|
+
'results': results,
|
|
150
|
+
'log': [summary],
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
# ──────────────────────────── 两个触发点 ────────────────────────────
|
|
155
|
+
#
|
|
156
|
+
# 🔴 **两条路职责不同,不许合并**:
|
|
157
|
+
# · `install_hook()` —— 挂在 `post_migrate` 上,**只在 `manage.py migrate` 之后**发。
|
|
158
|
+
# 它管"库刚被改过":一次性数据迁移跑完了,此时必须重算结论(顺序由框架保证:迁移在前)。
|
|
159
|
+
# · `startup_once()` —— 由 `wsgi.py` 显式调一次。`post_migrate` 在 `runserver` / `gunicorn`
|
|
160
|
+
# 起来时**不会发**,所以"启动体检"只有这里能做。
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def install_hook() -> None:
|
|
164
|
+
"""把"迁移之后跑一遍全量体检"挂到 `post_migrate` 上(幂等)。"""
|
|
165
|
+
post_migrate.connect(
|
|
166
|
+
_on_post_migrate,
|
|
167
|
+
dispatch_uid='labull_framework.magic_block.health',
|
|
168
|
+
sender='labull_framework',
|
|
169
|
+
)
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def _on_post_migrate(sender: object, **kwargs: object) -> None:
|
|
173
|
+
"""`post_migrate` 接收器:迁移之后 → 补管理员 → 全量体检。
|
|
174
|
+
|
|
175
|
+
🔴 它**不调 `startup_once()`**:那个带进程级 latch,迁移之后调它会拿到"已经跑过"、
|
|
176
|
+
这一轮体检被吞掉 —— 而这一轮恰恰是数据迁移刚改过库之后最需要的那一次。
|
|
177
|
+
🔴 `install_hook` 已经把 `sender` 限成本 app 的迁移(别的 app 迁移完跑一遍纯属浪费)。
|
|
178
|
+
"""
|
|
179
|
+
del sender, kwargs
|
|
180
|
+
bootstrap_admins()
|
|
181
|
+
try:
|
|
182
|
+
check_all_blocks()
|
|
183
|
+
except Exception as exc: # noqa: BLE001 —— 体检失败不影响迁移
|
|
184
|
+
logger.error('迁移之后的全量体检失败(不影响服务):%s: %s', type(exc).__name__, exc)
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
def startup_once() -> None:
|
|
188
|
+
"""进程启动时跑一次:**补管理员 → 全量体检**。无参、永不抛、进程级 latch。
|
|
189
|
+
|
|
190
|
+
🔴 名字与签名是 `wsgi.py` 的硬契约,别改。
|
|
191
|
+
"""
|
|
192
|
+
global _STARTED
|
|
193
|
+
if _STARTED:
|
|
194
|
+
return
|
|
195
|
+
_STARTED = True
|
|
196
|
+
bootstrap_admins()
|
|
197
|
+
try:
|
|
198
|
+
for line in check_all_blocks()['log']:
|
|
199
|
+
logger.info(line)
|
|
200
|
+
except Exception as exc: # noqa: BLE001 —— 冷启动时"表还没建"很常见,必须安静地过去
|
|
201
|
+
logger.error('启动体检失败(块按库里现有的启用状态跑):%s: %s', type(exc).__name__, exc)
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
#: `startup_once()` 的进程级 latch。
|
|
205
|
+
_STARTED = False
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
def bootstrap_admins() -> None:
|
|
209
|
+
"""按环境变量点名的人补 staff(**只增不减、永不抛**)。
|
|
210
|
+
|
|
211
|
+
⚠️ 那个模块可能还没被装上 —— 懒引用,取不到就跳过这一件事(体检不该因此不跑)。
|
|
212
|
+
"""
|
|
213
|
+
try:
|
|
214
|
+
from ..auth.bootstrap import sync_staff_work_ids
|
|
215
|
+
except Exception: # noqa: BLE001 —— 没装那一块就不是错
|
|
216
|
+
return
|
|
217
|
+
try:
|
|
218
|
+
sync_staff_work_ids()
|
|
219
|
+
except Exception as exc: # noqa: BLE001 —— 补管理员失败不影响体检与启动
|
|
220
|
+
logger.error('启动补管理员失败(不影响服务):%s: %s', type(exc).__name__, exc)
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
"""
|
|
2
|
+
编译缓存:块后端的正文在库里(不是一个 .py 文件),所以只能 `exec(compile(src), namespace)` 加载,
|
|
3
|
+
加载结果按 `updated_at` 做版本键缓存在进程内 —— **保存即生效,不重启**。
|
|
4
|
+
|
|
5
|
+
🔴 编译**不做任何校验**:那一份在 `validate.py`(保存与体检都先过它)。这里只把已经通过的代码
|
|
6
|
+
变成可调用的对象;两份判据并成一份的后果是"作者看到保存通过、运行期又说不合法"。
|
|
7
|
+
🔴 块**可以只有前端**(包里没有 `backend_open.py` 不是错误),那时路由表就是空的。
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
from collections import OrderedDict
|
|
13
|
+
from dataclasses import dataclass, field
|
|
14
|
+
from datetime import datetime
|
|
15
|
+
from typing import Any, Callable
|
|
16
|
+
|
|
17
|
+
from .sdk import RouteMeta, routes_of_namespace
|
|
18
|
+
|
|
19
|
+
#: 缓存的条目上限(块本来就少,这个数只是不让缓存无界增长)。
|
|
20
|
+
CACHE_MAX = 64
|
|
21
|
+
|
|
22
|
+
#: 编译后的一条路由:handler + 它声明的权限。
|
|
23
|
+
RouteHandler = Callable[[Any, Any], Any]
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
@dataclass(frozen=True)
|
|
27
|
+
class CompiledRoute:
|
|
28
|
+
"""一条编译好的路由。"""
|
|
29
|
+
|
|
30
|
+
handler: RouteHandler
|
|
31
|
+
#: 声明的 Django 权限 codename;空串 = 任何登录用户。
|
|
32
|
+
permission: str = ''
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
@dataclass(frozen=True)
|
|
36
|
+
class CompiledBackend:
|
|
37
|
+
"""一个块的编译产物:`类名.方法名` → handler。
|
|
38
|
+
|
|
39
|
+
🔴 加载失败(构造函数炸了 / 顶层出错)只让**这个块没有路由**,不带崩进程 ——
|
|
40
|
+
报文进 `errors`,作者调它时拿到的是 404(列出这个块真实有的路由名,这里是空的)。
|
|
41
|
+
"""
|
|
42
|
+
|
|
43
|
+
routes: dict[str, CompiledRoute] = field(default_factory=dict)
|
|
44
|
+
#: 这份表是从哪一版正文编出来的(`updated_at` 的毫秒时间戳)。
|
|
45
|
+
version: int = 0
|
|
46
|
+
#: 编译时的错误(逐条中文,只给日志)。
|
|
47
|
+
errors: tuple[str, ...] = ()
|
|
48
|
+
|
|
49
|
+
def route_names(self) -> list[str]:
|
|
50
|
+
"""这个块真实有的路由名(稳定排序 —— 报文里要列给作者看)。"""
|
|
51
|
+
return sorted(self.routes)
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def to_version(value: datetime | None) -> int:
|
|
55
|
+
"""`updated_at` → 版本号(毫秒时间戳)。`None` ⇒ 0(与任何真实时间戳都不等,必然重编)。"""
|
|
56
|
+
if value is None:
|
|
57
|
+
return 0
|
|
58
|
+
try:
|
|
59
|
+
return int(value.timestamp() * 1000)
|
|
60
|
+
except (AttributeError, ValueError, OSError):
|
|
61
|
+
return 0
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def compile_backend(files: dict[str, str], version: int = 0) -> CompiledBackend:
|
|
65
|
+
"""把一包文件编译成路由表(**不碰数据库**)。"""
|
|
66
|
+
source = (files or {}).get('backend_open.py') or ''
|
|
67
|
+
if not source.strip():
|
|
68
|
+
return CompiledBackend(routes={}, version=version)
|
|
69
|
+
try:
|
|
70
|
+
namespace: dict[str, Any] = {'__name__': 'labull_magic_block'}
|
|
71
|
+
exec(compile(source, 'backend_open.py', 'exec'), namespace)
|
|
72
|
+
classes = {
|
|
73
|
+
name: value
|
|
74
|
+
for name, value in namespace.items()
|
|
75
|
+
if isinstance(value, type) and not name.startswith('_')
|
|
76
|
+
}
|
|
77
|
+
meta: dict[str, RouteMeta] = routes_of_namespace(classes)
|
|
78
|
+
routes: dict[str, CompiledRoute] = {}
|
|
79
|
+
for route_name, route_meta in meta.items():
|
|
80
|
+
class_name, _, method_name = route_name.partition('.')
|
|
81
|
+
# 🔴 handler 绑在**实例**上:作者在 `__init__` 里赋的常量因此可用。
|
|
82
|
+
# 没写到 `meta` 里的方法不是路由(路径从装饰器来)。
|
|
83
|
+
instance = classes[class_name]()
|
|
84
|
+
handler = getattr(instance, method_name, None)
|
|
85
|
+
if callable(handler):
|
|
86
|
+
routes[route_name] = CompiledRoute(handler=handler, permission=route_meta.permission)
|
|
87
|
+
return CompiledBackend(routes=routes, version=version)
|
|
88
|
+
except Exception as exc: # noqa: BLE001 —— 任何异常都只让"这个块没有路由"
|
|
89
|
+
return CompiledBackend(routes={}, version=version, errors=(f'加载失败:{type(exc).__name__}: {exc}',))
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
class BlockRegistry:
|
|
93
|
+
"""进程内单例:块 uuid → 当前生效的路由表。
|
|
94
|
+
|
|
95
|
+
🔴 版本判据是**库里那一行的 `updated_at`**:别人保存了这个块,本进程在下一次请求就看见
|
|
96
|
+
(多 worker 部署时每个 worker 各有一份缓存,各自发现版本变了 —— 这是可以接受的)。
|
|
97
|
+
"""
|
|
98
|
+
|
|
99
|
+
def __init__(self) -> None:
|
|
100
|
+
self._cache: OrderedDict[str, CompiledBackend] = OrderedDict()
|
|
101
|
+
|
|
102
|
+
def get(self, block_id: str) -> CompiledBackend:
|
|
103
|
+
"""取当前生效的路由表(版本对不上就重新编译)。库里没有这个块 ⇒ `LookupError`。"""
|
|
104
|
+
from ..models import MagicBlock
|
|
105
|
+
|
|
106
|
+
row = MagicBlock.objects.filter(id=block_id).only('id', 'updated_at', 'files').first()
|
|
107
|
+
if row is None:
|
|
108
|
+
raise LookupError(f'没有这个块:{block_id}')
|
|
109
|
+
version = to_version(row.updated_at)
|
|
110
|
+
hit = self._cache.get(block_id)
|
|
111
|
+
if hit is not None and hit.version == version:
|
|
112
|
+
self._cache.move_to_end(block_id)
|
|
113
|
+
return hit
|
|
114
|
+
compiled = compile_backend(row.files or {}, version)
|
|
115
|
+
self._put(block_id, compiled)
|
|
116
|
+
return compiled
|
|
117
|
+
|
|
118
|
+
def adopt(self, block_id: str, files: dict[str, str]) -> CompiledBackend:
|
|
119
|
+
"""保存 / 回滚成功之后换新(**不重启即生效**的唯一落点)。
|
|
120
|
+
|
|
121
|
+
🔴 调用方必须**先**校验通过、**再**写库、最后才调这里 —— 这样"库里那一版"与
|
|
122
|
+
"内存里跑的那一版"永远同步。版本从库里重新读(`auto_now` 的时间由 Django 打,调用方算不出)。
|
|
123
|
+
"""
|
|
124
|
+
from ..models import MagicBlock
|
|
125
|
+
|
|
126
|
+
row = MagicBlock.objects.filter(id=block_id).only('id', 'updated_at').first()
|
|
127
|
+
compiled = compile_backend(files or {}, to_version(row.updated_at) if row is not None else 0)
|
|
128
|
+
self._put(block_id, compiled)
|
|
129
|
+
return compiled
|
|
130
|
+
|
|
131
|
+
def invalidate(self, block_id: str) -> None:
|
|
132
|
+
"""这个块没了:把缓存丢掉(不丢的话,下一次 `get()` 之前那段时间还调得到那段代码)。"""
|
|
133
|
+
self._cache.pop(block_id, None)
|
|
134
|
+
|
|
135
|
+
def enabled(self, block_id: str) -> tuple[bool, str]:
|
|
136
|
+
"""这个块现在能不能用:`(能不能, 停用原因)`。库里没有 ⇒ `(False, '')`。
|
|
137
|
+
|
|
138
|
+
🔴 `is not False` 而不是 `bool(...)`:库里可能是 `NULL`(历史行),那种行按"启用"算。
|
|
139
|
+
🔴 停用要**明确拒绝**(403 + 原因),不装成"没有这个块"—— 后者会让作者去查 uuid 写错。
|
|
140
|
+
"""
|
|
141
|
+
from ..models import MagicBlock
|
|
142
|
+
|
|
143
|
+
row = MagicBlock.objects.filter(id=block_id).values('enabled', 'disabled_reason').first()
|
|
144
|
+
if row is None:
|
|
145
|
+
return False, ''
|
|
146
|
+
return row['enabled'] is not False, str(row['disabled_reason'] or '')
|
|
147
|
+
|
|
148
|
+
def _put(self, block_id: str, compiled: CompiledBackend) -> None:
|
|
149
|
+
"""写缓存(超上限先丢最旧的 —— `OrderedDict` 的插入序就是 LRU 序)。"""
|
|
150
|
+
self._cache.pop(block_id, None)
|
|
151
|
+
self._cache[block_id] = compiled
|
|
152
|
+
while len(self._cache) > CACHE_MAX:
|
|
153
|
+
self._cache.popitem(last=False)
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
#: 进程内单例:视图是普通函数,模块级对象就是 Django 里的"单例"。
|
|
157
|
+
REGISTRY = BlockRegistry()
|
|
@@ -0,0 +1,245 @@
|
|
|
1
|
+
"""
|
|
2
|
+
块作者看到的**契约本体**:`backend_open.py` 怎么写、`ctx` 有几个键、`errors` 怎么抛,
|
|
3
|
+
全部以本文件为准。块包校验(`validate.py`)与执行(`registry.py` / `dispatch.py`)
|
|
4
|
+
读的是同一批常量与同一份元数据 —— 三处各写一份必然走偏。
|
|
5
|
+
|
|
6
|
+
🔴 `ctx` **只有 4 个键**(`CTX_KEYS`):`user` / `config` / `request` / `block`。
|
|
7
|
+
一个都不许加 —— 加一个键就是给块代码多一样能力,而契约、文档、校验三边都要跟着改。
|
|
8
|
+
⚠️ **业务模型不经过 `ctx`**:块后端是普通的 Python,`from modules.orders.models import Order`
|
|
9
|
+
就是 Django 的写法(`ctx` 里再放一份"表名 → 模型"的映射,等于同一件事两个来源)。
|
|
10
|
+
🔴 `errors` 必须是**同一个对象**(不是每次 new):块里写 `except errors.NotFound` 要抓得住。
|
|
11
|
+
🔴 这里的装饰器**只挂标记**:签名对不对、名字许不许,是 `validate.py` 的静态判据,不是运行期的事。
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import json
|
|
17
|
+
from dataclasses import dataclass
|
|
18
|
+
from datetime import date, datetime, time
|
|
19
|
+
from decimal import Decimal
|
|
20
|
+
from typing import Any, Callable
|
|
21
|
+
from uuid import UUID
|
|
22
|
+
|
|
23
|
+
#: 块包入口(必备件):宿主在不透明源 iframe 里渲染的就是它。
|
|
24
|
+
BLOCK_ENTRY = 'index.open.html'
|
|
25
|
+
|
|
26
|
+
#: 块包清单(必备件):`id` / `module` / `name` / `brief` 四个键全部必填。
|
|
27
|
+
#: 🔴 认块**只认它里面的 `id`**(与页面里写死的那个 uuid 逐字一致)。
|
|
28
|
+
BLOCK_MANIFEST = 'magic-block.open.yaml'
|
|
29
|
+
|
|
30
|
+
#: 块自己的后端(**可选**):包里有别的名字的文件都不是后端,不会被校验、也不会挂路由。
|
|
31
|
+
BLOCK_BACKEND_FILE = 'backend_open.py'
|
|
32
|
+
|
|
33
|
+
#: 块作者唯一许写的那条 import 的模块路径(逐字)。块模块被塞进 `sys.modules` 的就是这个键。
|
|
34
|
+
LIB_SDK = 'labull_framework.magic_block.sdk'
|
|
35
|
+
|
|
36
|
+
#: 块配置的前缀:环境变量 `BLOCK_OCR_KEY` ⇒ `ctx.config['OCR_KEY']`。
|
|
37
|
+
BLOCK_CONFIG_PREFIX = 'BLOCK_'
|
|
38
|
+
|
|
39
|
+
#: 单次 `/bx/…` 调用的软超时(秒):超时回 504,但那个方法**还在后台跑**(见 `dispatch.py`)。
|
|
40
|
+
ROUTE_TIMEOUT_SECONDS = 15
|
|
41
|
+
|
|
42
|
+
#: 请求正文的字节上限(24 MiB)。分块读、边读边数,不靠 `Content-Length`。
|
|
43
|
+
MAX_BODY_BYTES = 24 * 1024 * 1024
|
|
44
|
+
|
|
45
|
+
#: `ctx` 的键(4 个,一个都不许加)。🔴 顺序也是契约:文档与类型照它排。
|
|
46
|
+
CTX_KEYS: tuple[str, ...] = ('user', 'config', 'request', 'block')
|
|
47
|
+
|
|
48
|
+
#: 路由元数据挂在方法对象上的属性名。
|
|
49
|
+
#: 🔴 用属性而不是"另建一张注册表":装饰器写在方法定义上面,那时类还不存在,没有任何地方可登记。
|
|
50
|
+
ROUTE_META = '__labull_magic_route__'
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
# ──────────────────────────── 路由声明 ────────────────────────────
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
@dataclass(frozen=True)
|
|
57
|
+
class RouteMeta:
|
|
58
|
+
"""一条路由的声明:`@MagicBackendAPI(permission=…)` 挂在方法上的那份元数据。"""
|
|
59
|
+
|
|
60
|
+
#: 调这条路由要的 Django 权限 codename;空串 = **任何登录用户**都能调。
|
|
61
|
+
#:
|
|
62
|
+
#: 🔴 省略 `permission` 是刻意的 fail-open:该拦在 handler 里判("这一行是不是他的"),
|
|
63
|
+
#: 不是靠装饰器一个默认值。要收紧就写 `permission='app.codename'`(Django 的 has_perm)。
|
|
64
|
+
permission: str = ''
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
@dataclass(frozen=True)
|
|
68
|
+
class _RouteDecorator:
|
|
69
|
+
"""装饰器返回的东西:既是装饰器,也是元数据载体。"""
|
|
70
|
+
|
|
71
|
+
permission: str
|
|
72
|
+
|
|
73
|
+
def __call__(self, method: Callable[..., Any]) -> Callable[..., Any]:
|
|
74
|
+
setattr(method, ROUTE_META, RouteMeta(permission=self.permission))
|
|
75
|
+
return method
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def MagicBackendAPI(permission: str | None = None) -> _RouteDecorator:
|
|
79
|
+
"""路由声明装饰器:**只是往方法上挂一份元数据**,本身零安全价值。
|
|
80
|
+
|
|
81
|
+
🔴 没挂它的方法**不是路由**(路径从装饰器来,不是从"是 public 方法"来)——
|
|
82
|
+
少写一行装饰器的表现是"前端调它回 404(报文里列出真实有的路由名)",不是"谁都能调"。
|
|
83
|
+
🔴 传进来的东西不是字符串就当场 `TypeError`:它发生在**保存试加载**(模块级装饰器求值)时,
|
|
84
|
+
静默当"任何登录用户"会让一个权限口子无声地开着。
|
|
85
|
+
"""
|
|
86
|
+
if permission is None:
|
|
87
|
+
return _RouteDecorator('')
|
|
88
|
+
if not isinstance(permission, str):
|
|
89
|
+
raise TypeError(
|
|
90
|
+
'@MagicBackendAPI(permission=…) 要么省略(任何登录用户),要么给一个 Django 权限 codename 字符串'
|
|
91
|
+
)
|
|
92
|
+
return _RouteDecorator(permission.strip())
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def route_meta_of(method: object) -> RouteMeta | None:
|
|
96
|
+
"""读一个方法上的路由元数据(没挂装饰器 ⇒ `None`,也就是"这方法不是路由")。"""
|
|
97
|
+
meta = getattr(method, ROUTE_META, None)
|
|
98
|
+
return meta if isinstance(meta, RouteMeta) else None
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def routes_of_namespace(classes: dict[str, type]) -> dict[str, RouteMeta]:
|
|
102
|
+
"""从**已加载的类**里读出 `类名.方法名` → 声明(顺序 = 类与方法的书写顺序)。
|
|
103
|
+
|
|
104
|
+
⚠️ 与 `validate.routes_of()` 是两件事、不是两份判据:那一份从源码文本(`ast`)读,
|
|
105
|
+
管保存校验与接口声明;这一份从已加载的对象读,管运行期真拿得到的 handler。
|
|
106
|
+
两者都只认这个装饰器挂上的元数据,所以结论一致。
|
|
107
|
+
"""
|
|
108
|
+
out: dict[str, RouteMeta] = {}
|
|
109
|
+
for class_name, cls in classes.items():
|
|
110
|
+
for method_name, method in vars(cls).items():
|
|
111
|
+
if method_name.startswith('_'):
|
|
112
|
+
continue
|
|
113
|
+
meta = route_meta_of(method)
|
|
114
|
+
if meta is not None:
|
|
115
|
+
out[f'{class_name}.{method_name}'] = meta
|
|
116
|
+
return out
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
# ──────────────────────────── 块作者能抛的错误 ────────────────────────────
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
class MagicBackendHTTPError(Exception):
|
|
123
|
+
"""块后端能抛的 HTTP 语义错误:分发器按 `status` 映射状态码,别的异常一律 500。
|
|
124
|
+
|
|
125
|
+
🔴 状态码口径与 HTTP 一致(400 / 401 / 403 / 404 / 409 / 500),否则"块里抛 403"
|
|
126
|
+
会变成 500,人看到的是"服务器错误"而不是"你没权限"。
|
|
127
|
+
"""
|
|
128
|
+
|
|
129
|
+
status: int = 500
|
|
130
|
+
|
|
131
|
+
def __init__(self, message: str = '') -> None:
|
|
132
|
+
super().__init__(message)
|
|
133
|
+
self.message = str(message)
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def _error_class(name: str, status: int) -> type[MagicBackendHTTPError]:
|
|
137
|
+
"""造一个带状态码的错误类(六个名字共用这一处,省得六段一模一样的类体)。"""
|
|
138
|
+
return type(name, (MagicBackendHTTPError,), {'status': status})
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
class _Errors:
|
|
142
|
+
"""`errors` —— 块作者抛的那六个(每个都是"状态码 + 报文"的一种语义)。
|
|
143
|
+
|
|
144
|
+
🔴 六个成员是**同一个对象上的类属性**:块代码里 `except errors.NotFound` 因此成立。
|
|
145
|
+
"""
|
|
146
|
+
|
|
147
|
+
BadRequest = _error_class('BadRequest', 400)
|
|
148
|
+
Unauthorized = _error_class('Unauthorized', 401)
|
|
149
|
+
Forbidden = _error_class('Forbidden', 403)
|
|
150
|
+
NotFound = _error_class('NotFound', 404)
|
|
151
|
+
Conflict = _error_class('Conflict', 409)
|
|
152
|
+
Internal = _error_class('Internal', 500)
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
#: 见到 `errors` 就是这一份(块包里 import 的也是它)。
|
|
156
|
+
errors = _Errors()
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
# ──────────────────────────── ctx 的形状 ────────────────────────────
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
@dataclass(frozen=True)
|
|
163
|
+
class BlockUser:
|
|
164
|
+
"""`ctx.user` —— handler 里认人的唯一口径。
|
|
165
|
+
|
|
166
|
+
🔴 来源只有 `request.user`(Django 的会话),**绝不从 body 取**:
|
|
167
|
+
body 是调用方说了算的,拿它认人等于没有身份。
|
|
168
|
+
🔴 `groups` / `permissions` 给的是**名字**(组名 / 权限 codename),不是 Django 对象:
|
|
169
|
+
块里判权限用 `'app.codename' in ctx.user.permissions` 就够,不必碰 ORM。
|
|
170
|
+
"""
|
|
171
|
+
|
|
172
|
+
#: 本地用户主键(Django 的 `pk`)。
|
|
173
|
+
id: str
|
|
174
|
+
#: 登录用户名(OIDC 的 `sub` 落在这一列)。
|
|
175
|
+
username: str
|
|
176
|
+
#: 工号(登录时由 `username` 映射而来;没有就是空串)。
|
|
177
|
+
work_id: str
|
|
178
|
+
#: 显示名(拿不到就是空串 —— 不编造)。
|
|
179
|
+
name: str
|
|
180
|
+
#: Django 自带的 staff 位("是不是管理员"的唯一判据)。
|
|
181
|
+
is_staff: bool
|
|
182
|
+
#: 所在组的名字。
|
|
183
|
+
groups: tuple[str, ...] = ()
|
|
184
|
+
#: 直接权限 + 组权限的 codename(`app_label.codename` 形式)。
|
|
185
|
+
permissions: tuple[str, ...] = ()
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
@dataclass(frozen=True)
|
|
189
|
+
class BlockCtx:
|
|
190
|
+
"""handler 第二个参数里的 `ctx` —— **块里能拿到的全部能力**(就这 4 个键)。
|
|
191
|
+
|
|
192
|
+
🔴 这一段**没有查询操作符**(`eq` / `and` / `desc` …):Django 原生 `Q` / `F` /
|
|
193
|
+
`filter` / `order_by` 就够了,多一层自己的 DSL 是净负担。
|
|
194
|
+
🔴 **也没有"业务表清单"**:块后端直接 import 自己的模型(`from modules.orders.models import Order`)——
|
|
195
|
+
那是 Django 的写法,而 `ctx` 里再放一份映射就是同一件事两个来源。
|
|
196
|
+
"""
|
|
197
|
+
|
|
198
|
+
#: 认人的唯一口径。
|
|
199
|
+
user: BlockUser
|
|
200
|
+
#: 配置面:环境里 `BLOCK_` 前缀的那些(前缀剥掉当键;没配的键**不存在**)。
|
|
201
|
+
config: dict[str, str]
|
|
202
|
+
#: HTTP 请求原样给的(读 header / 原始字节用)。⚠️ **不该拿它判身份** —— 那只有 `ctx.user`。
|
|
203
|
+
request: Any
|
|
204
|
+
#: 当前块的身份(`id` / `module` / `name` / `brief`),只读。
|
|
205
|
+
block: dict[str, Any]
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
# ──────────────────────────── 返回值只能是 JSON ────────────────────────────
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
class _BlockJSONEncoder(json.JSONEncoder):
|
|
212
|
+
"""块 handler 返回值的编码器:日期 / 金额 / uuid / 集合要能序列化。
|
|
213
|
+
|
|
214
|
+
🔴 每一样都转成**字符串**:JSON 的 number 是双精度,`Decimal` 走它有**静默丢精度**的风险
|
|
215
|
+
(金额上那是事故);`datetime` 本来就没有 JSON 原生表示。字符串是唯一不撒谎的表示。
|
|
216
|
+
🔴 原始字节**明确报错**,不偷偷 base64 —— 那会让作者以为前端拿到的是那段字节。
|
|
217
|
+
"""
|
|
218
|
+
|
|
219
|
+
def default(self, o: Any) -> Any:
|
|
220
|
+
if isinstance(o, Decimal):
|
|
221
|
+
return str(o)
|
|
222
|
+
if isinstance(o, (datetime, date, time)):
|
|
223
|
+
return o.isoformat()
|
|
224
|
+
if isinstance(o, UUID):
|
|
225
|
+
return str(o)
|
|
226
|
+
if isinstance(o, (bytes, bytearray, memoryview)):
|
|
227
|
+
raise TypeError('返回值里有原始字节:JSON 里没有字节类型,请自己 base64 成字符串')
|
|
228
|
+
if isinstance(o, (set, frozenset)):
|
|
229
|
+
# 不排序:set 的顺序本来就是实现细节,排了反而像它有语义。
|
|
230
|
+
return list(o)
|
|
231
|
+
if type(o).__module__.startswith('django') and hasattr(o, '__str__'):
|
|
232
|
+
# Django 的惰性对象(翻译串那类):取它的字符串形式。
|
|
233
|
+
return str(o)
|
|
234
|
+
return super().default(o)
|
|
235
|
+
|
|
236
|
+
|
|
237
|
+
def block_json(value: Any) -> str:
|
|
238
|
+
"""把 handler 的返回值编成 JSON 文本(`None` ⇒ `'null'`)。
|
|
239
|
+
|
|
240
|
+
🔴 编不出来就**报出来**,绝不回半个对象 —— 半个 JSON 比一条错误难查得多。
|
|
241
|
+
"""
|
|
242
|
+
try:
|
|
243
|
+
return json.dumps(value, cls=_BlockJSONEncoder, ensure_ascii=False)
|
|
244
|
+
except (TypeError, ValueError) as exc:
|
|
245
|
+
raise errors.Internal(f'返回值不能序列化成 JSON:{exc}') from exc
|