beacon-mfg-mcp 1.12.2 → 1.12.4

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.
package/README.md CHANGED
@@ -60,6 +60,19 @@ linked_entity 温州市亿鸿装饰工程有限公司 ← 营业执照
60
60
  登记机关),只有政府公开数据来源的主体才有,且**只读不改**;`linked_entity` 是**场所与主体的
61
61
  关联边**,可撤销、可人工修正。
62
62
 
63
+ ## 工艺证据等级(search_vendors 的 `process_evidence`)
64
+
65
+ 工艺码有**三种来源**,混为一谈会把「这一类大概都做」当成「这家确实做」:
66
+
67
+ | 值 | 含义 | 该不该当事实 |
68
+ |----|------|--------------|
69
+ | `null` | 能力卡 / 企业自述登记的事实工艺(或类默认但只有 1~3 个码) | 可当事实,仍建议 `get_capability_card` 复核 |
70
+ | `inferred:A1` ~ `inferred:B0` | 零采集推断,字母是证据分档(A1 三路一致 … B0 单路有信号) | 推断不是核实,严苛场景要追问 |
71
+ | `class_default` | 该记录挂了 ≥4 个「国标小类默认工艺码」,是同类企业的众数,**不是这家企业的工艺事实** | ❌ 必须降权,不要据此下单 |
72
+
73
+ `class_default` 覆盖约 1.3 万条。工艺码一个没删(按工艺检索的召回不受影响),
74
+ 只是把证据等级如实标出来 —— 让 Agent 少做一轮二次筛选。
75
+
63
76
  ## 身份与额度(不需要注册)
64
77
 
65
78
  首次运行会在 `~/.beacon-mfg/` 生成一个匿名身份(机器本地 UUID → 单向哈希),
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "beacon-mfg-mcp",
3
- "version": "1.12.2",
3
+ "version": "1.12.4",
4
4
  "mcpName": "io.github.eiry16/beacon-mfg-mcp",
5
5
  "description": "Read-only MCP server exposing the BeaconMFG灯塔 directory of business entities across ALL GB/T 4754 industry divisions (manufacturing, services, retail, logistics, catering, IT, and more) to any MCP-compatible agent. No signup and no API key: an anonymous identity is generated on first run, phone binding is optional (only needed for L2 capability cards).",
