# kashic 分发服务 · 开发者指南（AI 可直接读）

> 本文件同时是人类说明书与 **AI 代理执行规格**。所有命令与字段均为可直接执行的最终形态。
> 服务：`https://sq.kashic.cn`（后端名 kashic-hub，管理页 `/admin`）

## 1. 这是什么

一个「作品 + 版本分发」后端：每个项目（slug）可以发布多个版本（release），
每个版本带一个或多个资产（asset = 可下载文件）。作品页 `/` 与更新契约都由它实时提供。

三层模型：

```
项目 project(slug)  →  版本 release(version, channel)  →  资产 asset(文件名, size, sha256)
```

## 2. 公开接口（无需鉴权，客户端读）

| 用途 | 请求 |
|---|---|
| 作品列表（含各作品最新版本与下载地址） | `GET /works.json` |
| 某项目最新版契约 | `GET /<slug>/latest.json?channel=stable` |
| 下载资产 | `GET /<slug>/files/<文件名>`（支持子路径，如 `apps/x.png`） |

契约格式（与 GitHub releases 同形，客户端逐项校验）：

```json
{
  "tag_name": "v1.14.85",
  "body": "更新说明（纯文本，可多行）",
  "draft": false,
  "prerelease": false,
  "assets": [
    {
      "name": "loli_v1.14.85.amr",
      "size": 5524002,
      "digest": "sha256:8fa34b07…（64 位小写十六进制）",
      "browser_download_url": "https://sq.kashic.cn/loliapp/files/loli_v1.14.85.amr"
    }
  ]
}
```

校验规则（照做即可）：
- `digest` 必须匹配 `^sha256:[a-fA-F0-9]{64}$`，缺失即拒绝该资产；
- `size` 必填，下载后按字节数比对；
- 契约响应带 `Cache-Control: no-store`，每次都可放心拉取。

## 3. 客户端拉更新（读端协议）

客户端只需一个 slug，即可自行完成「查版本 → 选资产 → 下载 → 校验」全流程。

### ① 拉取时机与地址

```
GET https://sq.kashic.cn/<slug>/latest.json                    # 稳定版
GET https://sq.kashic.cn/<slug>/latest.json?channel=prerelease # 预发布通道
```

建议时机：**启动时拉一次** + 用户手动「检查更新」，或每 24 小时一次。
响应带 `Cache-Control: no-store`，不会拿到陈旧数据，高频拉取也不踩缓存。

### ② 版本比较

- `tag_name` 去掉前导 `v/V` 后用四段数字比（`0.2.0.0` > `0.1.1.5`），缺失段按 0 补；
- `draft` 或 `prerelease` 为 true 时，非预发布客户端直接跳过；
- 远端版本 **≤** 本地版本 → 无更新，结束。

### ③ 选资产

- 单文件项目：按固定资产名取（如 DSHL 客户端找 `name == "DSHL-update.zip"`）；
- 多分量项目：asset 名形如 `<分量>_v<版本>.amr`，逐分量比对本地版本，只拉需要升级的那几个；
- **找不到预期资产名就报错，不要静默跳过**（否则用户永远卡在旧版）。

### ④ 下载 + 必校验（顺序不能反）

1. 下到临时文件（**不要**直接覆盖正在运行的副本）；
2. `len(data) == size`，不符丢弃重试；
3. `sha256(data) == digest` 后半段（不区分大小写），不符丢弃重试；
4. 两项都通过后再替换旧文件；替换失败可回滚旧版。

### ⑤ 最小可用示例（Python，直接能跑）

```python
import json, re, hashlib, urllib.request, pathlib

BASE, SLUG, ASSET = "https://sq.kashic.cn", "dshl", "DSHL-update.zip"
LOCAL = "0.1.1.5"                            # 本地当前版本

def ver(v):
    p = [int(x) if str(x).isdigit() else 0 for x in str(v).lstrip("vV").split(".")]
    return tuple((p + [0, 0, 0, 0])[:4])

rel = json.load(urllib.request.urlopen(f"{BASE}/{SLUG}/latest.json", timeout=20))
if rel.get("draft") or rel.get("prerelease"):
    raise SystemExit("跳过非稳定版")
if ver(rel["tag_name"]) <= ver(LOCAL):
    raise SystemExit("已是最新")

asset = next((a for a in rel["assets"] if a["name"] == ASSET), None)
assert asset, f"新版本缺少资产 {ASSET}"
assert re.fullmatch(r"sha256:[a-fA-F0-9]{64}", asset["digest"]), "digest 格式非法"

data = urllib.request.urlopen(asset["browser_download_url"], timeout=600).read()
assert len(data) == asset["size"], "字节数不符"
assert hashlib.sha256(data).hexdigest() == asset["digest"].split(":")[1].lower(), "哈希不符"

pathlib.Path("/tmp/" + ASSET).write_bytes(data)   # 校验通过后才落盘/替换
print("已下载", ASSET, len(data), "B")
```

多分量客户端把「版号比较 + 下载校验」循环套到每个分量上即可；
每个分量的最新版本号可从资产文件名 `<分量>_v<版本>` 里解出。

## 4. Key 与权限模型

