labull-framework 0.1.6__tar.gz → 0.1.8__tar.gz

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.
Files changed (48) hide show
  1. {labull_framework-0.1.6/labull_framework.egg-info → labull_framework-0.1.8}/PKG-INFO +1 -1
  2. {labull_framework-0.1.6 → labull_framework-0.1.8}/labull_framework/magic_block/devkit.py +270 -8
  3. {labull_framework-0.1.6 → labull_framework-0.1.8}/labull_framework/magic_block/dispatch.py +14 -0
  4. {labull_framework-0.1.6 → labull_framework-0.1.8}/labull_framework/magic_block/export.py +19 -1
  5. {labull_framework-0.1.6 → labull_framework-0.1.8/labull_framework.egg-info}/PKG-INFO +1 -1
  6. {labull_framework-0.1.6 → labull_framework-0.1.8}/pyproject.toml +1 -1
  7. {labull_framework-0.1.6 → labull_framework-0.1.8}/LICENSE +0 -0
  8. {labull_framework-0.1.6 → labull_framework-0.1.8}/MANIFEST.in +0 -0
  9. {labull_framework-0.1.6 → labull_framework-0.1.8}/README-pypi.md +0 -0
  10. {labull_framework-0.1.6 → labull_framework-0.1.8}/labull_framework/__init__.py +0 -0
  11. {labull_framework-0.1.6 → labull_framework-0.1.8}/labull_framework/admin.py +0 -0
  12. {labull_framework-0.1.6 → labull_framework-0.1.8}/labull_framework/apps.py +0 -0
  13. {labull_framework-0.1.6 → labull_framework-0.1.8}/labull_framework/auth/__init__.py +0 -0
  14. {labull_framework-0.1.6 → labull_framework-0.1.8}/labull_framework/auth/backend.py +0 -0
  15. {labull_framework-0.1.6 → labull_framework-0.1.8}/labull_framework/auth/bootstrap.py +0 -0
  16. {labull_framework-0.1.6 → labull_framework-0.1.8}/labull_framework/auth/sso.py +0 -0
  17. {labull_framework-0.1.6 → labull_framework-0.1.8}/labull_framework/auth/views.py +0 -0
  18. {labull_framework-0.1.6 → labull_framework-0.1.8}/labull_framework/canvas/__init__.py +0 -0
  19. {labull_framework-0.1.6 → labull_framework-0.1.8}/labull_framework/canvas/data.py +0 -0
  20. {labull_framework-0.1.6 → labull_framework-0.1.8}/labull_framework/canvas/gitinfo.py +0 -0
  21. {labull_framework-0.1.6 → labull_framework-0.1.8}/labull_framework/canvas/static/canvas.css +0 -0
  22. {labull_framework-0.1.6 → labull_framework-0.1.8}/labull_framework/canvas/static/canvas.js +0 -0
  23. {labull_framework-0.1.6 → labull_framework-0.1.8}/labull_framework/canvas/static/page.html +0 -0
  24. {labull_framework-0.1.6 → labull_framework-0.1.8}/labull_framework/canvas/views.py +0 -0
  25. {labull_framework-0.1.6 → labull_framework-0.1.8}/labull_framework/env.py +0 -0
  26. {labull_framework-0.1.6 → labull_framework-0.1.8}/labull_framework/jsonio.py +0 -0
  27. {labull_framework-0.1.6 → labull_framework-0.1.8}/labull_framework/magic_block/__init__.py +0 -0
  28. {labull_framework-0.1.6 → labull_framework-0.1.8}/labull_framework/magic_block/blockpackage.py +0 -0
  29. {labull_framework-0.1.6 → labull_framework-0.1.8}/labull_framework/magic_block/deepcheck.py +0 -0
  30. {labull_framework-0.1.6 → labull_framework-0.1.8}/labull_framework/magic_block/health.py +0 -0
  31. {labull_framework-0.1.6 → labull_framework-0.1.8}/labull_framework/magic_block/registry.py +0 -0
  32. {labull_framework-0.1.6 → labull_framework-0.1.8}/labull_framework/magic_block/sdk.py +0 -0
  33. {labull_framework-0.1.6 → labull_framework-0.1.8}/labull_framework/magic_block/service.py +0 -0
  34. {labull_framework-0.1.6 → labull_framework-0.1.8}/labull_framework/magic_block/validate.py +0 -0
  35. {labull_framework-0.1.6 → labull_framework-0.1.8}/labull_framework/magic_block/views.py +0 -0
  36. {labull_framework-0.1.6 → labull_framework-0.1.8}/labull_framework/management/__init__.py +0 -0
  37. {labull_framework-0.1.6 → labull_framework-0.1.8}/labull_framework/management/commands/__init__.py +0 -0
  38. {labull_framework-0.1.6 → labull_framework-0.1.8}/labull_framework/management/commands/labull_contract.py +0 -0
  39. {labull_framework-0.1.6 → labull_framework-0.1.8}/labull_framework/migrations/0001_initial.py +0 -0
  40. {labull_framework-0.1.6 → labull_framework-0.1.8}/labull_framework/migrations/__init__.py +0 -0
  41. {labull_framework-0.1.6 → labull_framework-0.1.8}/labull_framework/models.py +0 -0
  42. {labull_framework-0.1.6 → labull_framework-0.1.8}/labull_framework/settings.py +0 -0
  43. {labull_framework-0.1.6 → labull_framework-0.1.8}/labull_framework/urls.py +0 -0
  44. {labull_framework-0.1.6 → labull_framework-0.1.8}/labull_framework.egg-info/SOURCES.txt +0 -0
  45. {labull_framework-0.1.6 → labull_framework-0.1.8}/labull_framework.egg-info/dependency_links.txt +0 -0
  46. {labull_framework-0.1.6 → labull_framework-0.1.8}/labull_framework.egg-info/requires.txt +0 -0
  47. {labull_framework-0.1.6 → labull_framework-0.1.8}/labull_framework.egg-info/top_level.txt +0 -0
  48. {labull_framework-0.1.6 → labull_framework-0.1.8}/setup.cfg +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: labull-framework
3
- Version: 0.1.6
3
+ Version: 0.1.8
4
4
  Summary: Labull Framework: a pluggable Django app with Authing OIDC login and magic blocks
5
5
  Author: Labull Framework
6
6
  License-Expression: MIT
@@ -47,6 +47,13 @@ PAGES_LOCK = 'pages.lock.ts'
47
47
  #: ⚠️ 这两个名字与 `frontend/magic-block/src/theme.ts` 的 `THEME_FILE` / `CONTROLS_FILE` **逐字一致**。
48
48
  THEME = 'labull.css'
49
49
  CONTROLS = 'CONTROLS.lock.html'
50
+ #: 面向**编码 agent**(Claude Code / Copilot / Cursor …)的那一份:哪些绝不许做、权限住在哪。
51
+ #: 🔴 为什么单独一份:`AGENTS.md` 是那几个工具的**约定文件名**,它们会优先读它。
52
+ #: 而前两次真出过的事故都不是"不会写代码",是**没人告诉过边界**:
53
+ #: · 自己发明一套权限码,再拿 Django 的 `auth_permission` 当容器装(那块清单里根本没有那张表);
54
+ #: · 把"拆文件"理解成块内互相 import。
55
+ #: ⚠️ 与 `GUIDE` 分工:GUIDE 讲"怎么写",这一份讲"**不许怎么做、以及拿不准时先查哪里**"。
56
+ AGENTS = 'AGENTS.lock.md'
50
57
  #: 后端依赖的那份清单(**项目自己的 `requirements.txt` 原样抄一份**):
51
58
  #: 块后端跑在项目的解释器里,作者要 import 什么就得先装什么 —— 给他一份可照抄的参考。
52
59
  #: 文件本身是 `requirements.txt` 的逐字副本(开头加了一行说明注释),可以直接 `pip install -r`。
@@ -276,6 +283,89 @@ def _relation_lines(table: dict[str, Any]) -> list[str]:
276
283
  return out
277
284
 
278
285
 
286
+ #: 哪些 app 算"平台自己的"(与项目业务分开说:它只管登录与块本身)。
287
+ _PLATFORM_APPS: frozenset[str] = frozenset({'labull_framework'})
288
+ #: 哪些 app 算 **Django 自带的元数据**(模型级 CRUD / admin / 会话 / 迁移)—— 一律点名"别当业务口径"。
289
+ #: ⚠️ 名字是 app_label,`django.contrib.auth` ⇒ `auth`。
290
+ _DJANGO_APPS: frozenset[str] = frozenset({
291
+ 'auth', 'admin', 'contenttypes', 'sessions', 'messages', 'staticfiles', 'sites',
292
+ 'redirects', 'flatpages', 'humanize', 'postgres', 'gis', 'admindocs',
293
+ })
294
+ #: 这些前缀太泛,**不算**"权限样字段组"的前缀(`is_active` / `can_edit` 这种到处都是)。
295
+ _GENERIC_PREFIXES: frozenset[str] = frozenset({
296
+ 'is', 'can', 'has', 'allow', 'enable', 'enabled', 'active', 'visible', 'deleted', 'readonly',
297
+ 'public', 'private', 'draft', 'locked', 'sent', 'done',
298
+ })
299
+ #: "授权位"。出现在 help_text / verbose_name 里就说明这一列是**给谁用的**,不是数据。
300
+ _PERMISSION_HINTS: tuple[str, ...] = ('仅', '只允许', 'only ', 'permission', '权限', '授权')
301
+
302
+
303
+ def _source_of(module: str) -> str:
304
+ """一个 app_label 属于哪一类来源:`business` / `platform` / `django`。"""
305
+ label = str(module or '')
306
+ if label in _DJANGO_APPS or label.startswith('django.'):
307
+ return 'django'
308
+ if label in _PLATFORM_APPS:
309
+ return 'platform'
310
+ return 'business'
311
+
312
+
313
+ def _field_prefix(name: str) -> str:
314
+ """字段名的第一段(`<前缀>_is_x` ⇒ `<前缀>`);没有下划线就不成组。"""
315
+ text = str(name or '')
316
+ head, sep, _rest = text.partition('_')
317
+ return head if sep and head else ''
318
+
319
+
320
+ def _permission_groups(fields: list[dict[str, Any]]) -> list[tuple[str, list[str]]]:
321
+ """这份模型里那几组"**权限样字段**":`[(前缀, [字段名, …])]`。
322
+
323
+ 🔴 判据是**通用的两条**(不认业务词):
324
+ ① 同一前缀的**布尔列成组出现**(≥2 个)—— 单独一个布尔列不算(`is_active` 到处都是);
325
+ ② 这一组里有字段的备注带限定语(`(仅XX)` / `只允许` / `permission`)——
326
+ 那是"**岗位内的授权位**",不是数据列。
327
+ 前缀太泛(`is_` / `can_` / `has_` …)的一律不算组:那种名字满世界都是。
328
+ 🔴 为什么值得单独列出来:需求里说"权限"时,开发者最容易把这类布尔位当成"业务数据"去写条件,
329
+ 而它们真正的语义是"**这个人在这个岗位上能不能干这件事**"——得跟组织/岗位一起读。
330
+ """
331
+ booleans = [f for f in fields if str(f.get('kind') or '') in ('boolean',) and f.get('name')]
332
+ buckets: dict[str, list[dict[str, Any]]] = {}
333
+ for item in booleans:
334
+ prefix = _field_prefix(str(item.get('name')))
335
+ if not prefix or prefix in _GENERIC_PREFIXES:
336
+ continue
337
+ buckets.setdefault(prefix, []).append(item)
338
+ out: list[tuple[str, list[str]]] = []
339
+ for prefix, items in buckets.items():
340
+ if len(items) < 2:
341
+ continue
342
+ hinted = any(
343
+ any(mark in f'{item.get("brief") or ""}{item.get("briefLine") or ""}' for mark in _PERMISSION_HINTS)
344
+ or '_can_' in str(item.get('name'))
345
+ for item in items
346
+ )
347
+ if not hinted:
348
+ continue
349
+ out.append((prefix, [str(item.get('name')) for item in items]))
350
+ # 组多的排前面,同数量按前缀排序(结果稳定,导出可复现)。
351
+ out.sort(key=lambda pair: (-len(pair[1]), pair[0]))
352
+ return out
353
+
354
+
355
+ def _permission_index(table: dict[str, Any]) -> list[str]:
356
+ """一个模型的"权限样字段组"索引(没有就回空)。"""
357
+ fields = [field for field in (table.get('fields') or []) if isinstance(field, dict)]
358
+ groups = _permission_groups(fields)
359
+ if not groups:
360
+ return []
361
+ total = sum(len(names) for _prefix, names in groups)
362
+ parts = ' / '.join(f'{prefix}_*({len(names)})' for prefix, names in groups)
363
+ head = f' # 权限样字段组:{parts} —— 合计 {total} 个布尔位'
364
+ detail = (' # (授权位,不是数据:"某个岗位上能不能干这件事"的开关,要和岗位/组织一起读;'
365
+ '各组的字段行就在下面)')
366
+ return [head, detail]
367
+
368
+
279
369
  def _table_block(table: dict[str, Any]) -> list[str]:
