# 品源协议 · 参考实现与一致性套件

> 规范正文：[`../docs/PROTOCOL.md`](../docs/PROTOCOL.md) · 兼容性承诺：[`../docs/COMPATIBILITY.md`](../docs/COMPATIBILITY.md)
> 许可：[`LICENSE`](./LICENSE) —— **允许任何人免费实现兼容客户端/服务端（含商业用途）**

这个目录是「协议」这一层的实现参考。**产品服务端在 `../server/`，不在这里，也不在本目录的许可范围内。**

| 文件 | 作用 |
| --- | --- |
| `pyclient.py` | **消费侧参考实现**（纯标准库、Python 3.6+、零依赖） |
| `jsclient.mjs` | 第二个实现（Node，只读规范写成）—— 用来发现"规范自己没写清"的地方 |
| `pinyuan_mcp.py` | **MCP stdio 桥**（手写、零依赖）：把渠道面暴露成 MCP 工具，接进任何 MCP 客户端 |
| `conformance.py` | **一致性套件** —— 任何声称兼容的实现都拿它自证 |
| `test-pyclient.py` | 参考实现自己的测试 |
| `test-conformance.py` | 一致性套件自己的测试（判据的**归因**：429 不许判成不合规等） |
| `test-pinyuan-mcp.py` | MCP 桥自己的测试（**stdout 只许是协议**、异常不许打断循环） |
| `check-mcp-sdk.py` | **外部证据**：用真 `mcp` SDK 当客户端驱动那座手写桥（需要装了 `mcp` 的解释器） |

> 三个 `test-*.py` 都是**纯 unittest、零网络、零依赖**，注入假传输层，不产生任何真实请求。
> 跑法：`python3 tools/test-pyclient.py` / `python3 tools/test-conformance.py` /
> `python3 tools/test-pinyuan-mcp.py`
> （文件名带连字符，`unittest` 的目录发现会跳过它们，要显式给路径）。
> 三者都接在 `tools/preflight.sh` 的第 ⑥ 步里 —— **门写了不跑 = 没有门**。
>
> `check-mcp-sdk.py` **不在** preflight 里，因为它需要 `mcp` SDK，而品源的 venv 刻意没装
> （见 `pinyuan_mcp.py` 模块头）。拿装了 `mcp` 的解释器手动跑，
> 或者连真实例跑完整端到端：
> `PY_BASE=... PY_CHANNEL_KEY=... <装了mcp的python> tools/check-mcp-sdk.py`

---

## 1. 用参考实现（推荐做法）

```python
import pyclient

c = pyclient.Client.from_env()                    # 读 PY_BASE / PY_CHANNEL_KEY
stores, pages = c.walk_stores()                   # 走完游标，不会静默少店
items, pages = c.walk_products(store="st0001", min_level="recommended")
for it in items:
    f = pyclient.item_fields(it)                  # 属性字段收口在这里
    print(pyclient.item_store_code(it), it["code"], f.get("price"))

c.stock_report(uid="<商品 uid>", sold=1, request_id="订单号-唯一串")   # 幂等口
```

环境变量：`PY_BASE`（如 `https://pinyuan.sxai77.cn`）、`PY_CHANNEL_KEY`；
可选 `PY_TIMEOUT`、`PY_PAGE_SIZE`。

### 为什么要有这份 client（不是"避免重复"这么轻）

在此之前，每个消费方各自手抄了一遍「拼 URL + 解析信封」，**实测踩过三类事故**：

1. **字段名读错**：组织键是 `org_uid`，有消费方写成 `org_id` → 取出来永远是 `None`，
   整条连锁对齐**静默失效**却没人报错。
2. **认款键用错**：跨店认同款要用 `barcode_norm`（规范化条码）或 `qualified_code`；
   有消费方拿了 `fields.barcode` 这份**展示文本**（带「（演示）」后缀）→ 最后爆在财务对账上。
3. **加分页后静默少数据**：店目录默认 200 条/页，三个消费方都只读首页 → 跨店推荐少掉一大半店铺。
   **不报错、只是少** —— 线上最难查的一类。