6
6
  "keywords": [
@@ -3,7 +3,7 @@
3
3
  "display_name": "输送/物流搬运设备",
4
4
  "version": "0.2.0",
5
5
  "extends": "_base",
6
- "description": "输送线 / 输送机 / 传送带 / 提升机 / 分拣线等连续搬运与物料搬运设备。GB 3434 连续搬运设备制造(输送机械/装卸机械/给料机械)是语义最贴切的国标码。本 pack 补的是「产品词→行业」语义映射缺口:许多输送厂把品类写在厂名里、国标码却落在 3360/3399/3451/3484,故召回靠厂名,行业收敛靠名称感知防误删(见 mcp_bridge._PACK_NAME_HINTS)。",
6
+ "description": "输送线 / 输送机 / 传送带 / 提升机 / 分拣线等连续搬运与物料搬运设备。对应 GB 3434 连续搬运设备制造(输送机械 / 装卸机械 / 给料机械)。",
7
7
 
8
8
  "attributes": {
9
9
  "product_type": {
@@ -3,7 +3,7 @@
3
3
  "display_name": "床垫",
4
4
  "version": "0.2.0",
5
5
  "extends": "_base",
6
- "description": "第二个行业包。用途是验证「新增行业只加 pack,不碰 schema/ 内核」。",
6
+ "description": "床垫 / 弹簧床垫 / 记忆棉床垫等寝具,覆盖尺寸、厚度、材质、硬度等询价属性。",
7
7
 
8
8
  "attributes": {
9
9
  "size": {
@@ -3,7 +3,7 @@
3
3
  "display_name": "钣金冲压",
4
4
  "version": "0.2.0",
5
5
  "extends": "_base",
6
- "description": "beacon-mfg 原生品类(一级品类『钣金冲压』)。验证 rfq-kernel 可直接消费 beacon-mfg 真实能力卡:适配器把 L1 capability.json 转成标准供应商卡后,本 pack 即可驱动匹配,内核与 schema 零改动。",
6
+ "description": "钣金 / 冲压 / 机箱机柜 / 金属结构件等金属板材成形加工,覆盖材料、厚度、精度等询价属性。",
7
7
 
8
8
  "attributes": {
9
9
  "material": {
@@ -11,7 +11,7 @@
11
11
  自报(self_declared)或无证书号 → verified=false → 不作数。
12
12
  这正是「ISO9001 需上传证书号、且平台/现场核验过才作数」。
13
13
 
14
- 真实卡权威位置(与 server/routers/rfq.py 一致):
14
+ 真实卡权威位置:
15
15
  skills/registry/capability/{sid}.json
16
16
  skills/vendors/{id}/capability.json 作为兜底(两者内容同源)。
17
17
  生产环境应优先 registry/capability,不要遍历 data/gb 分片。
@@ -42,7 +42,7 @@ def _all_packs() -> dict:
42
42
 
43
43
  # 名称感知防误删:当候选供应商的厂名含本 pack 的具体产品词组时,
44
44
  # 即使其国标码(gb)被 pack_of_gb 归到「另一个 pack」,也视为本行业、绝不误删。
45
- # 这是 beacon-mfg 红线「真实企业必须被看见」的兜底:许多长尾厂把品类写在厂名里,
45
+ # 这是「真实企业必须被看见」原则的兜底:许多长尾厂把品类写在厂名里,
46
46
  # 国标码却落在别的类(如输送厂 gb 常是 3360/3399/3451/3484 而非 3434)。
47
47
  # 关键词只收「具体产品词组」,避免歧义单字(如 滚筒 会撞 滚筒洗衣机、物流/设备 过宽)。
48
48
  _PACK_NAME_HINTS = {
@@ -263,7 +263,7 @@ def build_session(demand_text: str, recs: list, pack_id: str | None,
263
263
  # 若检测到行业,用国标码(GB)把召回池收敛到本行业,剔除跨行业噪声
264
264
  # (如注塑厂/POM、表面处理/anodizing 混入「钣金冲压」查询)。
265
265
  # 判别规则保守:仅当记录的 gb 明确映射到「另一个 pack」时才剔除;
266
- # gb 为空或未知的一律保留,绝不误删真实企业(beacon-mfg 红线)。
266
+ # gb 为空或未知的一律保留,绝不误删真实企业。
267
267
  # 若剔除后池将变空(极端情况),则放弃剔除,保留原池。
268
268
  if pack_id:
269
269
  hints = _PACK_NAME_HINTS.get(pack_id, [])
package/server.py CHANGED
@@ -97,15 +97,14 @@ except Exception: # 桥接缺失/异常 → 降级,MCP 其余 3 个只读 too
97
97
  _bridge = None
98
98
 
99
99
  # --------------------------------------------------------------------------- #
100
- # id token(D,2026-10-28)
100
+ # id token
101
101
  #
102
- # 对外 id 是 HMAC 置换后的 8 字符 token,不是 `CN-MFG-0000163` 这种顺序编号 ——
103
- # 顺序编号等于「从 1 遍历到 222875 就能把全库重建一遍」。
102
+ # 对外 id 是 HMAC 置换后的 8 字符 token,不是顺序编号 —— 顺序编号可被枚举遍历,
103
+ # 等于把整库暴露给批量抓取。
104
104
  #
105
105
  # 为什么客户端也要有这份算法:MCP 的检索是**本地**跑的(拉分片、本地打分),
106
- # 它得把结果里的内部 id 换成 token 才能对外给。算法文件与
107
- # scripts/vid_token.py **逐字相同**(sync_vid_block.py 维护),密钥来自
108
- # BEACON_VID_KEY。没有密钥时(npm 客户端默认情况)**不改写** —— 见 has_key()。
106
+ # 它得把结果里的内部 id 换成 token 才能对外给。同目录的算法副本必须保持同步;
107
+ # 没有密钥时(npm 客户端默认情况)**不改写** —— 见 has_key()。
109
108
  # --------------------------------------------------------------------------- #
110
109
  try:
111
110
  from vid_token import ( # 同目录副本(npm 只分发 mcp/)
@@ -313,7 +312,7 @@ def _recall_for_sourcing(text: str, top_k: int = 200, pack_id: str | None = None
313
312
  # 国标码命中检测行业 / 厂名含本 pack 产品词:本行业强信号,即便需求词全为
314
313
  # 稀有词(df<2,如「风送线」)导致 token 打分全为 0,也要保留——这些是
315
314
  # 真实供应商(GB 种子加法补召的 3434 搬运设备厂、或厂名含产品词的长尾厂),
316
- # 不能因为「没有高频区分词」就被 w<=0 一概滤除(beacon-mfg 红线:真实企业必须被看见)。
315
+ # 不能因为「没有高频区分词」就被 w<=0 一概滤除(真实企业必须被看见,不能漏召回)。
317
316
  gb_hit = pack_id and _bridge is not None and _bridge.pack_of_gb(r.get("gb")) == pack_id
318
317
  name_hit = pack_id and name_surfaces and any(s in name_hay for s in name_surfaces)
319
318
  if w <= 0 and not (gb_hit or name_hit):
@@ -323,7 +322,7 @@ def _recall_for_sourcing(text: str, top_k: int = 200, pack_id: str | None = None
323
322
  w += 1000.0
324
323
  # 厂名含本 pack 产品词、但 gb 未映射本行业的长尾真实企业(如输送厂 gb=3360/3451
325
324
  # 而非 3434):同样视为本行业强信号给次高加成,避免被 GB 种子挤到池底
326
- # (beacon-mfg 红线:真实企业必须被看见)。
325
+ # (真实企业必须被看见,不能漏召回)。
327
326
  elif name_hit:
328
327
  w += 500.0
329
328
  r2 = dict(r)
@@ -338,10 +337,10 @@ def _recall_for_sourcing(text: str, top_k: int = 200, pack_id: str | None = None
338
337
  # --------------------------------------------------------------------------- #
339
338
  # 版本号单一来源:同目录 package.json(npm 发布包的 version)。
340
339
  # 全项目所有版本号(MCP / HTTP 服务 / Python API / Android APK)
341
- # 都对齐到这个值,`scripts/check_version.py` 在发布前校验它们没有漂移。
340
+ # 都对齐到这个值,发布前的一致性闸门会校验它们没有漂移。
342
341
  # 读不到 package.json 时的兜底值 —— 改版本时**必须**和 package.json 一起改,
343
342
  # 否则 check_version.py 会报错(这就是它的用途)。
344
- VERSION_FALLBACK = "1.12.2"
343
+ VERSION_FALLBACK = "1.12.4"
345
344
 
346
345
 
347
346
  def _pkg_version() -> str:
@@ -557,8 +556,7 @@ def _header_value(v: Any) -> str:
557
556
 
558
557
  后果远比「少记一条埋点」严重:`_beacon_headers()` 是**所有** HTTP 读共用
559
558
  (manifest、索引桶、指纹分片全走它),于是任何含中文关键词的检索在冷缓存下
560
- **整体不可用**,而报错文案指向「网络/代理不可达」。2026-10-24 由用户在另一台
561
- 机器上实测定位(1.4.2 → 1.11.0 均存在;本机因 manifest 已进缓存而未复现)。
559
+ **整体不可用**,而报错文案指向「网络/代理不可达」,容易误导排查方向。
562
560
 
563
561
  处理方式取 **percent-encode** 而不是丢弃:中文产品词正是要观测的主体,
564
562
  过滤掉等于把中文场景的遥测清零。worker 侧用 `decodeURIComponent` 还原。
@@ -580,9 +578,8 @@ def _quote_path(relpath: str) -> str:
580
578
  `http.client` 组装请求行时按 `'ascii'` 编码,路径里出现原始 UTF-8 字节
581
579
  就在**建连之前**抛 `UnicodeEncodeError: 'ascii' codec can't encode ...`。
582
580
 
583
- 2026-10-24 实测路径:`_city_postings()` 拼 `skills/registry/index/city/贵阳.json`,
584
- 城市名是发布侧的**原始中文文件名**(391 个城市全是中文名,没有哈希化)——
585
- 中文城市 + 冷缓存首个查询必定在这一跳失败。
581
+ `_city_postings()` 拼出的城市索引使用发布侧的**原始中文文件名**(未做哈希化)——
582
+ 中文城市 + 冷缓存首个查询必定在这一跳失败。
586
583
 
587
584
  为什么不在 `_city_postings()` 里就地 quote:`fetch_text()` 会**先用 relpath
588
585
  读本地工作树/dirgit HEAD**(本地仓库模式),把 relpath 提前编码会让本地读
@@ -617,15 +614,11 @@ def _beacon_headers() -> Dict[str, str]:
617
614
  h["X-Beacon-Cid"] = _cid()
618
615
 
619
616
  cur = _cur_get() or {}
620
- # op = 一次 tool 调用。服务端按 op 去重计量配额:一次「上海」检索要拉
621
- # 178 个分片,按请求计费会把正常用户直接打死(见 pages/_worker.js 注释)。
617
+ # op = 一次 tool 调用。服务端按 op 去重计量配额:一次检索会拉很多分片,
618
+ # 按请求计费会把正常用户直接打死。
622
619
  #
623
- # ⚠ **没有 op 就一定会 401**(2026-10-30 线上实测)。worker 门禁第 4 条是
624
- # `enforce && !op → 401 client_outdated`,它把「缺 op」当成旧版客户端。
625
- # 而 op 只存在于 tool 调用期间(`_cur_set`),**启动预热、跨线程池、
626
- # 以及任何非 tool 触发的取数都没有它**。缺了就整片 401:
627
- # · /data/manifest.json → 冷启动 8 次 401 重试(实测 67.7s)
628
- # · /data/gb/** → get_vendor 全部 fetch_failed
620
+ # ⚠ op 只存在于 tool 调用期间(`_cur_set`):**启动预热、跨线程池、
621
+ # 以及任何非 tool 触发的取数都没有它**,缺了会被服务端当成旧版客户端拒绝。
629
622
  # 所以这里必须有兜底 op。用 `boot-<pid>` 而不是给所有请求塞同一个常量:
630
623
  # 服务端按 op 去重,同一进程的多条预热/兜底请求计作一次操作,
631
624
  # 既不会把配额打爆,也不会让「一个进程」在台账里看起来像几百次调用。
@@ -1094,11 +1087,10 @@ def _fetch_many(paths: List[str], workers: int = 0) -> List[Optional[str]]:
1094
1087
  return [_shard_text(p) for p in paths]
1095
1088
  n = max(1, int(workers or _FETCH_WORKERS))
1096
1089
  out: List[Optional[str]] = [None] * len(paths)
1097
- # ⚠ ContextVar 不会自动继承到线程池的工作线程(2026-10-30 线上复现)。
1090
+ # ⚠ ContextVar 不会自动继承到线程池的工作线程。
1098
1091
  # 本次 tool 调用的 op 在 `_cur_get()` 里,`X-Beacon-Op` 头靠它发出;
1099
- # 丢了 op → worker 判 `client_outdated` → **整片 401**。
1100
- # 症状极具误导性:同一次 get_vendor 里,单读的 idmap 200、并发取的
1101
- # /data/gb/** 全 401 → 报 "fetch_failed"(看起来像企业不存在或网络抖)。
1092
+ # 丢了 op 会被服务端当成旧版客户端拒绝,整批取数失败。
1093
+ # 症状极具误导性:看起来像企业不存在或网络抖动,排查方向容易跑偏。
1102
1094
  # 每任务一份 `copy()`,因为同一个 Context 不能并发进入。
1103
1095
  import contextvars
1104
1096
  _base = contextvars.copy_context()
@@ -2372,20 +2364,18 @@ _fp_index: Optional[List[Dict[str, Any]]] = None
2372
2364
  _fp_index_key: Optional[str] = None
2373
2365
  _name_gb_slot: Optional[tuple] = None # (id -> gb 映射, loaded_at, mtime)
2374
2366
 
2375
- # 全量兜底的**上限**(2026-10-29)。`_build_fp_index` 是「预构建索引不可用」时的
2376
- # 最后一道网:git archive 一次批量读 267~467 个 fp 分片,实测 210,555 条要 26.7 秒。
2367
+ # 全量兜底的**上限**。`_build_fp_index` 是「预构建索引不可用」时的
2368
+ # 最后一道网:一次批量读取全部分片,耗时随数据量线性增长。
2377
2369
  #
2378
2370
  # 它本来只在「索引缺失/陈旧」时才会被走到,但 `get_vendor` 在 id 反查失败时会拿它
2379
- # 兜底——那是一条**用户正在等着看点开的那一家**的同步路径。实测绕过 `_vid_in`
2380
- # 直接调 `get_vendor(token_id)` 就掉进来了:31s / 38s 两次复现,客户端表现为「卡死」。
2371
+ # 兜底——那是一条**用户正在等着看点开的那一家**的同步路径,必须设上限。
2381
2372
  #
2382
2373
  # 所以给它两道闸:时间闸(超时即返回已拿到的部分,并如实标记截断)+ 条数闸。
2383
2374
  # 两道闸都在**返回前**判定,且截断结果绝不写进 `_fp_index`(进程级缓存,写脏等于
2384
2375
  # 让后续每次查询都拿半份数据,见下方 fallback 分支的同类注释)。
2385
2376
  #
2386
- # 默认值刻意**高于**本机全量构建实测耗时(26.7s / 21 万条):这道闸的职责是拦
2387
- # 「系统病态」(曾经出现 479 次请求、2258s 才返回 0 条),不是把正常构建切残——
2388
- # 8s 的初版实测会把索引截成 6.1 万条(29%),普通检索于是悄悄少 2/3 记录。
2377
+ # 默认值刻意**高于**正常全量构建耗时:这道闸的职责是拦「系统病态」,
2378
+ # 不是把正常构建切残 —— 预算过紧会把索引截掉一大截,普通检索于是悄悄少记录。
2389
2379
  # 需要更紧预算的地方自己传 deadline_s(`get_vendor` 就传 5s,见
2390
2380
  # `_VENDOR_FP_DEADLINE`)。
2391
2381
  _FP_BUILD_DEADLINE = float(os.environ.get("BEACON_FP_BUILD_DEADLINE", "40") or "0")
@@ -2426,10 +2416,9 @@ def _data_version() -> str:
2426
2416
  git 模式 = HEAD sha(提交变则版本变);
2427
2417
  HTTP 模式 = 清单**内容指纹**。
2428
2418
 
2429
- 2026-10-24 修:原来 HTTP 模式只取 `generated_at`,它是 `date.today()`(**日期精度**)。
2430
- 同一天内重复发布(重跑索引、补数据)版本号不变 → 落盘缓存永远命中旧快照 →
2431
- 线上索引卡在 210555 条,MCP 每次搜索都判 `index_behind_data` 并退化成扫全部
2432
- 467 个分片(单次 130s+),重跑 gen_search_index 也「看不出效果」。
2419
+ HTTP 模式原先只取 `generated_at`,它是 `date.today()`(**日期精度**)。
2420
+ 同一天内重复发布(重跑索引、补数据)版本号不变 → 落盘缓存永远命中旧快照,
2421
+ 重跑索引也「看不出效果」。
2433
2422
  改为「generated_at + 各类型记录数 + 索引桶哈希聚合」,数据或索引任一变化即失效。
2434
2423
 
2435
2424
  取不到就返回空串,调用方据此**禁用**缓存(宁慢不脏)。
@@ -2512,8 +2501,8 @@ def _idmap_rel(vid: str) -> Optional[str]:
2512
2501
  """id 落在哪一片。
2513
2502
 
2514
2503
  token 寻址:片名 = token 的前 2 字符(32×32 = 1024 片)。token 是随机
2515
- 排列的,前 2 字符在其上是**均匀**的 → 21 万条散进去每片约 205 行 / 3 KB,
2516
- 与旧的「1000 行 / 14 KB」同量级,而且不需要任何目录表。
2504
+ 排列的,前 2 字符在其上是**均匀**的 → 全量记录散进去每片行数很少,
2505
+ 而且不需要任何目录表。
2517
2506
  """
2518
2507
  if _idmap_addr() == _IDMAP_ADDR_TOKEN:
2519
2508
  if vid and _TOKEN_ID_RE.match(vid):
@@ -2896,9 +2885,9 @@ def _build_fp_index(deadline_s: Optional[float] = None) -> List[Dict[str, Any]]:
2896
2885
  # fallback:逐分片读取(HTTP 模式,或 git archive 异常时)
2897
2886
  recs = []
2898
2887
  # git show / HTTP GET 均为 I/O 密集,线程池并发拉取可大幅缩短首次构建耗时。
2899
- # 分批 + 失败过半即停(2026-10-24):全量回退是 467 个分片,若故障是系统性的
2900
- # (401/429/断网),挨个试完只会把一次失败放大成几百次重试 —— 实测 479 次
2901
- # 请求、累计 2258s 才返回 0 条,用户只看到「卡住然后没有结果」。
2888
+ # 分批 + 失败过半即停:全量回退要拉的分片很多,若故障是系统性的
2889
+ # (401/429/断网),挨个试完只会把一次失败放大成几百次重试,
2890
+ # 用户只看到「卡住然后没有结果」。
2902
2891
  _BATCH = 16
2903
2892
  _tried = 0
2904
2893
  _ok = 0
@@ -3121,7 +3110,7 @@ def _brand_of(name: str) -> str:
3121
3110
 
3122
3111
  刻意**不做**更激进的归并(去「店/馆/楼」后缀、模糊匹配):那会把「陆振兴」和
3123
3112
  「陆正兴」这种一字之差的不同主体并成一家 —— 错比噪声严重。
3124
- 与 scripts/gen_brand_index.py 的 brand_of 同一口径。
3113
+ 品牌口径与发布侧索引保持一致。
3125
3114
  """
3126
3115
  s = (name or "").strip()
3127
3116
  prev = None
@@ -3199,7 +3188,7 @@ def _haversine_km(lat1: float, lng1: float, lat2: float, lng2: float) -> float:
3199
3188
  return 6371.0088 * 2 * math.asin(math.sqrt(a))
3200
3189
 
3201
3190
 
3202
- # 中国经纬度范围(与 scripts/gen_fingerprint.py 的坐标写入校验同口径)。
3191
+ # 中国经纬度范围(与发布侧坐标写入校验同口径)。
3203
3192
  # 超出即视为调用方传错(常见根因:经纬度顺序写反、用了海外国标 API)。
3204
3193
  _GEO_LAT_RANGE = (3.0, 54.0)
3205
3194
  _GEO_LNG_RANGE = (73.0, 136.0)
@@ -3275,16 +3264,21 @@ def _rec_summary(rec: Dict[str, Any]) -> Dict[str, Any]:
3275
3264
  "score": rec.get("sc"),
3276
3265
  "has_phone": bool(rec.get("tel")),
3277
3266
  "process": rec.get("proc", []),
3278
- # 工艺来源(2026-11-06):None = 能力卡/企业自述/按国标码推导的类默认;
3279
- # "inferred:A1" = 零采集推断,字母是分档(A1 三路一致 99.3% / A2 97.4% /
3280
- # A3 94.3% / B0 单路 96.0%,见 docs/PROCESS_INFERENCE.md)。
3281
- # 为什么要暴露:推断不是核实,"这家能做"和"这家这一类大概都做"必须可区分 ——
3282
- # 客户在严苛场景(超公差、医疗件、指定牌号)里要能据此追加追问,而不是直接下单。
3283
- "process_evidence": ("inferred:" + rec["pinf"]) if rec.get("pinf") else None,
3284
- # 业务能力码(2026-11-06):非制造业门类的「能干什么」,如 fmt_bar 酒吧 /
3267
+ # 工艺来源:
3268
+ # None = 能力卡 / 企业自述 / 类默认但工艺码少于 4 个
3269
+ # "inferred:A1" = 零采集推断,字母按证据强度分档(A1 最强,B0 单路)
3270
+ # "class_default" = 该记录挂了 ≥4 个「国标小类默认工艺码」,不是这家企业
3271
+ # 的事实工艺,只是同类众数。必须让 Agent 看得见:否则它会把
3272
+ # 「这一类大概都做」当成「这家确实做」,严苛场景直接下单。
3273
+ # 工艺码保持原样(召回不变),只加证据标签 —— 不编造也不减召回。
3274
+ "process_evidence": (
3275
+ "inferred:" + rec["pinf"] if rec.get("pinf")
3276
+ else ("class_default" if (rec.get("pv") == "derived"
3277
+ and len(rec.get("proc") or []) >= 4) else None)),
3278
+ # 业务能力码:非制造业门类的「能干什么」,如 fmt_bar 酒吧 /
3285
3279
  # edu_vocational 职业技能培训 / con_steel 钢结构。此前它只参与匹配不返回,
3286
3280
  # 于是「搜酒吧」的结果里看不出哪家真是酒吧。现在一并返回,agent 可据此
3287
- # 复核与二次筛选(码的中文名见 skills/schema/cap-codes/*.json)。
3281
+ # 复核与二次筛选。
3288
3282
  "capability": rec.get("cap", []),
3289
3283
  "material": rec.get("mat", []),
3290
3284
  "cert": rec.get("cert", []),
@@ -3307,7 +3301,7 @@ _RE_WORD = re.compile(r"[a-z0-9]+")
3307
3301
 
3308
3302
 
3309
3303
  def _tokenize(text: str) -> List[str]:
3310
- """切成可索引的词。必须与 scripts/gen_search_index.py 的 tokenize 保持一致。
3304
+ """切成可索引的词。必须与发布侧索引的 tokenize 保持一致。
3311
3305
 
3312
3306
  CJK 取 1-gram + 2-gram;拉丁/数字取整词 + 长度 >= 2 的前缀。
3313
3307
  """
@@ -3482,13 +3476,12 @@ def _read_many_text(paths: List[str]) -> Dict[str, str]:
3482
3476
  def _one(p: str) -> Optional[str]:
3483
3477
  return _ctx.copy().run(_shard_text, p) # 每个任务一份副本,可并发进入
3484
3478
 
3485
- # 分批提交 + **快速放弃**(2026-10-24)。
3486
- # 为什么必须分批:全量回退最多要拉 467 个分片。若失败是**系统性**的
3479
+ # 分批提交 + **快速放弃**。
3480
+ # 为什么必须分批:全量回退要拉的分片很多。若失败是**系统性**的
3487
3481
  # (断网、代理死、身份 401、429 限流),挨个试完只是把「一次失败」放大成
3488
- # 几百次重试 —— 用户侧看到的就是「工具卡了 100~300 秒然后返回 0 条」,
3489
- # 而且完全不知道是真没有这家企业还是取数挂了。实测过一次 479 次请求
3490
- # 累计 2258s 才返回 0 条。
3491
- # 判据:跑满 32 个后统计**失败占比**,超过一半就停 —— 此时哪怕剩下的全成功,
3482
+ # 几百次重试,用户侧只看到「工具卡很久然后返回 0 条」,
3483
+ # 而且完全不知道是真没有这家企业还是取数挂了。
3484
+ # 判据:跑满一批后统计**失败占比**,超过一半就停 —— 此时哪怕剩下的全成功,
3492
3485
  # 结果也已经残缺不全,继续拉没有意义,不如把故障如实报给调用方。
3493
3486
  _BATCH = 16
3494
3487
  _tried = 0
@@ -3548,9 +3541,8 @@ def _index_fresh() -> bool:
3548
3541
  def _index_reason() -> str:
3549
3542
  """索引为什么没被用上 —— 回给调用方,别再让人靠猜。
3550
3543
 
3551
- 2026-10-23 实测踩过:线上 MCP 一次「面馆」查询 `shards_scanned=320`(全量扫),
3552
- 但返回体只有 `via_index:false` 一个布尔,看不出是「索引没部署」「版本不符」
3553
- 还是「落后于数据」。排查要靠翻代码 —— 这个字段就是为这件事加的。
3544
+ 只回 `via_index:false` 一个布尔,看不出是「索引没部署」「版本不符」
3545
+ 还是「落后于数据」,排查要靠翻代码 —— 这个字段就是为这件事加的。
3554
3546
  """
3555
3547
  meta = _index_meta()
3556
3548
  if not meta:
@@ -3581,18 +3573,15 @@ _RECALL_DF_MIN = 2
3581
3573
 
3582
3574
  # --- 候选分片硬上限(保险丝,不是性能方案)--------------------------------- #
3583
3575
  # 老实现是「并集 >= 60% 分片就 return None → 调用方全量扫」。那个阈值是**相对量**,
3584
- # 分片数一涨就必然触发(282 片时不会,8,448 片时任何中频词都会)—— 而触发代价是
3585
- # 把整库拉下来。实测(scripts/audit_recall_cost.py,24 条真实查询,467 片):
3586
- # 最大 128 片(「加工」27.4%),60% 阈值一次都没撞上;但**索引路径本身**就能拉到
3587
- # 13~16 MB,因为倒排粒度是分片级,不是行级。这件事只能靠行级倒排解决(见
3588
- # docs/两步走落地方案.md 的 P2),本上限只负责让「最坏情况」有界:
3576
+ # 分片数一涨就必然触发,而触发代价是把整库拉下来。
3577
+ # 倒排粒度是分片级、不是行级,索引路径本身也可能拉回很大的体积;
3578
+ # 这件事只能靠行级倒排解决,本上限只负责让「最坏情况」有界:
3589
3579
  # 宁可少拉几片并如实上报,也绝不静默全量。
3590
- # 400 的取值:当前 467 片的 85%,给「数据翻倍到 ~900 片」留出不误伤的余量。
3580
+ # 上限取分片总量的大部分,给数据增长留出不误伤的余量。
3591
3581
  _RECALL_SHARD_CAP = 400
3592
3582
 
3593
3583
  # 本进程最近一次召回是否被截断。用 threading.local:工具调用是并发处理的,
3594
- # 用模块级字典会让 A 线程的截断标记串到 B 线程的回执里(这个坑 `_HTTP_LAST_ERROR`
3595
- # 已经踩过一次)。
3584
+ # 用模块级字典会让 A 线程的截断标记串到 B 线程的回执里。
3596
3585
  _recall_tls = threading.local()
3597
3586
 
3598
3587
 
@@ -3605,7 +3594,7 @@ def _cap_shards(picked, weights: Dict[str, float], where: str) -> List[str]:
3605
3594
  """候选分片超过 `_RECALL_SHARD_CAP` 时按命中密度截断,并记下截断事实。
3606
3595
 
3607
3596
  按权重(`idf × 该片命中数`)降序,而不是按路径字典序:密度高的片更可能给出
3608
- 相关记录,按路径截断等于随机丢弃 —— 撞「真实企业必须被看见」的红线。
3597
+ 相关记录,按路径截断等于随机丢弃真实企业。
3609
3598
 
3610
3599
  返回排序后的路径列表;截断与否都返回,调用方**不需要**分支。
3611
3600
  """
@@ -4205,16 +4194,14 @@ def search_vendors(query: str = "", city: str = "", gb: str = "",
4205
4194
  lat_v, lng_v, rad, geo_err = _geo_params(lat, lng, radius_km)
4206
4195
  if geo_err:
4207
4196
  return geo_err
4208
- # ── 分页深度上限(2026-10-22)────────────────────────────────────────
4197
+ # ── 分页深度上限 ─────────────────────────────────────────────────────
4209
4198
  # 这不是性能优化,是**反截取**。翻页遍历是从公开接口搬走整个库的唯一
4210
- # 可行路径:`total_matched=4684` 时 offset += 200 循环 24 次就拉干了。
4211
- # 而真实用户翻不到第 11 页 —— 他要的是「找几家能做的」,不是「第 4000 条是谁」。
4199
+ # 可行路径,而真实用户要的是「找几家能做的」,不会翻到很深的结果页。
4212
4200
  # ⚠ 这条只是体验层的一道闸:search 本身在客户端本地过滤,改客户端即可绕过。
4213
- # 真正的防线是服务端的数据面鉴权(pages/_worker.js 的 quotaGuard)。
4201
+ # 真正的防线在服务端的额度与鉴权层。
4214
4202
  raw_offset = max(0, int(offset))
4215
4203
  offset = min(raw_offset, OFFSET_MAX)
4216
- # 固定索引并发预热(2026-10-30):放在任何取数之前,把 9 个串行 RTT
4217
- # 压成一批。见 `_WARM_INDEX_PATHS` 上方的时间线实测。
4204
+ # 固定索引并发预热:放在任何取数之前,把串行 RTT 压成一批。
4218
4205
  _warm_indexes()
4219
4206
  q = (query or "").strip().lower()
4220
4207
  # 原话归一化:用户输入的是**一句人话**,不是空格分隔的关键词表。
@@ -4486,11 +4473,27 @@ def search_vendors(query: str = "", city: str = "", gb: str = "",
4486
4473
  # `alias_scanned` 仍按**记录数**计,与改前口径一致。
4487
4474
  if _via_server:
4488
4475
  def _alias_recs():
4489
- for _code in alias_by_code:
4490
- _r = _server_search(q="", city=city, gb=_code, et=et_want,
4491
- limit=SERVER_SEARCH_WINDOW)
4492
- if _r:
4493
- yield from _r["records"]
4476
+ codes = list(alias_by_code)[:6]
4477
+ if not codes:
4478
+ return
4479
+ # 2026-11-13 多码列举:一次请求把全部国标码交给 worker。
4480
+ # 旧 worker 不认 gbs:它只按首码 gb 应答、且不回 gbs_supported,
4481
+ # 于是这里退回逐码补发其余码 —— 绝不出现「取首个码漏掉另外两类」。
4482
+ _r = _server_search(q="", city=city, gb=codes, et=et_want,
4483
+ limit=SERVER_SEARCH_WINDOW)
4484
+ done = set()
4485
+ if _r:
4486
+ yield from _r["records"]
4487
+ if _r.get("gbs_supported") and len(codes) > 1:
4488
+ return
4489
+ done.add(codes[0])
4490
+ for _code in codes:
4491
+ if _code in done:
4492
+ continue
4493
+ _r2 = _server_search(q="", city=city, gb=_code, et=et_want,
4494
+ limit=SERVER_SEARCH_WINDOW)
4495
+ if _r2:
4496
+ yield from _r2["records"]
4494
4497
  _alias_iter = _alias_recs()
4495
4498
  else:
4496
4499
  _alias_iter = _records_from_paths(alias_paths or [])
@@ -4831,10 +4834,16 @@ _SEARCH_PROBE_UNTIL = [0.0]
4831
4834
  # ≤ ~80KB,差两个数量级。
4832
4835
  SERVER_SEARCH_WINDOW = 200
4833
4836
 
4837
+ # 服务端 AND 下沉的灰度开关(2026-11-13)。缺省关 = 线上行为与改动前逐字节一致。
4838
+ # 打开后 worker 只回「全部多字 term 都命中」的候选,窗口可从 200 收到 20 而不少召回
4839
+ # —— 这正是「降负载」的终局;但它是收紧召回,必须先过影子比对(scripts/shadow_and_compare.py)。
4840
+ _SERVER_AND = str(os.environ.get("BEACON_SERVER_AND", "0")).lower() in ("1", "true", "yes")
4841
+
4834
4842
 
4835
- def _server_search(q: str = "", city: str = "", gb: str = "", et: int = 0,
4843
+ def _server_search(q: str = "", city: str = "", gb: Any = "", et: int = 0,
4836
4844
  limit: int = SERVER_SEARCH_WINDOW,
4837
- cap: Optional[List[str]] = None) -> Optional[Dict[str, Any]]:
4845
+ cap: Optional[List[str]] = None,
4846
+ and_: Optional[bool] = None) -> Optional[Dict[str, Any]]:
4838
4847
  """POST /v1/search —— 服务端检索(「按条来拉」的检索面,2026-11-03)。
4839
4848
 
4840
4849
  返回 {"total": int, "records": [原始 fp 记录]};任何失败返回 None,
@@ -4849,8 +4858,13 @@ def _server_search(q: str = "", city: str = "", gb: str = "", et: int = 0,
4849
4858
  return None
4850
4859
  if time.time() < _SEARCH_PROBE_UNTIL[0]:
4851
4860
  return None
4852
- if not (str(q or "").strip() or str(city or "").strip()
4853
- or str(gb or "").strip() or cap):
4861
+ # 2026-11-13:gb 可为多个国标码(别名「注塑」→2929/3525/3399)。
4862
+ # 一次请求全交给 worker,别再逐码各发一次 —— 额度与 RTT 都按请求数翻。
4863
+ if isinstance(gb, (list, tuple, set)):
4864
+ gb_list = [str(x).strip() for x in gb if str(x).strip()][:6]
4865
+ else:
4866
+ gb_list = [str(gb).strip()] if str(gb or "").strip() else []
4867
+ if not (str(q or "").strip() or str(city or "").strip() or gb_list or cap):
4854
4868
  return None
4855
4869
  try:
4856
4870
  import identity as _idn
@@ -4858,10 +4872,22 @@ def _server_search(q: str = "", city: str = "", gb: str = "", et: int = 0,
4858
4872
  except Exception:
4859
4873
  pass
4860
4874
  payload: Dict[str, Any] = {"q": str(q or ""), "limit": int(limit), "offset": 0}
4875
+ # 服务端 AND 下沉(2026-11-13):`and=1` 让 worker 只回「全部多字 term 都命中」
4876
+ # 的候选,窗口里剩下的就是客户端 AND 之后大概率仍活着的那批。
4877
+ #
4878
+ # 默认跟着 `BEACON_SERVER_AND` 走(缺省关):这是一条**收紧召回**的改动,
4879
+ # 而老客户端把 /v1/search 当 OR 窗口用。必须先影子比对确认「只少不多、
4880
+ # 且少的都能被客户端的 AND 规则解释」再打开 —— 见 scripts/shadow_and_compare.py。
4881
+ _and = _SERVER_AND if and_ is None else bool(and_)
4882
+ if _and:
4883
+ payload["and"] = 1
4861
4884
  if str(city or "").strip():
4862
4885
  payload["city"] = str(city).strip()
4863
- if str(gb or "").strip():
4864
- payload["gb"] = str(gb).strip()
4886
+ if gb_list:
4887
+ # 旧 worker 只认单码 gb:首码照传,保证线上还没部署新 worker 时
4888
+ # 至少这一个码仍生效;新 worker 见到 gbs 数组会改用多码列举。
4889
+ payload["gb"] = gb_list[0]
4890
+ payload["gbs"] = gb_list
4865
4891
  if et:
4866
4892
  payload["et"] = int(et)
4867
4893
  if cap:
@@ -4893,7 +4919,9 @@ def _server_search(q: str = "", city: str = "", gb: str = "", et: int = 0,
4893
4919
  if isinstance(hits, list):
4894
4920
  return {"total": int(o.get("total_matched") or 0),
4895
4921
  "cap_matched": int(o.get("cap_matched") or 0),
4896
- "records": hits}
4922
+ "records": hits,
4923
+ "gbs_supported": bool(o.get("gbs_supported")),
4924
+ "and_supported": bool(o.get("and_supported"))}
4897
4925
  continue
4898
4926
  except _ue.HTTPError as e:
4899
4927
  st[0] = e.code
@@ -4904,7 +4932,8 @@ def _server_search(q: str = "", city: str = "", gb: str = "", et: int = 0,
4904
4932
  try:
4905
4933
  _o = json.loads(e.read().decode("utf-8"))
4906
4934
  if _o.get("error") == "bad_gb":
4907
- return {"total": 0, "records": []}
4935
+ return {"total": 0, "records": [],
4936
+ "gbs_supported": False}
4908
4937
  except Exception:
4909
4938
  pass
4910
4939
  continue
@@ -4950,10 +4979,9 @@ def _v1_rec(vid: str) -> Optional[Dict[str, Any]]:
4950
4979
  return None
4951
4980
  # 端点还不存在时的静默期。
4952
4981
  #
4953
- # 为什么必须有:线上 worker 没部署 /v1/rec 之前,每次 get_vendor 都会先付
4954
- # 一个 404 往返。本机实测单次 RTT 0.6~2.9 秒,这等于**给每条详情加一秒**,
4955
- # 而这一秒换不到任何东西 —— 是纯粹的回归。
4956
- # 探测到端点不存在后安静 5 分钟:既不让每条详情付钱,又能在部署后
4982
+ # 为什么必须有:目标端点不存在时,每次 get_vendor 都会先付一次 404 往返,
4983
+ # 而这一跳换不到任何东西 —— 是纯粹的回归。
4984
+ # 探测到端点不存在后安静一段时间:既不让每条详情付钱,又能在部署后
4957
4985
  # 自己恢复(不需要重启 MCP 子进程)。
4958
4986
  if time.time() < _REC_PROBE_UNTIL[0]:
4959
4987
  return None
@@ -5263,6 +5291,38 @@ def _cap_card_ids() -> Optional[set]:
5263
5291
  return _CAP_CARD_IDS
5264
5292
 
5265
5293
 
5294
+ def _card_id_keys(vid: str) -> set:
5295
+ """入参 id 在清单白名单里可能出现的**全部形态**。
5296
+
5297
+ 白名单的元素形态取决于清单是哪一代产物:token 化之后是 8 字符 token,
5298
+ 之前是内部编号的数字后缀。而入参同样有两种来源:App/搜索结果给 token,
5299
+ 控制台与本地调试给 `CN-MFG-…` 内部 id。
5300
+
5301
+ 两边形态一旦对不上,结果不是报错而是**静默答"没卡"**:一次网络都不发,
5302
+ 客户端 Agent 直接失去 L1 能力卡。所以这里不做"猜一种形态",而是把三种
5303
+ 可能都摆出来取交集 —— 多算两个字符串远比漏判安全。
5304
+ """
5305
+ keys = {vid}
5306
+ tail = vid.rsplit("-", 1)[-1]
5307
+ if tail:
5308
+ keys.add(tail) # 内部 id 的数字后缀(老快照)
5309
+ try:
5310
+ keys.add(_vid_url(vid)) # 内部 id → 对外 token(现行快照)
5311
+ except Exception:
5312
+ pass
5313
+ if _TOKEN_ID_RE.match(vid) and _vid_ready():
5314
+ # token 入参 × 老快照(白名单还是数字后缀)这一格:只能解回去。
5315
+ # 反解失败(无密钥 / 伪 token)就静默跳过,交给下面真正的取卡去判。
5316
+ try:
5317
+ from vid_token import decode as _dec
5318
+ inner = _dec(vid, _vid_secret())
5319
+ if inner:
5320
+ keys.add(inner.rsplit("-", 1)[-1])
5321
+ except Exception:
5322
+ pass
5323
+ return {k for k in keys if k}
5324
+
5325
+
5266
5326
  def get_capability_card(vid: str) -> Dict[str, Any]:
5267
5327
  vid = (vid or "").strip()
5268
5328
  if not vid:
@@ -5270,18 +5330,17 @@ def get_capability_card(vid: str) -> Dict[str, Any]:
5270
5330
  # 短路:清单里查得到这张表时,没卡的 id 立刻回,别去撞三基址 404。
5271
5331
  want = _cap_card_ids()
5272
5332
  if want is not None:
5273
- # 集合里的元素**就是发布面上的对外 id**:token 化发布之后是 8 字符
5274
- # token,之前是内部编号的数字后缀。
5333
+ # 集合里的元素是**发布面上的对外 id**:token 化发布之后是 8 字符 token,
5334
+ # 之前是内部编号的数字后缀;入参也可能是这两种之一。
5275
5335
  #
5276
- # ⚠ 旧实现一律取 `vid.split("-")[-1]` 当"数字后缀":token 里没有连字符,
5277
- # 取出来还是它自己,`isdigit()` 直接为假 → **任何 token 都被判成"没卡"**,
5278
- # 连一次请求都不发。2026-10-29 实测:token WE694FVV 明明
5279
- # `/skills/registry/capability/WE694FVV.json` 返回 200,MCP 却回
5280
- # has_card=false —— 客户端 Agent 由此彻底拿不到 L1。
5281
- # 判据改为"形态即键":token 形态按原值比,其余才退回数字后缀(兼容本地
5282
- # git 模式与未 token 化的老快照)。
5283
- key = vid if _TOKEN_ID_RE.match(vid) else vid.split("-")[-1]
5284
- if key not in want:
5336
+ # ⚠ 这里出过两次事故,都是"只猜一种形态"造成的:
5337
+ # · 一律取数字后缀 → token 入参(没有连字符)被判成"没卡",一次请求都不发;
5338
+ # · 只认 token 形态 → 内部 id 入参(`CN-MFG-…` 取后缀得 `0020317`)与
5339
+ # token 化后的白名单永远不相交,**任何有卡企业都回 has_card=false**。
5340
+ # 2026-11-13 实测:耐特斯 CN-MFG-0020317 的
5341
+ # `/skills/registry/capability/<token>.json` 明明 200,MCP 却答没卡。
5342
+ # 判据改为"候选集取交集",两种入参 × 两代清单都能命中。
5343
+ if not (_card_id_keys(vid) & want):
5285
5344
  return {
5286
5345
  "id": vid, "has_card": False,
5287
5346
  "note": "该供应商暂无已发布能力卡(全库仅 %d 家有卡)。"
@@ -5478,6 +5537,9 @@ TOOLS = [
5478
5537
  "加 radius_km 只留圈内(地标名需调用方先解析成坐标)。"
5479
5538
  "返回精简档案(id/名称/城市/区县/国标码/认证等级/"
5480
5539
  "工艺/材料/认证/是否含电话/连锁门店数/距离)。"
5540
+ "每条带 process_evidence:null=有事实工艺;inferred:A1~B0=零采集"
5541
+ "推断(分档非核实);class_default=挂的是国标小类默认工艺码(≥4个),"
5542
+ "不是这家企业的事实工艺,严苛场景请降权或追问核实。"
5481
5543
  "⚠ 关键词是**字面子串**匹配:用户的口语词若与名录写法不同(如「机加工」"
5482
5544
  "vs 归档用的「机械零部件加工」、「面馆」vs「苏式面馆」),命中会偏少。"
5483
5545
  "此时不要直接报「没有」——看返回体里的 hint / suggested_gb,"
@@ -5537,7 +5599,7 @@ TOOLS = [
5537
5599
  "type": "object",
5538
5600
  "properties": {
5539
5601
  "id": {"type": "string",
5540
- "description": "单个主体 id,如 CN-MFG-0000163(与 ids 二选一,只传它时回执是 {\"vendor\": …})"},
5602
+ "description": "单个主体 id(与 ids 二选一,只传它时回执是 {\"vendor\": …})"},
5541
5603
  "ids": {"type": "array", "items": {"type": "string"}, "maxItems": 20,
5542
5604
  "description": "批量主体 id,最多 20 个;自动去重、按传入顺序返回。"
5543
5605
  "回执是 {\"vendors\": [...], \"count\": n, \"failed\": [...]},"
@@ -5776,10 +5838,8 @@ def vendor_onboarding(supplier_id: str = "", company: str = "",
5776
5838
  "auth_required": True,
5777
5839
  "contract_url": AGENT_CONTRACT_URL,
5778
5840
  "api_base": AGENT_API_BASE,
5779
- "docs": "契约 JSON 本身即完整文档;平台本地工作副本另有 docs/AGENT_ONBOARD.md"
5780
- "(非分发面,不在公开 GitHub 仓库)",
5781
- "client": "按契约用任意 HTTP 客户端 / curl 实现即可;平台工作副本另有 scripts/agent_onboard.py "
5782
- "CLI(同样不在公开仓库)",
5841
+ "docs": "契约 JSON 本身即完整文档,按其中字段即可完成接入",
5842
+ "client": "按契约用任意 HTTP 客户端 / curl 实现即可",
5783
5843
  "contract_version": "3.1",
5784
5844
  "auth": {
5785
5845
  "login": "POST /auth/sms/send → /auth/sms/verify(腾讯云短信真实下发,"
package/vid_token.py CHANGED
@@ -1,15 +1,12 @@
1
1
  #!/usr/bin/env python3
2
2
  # -*- coding: utf-8 -*-
3
- """供应商 id 的对外 token 化(D,2026-10-28)。
3
+ """供应商 id 的对外 token 化。
4
4
 
5
5
  问题
6
6
  ====
7
- 内部 id 是 `CN-MFG-0000163` —— **顺序编号**。这意味着「存在性可枚举」:
8
- 实测 `/skills/registry/capability/CN-MFG-0000009.json` → 200、
9
- `.../0000010.json` → 404,于是从 1 遍历到 222875 就能把全库重建一遍。
10
- A(manifest 需要身份)+ B(R2 私有)+ C(服务端签发令牌)把「拖库」从 5 分钟
11
- 抬到几十小时,但**枚举本身仍然成立**:只要 id 可预测,攻击者不需要任何
12
- 目录信息,盲扫 URL 就够了。D 要终结的正是这一点。
7
+ 内部 id 是顺序编号,存在性可枚举:只要 id 可预测,不需要任何目录信息,
8
+ 盲扫 URL 就能逐条试探。服务端门禁只能抬高成本,不能消除枚举本身。
9
+ 对外标识必须是不可预测的置换结果,而不是可推导的编号。
13
10
 
14
11
  做法
15
12
  ====
@@ -19,10 +16,10 @@ A(manifest 需要身份)+ B(R2 私有)+ C(服务端签发令牌)把
19
16
  为什么是「置换」而不是「哈希」
20
17
  ------------------------------
21
18
  哈希(SHA/HMAC)不可逆 → 服务端收到 token 后无法还原内部 id,只能建一张
22
- 21 万行的映射表并每次查库 —— 那是给每个请求加一次 D1 往返。
19
+ 全量映射表并每次查库 —— 那是给每个请求加一次数据库往返。
23
20
  置换(双射)可逆,还原是纯计算、零 IO。这在这里是决定性的:
24
- · capability / vendors 路径要还原后才知道去 R2 取哪个对象
25
- · 记账(beacon_card_view.vid)要还原成内部 id 才能和存量数据对齐
21
+ · 对象路径要还原后才知道去存储取哪个文件
22
+ · 记账要还原成内部 id 才能和存量数据对齐
26
23
 
27
24
  为什么是 Feistel 而不是「XOR 一个常数」
28
25
  --------------------------------------
@@ -42,10 +39,10 @@ Crockford base32(去掉 I/L/O/U,避免和 1/0 混淆),8 字符 = 40 bit
42
39
 
43
40
  用法
44
41
  ====
45
- python scripts/vid_token.py selftest
46
- python scripts/vid_token.py enc CN-MFG-0000163
47
- python scripts/vid_token.py dec 7K3M9QXA
48
- python scripts/vid_token.py bench # 21 万条全量往返校验
42
+ python vid_token.py selftest
43
+ python vid_token.py enc <内部 id>
44
+ python vid_token.py dec <token>
45
+ python vid_token.py bench # 全量往返校验
49
46
  """
50
47
  from __future__ import annotations
51
48