280
370
  """一张表那一块(类名 / 备注 / 真实 import / 枚举类 / 字段 / 关联)。"""
281
371
  name = str(table.get('name') or '')
@@ -285,7 +375,10 @@ def _table_block(table: dict[str, Any]) -> list[str]:
285
375
  brief = _brief(table.get('brief') or table.get('briefLine') or table.get('verbose'))
286
376
  where = f'表 {_flat(table.get("table"))}' + (f' · 模块 {module}' if module else '')
287
377
  out = [f'class {name}:', f' """{brief}({where})"""' if brief else f' """{where}"""']
288
- if module:
378
+ # 🔴 那张"一般这样 import"的提示**只对业务表与平台表给**:Django 自带的那几张(`auth` / `admin` …)
379
+ # 拼出来的路径是**错的**(`Permission` 在 `django.contrib.auth.models`,不在 `auth.models`),
380
+ # 而清单里给一条错的 import 只会把人推向"照抄一句、然后自己补权限逻辑"那条路。
381
+ if module and _source_of(module) != 'django':
289
382
  out.append(f' # 一般这样 import:from {module}.models import {name}')
290
383
  enums = table.get('enums') or {}
291
384
  fields = [field for field in (table.get('fields') or []) if isinstance(field, dict)]
@@ -293,6 +386,10 @@ def _table_block(table: dict[str, Any]) -> list[str]:
293
386
  # 而成员表在 table 那一层。传进去比让每个字段自己去找一份可靠(也就不会有第二份匹配逻辑)。
294
387
  used = {str(field.get('enumRef') or '') for field in fields if field.get('enumRef')}
295
388
  out.extend(_enum_classes(table, used))
389
+ # 🔴 "**权限样字段组**"索引:那些成组出现的布尔位(`<前缀>_is_*` / `<前缀>_can_*`
390
+ # + 备注里的 `(仅XX)`)是**授权位**,不是数据列。索引摆在这一块最上面,
391
+ # 就是为了让人(和 agent)在逐行读字段之前先看见:"这张表的权限在哪儿"。
392
+ out.extend(_permission_index(table))
296
393
  for field in fields:
297
394
  line = _field_lines({**field, '_enums': enums})
298
395
  if line:
@@ -316,7 +413,7 @@ EXAMPLE_DIR = 'example'
316
413
 
317
414
  #: 例子包里那几份文件(名字顺序就是铺出来给人看的顺序)。