三件事同一个根因：**"字段在哪、游标怎么走"这份知识被复制到 N 个地方，每一份都会独立漂移。**
所以它收口成一个模块：**import，不要手抄。**

### 纪律（不是建议）

* **禁止**自己拼 `/api/v1/channel/...` 的 URL —— 走本模块的方法。
* **禁止**自己从信封里摸字段位置 —— 走 `item_fields` / `item_store_code` / `item_uid` /
  `item_qualified_code`，以及 `walk_products` / `walk_stores`。
* 分页**必须走完游标**：单页口（`products` / `stores`）只回一页；除非你明确只要第一页。

### 信封键面的坑（本模块已吸收，你实现自己的客户端时也要处理）

* 商品信封列表键是 `items`；**店目录是 `stores`**（不是 `items`）。
* 商品/店目录游标键是 `next_cursor`；**`/channel/moves` 是 `next_since`**。
* 商品条目属性在 `item["fields"]`；类型化副本 `fields_structured`；来源核验 `field_meta`。
  **三者都在条目内部**，不在信封顶层。

### Python 3.6 兼容（改动时保持）

生产服务器是 python3.6.8。本目录**刻意**不用 f-string、dataclass、`:=`、
`subprocess.run(capture_output=/text=)`。自查：

```bash
python3 -c "import ast;ast.parse(open('tools/pyclient.py').read(),feature_version=(3,6))"
grep -n "f\"" tools/pyclient.py        # 应为空
```

---

## 2. 跑一致性套件

```bash
python3 tools/conformance.py --base http://127.0.0.1:8972 --key <渠道密钥>
```

- 默认 **只读**；写口检查（`stock_report` 幂等）需显式加 `--write`。
  ⚠️ **`--write` 会留痕**（扣库存 + 落 `StockMove`/`StockReportReceipt`），所以默认关掉；
  只在可丢弃的实例上开。
- 默认 base 是本地；传远端地址会有额外确认要求（防止误打生产）。
- 有 `FAIL` 则退出码非 0，可直接接进 CI。
- `SKIP` 不是失败。两种常见 SKIP：①某能力在本实现里还没实现（例如某第三方实现
  对 `/channel/aggregate` 回 `501`）—— 不该红；②**没带 `--key`**，而该口要求渠道鉴权 ——
  "没带钥就判不了"，也不是不合规。

**你要宣称"兼容品源协议"，就跑它，全绿。**

### 2.1 认证跑法：必须在 `DEBUG=False` 的实例上

`D1`（错误体必须是 JSON）在 **`DEBUG=True` 的实例上一定是红的**，而且这不是被测方的问题：

> Django 的基类处理器在 `DEBUG` 模式下**直接走** `technical_404_response`，
> 压根不会调到自定义 `handler404`。所以本地 dev 实例（`DEBUG=True`）吐 HTML 是正常现象。

品源侧的修法在 `server/pinyuan/urls.py`：`/api/` 前缀回 JSON，**其余路径原样退回 Django 默认**
（商家端/管理端是 HTML 页面，不能被 JSON 化）。这条由 `server/core/test_api_errors.py` 在
`DEBUG=False` 下守着。

因此认证口径是：**用 `DEBUG=False` 的实例跑全绿**。套件在 `D1` 红时会自己检出并提示这一点。

### 2.2 本地怎么拿一把钥（明文只在签发时出现一次）

`ChannelKey` 库内**只存 `sha256hex`**，所以已有钥的明文**不可恢复** —— 本地要跑套件，
就新签一把（在**本地 dev 库**上）：

```bash
cd server && ../.venv/bin/python manage.py shell -c "
from core.models import ChannelKey
ck, plain = ChannelKey.gen(name='conformance-suite', scope='all', can_write=True, rate_per_min=6000)
print(plain)
"
```

⚠️ **明文是凭据，不要写进任何仓库文件**（本 README 故意不给）。跑完 `--write` 会在本地库留下
扣减与流水（**那正是它默认关闭的原因**）。

### 2.3 已知的弱断言（诚实地标出来）