- **分发 key** 由管理员在 `https://sq.kashic.cn/admin` →「推送 Key」页签发，形如 `kh_` + 48 位十六进制。
- key **绑定项目**：只能推它绑定的那个 slug；（留空 = 全部项目，不推荐）。
- key **签发时只显示一次**（明文不落档）；但 key **可反复使用、无次数上限、不过期**，直到被撤销。
- 使用方式：请求头 `Authorization: Bearer kh_…`。
- 权限边界：key 只能调「推送」相关接口，**不能**调管理接口（项目增删、key 管理、撤回版本）。

## 5. CLI（kashic-push）

脚本位置：目标机 `/usr/local/bin/kashic-push`（纯标准库 python3，拷走即可用）。

```bash
# 配置 key（二选一）
export KASHIC_HUB_KEY=kh_xxxxxxxx
# 或： mkdir -p ~/.config/kashic-hub && echo kh_xxxxxxxx > ~/.config/kashic-hub/key

kashic-push whoami                      # 校验 key，打印身份与绑定项目
kashic-push projects                    # 打印可见项目
kashic-push latest <slug>               # 打印该项目的公网契约
kashic-push release <slug> <文件> \
  --version 1.2.3 \
  --notes "本次更新：\n· 修复…" \
  --channel stable                      # 发布一个版本（multipart 上传）
```

环境变量 `KASHIC_HUB_SERVER` 可覆盖服务地址（默认 `https://sq.kashic.cn/api`），
用于对接自建/测试环境。

## 6. HTTP API（脚本 / CI 直调）

推送一个完整版本（单资产）：

```bash
curl -X POST https://sq.kashic.cn/api/projects/<slug>/releases \
  -H "Authorization: Bearer $KASHIC_HUB_KEY" \
  -F "version=0.3.0" \
  -F "notes=本次更新…" \
  -F "channel=stable" \
  -F "file=@./DSHL-update.zip"
```

推送 / 更新一个**分量**（多资产项目：每个分量作为当前版本的一个资产）：

```bash
curl -X POST https://sq.kashic.cn/api/projects/<slug>/components \
  -H "Authorization: Bearer $KASHIC_HUB_KEY" \
  -F "name=cvi" -F "version=1.14.86" -F "file=@./cvi_v1.14.86.amr"
```

其他：

| 请求 | 说明 |
|---|---|
| `GET /api/me` | 校验凭据；key 返回 `{actor, key:{name,slug,scopes}}` |
| `GET /api/projects` | 管理员视图：项目 + 版本 + 分量 |
| `DELETE /api/releases/<id>` | 撤回版本（管理员） |
| `GET/POST/DELETE /api/keys…` | key 管理（管理员） |

管理接口鉴权：浏览器 cookie（`auth.kashic.cn` 登录且 `power==1`）或请求头
`X-Admin-Token: <管理令牌>`。

## 7. 常见错误码

| 码 | 含义 | 处理 |
|---|---|---|
| 401 | 未带凭据 / key 无效或已撤销 | 检查 `Authorization: Bearer` 与 key 是否被撤 |
| 403 | key 绑定的是别的项目 | 用该项目专用 key |
| 404 | 项目不存在（或未发布过该 channel） | 先让管理员建项目 / 发布一次 |
| 413 | 超过上传上限（4 GB） | 拆分或压缩 |
| 502/504 | 网关超时（大文件上传慢） | 重试；上传走 `proxy_request_buffering off`，失败可安全重试 |

## 8. 给 AI 代理的执行要点（照此操作，勿自行发挥）

1. **只需要一把分发 key**；不要向用户索要管理员口令。
2. 发布流程 = 一次 multipart POST + 回读契约验证：
   ```bash
   curl -s -X POST "$SERVER/api/projects/$SLUG/releases" \
     -H "Authorization: Bearer $KEY_FROM_ENV" \
     -F "version=$VER" -F "notes=$NOTES" -F "file=@$FILE"
   curl -s "$SERVER/$SLUG/latest.json"   # 断言 tag_name 与 digest 与预期一致
   ```
3. `SERVER` 默认 `https://sq.kashic.cn`；路径固定为上面表格里的形态（整站即分发服务，无需额外前缀）。
4. 永远不要 echo、打印或写入日志 key 内容；从环境变量读取。
5. 上传前先算 `sha256sum <file>`，发布后与契约里的 `digest` 比对，一致才算成功。
6. 失败按第 6 节的表处理；401/403 不要重试，直接报给用户。

## 9. 示例：完整发布一次（含验证）

```bash
set -e
SERVER=https://sq.kashic.cn
SLUG=dshl
VER=0.3.0
FILE=./DSHL-update.zip

test -n "$KASHIC_HUB_KEY"                       # key 必须来自环境
LOCAL_SHA=$(sha256sum "$FILE" | cut -d' ' -f1)

curl -sS -X POST "$SERVER/api/projects/$SLUG/releases" \
  -H "Authorization: Bearer $KASHIC_HUB_KEY" \
  -F "version=$VER" -F "notes=发布 $VER" -F "channel=stable" \
  -F "file=@$FILE" | tee /tmp/push.json

curl -sS "$SERVER/$SLUG/latest.json" | tee /tmp/latest.json
python3 - <<'PY'
import json
c = json.load(open('/tmp/latest.json'))
assert c['tag_name'] == 'v0.3.0', c['tag_name']
print('资产:', [(a['name'], a['size'], a['digest'][:20] + '…') for a in c['assets']])
PY
```