318
415
  EXAMPLE_FILES: tuple[tuple[str, str], ...] = (
319
- ('README.md', '# 例子:身份证 OCR → 客户档案\n\n这个目录是开发包里的**示例**,不是某个块的代码。它演示一条完整的活:\n**前端选中一张证件照 → 交给块的后端 → 后端调阿里云 OCR → 结果回前端确认 → 存进客户档案**。\n\n- 这个目录里**混着两类东西**,用的时候要分开看:\n - **能当块用的那几份**(前端拆了 4 份、后端拆了 3 份,加清单与这一份说明):\n `index.open.html` / `app.css` / `app.js` / `ui.js` / `magic-block.open.yaml` /\n `backend_open.py` / `ocr.py` / `customer.py` / 这一份 `README.md`;\n - **与开发目录顶层同名的那几份生成物**(`backend.lock.pyi` / `GUIDE.lock.md` / `README.lock.md` /\n `requirements.lock.txt`,以及前端补的 `pages.lock.ts` / `labull.css` / `CONTROLS.lock.html`):\n **只是给你查的参考资料,不是这个块的一部分**(读回时它们会被跳过)。\n- 想用它:把那**几份**(上面前一类)移进 `workspace/<可读名>-<你自己的块 uuid>/`,\n 把清单里的 `id` 换成那个 uuid,然后回管理台「读回改动」→ 勾上它 → 保存。\n **什么都不用先装**(除了下面那三行配置)。\n- 移过去之后、**保存之前**,在开发目录里跑一句自检:\n `node smoke.lock.mjs <你那个文件夹名>` —— 清单、前端引用、后端规矩它都会过一遍。\n- 它对应 `orders` 那张客户表的四列:`id_number` / `gender` / `birthday` / `address`\n (`Customer` 上那四列本来就是给身份证 OCR 留的落点)。\n\n## 〇、这个例子**拆成了几个文件**(可以照这么写,但有两条规矩)\n\n| 文件 | 是什么 |\n|---|---|\n| `index.open.html` | 入口:只有结构,样式与脚本用 `link` / `script` 引进来 |\n| `app.css` | 这个块的样式 |\n| `ui.js` | 界面那点事(取元素、显示状态、填/读表单)—— 普通脚本,挂在 `window.ocrUi` 上 |\n| `app.js` | 主逻辑:选图 → 调后端 → 存库 |\n| `backend_open.py` | 后端**路由层**:两个 `@MagicBackendAPI` 方法 |\n| `ocr.py` | **参考实现**:怎么调阿里云 SDK(不产生路由,不参与运行) |\n| `customer.py` | **参考实现**:怎么写进客户表(同上) |\n\n- **前端**:随便拆。宿主拼预览时会把 `<script src>` / `<link href>` 指向的**包内文件**内联进同一个\n 文档,所以引用写法与普通网页一样。⚠️ 但**入口引用的文件一个都不能少**(少一个就是一整块白屏)。\n ⚠️ 块里**没有模块系统**:不能写 `import` / `export`,各文件之间靠**全局名字**协作(例子用 `window.ocrUi`)。\n- **后端**:每个 `.py` 都是**独立入口**(平台逐个加载,与 `backend_open.py` 待遇完全一样)⇒\n **文件之间不能互相引用**(`from ocr import ...` 会被保存时的判据与 `smoke.lock.mjs` 拦下)。\n 想分文件就分,但每个文件要能独立站住;公共逻辑要么各写一份,要么放进**项目**里的模块\n (那是普通 Python,随便 import)。`ocr.py` / `customer.py` 因此是"可以整段复制进路由"的参考,\n 而不是被 import 的库。\n\n\n## 一、要配的三项(缺一项块会点名说缺哪个)\n\n块**只能**读带 `BLOCK_` 前缀的配置(前缀会被剥掉,所以块里写 `ctx.config[\'OCR_ENDPOINT\']`):\n\n```dotenv\nBLOCK_OCR_ENDPOINT=ocr-api.cn-hangzhou.aliyuncs.com\nBLOCK_OCR_ACCESS_KEY_ID=<RAM 用户的 AccessKey ID>\nBLOCK_OCR_ACCESS_KEY_SECRET=<RAM 用户的 AccessKey Secret>\n```\n\n- 这三项加在**项目**的 `.env` 里(不是块的文件夹里),改完要重启服务。\n- 那个 RAM 用户要**有 OCR 的权限**,并且阿里云控制台上要**开通文字识别服务** —— 没开通时\n 报的是 `Forbidden` 之类,看着像"密钥不对",其实是服务没开。\n- 块里**永远拿不到**别的环境变量:只有 `BLOCK_` 这一族会进 `ctx.config`。\n\n依赖:后端要用阿里云 OCR 的官方 Python SDK(`pip install alibabacloud_ocr_api20210707`)。\n\n## 二、为什么用官方 SDK,而不是自己拼签名\n\n这一条是**实测**出来的,值得记一笔:手写 RPC 签名(HMAC-SHA1 + percent-编码)我可以让它\n**通过鉴权**,但**图片传不进去** —— 同一账号、同一张图:\n\n| 调用方式 | 服务端的回答 | 意思 |\n|---|---|---|\n| 手写签名 + base64 字符串 | `imageUrlOrBodyEmpty` | 服务端**根本没读到图** |\n| 官方 SDK,`body=<字节>` | `illegalImageContent` | 读到了,只是那张图不是身份证 |\n\n差异在请求类里:`RecognizeIdcardRequest.body` 是**小写**名字、而且是**二进制流**,不是 base64 字符串。\n自己拼那一套还要同时算对"签名用的编码"与"正文用的编码"两份(它们**不是同一份**,我在这上面来回错了三次),\n任何一个参数改一下都可能悄悄失效 —— 不值当。**照 SDK 的形状写**。\n\n## 三、两个刻意的设计\n\n1. **探针不会真的去打一次 OCR。** 保存与体检时每个 handler 会被真的跑一遍(默认空参数),\n 所以 `recognize()` 的第一句就是"没给我图片 ⇒ BadRequest"。少了这一句,每存一次块都会白打一次\n OCR,打不通还会把块判成"体检不通过"。\n2. **识别与落库分成两个接口。** `recognize()` 只读、`save_customer()` 才写(而且声明了权限)。\n OCR 会认错字,让人**看一眼再存**比"识别完直接落库"稳。\n\n## 四、改造成你自己的\n\n| 想改什么 | 改哪里 |\n|---|---|\n| 换成别的证件 / 别的 OCR 接口 | `backend_open.py` 的 `recognize_idcard()` 与 `parse_idcard()` |\n| 落进别的表 / 别的列 | `save_customer()` 里那张 `Customer` 与那几个键 |\n| 图片不走 base64(比如先传对象存储) | 前端把上传拿到的 URL 传过来,后端改用 SDK 的 `url=...` 参数 |\n| 出生日期要真的当日期用 | `birthday` 现在是"OCR 怎么念就怎么存"的文本;转 `DateField` 要自己处理各种念法 |\n'),
416
+ ('README.md', '# 例子:身份证 OCR → 客户档案\n\n这个目录是开发包里的**示例**,不是某个块的代码。它演示一条完整的活:\n**前端选中一张证件照 → 交给块的后端 → 后端调阿里云 OCR → 结果回前端确认 → 存进客户档案**。\n\n- 这个目录里**混着两类东西**,用的时候要分开看:\n - **能当块用的那几份**(前端拆了 4 份、后端拆了 3 份,加清单与这一份说明):\n `index.open.html` / `app.css` / `app.js` / `ui.js` / `magic-block.open.yaml` /\n `backend_open.py` / `ocr.py` / `customer.py` / 这一份 `README.md`;\n - **与开发目录顶层同名的那几份生成物**(`backend.lock.pyi` / `GUIDE.lock.md` / `README.lock.md` /\n `requirements.lock.txt`,以及前端补的 `pages.lock.ts` / `labull.css` / `CONTROLS.lock.html`):\n **只是给你查的参考资料,不是这个块的一部分**(读回时它们会被跳过)。\n- 想用它:把那**几份**(上面前一类)移进 `workspace/<可读名>-<你自己的块 uuid>/`,\n 把清单里的 `id` 换成那个 uuid,然后回管理台「读回改动」→ 勾上它 → 保存。\n **什么都不用先装**(除了下面那三行配置)。\n- 移过去之后、**保存之前**,在开发目录里跑一句自检:\n `node smoke.lock.mjs <你那个文件夹名>` —— 清单、前端引用、后端规矩它都会过一遍。\n- 🔴 **例子里的表名与列名只是它自己的场景**(它对应示例项目里"客户档案"那张表):\n 照抄结构,不要照抄名字 —— 你的项目里是哪张表、哪几列,查 `backend.lock.pyi`。\n\n## 〇、这个例子**拆成了几个文件**(可以照这么写,但有两条规矩)\n\n| 文件 | 是什么 |\n|---|---|\n| `index.open.html` | 入口:只有结构,样式与脚本用 `link` / `script` 引进来 |\n| `app.css` | 这个块的样式 |\n| `ui.js` | 界面那点事(取元素、显示状态、填/读表单)—— 普通脚本,挂在 `window.ocrUi` 上 |\n| `app.js` | 主逻辑:选图 → 调后端 → 存库 |\n| `backend_open.py` | 后端**路由层**:两个 `@MagicBackendAPI` 方法 |\n| `ocr.py` | **参考实现**:怎么调阿里云 SDK(不产生路由,不参与运行) |\n| `customer.py` | **参考实现**:怎么写进客户表(同上) |\n\n- **前端**:随便拆。宿主拼预览时会把 `<script src>` / `<link href>` 指向的**包内文件**内联进同一个\n 文档,所以引用写法与普通网页一样。⚠️ 但**入口引用的文件一个都不能少**(少一个就是一整块白屏)。\n ⚠️ 块里**没有模块系统**:不能写 `import` / `export`,各文件之间靠**全局名字**协作(例子用 `window.ocrUi`)。\n- **后端**:每个 `.py` 都是**独立入口**(平台逐个加载,与 `backend_open.py` 待遇完全一样)⇒\n **文件之间不能互相引用**(`from ocr import ...` 会被保存时的判据与 `smoke.lock.mjs` 拦下)。\n 想分文件就分,但每个文件要能独立站住;公共逻辑要么各写一份,要么放进**项目**里的模块\n (那是普通 Python,随便 import)。`ocr.py` / `customer.py` 因此是"可以整段复制进路由"的参考,\n 而不是被 import 的库。\n\n\n## 一、要配的三项(缺一项块会点名说缺哪个)\n\n块**只能**读带 `BLOCK_` 前缀的配置(前缀会被剥掉,所以块里写 `ctx.config[\'OCR_ENDPOINT\']`):\n\n```dotenv\nBLOCK_OCR_ENDPOINT=ocr-api.cn-hangzhou.aliyuncs.com\nBLOCK_OCR_ACCESS_KEY_ID=<RAM 用户的 AccessKey ID>\nBLOCK_OCR_ACCESS_KEY_SECRET=<RAM 用户的 AccessKey Secret>\n```\n\n- 这三项加在**项目**的 `.env` 里(不是块的文件夹里),改完要重启服务。\n- 那个 RAM 用户要**有 OCR 的权限**,并且阿里云控制台上要**开通文字识别服务** —— 没开通时\n 报的是 `Forbidden` 之类,看着像"密钥不对",其实是服务没开。\n- 块里**永远拿不到**别的环境变量:只有 `BLOCK_` 这一族会进 `ctx.config`。\n\n依赖:后端要用阿里云 OCR 的官方 Python SDK(`pip install alibabacloud_ocr_api20210707`)。\n\n## 二、为什么用官方 SDK,而不是自己拼签名\n\n这一条是**实测**出来的,值得记一笔:手写 RPC 签名(HMAC-SHA1 + percent-编码)我可以让它\n**通过鉴权**,但**图片传不进去** —— 同一账号、同一张图:\n\n| 调用方式 | 服务端的回答 | 意思 |\n|---|---|---|\n| 手写签名 + base64 字符串 | `imageUrlOrBodyEmpty` | 服务端**根本没读到图** |\n| 官方 SDK,`body=<字节>` | `illegalImageContent` | 读到了,只是那张图不是身份证 |\n\n差异在请求类里:`RecognizeIdcardRequest.body` 是**小写**名字、而且是**二进制流**,不是 base64 字符串。\n自己拼那一套还要同时算对"签名用的编码"与"正文用的编码"两份(它们**不是同一份**,我在这上面来回错了三次),\n任何一个参数改一下都可能悄悄失效 —— 不值当。**照 SDK 的形状写**。\n\n## 三、两个刻意的设计\n\n1. **探针不会真的去打一次 OCR。** 保存与体检时每个 handler 会被真的跑一遍(默认空参数),\n 所以 `recognize()` 的第一句就是"没给我图片 ⇒ BadRequest"。少了这一句,每存一次块都会白打一次\n OCR,打不通还会把块判成"体检不通过"。\n2. **识别与落库分成两个接口。** `recognize()` 只读、`save_customer()` 才写(而且声明了权限)。\n OCR 会认错字,让人**看一眼再存**比"识别完直接落库"稳。\n\n## 四、改造成你自己的\n\n| 想改什么 | 改哪里 |\n|---|---|\n| 换成别的证件 / 别的 OCR 接口 | `backend_open.py` 的 `recognize_idcard()` 与 `parse_idcard()` |\n| 落进别的表 / 别的列 | `save_customer()` 里那张 `Customer` 与那几个键 |\n| 图片不走 base64(比如先传对象存储) | 前端把上传拿到的 URL 传过来,后端改用 SDK 的 `url=...` 参数 |\n| 出生日期要真的当日期用 | `birthday` 现在是"OCR 怎么念就怎么存"的文本;转 `DateField` 要自己处理各种念法 |\n'),
320
417
  ('index.open.html', '<!doctype html>\n<!-- 身份证 OCR 案例块的前端 —— **入口**(样式与逻辑在同目录的 app.css / app.js / ui.js 里)。\n 🔴 这个包**拆成了几个文件**,这在前端是完全可以的:宿主拼预览时会把\n `<script src>` / `<link href>` 指向的**包内文件**内联进同一个文档(见 `preview.ts`),\n 所以引用写法与普通网页一样。\n 🔴 但**公共文件不能丢**:入口里引用的每一个文件都必须在包里 —— 少一个就是一整块白屏\n (`smoke.mjs` 会把这条查出来)。\n 🔴 这块 iframe 是不透明源的(没有 allow-same-origin)⇒ 拿不到宿主的登录态、读不到 cookie。\n 要当前用户 / 跳页 / 调自己的后端,一律走宿主注入的那座桥(`magicBlock` / `magicBackendApi`)。 -->\n<html lang="zh">\n<head>\n <meta charset="utf-8">\n <title>身份证 OCR 建档</title>\n <link rel="stylesheet" href="app.css">\n</head>\n<body>\n <p class="muted" id="who">正在确认身份…</p>\n\n <fieldset>\n <legend>① 选一张身份证正面</legend>\n <input type="file" id="shot" accept="image/*">\n <img id="thumb" alt="" hidden>\n <button id="run" disabled>识别</button>\n <span class="muted" id="stage"></span>\n </fieldset>\n\n <fieldset id="result" hidden>\n <legend>② 识别结果(**看一眼再存**)</legend>\n <p><label>姓名</label><input type="text" id="f-name"></p>\n <p><label>身份证号</label><input type="text" id="f-id"></p>\n <p><label>性别</label><input type="text" id="f-sex"></p>\n <p><label>出生</label><input type="text" id="f-birth"></p>\n <p><label>住址</label><input type="text" id="f-addr"></p>\n <button id="save">存进客户档案</button>\n <span class="muted" id="saved"></span>\n </fieldset>\n\n <p class="bad" id="error"></p>\n\n <script src="ui.js"></script>\n <script src="app.js"></script>\n</body>\n</html>\n'),
321
418
  ('app.css', '/* 身份证 OCR 案例块的样式(宿主会把这份内联进 iframe)。\n 颜色与间距用**主题给的 token**(`--sp-*`)比自己定一套更稳:宿主本来就会把 labull 主题\n 注入每个块,用 token 就能跟着应用换肤走。 */\n\nbody { margin: 0; padding: 12px; font: 14px/1.7 system-ui, sans-serif; color: #1f2937; }\n\nfieldset { margin: 0 0 10px; padding: 8px 10px; border: 1px solid #e5e7eb; border-radius: 8px; }\nlegend { padding: 0 4px; color: #6b7280; }\n\nlabel { display: inline-block; min-width: 4.5em; color: #6b7280; }\ninput[type=text] { width: min(420px, 70vw); padding: 3px 6px; border: 1px solid #d1d5db; border-radius: 6px; }\n\nbutton { padding: 3px 10px; border: 1px solid #d1d5db; border-radius: 6px; background: #fff; cursor: pointer; }\nbutton[disabled] { opacity: .5; cursor: default; }\n\n.muted { color: #6b7280; }\n.bad { color: #b91c1c; }\nimg { max-height: 90px; border-radius: 6px; outline: 1px solid #e5e7eb; }\n\n/* 🔴 块自己**不许出滚动条**:滚的是标签页那一层(`html, body { overflow: hidden }` 由宿主注入)。 */\n'),
322
419
  ('app.js', "/* 这个块的主逻辑:选图 → 调 OCR → 存进客户档案。\n *\n * 🔴 桥有两种用法都在这里:\n * · `magicBackendApi.<类>.<方法>` → 打到你自己的 `backend_open.py`(路由名 = 类名.方法名)\n * · `magicBlock.getMe()` → 当前用户(认人只认它,别从请求参数里取)\n * 🔴 界面那点事在 `ui.js` 里(同目录、由入口 `index.open.html` 一起引进来)。\n * 块里**没有模块系统**:普通 `<script>` 之间靠全局名字协作(`window.ocrUi`)。\n */\n\n(function () {\n var ui = window.ocrUi\n\n /** 把选中文件读成 base64(**不带** `data:image/png;base64,` 前缀:后端要的是能 b64decode 的那部分)。 */\n function readAsBase64(file) {\n return new Promise(function (resolve, reject) {\n var reader = new FileReader()\n reader.onerror = function () { reject(new Error('这个文件读不出来')) }\n reader.onload = function () {\n resolve(String(reader.result).split(',')[1] || '')\n }\n reader.readAsDataURL(file)\n })\n }\n\n var picked = ''\n\n document.getElementById('shot').onchange = function (event) {\n var file = event.target.files && event.target.files[0]\n ui.resetResult()\n if (!file) {\n picked = ''\n ui.busy('run', true)\n return\n }\n readAsBase64(file)\n .then(function (base64) {\n picked = base64\n ui.setPicked('data:image/*;base64,' + base64)\n ui.say('选好了,点「识别」')\n })\n .catch(function (error) { ui.say(error.message, true) })\n }\n\n document.getElementById('run').onclick = function () {\n ui.busy('run', true, '识别中…')\n ui.say('识别中…(服务端在调 OCR)')\n magicBackendApi.IdCardOcrBlock.recognize({ image: picked })\n .then(function (data) {\n ui.busy('run', false, '识别')\n ui.fill(data.fields)\n ui.say('识别完了:确认无误再存(识别可能出错,所以不自动落库)')\n })\n .catch(function (error) {\n /* 失败是 rejected Promise,`message` 就是后端抛的中文原话。 */\n ui.busy('run', false, '识别')\n ui.say(error.message, true)\n })\n }\n\n document.getElementById('save').onclick = function () {\n ui.busy('save', true, '保存中…')\n magicBackendApi.IdCardOcrBlock.save_customer({ fields: ui.read() })\n .then(function (data) {\n ui.busy('save', false, '存进客户档案')\n ui.setSaved(data.created ? ('已新建档案 #' + data.id) : ('已更新档案 #' + data.id))\n })\n .catch(function (error) {\n ui.busy('save', false, '存进客户档案')\n ui.say(error.message, true)\n })\n }\n\n magicBlock.getMe()\n .then(function (me) { ui.setWho(me) })\n .catch(function (error) { ui.say(error.message, true) })\n})()\n"),
@@ -331,7 +428,7 @@ EXAMPLE_FILES: tuple[tuple[str, str], ...] = (
331
428
  #: 块自检脚本的正文:真源是仓库里的 `tools/smoke.mjs`(能直接 `node` 跑、也能改),
332
429
  #: 这里逐字抄一份铺进开发目录 —— 作者在**发布前**本地就能把块过一遍。
333
430
  #: ⚠️ 与 `EXAMPLE_FILES` 一样是**生成物**:改脚本请改 `tools/smoke.mjs`,然后重跑生成器。
334
- SMOKE_SCRIPT: str = '/* 块包自检(**本地就能跑**):把"发布前一定会后悔的那几条"提前抓出来。\n *\n * 用法(在开发目录里,或者指向它):\n * node smoke.mjs # 只查"有改动的那几个块"(跟 git 比),没有就查全部\n * node smoke.mjs --all # 查全部块\n * node smoke.mjs <块文件夹|块 uuid> # 只查一个块(可以给多个)\n * node smoke.mjs --workspace <开发目录>\n * LABULL_SMOKE_PYTHON=<python> node smoke.mjs # 指一个装了 Django 的解释器 ⇒ 多跑一层\n *\n * 它查两类东西:\n * ① **纯文本层(不需要任何依赖)**:清单四个键、文件名形状、前端引用链\n * (`<script src>` / `<link href>` 指向的文件在不在包里)、"包里的文件没人引用"、\n * 以及**后端文件之间不许互相引用**这条规矩。\n * ② **平台那一层(有 Python + Django 时才跑)**:真的按平台自己的判据加载一次\n * (`labull_framework.magic_block.validate`)—— 那份判据是**同一份代码**,\n * 不是在这里抄一遍(抄一遍的下场是"本地说没事、管理台说不合格")。\n *\n * 🔴 退了就报清楚:没装 Python / 没装 Django 时,第 ② 层会明说"这一层没跑",\n * **不假装自己全绿**(自检的意义就是提前发现,不是给个安心)。\n * 🔴 输出不用 emoji:中文 Windows 控制台是 cp936,emoji 会变成乱码让整行不可读。\n */\n\nimport { readdirSync, readFileSync, statSync } from \'node:fs\'\nimport { basename, extname, join, relative, resolve, sep } from \'node:path\'\nimport { existsSync } from \'node:fs\'\nimport { spawnSync } from \'node:child_process\'\n\nconst ENTRY = \'index.open.html\'\nconst MANIFEST = \'magic-block.open.yaml\'\nconst BACKEND = \'backend_open.py\'\n/** 平台随开发目录铺下来的生成物:它们**不进块包**,引用不到也不算错。 */\nconst PLATFORM_FILES = new Set([\n \'api.open.d.ts\', \'VERSION.lock\', \'README.lock.md\', \'GUIDE.lock.md\', \'backend.lock.pyi\',\n \'pages.lock.ts\', \'labull.css\', \'CONTROLS.lock.html\', \'requirements.lock.txt\',\n])\n/** 爬目录时跳过的目录(与后端 `export.SKIP_DIRS` 同一份口径)。 */\nconst SKIP_DIRS = new Set([\'.git\', \'.venv\', \'venv\', \'__pycache__\', \'node_modules\', \'dist\', \'build\', \'.idea\', \'.vscode\', \'example\'])\n/** 认哪些扩展名算"能进包的文本"。 */\nconst TEXT_EXT = new Set([\'.html\', \'.htm\', \'.js\', \'.mjs\', \'.cjs\', \'.ts\', \'.css\', \'.json\', \'.yaml\', \'.yml\', \'.md\', \'.txt\', \'.py\', \'.svg\'])\nconst MAX_DEPTH = 3\nconst MAX_FILE_BYTES = 512 * 1024\n\nconst argv = process.argv.slice(2)\nconst flags = { all: false, workspace: \'\', python: process.env.LABULL_SMOKE_PYTHON || \'\', targets: [] }\nfor (let i = 0; i < argv.length; i += 1) {\n if (argv[i] === \'--all\') flags.all = true\n else if (argv[i] === \'--workspace\') flags.workspace = argv[++i] || \'\'\n else if (argv[i] === \'--python\') flags.python = argv[++i] || \'\'\n else flags.targets.push(argv[i])\n}\n\nconst pass = []\nconst fail = []\nconst skip = []\nconst ok = (message) => { pass.push(message); console.log(` [OK] ${message}`) }\nconst ng = (message) => { fail.push(message); console.log(` [NG] ${message}`) }\nconst note = (message) => { skip.push(message); console.log(` [--] ${message}`) }\nconst info = (message) => console.log(message)\n\nfunction readText(path) {\n const raw = readFileSync(path)\n if (raw.length > MAX_FILE_BYTES) return { text: \'\', tooBig: true }\n return { text: raw.toString(\'utf8\').replace(/^\\uFEFF/, \'\'), tooBig: false }\n}\n\n// ──────────────────────────── 找开发目录与块 ────────────────────────────\n\nfunction findWorkspace(start) {\n let dir = resolve(start)\n for (let depth = 0; depth < 6; depth += 1) {\n const workspace = join(dir, \'workspace\')\n if (existsSync(workspace) && statSync(workspace).isDirectory()) return workspace\n if (basename(dir) === \'workspace\') return dir\n const parent = resolve(dir, \'..\')\n if (parent === dir) break\n dir = parent\n }\n return \'\'\n}\n\nfunction listBlocks(workspace) {\n const out = []\n for (const name of readdirSync(workspace).sort()) {\n const full = join(workspace, name)\n if (!statSync(full).isDirectory()) continue\n // 🔴 只认**块文件夹**(里面有清单):开发目录里还有 `example/` 这种平台铺下来的东西,\n // 把它也当成一个块去查,只会平白报一堆"缺这个缺那个"。\n if (!existsSync(join(full, MANIFEST)) && !existsSync(join(full, ENTRY))) continue\n out.push(full)\n }\n return out\n}\n\n/** 一个块文件夹 → `{相对路径: 正文}`(与平台 `export` 的扫描口径一致)。 */\nfunction readBlock(dir) {\n const files = {}\n const walk = (current, segments, depth) => {\n for (const name of readdirSync(current).sort()) {\n const full = join(current, name)\n const stat = statSync(full)\n const at = [...segments, name]\n if (stat.isDirectory()) {\n if (SKIP_DIRS.has(name) || depth >= MAX_DEPTH) continue\n walk(full, at, depth + 1)\n continue\n }\n if (!TEXT_EXT.has(extname(name).toLowerCase())) continue\n if (stat.size > MAX_FILE_BYTES) { files[at.join(\'/\')] = \'\'; continue }\n files[at.join(\'/\')] = readText(full).text\n }\n }\n walk(dir, [], 0)\n return files\n}\n\n// ──────────────────────────── ① 纯文本层 ────────────────────────────\n\nfunction parseManifest(text) {\n const found = {}\n const issues = []\n const stripComment = (line) => {\n let quote = \'\'\n for (let i = 0; i < line.length; i += 1) {\n const char = line[i]\n if (quote) { if (char === quote) quote = \'\' }\n else if (char === \'"\' || char === "\'") quote = char\n else if (char === \'#\') return line.slice(0, i)\n }\n return line\n }\n text.split(/\\r?\\n/).forEach((raw, index) => {\n const line = stripComment(raw).trim()\n if (!line) return\n const at = line.indexOf(\':\')\n if (at < 0) { issues.push(`第 ${index + 1} 行不是「键: 值」:${line}`); return }\n const key = line.slice(0, at).trim()\n const value = line.slice(at + 1).trim().replace(/^([\'"])(.*)\\1$/, \'$2\')\n if (![\'id\', \'module\', \'name\', \'brief\'].includes(key)) { issues.push(`第 ${index + 1} 行有不认识的键:${key}`); return }\n if (found[key] !== undefined) { issues.push(`第 ${index + 1} 行重复写了 ${key}`); return }\n found[key] = value\n })\n for (const key of [\'id\', \'module\', \'name\', \'brief\']) {\n if (!found[key]) issues.push(`清单缺 ${key}(或者是空的)`)\n }\n if (found.id && !/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(found.id)) {\n issues.push(`清单里的 id 不是 uuid:${found.id}`)\n }\n if (found.name && found.name.length > 40) issues.push(`name 超过 40 个字:${found.name.length}`)\n return { found, issues }\n}\n\n/** 前端引用链:入口里 `src=` / `href=` 指向的**包内**文件在不在。回 `{missing, referenced}`。 */\nfunction frontendRefs(files) {\n const referenced = new Map() // 引用者 → 被引用的相对路径\n const missing = []\n const pick = (ref) => {\n if (/^(?:[a-z][a-z0-9+.-]*:|\\/\\/)/i.test(ref)) return undefined // 外链 / data: → 不是包内文件\n const clean = ref.split(/[?#]/)[0].replace(/^\\.\\//, \'\').replace(/^\\/+/, \'\')\n if (!clean) return undefined\n if (files[clean] !== undefined) return clean\n const base = clean.split(\'/\').pop()\n return base ? Object.keys(files).find((name) => name.split(\'/\').pop() === base) : undefined\n }\n const scan = (owner, body) => {\n for (const match of body.matchAll(/<(?:script|link)\\b[^>]*?(?:src|href)\\s*=\\s*(["\'])(.*?)\\1[^>]*>/gi)) {\n const ref = match[2]\n const hit = pick(ref)\n if (hit) referenced.set(owner, [...(referenced.get(owner) || []), hit])\n else if (!/^(?:[a-z][a-z0-9+.-]*:|\\/\\/)/i.test(ref) && basename(ref) !== \'labull.css\') {\n missing.push(`${owner} 引用的 ${ref} 不在包里`)\n }\n }\n }\n for (const [name, body] of Object.entries(files)) {\n if (name.endsWith(\'.html\')) scan(name, body)\n }\n return { missing, referenced }\n}\n\n/** 每个 `.py` 里 import 的**包内**文件("文件之间不许互相引用"这条规矩的判据)。 */\nfunction siblingImports(files) {\n const known = new Map()\n for (const name of Object.keys(files)) {\n if (!name.endsWith(\'.py\')) continue\n const stem = name.slice(0, -3)\n known.set(stem, name)\n known.set(stem.split(\'/\').pop(), name)\n }\n const out = []\n for (const [name, body] of Object.entries(files)) {\n if (!name.endsWith(\'.py\')) continue\n for (const line of body.split(/\\r?\\n/)) {\n const trimmed = line.trim()\n if (trimmed.startsWith(\'#\')) continue\n const from = /^from\\s+([A-Za-z_][\\w.]*)\\s+import\\b/.exec(trimmed)\n const plain = /^import\\s+(.+)$/.exec(trimmed)\n const names = []\n if (from) names.push(from[1])\n else if (plain) names.push(...plain[1].split(\',\').map((item) => item.trim().split(/\\s+as\\s+/)[0]))\n for (const imported of names) {\n const hit = known.get(imported) || known.get(imported.split(\'.\')[0])\n if (hit && hit !== name) out.push(`${name} import 了包内的 ${imported}(就是 ${hit})`)\n }\n }\n }\n return out\n}\n\nfunction checkBlock(dir) {\n const label = basename(dir)\n info(`\\n── ${label} ──`)\n const files = readBlock(dir)\n const names = Object.keys(files)\n\n if (!files[ENTRY]) ng(`${label}:缺入口 ${ENTRY}`)\n else ok(`${label}:有入口 ${ENTRY}`)\n\n if (files[MANIFEST] === undefined) {\n ng(`${label}:缺清单 ${MANIFEST}`)\n } else {\n const { found, issues } = parseManifest(files[MANIFEST])\n if (issues.length) ng(`${label}:清单有问题 —— ${issues.join(\';\')}`)\n else ok(`${label}:清单四个键齐(${found.module} / ${found.name})`)\n }\n\n for (const name of names) {\n if (!/^(?![A-Za-z]:)(?!\\/)[A-Za-z0-9._\\-/]+$/.test(name) || name.split(\'/\').includes(\'..\')) {\n ng(`${label}:文件名不合法 ${name}`)\n }\n }\n\n const { missing, referenced } = frontendRefs(files)\n for (const item of missing) ng(`${label}:${item}`)\n if (!missing.length && files[ENTRY]) ok(`${label}:前端的引用都指得到(入口里的 script/link 都在包里)`)\n\n // "包里的文件没人引用":只提示(入口文件与清单、后端本来就不该被引用)。\n const used = new Set([ENTRY, MANIFEST, BACKEND, ...[...referenced.values()].flat()])\n const orphans = names.filter((name) => !used.has(name) && !PLATFORM_FILES.has(name)\n && !name.endsWith(\'.py\') && name !== \'README.md\')\n if (orphans.length) note(`${label}:这几个文件没有任何引用(写进包里也没人用):${orphans.join(\' / \')}`)\n\n const cross = siblingImports(files)\n if (cross.length) {\n for (const item of cross) {\n ng(`${label}:${item} —— **块里文件之间不能互相引用**(平台把每个 .py 各自独立加载):`\n + \'要么把那段逻辑复制过去,要么放进项目里的公共模块再 import\')\n }\n } else if (names.some((name) => name.endsWith(\'.py\'))) {\n ok(`${label}:后端那些 .py 之间没有互相引用(${names.filter((n) => n.endsWith(\'.py\')).join(\' / \')})`)\n }\n return files\n}\n\n// ──────────────────────────── ② 平台那一层 ────────────────────────────\n\nfunction findPython() {\n if (flags.python) return flags.python\n const candidates = [process.env.LABULL_PYTHON, \'python\', \'python3\', \'py\'].filter(Boolean)\n const probe = \'import django, labull_framework; print("ok")\'\n for (const candidate of candidates) {\n const done = spawnSync(candidate, [\'-c\', probe], {\n encoding: \'utf-8\',\n env: { ...process.env, PYTHONIOENCODING: \'utf-8\' },\n })\n if (done.status === 0 && String(done.stdout).includes(\'ok\')) return candidate\n }\n return \'\'\n}\n\nfunction platformCheck(workspace, python, payload) {\n const script = [\n \'import json, os, sys\',\n \'from labull_framework.magic_block import validate\',\n \'payload = json.loads(os.environ["LABULL_SMOKE_PAYLOAD"])\',\n \'out = []\',\n \'for item in payload:\',\n \' issues = validate.check_block(item["files"])\',\n \' out.append({"name": item["name"], "issues": [list(pair) for pair in issues]})\',\n \'print("LABULL_SMOKE" + json.dumps(out, ensure_ascii=False))\',\n ].join(\'\\n\')\n const done = spawnSync(python, [\'-c\', script], {\n encoding: \'utf-8\',\n env: { ...process.env, PYTHONIOENCODING: \'utf-8\', LABULL_SMOKE_PAYLOAD: JSON.stringify(payload) },\n })\n const text = `${done.stdout || \'\'}${done.stderr || \'\'}`\n const at = text.indexOf(\'LABULL_SMOKE\')\n if (done.status !== 0 || at < 0) {\n note(`平台那一层没跑起来(退出码 ${done.status}):${text.trim().split(\'\\n\').slice(-1)[0] || \'没有输出\'}`)\n return\n }\n const results = JSON.parse(text.slice(at + \'LABULL_SMOKE\'.length))\n for (const item of results) {\n if (!item.issues.length) { ok(`${item.name}:过平台那一层判据(清单/结构/试加载/引用深检)`); continue }\n for (const [stage, message] of item.issues) ng(`${item.name}:[${stage}] ${message}`)\n }\n}\n\n// ──────────────────────────── 主流程 ────────────────────────────\n\nconst workspace = flags.workspace\n ? (findWorkspace(flags.workspace) || resolve(flags.workspace))\n : findWorkspace(process.cwd())\nif (!workspace) {\n console.log(\'找不到开发目录(里面应该有 workspace/ 那一层)。用法:node smoke.mjs [--workspace <开发目录>] [块…]\')\n process.exit(2)\n}\nlet blocks = listBlocks(workspace)\nif (flags.targets.length) {\n blocks = blocks.filter((dir) => flags.targets.some((target) => basename(dir).includes(target)))\n if (!blocks.length) { console.log(`按 ${flags.targets.join(\' / \')} 没找到块(在 ${workspace} 里)`); process.exit(2) }\n}\ninfo(`开发目录:${workspace}`)\ninfo(`要查的块:${blocks.length} 个${flags.targets.length ? `(按 ${flags.targets.join(\' / \')} 筛的)` : flags.all ? \'(全部)\' : \'\'}`)\n\nconst payload = []\nfor (const dir of blocks) {\n const files = checkBlock(dir)\n payload.push({ name: basename(dir), files })\n}\n\nconst python = findPython()\nif (!python) {\n note(\'没找到装了 Django 的 Python ⇒ **平台那一层没跑**(试加载 / 引用深检 / 路由那几条没验)。\'\n + \'设 LABULL_SMOKE_PYTHON=<项目解释器> 或 --python <python> 再跑一次。\')\n} else {\n info(`\\n── 平台判据(用 ${python})──`)\n platformCheck(workspace, python, payload)\n}\n\ninfo(`\\n合计:过 ${pass.length} 条、不过 ${fail.length} 条${skip.length ? `、跳过 ${skip.length} 条` : \'\'}`)\nif (fail.length) {\n info(\'不过的:\')\n for (const item of fail) info(` - ${item}`)\n process.exit(1)\n}\ninfo(\'都过了。\')\n'
431
+ SMOKE_SCRIPT: str = '/* 块包自检(**本地就能跑**):把"发布前一定会后悔的那几条"提前抓出来。\n *\n * 用法(在开发目录里,或者指向它):\n * node smoke.mjs # 只查"有改动的那几个块"(跟 git 比),没有就查全部\n * node smoke.mjs --all # 查全部块\n * node smoke.mjs <块文件夹|块 uuid> # 只查一个块(可以给多个)\n * node smoke.mjs --workspace <开发目录>\n * LABULL_SMOKE_PYTHON=<python> node smoke.mjs # 指一个装了 Django 的解释器 ⇒ 多跑一层\n *\n * 它查两类东西:\n * ① **纯文本层(不需要任何依赖)**:清单四个键、文件名形状、前端引用链\n * (`<script src>` / `<link href>` 指向的文件在不在包里)、"包里的文件没人引用"、\n * 以及**后端文件之间不许互相引用**这条规矩。\n * ② **平台那一层(有 Python + Django 时才跑)**:真的按平台自己的判据加载一次\n * (`labull_framework.magic_block.validate`)—— 那份判据是**同一份代码**,\n * 不是在这里抄一遍(抄一遍的下场是"本地说没事、管理台说不合格")。\n *\n * 🔴 退了就报清楚:没装 Python / 没装 Django 时,第 ② 层会明说"这一层没跑",\n * **不假装自己全绿**(自检的意义就是提前发现,不是给个安心)。\n * 🔴 输出不用 emoji:中文 Windows 控制台是 cp936,emoji 会变成乱码让整行不可读。\n */\n\nimport { readdirSync, readFileSync, statSync } from \'node:fs\'\nimport { basename, extname, join, relative, resolve, sep } from \'node:path\'\nimport { existsSync } from \'node:fs\'\nimport { spawnSync } from \'node:child_process\'\n\nconst ENTRY = \'index.open.html\'\nconst MANIFEST = \'magic-block.open.yaml\'\nconst BACKEND = \'backend_open.py\'\n/** 平台随开发目录铺下来的生成物:它们**不进块包**,引用不到也不算错。 */\nconst PLATFORM_FILES = new Set([\n \'api.open.d.ts\', \'VERSION.lock\', \'README.lock.md\', \'GUIDE.lock.md\', \'backend.lock.pyi\',\n \'pages.lock.ts\', \'labull.css\', \'CONTROLS.lock.html\', \'requirements.lock.txt\',\n])\n/** 爬目录时跳过的目录(与后端 `export.SKIP_DIRS` 同一份口径)。 */\nconst SKIP_DIRS = new Set([\'.git\', \'.venv\', \'venv\', \'__pycache__\', \'node_modules\', \'dist\', \'build\', \'.idea\', \'.vscode\', \'example\'])\n/** 认哪些扩展名算"能进包的文本"。 */\nconst TEXT_EXT = new Set([\'.html\', \'.htm\', \'.js\', \'.mjs\', \'.cjs\', \'.ts\', \'.css\', \'.json\', \'.yaml\', \'.yml\', \'.md\', \'.txt\', \'.py\', \'.svg\'])\nconst MAX_DEPTH = 3\nconst MAX_FILE_BYTES = 512 * 1024\n\nconst argv = process.argv.slice(2)\nconst flags = { all: false, workspace: \'\', python: process.env.LABULL_SMOKE_PYTHON || \'\', targets: [] }\nfor (let i = 0; i < argv.length; i += 1) {\n if (argv[i] === \'--all\') flags.all = true\n else if (argv[i] === \'--workspace\') flags.workspace = argv[++i] || \'\'\n else if (argv[i] === \'--python\') flags.python = argv[++i] || \'\'\n else flags.targets.push(argv[i])\n}\n\nconst pass = []\nconst fail = []\nconst skip = []\nconst ok = (message) => { pass.push(message); console.log(` [OK] ${message}`) }\nconst ng = (message) => { fail.push(message); console.log(` [NG] ${message}`) }\nconst note = (message) => { skip.push(message); console.log(` [--] ${message}`) }\nconst info = (message) => console.log(message)\n\nfunction readText(path) {\n const raw = readFileSync(path)\n if (raw.length > MAX_FILE_BYTES) return { text: \'\', tooBig: true }\n return { text: raw.toString(\'utf8\').replace(/^\\uFEFF/, \'\'), tooBig: false }\n}\n\n// ──────────────────────────── 找开发目录与块 ────────────────────────────\n\nfunction findWorkspace(start) {\n let dir = resolve(start)\n for (let depth = 0; depth < 6; depth += 1) {\n const workspace = join(dir, \'workspace\')\n if (existsSync(workspace) && statSync(workspace).isDirectory()) return workspace\n if (basename(dir) === \'workspace\') return dir\n const parent = resolve(dir, \'..\')\n if (parent === dir) break\n dir = parent\n }\n return \'\'\n}\n\nfunction listBlocks(workspace) {\n const out = []\n for (const name of readdirSync(workspace).sort()) {\n const full = join(workspace, name)\n if (!statSync(full).isDirectory()) continue\n // 🔴 只认**块文件夹**(里面有清单):开发目录里还有 `example/` 这种平台铺下来的东西,\n // 把它也当成一个块去查,只会平白报一堆"缺这个缺那个"。\n if (!existsSync(join(full, MANIFEST)) && !existsSync(join(full, ENTRY))) continue\n out.push(full)\n }\n return out\n}\n\n/** 一个块文件夹 → `{相对路径: 正文}`(与平台 `export` 的扫描口径一致)。 */\nfunction readBlock(dir) {\n const files = {}\n const walk = (current, segments, depth) => {\n for (const name of readdirSync(current).sort()) {\n const full = join(current, name)\n const stat = statSync(full)\n const at = [...segments, name]\n if (stat.isDirectory()) {\n if (SKIP_DIRS.has(name) || depth >= MAX_DEPTH) continue\n walk(full, at, depth + 1)\n continue\n }\n if (!TEXT_EXT.has(extname(name).toLowerCase())) continue\n if (stat.size > MAX_FILE_BYTES) { files[at.join(\'/\')] = \'\'; continue }\n files[at.join(\'/\')] = readText(full).text\n }\n }\n walk(dir, [], 0)\n return files\n}\n\n// ──────────────────────────── ① 纯文本层 ────────────────────────────\n\nfunction parseManifest(text) {\n const found = {}\n const issues = []\n const stripComment = (line) => {\n let quote = \'\'\n for (let i = 0; i < line.length; i += 1) {\n const char = line[i]\n if (quote) { if (char === quote) quote = \'\' }\n else if (char === \'"\' || char === "\'") quote = char\n else if (char === \'#\') return line.slice(0, i)\n }\n return line\n }\n text.split(/\\r?\\n/).forEach((raw, index) => {\n const line = stripComment(raw).trim()\n if (!line) return\n const at = line.indexOf(\':\')\n if (at < 0) { issues.push(`第 ${index + 1} 行不是「键: 值」:${line}`); return }\n const key = line.slice(0, at).trim()\n const value = line.slice(at + 1).trim().replace(/^([\'"])(.*)\\1$/, \'$2\')\n if (![\'id\', \'module\', \'name\', \'brief\'].includes(key)) { issues.push(`第 ${index + 1} 行有不认识的键:${key}`); return }\n if (found[key] !== undefined) { issues.push(`第 ${index + 1} 行重复写了 ${key}`); return }\n found[key] = value\n })\n for (const key of [\'id\', \'module\', \'name\', \'brief\']) {\n if (!found[key]) issues.push(`清单缺 ${key}(或者是空的)`)\n }\n if (found.id && !/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(found.id)) {\n issues.push(`清单里的 id 不是 uuid:${found.id}`)\n }\n if (found.name && found.name.length > 40) issues.push(`name 超过 40 个字:${found.name.length}`)\n return { found, issues }\n}\n\n/** 前端引用链:入口里 `src=` / `href=` 指向的**包内**文件在不在。回 `{missing, referenced}`。 */\nfunction frontendRefs(files) {\n const referenced = new Map() // 引用者 → 被引用的相对路径\n const missing = []\n const pick = (ref) => {\n if (/^(?:[a-z][a-z0-9+.-]*:|\\/\\/)/i.test(ref)) return undefined // 外链 / data: → 不是包内文件\n const clean = ref.split(/[?#]/)[0].replace(/^\\.\\//, \'\').replace(/^\\/+/, \'\')\n if (!clean) return undefined\n if (files[clean] !== undefined) return clean\n const base = clean.split(\'/\').pop()\n return base ? Object.keys(files).find((name) => name.split(\'/\').pop() === base) : undefined\n }\n const scan = (owner, body) => {\n for (const match of body.matchAll(/<(?:script|link)\\b[^>]*?(?:src|href)\\s*=\\s*(["\'])(.*?)\\1[^>]*>/gi)) {\n const ref = match[2]\n const hit = pick(ref)\n if (hit) referenced.set(owner, [...(referenced.get(owner) || []), hit])\n else if (!/^(?:[a-z][a-z0-9+.-]*:|\\/\\/)/i.test(ref) && basename(ref) !== \'labull.css\') {\n missing.push(`${owner} 引用的 ${ref} 不在包里`)\n }\n }\n }\n for (const [name, body] of Object.entries(files)) {\n if (name.endsWith(\'.html\')) scan(name, body)\n }\n return { missing, referenced }\n}\n\n/** 每个 `.py` 里 import 的**包内**文件("文件之间不许互相引用"这条规矩的判据)。 */\nfunction siblingImports(files) {\n const known = new Map()\n for (const name of Object.keys(files)) {\n if (!name.endsWith(\'.py\')) continue\n const stem = name.slice(0, -3)\n known.set(stem, name)\n known.set(stem.split(\'/\').pop(), name)\n }\n const out = []\n for (const [name, body] of Object.entries(files)) {\n if (!name.endsWith(\'.py\')) continue\n for (const line of body.split(/\\r?\\n/)) {\n const trimmed = line.trim()\n if (trimmed.startsWith(\'#\')) continue\n const from = /^from\\s+([A-Za-z_][\\w.]*)\\s+import\\b/.exec(trimmed)\n const plain = /^import\\s+(.+)$/.exec(trimmed)\n const names = []\n if (from) names.push(from[1])\n else if (plain) names.push(...plain[1].split(\',\').map((item) => item.trim().split(/\\s+as\\s+/)[0]))\n for (const imported of names) {\n const hit = known.get(imported) || known.get(imported.split(\'.\')[0])\n if (hit && hit !== name) out.push(`${name} import 了包内的 ${imported}(就是 ${hit})`)\n }\n }\n }\n return out\n}\n\n/**\n * 从开发目录顶层那份 `backend.lock.pyi` 里读"**你现在配了哪些键**"。\n *\n * 🔴 为什么要从清单里读,而不是自己去扫环境:块作者本地通常**没有** Django(那份清单是平台\n * 导出时算的)。清单里那一块就是"导出这一刻真的存在的 `BLOCK_` 键名",是这里唯一的真源。\n * 格式约定:说明那行是「你现在配了哪些键」,紧跟其后的一串 `# KEY` 就是键名。\n */\nfunction readConfigKeys(workspace) {\n const path = join(workspace, \'..\', \'backend.lock.pyi\')\n if (!existsSync(path)) return null\n const lines = readText(path).text.split(/\\r?\\n/)\n const out = []\n let inside = false\n for (const line of lines) {\n if (line.includes(\'你现在配了哪些键\')) { inside = true; continue }\n if (!inside) continue\n const hit = /^#\\s{3}([A-Za-z_][A-Za-z0-9_]*)\\s*$/.exec(line)\n if (hit) { out.push(hit[1]); continue }\n if (line.trim() && !line.startsWith(\'#\')) break\n if (line.startsWith(\'# (\')) break // "(一个都没有:…)"\n }\n return out\n}\n\n/** 在开发目录附近找 `.env`(`.env` / `.env.local` / `.env.dev`),返回找到的第一个的键名集合。 */\nfunction readEnvKeys(workspace) {\n const names = new Set()\n let dir = resolve(workspace)\n for (let depth = 0; depth < 4; depth += 1) {\n for (const file of [\'.env\', \'.env.local\', \'.env.dev\']) {\n const full = join(dir, file)\n if (!existsSync(full)) continue\n for (const raw of readText(full).text.split(/\\r?\\n/)) {\n const line = raw.trim()\n if (!line || line.startsWith(\'#\')) continue\n const at = line.indexOf(\'=\')\n if (at <= 0) continue\n names.add(line.slice(0, at).trim())\n }\n return { file: full, names }\n }\n const parent = resolve(dir, \'..\')\n if (parent === dir) break\n dir = parent\n }\n return null\n}\n\n/** 一份 `.py` 里用到的配置键:`ctx.config[\'KEY\']` / `ctx.config.get(\'KEY\')` / `"KEY" in ctx.config`。 */\nfunction configUsage(files) {\n const out = []\n const patterns = [\n /config\\[\\s*[\'"]([A-Za-z_][A-Za-z0-9_]*)[\'"]\\s*\\]/g,\n /config\\.get\\(\\s*[\'"]([A-Za-z_][A-Za-z0-9_]*)[\'"]/g,\n /[\'"]([A-Za-z_][A-Za-z0-9_]*)[\'"]\\s+in\\s+config/g,\n ]\n for (const [name, body] of Object.entries(files)) {\n if (!name.endsWith(\'.py\')) continue\n for (const pattern of patterns) {\n for (const match of body.matchAll(pattern)) {\n if (!out.some((item) => item.file === name && item.key === match[1])) {\n out.push({ file: name, key: match[1] })\n }\n }\n }\n }\n return out\n}\n\n/**\n * 查"块用到的配置键对不对"——\n * ① 代码里用到的键必须在清单里(清单空着就跳过,不然没配过的人会被满屏报错吓到);\n * ② 找到了 `.env` 就再对一遍:写了却没配的键是**必错项**(运行期取不到),配了却没人用的只提示。\n */\nfunction checkConfig(label, files, workspace) {\n const known = readConfigKeys(workspace)\n const env = readEnvKeys(workspace)\n const usage = configUsage(files)\n let bad = 0\n for (const { file, key } of usage) {\n if (Array.isArray(known) && known.length > 0 && !known.includes(key)) {\n bad += 1\n ng(`${label}:${file} 里用了 ctx.config[\'${key}\'],但它不在清单里 —— `\n + `导出那一刻环境里配的是:${known.join(\' / \')}(写错的键运行期只会取到 None,不报错)`)\n }\n if (env && !env.names.has(`BLOCK_${key}`)) {\n bad += 1\n ng(`${label}:${file} 里用了 ctx.config[\'${key}\'],但 ${basename(env.file)} 里没有 BLOCK_${key}(运行期取不到)`)\n }\n }\n if (!env) {\n note(`${label}:附近的 .env 没找到 ⇒ "这个键配了没有"那一半没查(代码用到的键仍与清单对过)`)\n } else {\n const used = new Set(usage.map((item) => item.key))\n const unused = [...env.names]\n .filter((name) => name.startsWith(\'BLOCK_\') && !used.has(name.slice(\'BLOCK_\'.length)))\n .map((name) => name.slice(\'BLOCK_\'.length))\n if (unused.length) note(`${label}:${basename(env.file)} 里这几个 BLOCK_ 键没有被用到:${unused.join(\' / \')}`)\n }\n if (usage.length && bad === 0) {\n ok(`${label}:代码里用到的配置键都对得上(${usage.map((item) => item.key).join(\' / \')})`)\n }\n}\n\nfunction checkBlock(dir) {\n const label = basename(dir)\n info(`\\n── ${label} ──`)\n const files = readBlock(dir)\n const names = Object.keys(files)\n\n if (!files[ENTRY]) ng(`${label}:缺入口 ${ENTRY}`)\n else ok(`${label}:有入口 ${ENTRY}`)\n\n if (files[MANIFEST] === undefined) {\n ng(`${label}:缺清单 ${MANIFEST}`)\n } else {\n const { found, issues } = parseManifest(files[MANIFEST])\n if (issues.length) ng(`${label}:清单有问题 —— ${issues.join(\';\')}`)\n else ok(`${label}:清单四个键齐(${found.module} / ${found.name})`)\n }\n\n for (const name of names) {\n if (!/^(?![A-Za-z]:)(?!\\/)[A-Za-z0-9._\\-/]+$/.test(name) || name.split(\'/\').includes(\'..\')) {\n ng(`${label}:文件名不合法 ${name}`)\n }\n }\n\n const { missing, referenced } = frontendRefs(files)\n for (const item of missing) ng(`${label}:${item}`)\n if (!missing.length && files[ENTRY]) ok(`${label}:前端的引用都指得到(入口里的 script/link 都在包里)`)\n\n // "包里的文件没人引用":只提示(入口文件与清单、后端本来就不该被引用)。\n const used = new Set([ENTRY, MANIFEST, BACKEND, ...[...referenced.values()].flat()])\n const orphans = names.filter((name) => !used.has(name) && !PLATFORM_FILES.has(name)\n && !name.endsWith(\'.py\') && name !== \'README.md\')\n if (orphans.length) note(`${label}:这几个文件没有任何引用(写进包里也没人用):${orphans.join(\' / \')}`)\n\n const cross = siblingImports(files)\n if (cross.length) {\n for (const item of cross) {\n ng(`${label}:${item} —— **块里文件之间不能互相引用**(平台把每个 .py 各自独立加载):`\n + \'要么把那段逻辑复制过去,要么放进项目里的公共模块再 import\')\n }\n } else if (names.some((name) => name.endsWith(\'.py\'))) {\n ok(`${label}:后端那些 .py 之间没有互相引用(${names.filter((n) => n.endsWith(\'.py\')).join(\' / \')})`)\n }\n\n checkConfig(label, files, workspace)\n return files\n}\n\n// ──────────────────────────── ② 平台那一层 ────────────────────────────\n\nfunction findPython() {\n if (flags.python) return flags.python\n const candidates = [process.env.LABULL_PYTHON, \'python\', \'python3\', \'py\'].filter(Boolean)\n const probe = \'import django, labull_framework; print("ok")\'\n for (const candidate of candidates) {\n const done = spawnSync(candidate, [\'-c\', probe], {\n encoding: \'utf-8\',\n env: { ...process.env, PYTHONIOENCODING: \'utf-8\' },\n })\n if (done.status === 0 && String(done.stdout).includes(\'ok\')) return candidate\n }\n return \'\'\n}\n\nfunction platformCheck(workspace, python, payload) {\n const script = [\n \'import json, os, sys\',\n \'from labull_framework.magic_block import validate\',\n \'payload = json.loads(os.environ["LABULL_SMOKE_PAYLOAD"])\',\n \'out = []\',\n \'for item in payload:\',\n \' issues = validate.check_block(item["files"])\',\n \' out.append({"name": item["name"], "issues": [list(pair) for pair in issues]})\',\n \'print("LABULL_SMOKE" + json.dumps(out, ensure_ascii=False))\',\n ].join(\'\\n\')\n const done = spawnSync(python, [\'-c\', script], {\n encoding: \'utf-8\',\n env: { ...process.env, PYTHONIOENCODING: \'utf-8\', LABULL_SMOKE_PAYLOAD: JSON.stringify(payload) },\n })\n const text = `${done.stdout || \'\'}${done.stderr || \'\'}`\n const at = text.indexOf(\'LABULL_SMOKE\')\n if (done.status !== 0 || at < 0) {\n note(`平台那一层没跑起来(退出码 ${done.status}):${text.trim().split(\'\\n\').slice(-1)[0] || \'没有输出\'}`)\n return\n }\n const results = JSON.parse(text.slice(at + \'LABULL_SMOKE\'.length))\n for (const item of results) {\n if (!item.issues.length) { ok(`${item.name}:过平台那一层判据(清单/结构/试加载/引用深检)`); continue }\n for (const [stage, message] of item.issues) ng(`${item.name}:[${stage}] ${message}`)\n }\n}\n\n// ──────────────────────────── 主流程 ────────────────────────────\n\nconst workspace = flags.workspace\n ? (findWorkspace(flags.workspace) || resolve(flags.workspace))\n : findWorkspace(process.cwd())\nif (!workspace) {\n console.log(\'找不到开发目录(里面应该有 workspace/ 那一层)。用法:node smoke.mjs [--workspace <开发目录>] [块…]\')\n process.exit(2)\n}\nlet blocks = listBlocks(workspace)\nif (flags.targets.length) {\n blocks = blocks.filter((dir) => flags.targets.some((target) => basename(dir).includes(target)))\n if (!blocks.length) { console.log(`按 ${flags.targets.join(\' / \')} 没找到块(在 ${workspace} 里)`); process.exit(2) }\n}\ninfo(`开发目录:${workspace}`)\ninfo(`要查的块:${blocks.length} 个${flags.targets.length ? `(按 ${flags.targets.join(\' / \')} 筛的)` : flags.all ? \'(全部)\' : \'\'}`)\n\nconst payload = []\nfor (const dir of blocks) {\n const files = checkBlock(dir)\n payload.push({ name: basename(dir), files })\n}\n\nconst python = findPython()\nif (!python) {\n note(\'没找到装了 Django 的 Python ⇒ **平台那一层没跑**(试加载 / 引用深检 / 路由那几条没验)。\'\n + \'设 LABULL_SMOKE_PYTHON=<项目解释器> 或 --python <python> 再跑一次。\')\n} else {\n info(`\\n── 平台判据(用 ${python})──`)\n platformCheck(workspace, python, payload)\n}\n\ninfo(`\\n合计:过 ${pass.length} 条、不过 ${fail.length} 条${skip.length ? `、跳过 ${skip.length} 条` : \'\'}`)\nif (fail.length) {\n info(\'不过的:\')\n for (const item of fail) info(` - ${item}`)\n process.exit(1)\n}\ninfo(\'都过了。\')\n'
335
432
 
336
433
 
337
434
  def example_files(parts: dict[str, str] | None = None) -> dict[str, str]:
@@ -355,7 +452,7 @@ def example_files(parts: dict[str, str] | None = None) -> dict[str, str]:
355
452
  """
356
453
  given = parts or {}
357
454
  out: dict[str, str] = {}
358
- for name in (README, GUIDE, BACKEND_LOCK, REQUIREMENTS, CONTROLS, THEME):
455
+ for name in (README, GUIDE, AGENTS, BACKEND_LOCK, REQUIREMENTS, CONTROLS, THEME):
359
456
  if name in given:
360
457
  out[name] = given[name]
361
458
  # ⚠️ `PAGES_LOCK` 也在这里:它是前端的路由表算出来的(后端给不出),
@@ -368,10 +465,17 @@ def example_files(parts: dict[str, str] | None = None) -> dict[str, str]:
368
465
  return out
369
466
 
370
467
 
371
- def backend_lock_pyi(tables: list[dict[str, Any]] | None = None, items: tuple[EnvItem, ...] = ENV_ITEMS) -> str:
468
+ def backend_lock_pyi(
469
+ tables: list[dict[str, Any]] | None = None,
470
+ items: tuple[EnvItem, ...] = ENV_ITEMS,
471
+ config_keys: list[str] | None = None,
472
+ ) -> str:
372
473
  """`backend.lock.pyi` 的正文:后端能 import 的库 + `ctx` + 环境变量 + 项目里的模型。
373
474
 
374
475
  :param tables: `canvas.data.shape()['tables']`(模型形状);不给就只铺前面三段。
476
+ :param config_keys: 开发机上**真的配了**的块配置键(`dispatch.config_keys()`,已剥前缀)。
477
+ 不给就写成"一个都没有" —— 有这一份,作者才知道 `ctx.config` 里该写哪个键名,
478
+ 而不是靠猜(写错的键在运行期只会取到 `None`,不报错)。
375
479
  """
376
480
  lines: list[str] = [
377
481
  '"""生成物:每次导出都会重写,别手改(改了下次导出就没了)。',
@@ -429,6 +533,26 @@ def backend_lock_pyi(tables: list[dict[str, Any]] | None = None, items: tuple[En
429
533
  f'# ⚠️ `{sdk.BLOCK_CONFIG_PREFIX}` 那一族是块**唯一**读得到的配置:前缀剥掉当键进 `ctx.config`',
430
534
  '# (环境里 `' + sdk.BLOCK_CONFIG_PREFIX + 'OCR_KEY=a` ⇒ `ctx.config["OCR_KEY"] == "a"`);',
431
535
  '# 没配的键**不存在**(先判 `"OCR_KEY" in ctx.config`),别的变量一个都不给块。',
536
+ '#',
537
+ '# 🔴 **怎么写才不出错**(`ctx.config` 的键是**字符串**,写错了不会报错、只会在运行期取到 `None`):',
538
+ '# · 先看下面「你现在配了哪些键」那一块 —— 那是**导出这一刻**开发机上真的存在的键名;',
539
+ f'# · 提交前跑 `node {SMOKE}`:它会把你代码里 `ctx.config[\'…\']` 用到的键与这份清单、',
540
+ '# 以及开发目录旁边的 `.env` 对一遍 —— 写错的键、没配的键、配了却没用的键都会列出来;',
541
+ '# · 想加一个新键:在 `.env` 里写 `' + sdk.BLOCK_CONFIG_PREFIX + 'XXX=…` 再**重新导出一次**,',
542
+ '# 这份清单里就会多出 `XXX`(清单是导出那一刻算的,不是猜的)。',
543
+ ]
544
+ )
545
+ # 🔴 机器可读的"你现在能用哪些键":给 `smoke.lock.mjs` 解析(块开发者本地没有 Django,
546
+ # 那份脚本只能从这份清单里读"。格式是 `# KEY`,与上面那几条说明的缩进一致。
547
+ lines.append('#')
548
+ lines.append(f'# 你现在配了哪些键(`{sdk.BLOCK_CONFIG_PREFIX}` 剥掉前缀;导出这一刻的环境里存在的):')
549
+ if config_keys:
550
+ for key in config_keys:
551
+ lines.append(f'# {key}')
552
+ else:
553
+ lines.append(f'# (一个都没有:环境里没有 `{sdk.BLOCK_CONFIG_PREFIX}` 开头的变量)')
554
+ lines.extend(
555
+ [
432
556
  '',
433
557
  '# ── ⑤ 项目里的模型(照 .labull/contract.json 那份模型形状生成) ──',
434
558
  '# 枚举按**类属性**用(`Order.Status.OPEN`):成员名不是取值 —— `Order.Status.open` 不存在。',
@@ -436,11 +560,145 @@ def backend_lock_pyi(tables: list[dict[str, Any]] | None = None, items: tuple[En
436
560
  )
437
561
  if not tables:
438
562
  lines.append('# (这次导出没拿到模型形状:没有任何 app 注册模型,或者模型那一层没起来)')
563
+ lines.append('#')
564
+ # 🔴 **按来源分组**(用户要求,也是防"拿 Django 自带的表当业务口径"最直接的一招):
565
+ # 同一个清单里混着三类表,而它们**能不能当业务用**完全不同。
566
+ # Django 自带的那几张级联进来时单独一组、并点名"不许当业务口径"。
567
+ groups: list[tuple[str, str, list[dict[str, Any]]]] = [
568
+ ('business', '① 你自己的业务表(**需求里的"角色 / 权限 / 状态"只可能住在这些表的列上**)', []),
569
+ ('platform', '② 平台表(登录与块本身 —— **不是业务**:要块/用户走 `ctx`,别直接查)', []),
570
+ ('django', '③ ❌ Django 自带的元数据(模型级 CRUD / admin 用)—— **别拿来当业务口径**', []),
571
+ ]
439
572
  for table in tables or []:
440
- lines.extend(_table_block(table))
573
+ which = _source_of(str(table.get('module')))
574
+ for kind, _title, bucket in groups:
575
+ if kind == which:
576
+ bucket.append(table)
577
+ break
578
+ for index, (kind, title, bucket) in enumerate(groups, start=1):
579
+ if not bucket:
580
+ continue
581
+ lines.append(f'# ── 5.{index} {title} ──')
582
+ if kind == 'django':
583
+ lines.extend([
584
+ '# 这些表是"某张表能不能增删改查"(add_/change_/delete_/view_),给 admin 用的,',
585
+ '# **不是业务权限目录**。拿它当"权限"是最常见的一类错:看着几十条、一条业务含义都没有。',
586
+ '# ⚠️ 正常情况下这里应该是空的 —— 它们**本来就不该进清单**;出现说明项目直接用了',
587
+ '# `django.contrib.auth` 的模型,那属于业务越界(见同目录 `' + AGENTS + '` 第 1 条)。',
588
+ ])
589
+ for table in bucket:
590
+ lines.extend(_table_block(table))
441
591
  return '\n'.join(lines).rstrip() + '\n'
442
592
 
443
593
 
594
+ def dev_agents(tables: list[dict[str, Any]] | None = None) -> str:
595
+ """`AGENTS.lock.md` 的正文:**面向编码 agent 的契约**(不许做什么 / 拿不准查哪里)。
596
+
597
+ 🔴 这一份只写"**判据**",不写"风格":它列的每条都是**真的会出错**的那种
598
+ (真出过两次:自造权限码装进 `auth_permission`;把拆文件写成块内互相 import)。
599
+ 🔴 表名与模型名**从形状算出来**(不手抄):项目里有哪些业务表、平台表是哪两张、
600
+ Django 自带的那几张为什么不在清单里 —— 这些都要在**这个项目**的语境下说清。
601
+ """
602
+ business = [t for t in (tables or []) if _source_of(str(t.get('module'))) == 'business']
603
+ platform = [t for t in (tables or []) if _source_of(str(t.get('module'))) == 'platform']
604
+ by_module: dict[str, list[str]] = {}
605
+ for table in business:
606
+ by_module.setdefault(str(table.get('module')), []).append(str(table.get('name')))
607
+
608
+ lines = [
609
+ '# AGENTS —— 在这个项目里写 magic block 的规矩',
610
+ '',
611
+ '> 这份文件是**判据**,不是风格建议:底下每一条都对应一类真出过的事故。',
612
+ '> 与 `GUIDE.lock.md` 分工:GUIDE 讲"怎么写",这一份讲"**不许怎么做、拿不准先查哪里**"。',
613
+ '',
614
+ '## 一、十条硬规矩(第一条最常被违反)',
615
+ '',
616
+ '1. **不许拿 Django 自带的表当业务口径。** 具体是这些(以及任何 `django.contrib.*` 的表):',
617
+ ' `auth_permission` / `auth_group` / `auth_group_permissions` / `auth_user_groups` /',
618
+ ' `auth_user_user_permissions` / `django_content_type` / `django_admin_log` /',
619
+ ' `django_migrations` / `django_session`。',
620
+ ' 🔴 它们是**模型级 CRUD**(某张表能不能增删改查),给 admin 用的 —— **不是业务权限目录**。',
621
+ ' 🔴 它们**根本不在 `backend.lock.pyi` 里**(清单就是权威名单)。要碰它们只能自己',
622
+ ' `from django.contrib.auth.models import Permission` —— 那一步就是**越界**,不要写。',
623
+ '2. **不许自己发明权限码、角色、状态码。** 需求让你"加权限",先问:',
624
+ ' ① 表上是不是已经有"权限样字段组"?—— 看 `backend.lock.pyi` 里每个模型上面那一行索引',
625
+ ' (形如 `# 权限样字段组:<前缀>_*(N) / <前缀>_*(M) —— 合计 K 个布尔位`);',
626
+ ' ② "谁在什么岗位上"这类信息住在哪张表上 —— 先在 3.1 那批业务表里找一轮',
627
+ ' (名字或备注像"组织 / 岗位 / 成员 / 角色"的那种)。',
628
+ ' 先读**已有的列**,再写代码。**没有的列就是没有** —— 不要用 `auth_permission` 去补。',
629
+ '3. **块只能 import 这个项目里能 import 的东西。** 拿不准哪个模型能用、字段叫什么、',
630
+ ' 是不是枚举:查 `backend.lock.pyi`(**只有里面的东西能用**),别凭印象写。',
631
+ '4. **每个 handler 第一句就校验参数**(缺参数抛 `errors.BadRequest`)。',
632
+ ' 保存与体检会把每个 handler 真跑一遍(空参数):不校验的 handler 会去打真网络、',
633
+ ' 真写文件,或者把块判成"体检不通过"。',
634
+ '5. **只 promise 自己做得到的事。** 前端能拿到的只有那座桥(`magicBlock.*` /',
635
+ ' `magicBackendApi.*`):用户身份、跳标签、这一块自己的后端。它读不到地址栏、',
636
+ ' localStorage 里的宿主数据、别的块的状态 —— 要这些就先说清做不到。',
637
+ '6. **键名别猜**:块配置只有带 `BLOCK_` 前缀的环境变量会进 `ctx.config`(前缀剥掉当键)。',
638
+ ' 真实键名在 `backend.lock.pyi` 的「你现在配了哪些键」那一块;提交前跑 `node smoke.lock.mjs`,',
639
+ ' 它会替你核对代码里 `ctx.config[...]` 用到的键与清单、以及开发目录旁的 `.env`。',
640
+ '7. **不许把密钥写进代码或块包**(不要 `API_KEY = "..."`):走 `ctx.config`。',
641
+ ' 块包会进数据库、也会被导出到别人机器上。',
642
+ '8. **前端可以拆文件**(入口里照常 `<script src>` / `<link href>`,包内文件会被内联),',
643
+ ' 但**入口引用的文件一个都不能少**,而且块里**没有模块系统**(不能 `import` / `export`)。',
644
+ '9. **后端也能拆文件**:包里每个 `.py` 都是**独立入口**(平台逐个加载)—— 因此',
645
+ ' **文件之间不许互相引用**(`from helper import x` 会被保存拦下)。要共用逻辑就',
646
+ ' **复制一份**,或者放进**项目里**的公共模块(那是普通 Python)。',
647
+ '10. **不许在块里 `eval` / `exec` 拼代码**,也不许改平台行为(改 `settings`、注册全局钩子)。',
648
+ '',
649
+ '## 二、"权限"这个词在这个项目里指哪一类(**四种,别混**)',
650
+ '',
651
+ '1. **某张业务表上的"权限样字段组"**(最常见、也是需求真正指的那种):',
652
+ ' 形如 `<前缀>_is_*` / `<前缀>_can_*` 的**布尔位**,备注里常带 `(仅XX)` 这类限定语。',
653
+ ' 语义是"**某个岗位上能不能干这件事**":要和**岗位 / 组织**一起读,',
654
+ ' 不是"一组可以随便下发的权限码"。索引见 `backend.lock.pyi` 每个模型上面那一行。',
655
+ '2. **平台级能力**:`ctx.user.is_staff`(管理员)、`ctx.user.groups` /',
656
+ ' `ctx.user.permissions`(Django 的 `app.codename`);后端路由可以声明',
657
+ " `@MagicBackendAPI(permission='app.codename')`。",
658
+ "3. **Django 模型级 CRUD**(`auth_permission` 里那几组 `add_/change_/delete_/view_`):",
659
+ ' **通常不是**需求说的那个"权限"。拿它当业务权限目录 = **越界**(见规矩 1)。',
660
+ '4. **外部系统**(Authing 的角色 / 分组):要另配密钥去拉,块里**默认拿不到**;',
661
+ ' 需要就先说清"要配哪几个 `BLOCK_*`",别假装它已经在库里。',
662
+ '',
663
+ '## 三、这个项目里有哪些模型(按来源分,**只读第 1 类做业务**)',
664
+ '',
665
+ '### 3.1 你自己的业务表 ← 需求里的"角色 / 权限 / 状态"只可能住在这些表的列上',
666
+ '',
667
+ ]
668
+ if by_module:
669
+ for module in sorted(by_module):
670
+ lines.append(f'- `{module}`:' + ' / '.join(by_module[module]))
671
+ else:
672
+ lines.append('- (这次导出没拿到任何业务模型:项目里还没建模型,或者模型那一层没起来)')
673
+ lines.extend([
674
+ '',
675
+ '### 3.2 平台表 ← 只管登录与块本身,**不是业务**',
676
+ '',
677
+ ('- ' + ' / '.join(f'`{t.get("module")}.{t.get("name")}`' for t in platform)) if platform
678
+ else '- (这个项目里没扫到平台表)',
679
+ ' ⚠️ 块**不该直接查它们**:要块自己或当前用户,用 `ctx.block` / `ctx.user`(那是平台的接口)。',
680
+ '',
681
+ '### 3.3 ❌ Django 自带的表 —— **不在清单里,也不许用**',
682
+ '',
683
+ '`auth_permission` / `auth_group` / `django_content_type` / `django_admin_log` /',
684
+ '`django_session` / `django_migrations` …',
685
+ '',
686
+ '它们**一张都没进 `backend.lock.pyi`**(清单里那个 `class 列表`就是全部能用模型)。',
687
+ '要"权限目录"却发现只有那几组模型级 CRUD —— 那说明**需求指的不是它**:',
688
+ '回到第 2 节,看业务表上的权限样字段组,或者组织/岗位表。',
689
+ '',
690
+ '## 四、提交前(**每次都要**)',
691
+ '',
692
+ f'1. `node {SMOKE}`(全部块)或 `node {SMOKE} <块文件夹|uuid>`(只查一个):',
693
+ ' 清单 / 前端引用链 / 后端拆文件规矩 / `ctx.config` 键名,它都会过一遍。',
694
+ '2. 回管理台「读回改动」→ 勾上块 → 保存:保存时会在子进程里**真的加载一次**,',
695
+ ' 并做引用深检(模型字段、枚举值、属性是不是还在)与 handler 试跑。',
696
+ '',
697
+ '> 拿不准就把"我打算这么做"写下来先说一遍 —— 上面十条里有八条是"想清楚就不会犯"的。',
698
+ ])
699
+ return '\n'.join(lines) + '\n'
700
+
701
+
444
702
  def dev_readme(blocks: int) -> str:
445
703
  """开发目录里的说明(铺了什么、哪几份是生成物、改完怎么回库)。
446
704
 
@@ -459,8 +717,10 @@ def dev_readme(blocks: int) -> str:
459
717
  '3. 回管理台 → 选**同一个文件夹** → 「读回改动」→ 勾上要存的块 → 保存(保存时会真的试加载一次)。\n'
460
718
  '\n'
461
719
  f'🔴 **要查什么先看 `{GUIDE}`**(一页看完:一个块长什么样、前端能调什么、后端怎么写、怎么拆文件)。\n'
462
- f'🔴 想直接抄一个现成的:`{EXAMPLE_DIR}/` 里那份是完整、能存库、能跑通的例子(身份证 OCR →\n'
463
- ' 客户档案),它**前后端都拆成了几个文件**,正好示范"拆开写"该怎么拆。\n'
720
+ f'🔴 想直接抄一个现成的:`{EXAMPLE_DIR}/` 里那份是完整、能存库、能跑通的例子'
721
+ f'(**前后端都拆成了几个文件**),正好示范"拆开写"该怎么拆。\n'
722
+ ' ⚠️ 例子里那些表名 / 列名只是它自己的场景:照抄**结构**,名字按你这个项目的来'
723
+ f'(查 `{BACKEND_LOCK}`)。\n'
464
724
  '\n'
465
725
  '## 这个目录里有什么\n'
466
726
  '\n'
@@ -589,6 +849,8 @@ def dev_guide() -> str:
589
849
  f'- 抛 `errors.{errors.replace(" / ", "` / `errors.")}` ⇒ 对应的 HTTP 状态码;别的异常一律 500。\n'
590
850
  f'- 配置面:环境变量里**只有带 `{prefix}` 前缀的**才进 `ctx.config`(`{prefix}OCR_KEY=a` ⇒ `ctx.config["OCR_KEY"] == "a"`);\n'
591
851
  ' 没配的键**不存在**(先判 `"OCR_KEY" in ctx.config`)。\n'
852
+ ' 🔴 **键名别猜**:`ctx.config[...]` 的键是字符串,写错了不报错、只在运行期取到 `None`。\n'
853
+ f' `{BACKEND_LOCK}` 的第 ④ 段列着"**你现在配了哪些键**",提交前跑 `node {SMOKE}` 会替你核对。\n'
592
854
  '- 服务端能 import 什么、项目里有哪些模型与字段、还有哪些环境变量:见 `backend.lock.pyi`;\n'
593
855
  f' 后端**装了哪些包**(块后端 import 之前得确认它在):见 `{REQUIREMENTS}`(项目 `requirements.txt` 的副本)。\n'
594
856
  '\n'
@@ -166,6 +166,20 @@ def block_config() -> dict[str, str]:
166
166
  return out
167
167
 
168
168
 
169
+ def config_keys() -> list[str]:
170
+ """开发机上现在**真的配了**哪些块配置键(`BLOCK_` 剥掉前缀,排序)。
171
+
172
+ 🔴 与 `block_config()` 同一份判据(前缀只有一处):这一份是**给清单用的**(`backend.lock.pyi`
173
+ 里列出"你现在能用哪些键"),不是运行期取值。
174
+ 🔴 只回**键名**,永不回值:清单会落到用户自己的目录里,而那一族里可能有密钥。
175
+ """
176
+ return sorted(
177
+ name[len(BLOCK_CONFIG_PREFIX):]
178
+ for name in os.environ
179
+ if name.startswith(BLOCK_CONFIG_PREFIX) and name[len(BLOCK_CONFIG_PREFIX):]
180
+ )
181
+
182
+
169
183
  def build_context(request: HttpRequest, block: Any) -> BlockCtx:
170
184
  """组装交给 handler 的 `ctx`(**4 个键,见 `sdk.CTX_KEYS`**)。
171
185
 
@@ -43,6 +43,7 @@ PLATFORM_FILES: tuple[str, ...] = (
43
43
  'VERSION.lock',
44
44
  devkit.README,
45
45
  devkit.GUIDE,
46
+ devkit.AGENTS,
46
47
  devkit.BACKEND_LOCK,
47
48
  devkit.PAGES_LOCK,
48
49
  devkit.THEME,
@@ -117,6 +118,7 @@ _SKIP_ROOT_FILES: frozenset[str] = frozenset(
117
118
  {
118
119
  devkit.README.lower(),
119
120
  devkit.GUIDE.lower(),
121
+ devkit.AGENTS.lower(),
120
122
  devkit.BACKEND_LOCK.lower(),
121
123
  devkit.PAGES_LOCK.lower(),
122
124
  devkit.THEME.lower(),
@@ -248,8 +250,12 @@ def package_files(rows: Iterable[Any], tables: list[dict[str, Any]] | None = Non
248
250
  files[f'{DEV_BLOCKS_DIR}/{folder}/api.open.d.ts'] = dts
249
251
  files[f'{DEV_BLOCKS_DIR}/{folder}/VERSION.lock'] = f'{LIB_SDK}\n'
250
252
  blocks += 1
251
- files[devkit.BACKEND_LOCK] = devkit.backend_lock_pyi(tables)
253
+ files[devkit.BACKEND_LOCK] = devkit.backend_lock_pyi(tables, config_keys=_config_keys())
252
254
  files[devkit.GUIDE] = devkit.dev_guide()
255
+ # 🔴 **面向编码 agent 的契约**(`AGENTS.lock.md`,根目录):那几种工具(Claude Code / Copilot …)
256
+ # 会优先读这个文件名。它讲的是"哪些绝不许做、权限住在哪"——真出过两次事故的地方
257
+ # (自造权限码装进 auth_permission;把拆文件写成块内互相 import)。
258
+ files[devkit.AGENTS] = devkit.dev_agents(tables)
253
259
  files[devkit.REQUIREMENTS] = requirements_text()
254
260
  files[devkit.README] = devkit.dev_readme(blocks)
255
261
  # 🔴 **块自检脚本**铺在开发目录顶层(`smoke.lock.mjs`):作者在**发布前**本地就能把块过一遍
@@ -266,6 +272,7 @@ def package_files(rows: Iterable[Any], tables: list[dict[str, Any]] | None = Non
266
272
  example_parts = {
267
273
  devkit.README: files[devkit.README],
268
274
  devkit.GUIDE: files[devkit.GUIDE],
275
+ devkit.AGENTS: files[devkit.AGENTS],
269
276
  devkit.BACKEND_LOCK: files[devkit.BACKEND_LOCK],
270
277
  devkit.REQUIREMENTS: files[devkit.REQUIREMENTS],
271
278
  }
@@ -324,6 +331,17 @@ def scan_rules() -> dict:
324
331
  }
325
332
 
326
333
 
334
+ def _config_keys() -> list[str]:
335
+ """开发机上真的配了哪些块配置键(给 `backend.lock.pyi` 用)。
336
+
337
+ 🔴 与运行期取的是**同一份判据**(`dispatch.config_keys()`):两处各写一份前缀扫描,
338
+ 漂了就是"清单里没有、运行期却有"(作者会以为写错了)。
339
+ """
340
+ from .dispatch import config_keys
341
+
342
+ return config_keys()
343
+
344
+
327
345
  def export_package(blocks: Iterable[str] | None = None) -> dict:
328
346
  """`POST /magic-blocks/export` 的全部答案:开发包 + 读回时要照的规则。
329
347
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: labull-framework
3
- Version: 0.1.6
3
+ Version: 0.1.8
4
4
  Summary: Labull Framework: a pluggable Django app with Authing OIDC login and magic blocks
5
5
  Author: Labull Framework
6
6
  License-Expression: MIT
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "labull-framework"
7
- version = "0.1.6"
7
+ version = "0.1.8"
8
8
  description = "Labull Framework: a pluggable Django app with Authing OIDC login and magic blocks"
9
9
  readme = "README-pypi.md"
10
10
  requires-python = ">=3.10"