- **`C4`（`page_size ≤ 500`）在商品数少于 500 的实例上是空断言** —— 改坏上限也不会红。
  套件会在输出里自曝"弱断言、未真正触顶"。要有牙齿，得在 **>500 件可见商品**的实例上跑。
- 写口只覆盖最简路径（`sold=1`、无 SKU、无批次）；多规格 `sku_code`、批次 FIFO、
  并发同 `request_id` 撞键**尚未覆盖**。
- 限流（`429`）不测：套件用的密钥配额高，低配额下套件自身会撞 `429` 而报噪声失败。

---

## 3. 实现你自己的客户端

1. 读 [`../docs/PROTOCOL.md`](../docs/PROTOCOL.md)（规范性）与
   [`../docs/COMPATIBILITY.md`](../docs/COMPATIBILITY.md)（你会不会被搞坏）。
2. **运行时读元数据**：词汇表读 `/api/v1/schema`，门槛读 `/channel/field-rules`，
   店读 `/channel/stores`。**不要**硬编码这些清单。
3. 遵守兼容性四要件：容忍未知键、运行时读元数据、声明最低 `schema_version`、
   区分 `4xx`/`5xx`。
4. 用 `conformance.py` 自证。

---

## 4. 协议一致性守卫（防"文档变谎话"）

`docs/PROTOCOL.md` 附录 B 里有一组机器可核验的断言（HTML 注释形式）。
测试守卫在 `../server/core/test_spec_drift.py`，**跑服务端测试时会一并核验**：
文档声明的信封键、端点、分页上限、列表键/游标键，必须与实际实现一致。

改协议文档或改实现，**两者必须同时改** —— 否则守卫会红。
这就是本项目的纪律：**同一件事只有一份算法，文档也不例外。**

## 一致性套件怎么跑（第三方视角）

```bash
# 只读（默认）：不发任何写请求，不改任何数据
python3 tools/conformance.py --base https://pinyuan.sxai77.cn --i-know-its-remote

# 带渠道钥跑全量（读口 15 条需要钥；写口要 --write，会真扣库存，别对生产跑）
PY_CHANNEL_KEY=<你的钥> python3 tools/conformance.py --base https://pinyuan.sxai77.cn \
    --i-know-its-remote
```

**HTTPS 证书问题**：macOS 的 framework python 常没有系统证书链，直连 https 会
`CERTIFICATE_VERIFY_FAILED`。套件**有 certifi 就自动用它**；没装就 `pip install certifi`，
或 `export SSL_CERT_FILE=$(python3 -c "import certifi;print(certifi.where())")`。
连不上时套件会**直接告诉你这一步**（不再打印一屏 FAIL 让人误判成协议不合规）。

**对参考实现的实测结果**（2026-09-23，只读不带钥）：22 条 → **通过 7 / 失败 0 / 跳过 15**
（跳过的都是"没有钥判不了"的读口与写口）→ 结论：**全部通过（无 FAIL）**。

**对参考实现的实测结果 · 当前**（2026-10-15，本机 dev `DEBUG=True`）：

| 跑法 | 总条数 | 通过 | 失败 | 跳过 | 备注 |
|---|---|---|---|---|---|
| **带钥**（只读） | **32** | 28 | **1** | 3 | 失败那 1 条是 **D1** —— 本机 `DEBUG=True` 下 Django 绕过自定义 `handler404` 的**已知假红**，判据自己会打印病因并让你用 `DEBUG=False` 复测。跳过 3 条是 E 组（没加 `--write`） |
| **不带钥**（只读） | **25** | 8 | **1** | 16 | 同上；不带钥时 G 组只出 1 条（`G1`）—— 后面七条"没有钥判不了" |

> ★ **G 组是 2026-10-15 加的**（ACT `CID-MER-CAT` 目录口，8 条）。所以：
> 带钥总条数 `22 → 30`（+8），不带钥 `22 → 23`（+1）。
> **总条数不是常数**：它随"有没有钥、加不加 `--write`"变 —— 引用数字时必须连跑法一起写。
>
> ★ **同一天 schema 升了 v15**（新增 `price_valid_until`，价格有效截止）：
> `A3` 报的**字段数 `45 → 46`**。G 组条数不变（它判形状，多一个字段不改形状判据），
> 但 `G5` 多了一条**过期标价不得报 ready**的判据（依据 ACT 必备项④的
> 「应能支撑……**支付前确认**」），并配 3 条单测守它的两个反向
> （未过期不许误判、解析不了不许判 FAIL）。
>
> ★ **同一天又加了 A5/A6**（能力发现口 `/.well-known/rec-agent`）：
> 带钥 `30 → 32`，不带钥 `23 → 25`。其中 **A6 是跨检查** ——
> 拿发现口**声明过的端点**真去打一次：`404` = 自述撒谎（FAIL），`200`/`401` = 口存在。
> 该口**不是协议强制的**，所以它不存在时 A5/A6 判 **SKIP 而不是 FAIL**。

---

## 5. `tools/` 里脚本的分类（2026-10-10 补）

> 这份 README 之前**只讲协议客户端**（§1-§4：`pyclient` / `jsclient` / `conformance`），
> 对 `tools/` 里数量最多的那批脚本**一个字都没提**。
> 结果是新人打开目录看到二十多个平铺脚本，分不出哪些是"改了代码就得重跑"的门。

| 类别 | 位置 | 谁在跑 | 说明 |
|---|---|---|---|
| **门禁脚本** | `tools/cdp-verify-*.py`（**15 个**） | `tools/preflight.sh` 第 ⑦ 件，逐个跑 | 判据条数**不许少**。名单是 `preflight.sh` 里的 `GATES` 变量**唯一一处**——这个数字由 `server/core/test_doc_numbers.py` 与 `GATES`、与顶层文件集合**三向核对**（改成 14 或加个门不改这里，都会红） |
| **一次性验收** | `tools/archive/`（12 个） | 没人自动跑 | 某一次改动的现场验收，写完跑通就归档。见 `archive/README.md` |
| **资产盘点** | `tools/assets-inventory.py` → `docs/assets.json` | 人 / `core/test_assets_inventory.py` | 把 `web/` 下**每个文件**的栈归属、从哪个 html 入口加载、谁引用它算成 json（`--check` 只查不写）。⚠️ 它**只产事实**：「谁在用」「第几批退役」是判断，写在 `docs/retire-plan.md` —— 两份产物分工是 v1 的教训（脚本证明不了"没人在需要它"） |
| 协议客户端 | `tools/pyclient.py` · `jsclient.mjs` | 第三方 / 自检 | §1-§3 |
| 一致性套件 | `tools/conformance.py` | 第三方 / 验收 | §2 |
| 运维与门禁 | `preflight.sh` · `deploy.sh` · `ssh-target.sh` · `backup` 相关 | 人 / `deploy.sh` | 部署链路。**连哪台、用哪把钥只在 `ssh-target.sh` 一处决定**（默认 ssh 别名 `pinyuan-prod`），四个运维脚本都 source 它；配法与迁移步骤见 `docs/部署凭据.md` |

### 加一条真渲染判据的正确做法

1. 新脚本放 `tools/cdp-verify-<名字>.py`（**顶层**）；
2. 加进 `tools/preflight.sh` 的 `GATES`；
3. 用 `tools/cdpkit.py` 的共享层（`launch_chrome` / `Page` / `check`）—— **不要**再抄一份脚手架；
4. 跑 `bash tools/preflight.sh`，确认 ⑦ 那一节里你这条的**条数**和你写的断言数对得上。

> ★ 判据要能**变红**：本仓的纪律是"守卫失效必须会红"（见 `core/test_guards.py` 的反向哨兵）。
> 写完之后把被测的行为**故意做坏**一次，确认它红，再改回来。

### `esc()` 转义护栏

`tools/check-esc.py`（preflight 第 ⑩ 件）守着"每个 `esc()` 实现都必须转义单引号"。
它存在的原因见该文件 docstring：`esc` 曾有 9 个定义点、4 个漏转义单引号，
其中两个还**遮蔽**了 `web/ui.js` 里正确的 `PYUI.esc`。
