# 站内用户功能接入指南（WebStoreHouse 统一账号 + 官方同步 SDK）

发布到 web.merrykinger.com 画廊的项目，**零后端、零依赖**即可使用全站统一账号：识别当前登录用户、
按项目存取该用户的云端数据（进度/设置/存档），并获得**多设备同步**。项目与 API 同源
（项目跑在 `/p/<id>/` 子路径），浏览器自动携带登录 cookie（HttpOnly `mk_token`）。

给项目写代码时的三条纪律：

1. **未登录也要能玩**——用户功能是增强，不是门槛。未登录降级 localStorage，别弹窗逼人登录。
2. **登录状态只信 `/api/user/me`**——cookie 是 HttpOnly 的，JS 读不到。
3. **数据整文档读写**——一个命名空间就是一份 JSON 文档（≤256KB），读回来、改内存、整体写回。

## 30 秒接入（新项目一律用这个，不要手写 fetch）

站点托管了官方 SDK `/sdk/mksync-1.js`（全局 `MKSync`），多设备同步、基线门闩、乐观并发、
切回前台自动拉取、sendBeacon 兜底、指数退避全部内置：

```html
<script src="/sdk/mksync-1.js?v=dev"></script>
<!-- ?v= 规则:源码里写 ?v=dev(或干脆不写),publish.py 打包时自动替换成发布时间戳。
     Cloudflare 会给 js 加 4 小时浏览器缓存,带版本号才保证每次发布拿到新鲜 SDK。
     手工 rsync 部署的站内页面(不走 publish.py)自己写死时间戳。 -->
```

```js
const sync = MKSync.create({
  slug: 'myapp',                                // 必填:小写字母数字-,本地开发时 ns 为 myapp-devlocal
  initial: () => ({ score: 0, unlocked: [] }),  // 空档形状
  onChange: (data) => render(data),             // 云端有新数据到达(含启动首拉)时被调
});
const { authed, data } = await sync.start();    // 唯一必须 await 的调用;未登录也正常返回
render(data);

// 运行期改数据:本地立即落盘,2s 防抖推云
sync.set(d => ({ ...d, score: d.score + 1 }));
// 登录按钮(必须在用户手势里调,iframe 会拦非手势弹窗)
loginBtn.onclick = () => sync.login();
```

这就是全部。可选一行 `MKChip.attach(sync)`（配 `<script src="/sdk/mkchip-1.js">`）在角落放一个
现成的「登录以同步 / 用户名+状态点」角标；想融入自家 UI 就订阅 `sync.onStatus` 自绘。
自测页 `/sdk/demo.html`（本地 :4321 直接打开，含双标签多设备回归步骤）。

## MKSync.create(options) 速查

| 选项 | 默认 | 说明 |
|---|---|---|
| `slug` | 必填 | `^[a-z0-9][a-z0-9-]{0,22}$`，非法直接 throw；用于 devlocal ns 与 localStorage key。**22 = 服务端 ns 上限 32 − `-devlocal` 的 9 位**，这样本地开发的 ns 一定合法 |
| `ns` | 从 `/p/<id>/` 推断 | 显式覆盖命名空间（固定别名项目用） |
| `version` | 1 | 项目 schema 版本；云端读到更低版本交 `migrate(data, fromVersion)`，读到**更高**版本则只读冻结（见下） |
| `initial` | `() => ({})` | 空档工厂 |
| `merge` | 整档 LWW | `(remote, local) => merged`；只在"两台设备可能同时改同一份数据"时才需要自定义，顺序单机玩用默认即可。**必须幂等**（409 重试、每次 pull、promoteAnon 都会调）。助手：`MKSync.merge.max`（最高分）、`MKSync.merge.union`（成就/解锁并集） |
| `migrate` | — | 旧版本/老项目裸文档（`fromVersion === 0`）升级 |
| `local` | true | localStorage 镜像（`mk:<slug>@<user>` 按用户分桶）；项目自管本地存储时传 false（同时也放弃脏标持久化与匿名进度合并） |
| `debounceMs` | 2000 | 推云防抖 |
| `refreshMs` | 3000 | 切回前台重探的节流窗口（防事件风暴） |
| `reprobeMs` | 60000 | 已登录时重探 `/api/user/me` 的最小间隔 |
| `envelope` | true | 云端存信封 `{mk:1,v,savedAt,data}`；false 时云端存裸文档（**本地镜像恒为信封，不受此项影响**）。除非要兼容外部读取者，别关 |
| `pollMs` | 关 | 低频条件 GET 轮询；小于 10000 会被夹到 10000。跨标签靠 `storage` 事件即时收敛，一般不需要开 |
| `promote` | `'ifEmpty'` | 登录后怎么处理本机匿名进度：`'ifEmpty'` 云端为空才带走，否则置 `anonPending` 让 UI 问用户；`'merge'` 云端有档也自动合（必须同时给 `merge`，否则降级 ifEmpty）；`'never'` 完全不管 |
| `adoptNs` | — | `string[]`，旧命名空间迁入（换 id 重发时用）。**仅当云端本 ns 为空**时按顺序试一次，读到就 merge/LWW 进来并推到新 ns；旧档不删 |
| `onChange` | — | `(data, {source: 'boot'\|'remote'\|'conflict'\|'local'})`；`source !== 'local'` 时重绘 UI。跨标签收敛与 adoptNs 迁入都复用 `'remote'` |
| `onStatus` | — | 状态订阅，见下 |
| `debug` | false | 打开后所有静默失败 console.warn（排查 403/配额必备） |

实例方法：`start()` `get()` `set(next|fn)` `flush()`（结算等关键点立即推）`refresh()`（手动拉取）
`status()` `onChange(fn)` `onStatus(fn)`（都返回退订函数）`login()` `reset()`（删云端+清本地双桶）
`anon()` `promoteAnon(fn?)` `dismissAnon({erase})`（匿名进度，见下）`destroy()`。

`status()` → `{authed, user, display, ns, nsSource, state, baselineReady, readOnly, anonPending,
rev, savedAt, lastSyncAt, error}`，`state`: `idle→loading→(local | synced/pending/pushing/error)`。

- `nsSource`: `'path'`（从 `/p/<id>/` 推断，线上正常）/ `'option'`（create 显式传）/ `'devlocal'`
  （两者都没有 → `<slug>-devlocal`）。**线上看到 `devlocal` 就是接入姿势错了**，SDK 会在非
  localhost 时 console.warn。
- `readOnly`: 云端数据由更新版本的项目写过（`信封 v > 本地 version`）。SDK 只读采纳、**冻结全部上传**
  （防旧 schema 覆盖新数据），`error.code` 粘性停在 `version-newer`，提示用户刷新页面。
- `anonPending`: 本机存着未登录时攒下的进度，还没并进当前账号。
- `error.code`: `network`（退避自动重试）/ `server` / `auth`（退回未登录）/ `ns-conflict` /
  `too-large` / `quota` / `bad-request`（请求体被拒，不重试）/ `popup-blocked`（登录弹窗被拦，瞬时）/
  `version-newer`（只读冻结，粘性）。**新 code 会继续加，`switch(err.code)` 必须带 `default` 分支。**

### 匿名进度（用户先玩后登录）

未登录时数据攒在 `mk:<slug>`，登录后按用户分桶换到 `mk:<slug>@<user>`。默认 `promote:'ifEmpty'`：
云端为空就直接带走；云端已有档则**不自动合并**（共享设备上孩子匿名玩、家长登录，自动合并会污染账号），
只把 `status().anonPending` 置真交给 UI：

```js
if (sync.status().anonPending) {
  const a = sync.anon();               // { data, savedAt }，已过 migrate
  if (confirm(`合并这台设备上 ${new Date(a.savedAt).toLocaleString()} 的进度？`)) sync.promoteAnon();
  else sync.dismissAnon();             // 记住"不再提示"（写进本地信封的 pa 字段，跨刷新有效）
}
```

`MKChip.attach(sync)` 已经内置了这个按钮（`{promote:false}` 可关）。`promoteAnon(fn)` 可传自定义
合并函数 `(anon, current) => merged`；不传则用 `options.merge`，再没有就按 `savedAt` 整档 LWW。

### 整档 merge 的写法（G7）

```js
merge: (remote, local) => ({
  ...remote,                                                  // 未列出的字段以远端为准
  best:     MKSync.merge.max(remote.best, local.best),        // 最高分取大
  unlocked: MKSync.merge.union(remote.unlocked, local.unlocked), // 成就/解锁取并集
  settings: (local.savedAt >= remote.savedAt) ? local.settings : remote.settings, // 整块 LWW
  playCount: (remote.playCount | 0) + (local.playCount | 0),  // ⚠ 求和不幂等，重复合并会膨胀
})
```

纪律：**merge 必须幂等**——同一对 (remote, local) 会被 409 重试、每次 pull、promoteAnon 反复调用。
max/union/LWW 天然幂等；求和这类累积语义要么改成"取大"，要么在数据里存事件 id 集合去重。

## SDK 替你做了什么（多设备行为）

- **基线门闩**：首次成功 GET 云端前禁止任何写——新设备首屏绝不会拿空档把云端进度覆盖掉。
- **切回前台自动收敛**：`visibilitychange/focus/pageshow` 时无条件重探登录态 + 条件 GET
  （`If-None-Match`，云端没变只花一个 304）。在手机上玩完切回电脑标签页，不用刷新。
- **跨标签零网络收敛**：同一浏览器另一个标签页写了同一个桶，`storage` 事件立刻采纳（`source:'remote'`）。
- **断网关页不丢**：本地镜像带脏标 `d:1`，下次打开仍知道"这份改动没推上去"，走正常合并/LWW 补推
  （`local:false` 的项目拿不到这项）。
- **乐观并发**：写永远带 `If-Match: "<rev>"`，rev 不符 → 409 → 合并 → 带新 rev 重试（≤3 次），
  绝不降级为强写。默认合并是整档 LWW（按信封 `savedAt`，平手取远端）。
- **退出兜底**：页面转后台时 keepalive fetch 立即推；`pagehide` 仍有未推改动才 sendBeacon。
- **云端信封**：实际存 `{mk:1, v, savedAt, data:{...}}`；读到无 `mk` 的裸文档视为老项目存量，
  包成 `v:0` 交 `migrate`，零丢失。

## 登录引导（重要：项目跑在 iframe 里）

画廊打开项目走 `/play.html`，项目在 **iframe** 里运行。`sync.login()` 已处理好：
新标签页打开 `/account.html?next=<当前 pathname+search+hash>`（关卡/页签状态原样带回），登录完自动跳回；
用户切回原标签页时 SDK 自动重探并拉取。⚠️ 手写跳转的注意：参数名是 **`next`** 不是 `back`，且只接受
以单个 `/` 开头的**同源路径**（完整 URL 会被静默丢弃，登录后停在账号中心）。

`login()` 返回 **boolean**：`false` = 浏览器拦了弹窗（非用户手势调用、或 iframe 弹窗策略）。
这时把按钮就地换成真链接，用户点一下仍能登录：

```js
loginBtn.onclick = () => {
  if (sync.login() === false) {
    loginBtn.outerHTML = `<a href="${MKSync.loginUrl()}" target="_blank" rel="noopener">打开登录页 ↗</a>`;
  }
};
```

## 统一深浅色主题（1 行接入）

站内所有页面共用一个主题真相源（`localStorage['ws_theme']`，任一处切换全站实时跟随，含已打开的
其他标签和 iframe）。项目要支持深浅色时**不要自建主题键**，接入官方 SDK：

```html
<!-- <head> 里、第一个 stylesheet <link> 之前同步引入（防闪错色） -->
<script src="/sdk/mktheme-1.js?v=dev"></script>
```

- 切换按钮用 `id="themeToggle"` 或加 `data-mk-theme-toggle` 属性，点击自动接管（动态插入的按钮调
  `MKTheme.wire()` 补接线）。
- CSS 只需按 `html[data-theme="dark"]` 写深色覆盖（`data-theme` 恒为 `light|dark`）。
- canvas/SVG 等计算色用 `MKTheme.onChange((t) => redraw())` 或监听 `document` 的 `mk:theme` 事件重绘。
- 项目原来有自己的主题键：加 `data-legacy="旧键名"` 一次性迁移；旧值里表示浅色的别名再加
  `data-legacy-light="别名"`（如 Raffle 的 `cute`）。迁移后旧键会被删除。
- `MKTheme.set('light'|'dark'|null)`：null = 清除偏好回到跟随系统。API 全貌见 `mktheme.d.ts`。

**host 无关的项目**（需要 file:// 直开、引不了 `/sdk/` 绝对路径的）按协议手写，两段都要：

```html
<!-- head 内联防闪烁（每页一份） -->
<script>(function(){var t;try{t=localStorage.getItem("ws_theme")}catch(e){}
if(t!=="light"&&t!=="dark")t=matchMedia("(prefers-color-scheme: dark)").matches?"dark":"light";
document.documentElement.dataset.theme=t;})();</script>
```

```js
// 站点 JS 里:切换按钮写键后应用;别的文档改了只应用绝不回写(防两文档乒乓);未固定时跟系统
function resolve() {
  var t; try { t = localStorage.getItem('ws_theme'); } catch (e) {}
  return (t === 'light' || t === 'dark') ? t
       : (matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light');
}
btn.addEventListener('click', function () {
  var next = document.documentElement.dataset.theme === 'dark' ? 'light' : 'dark';
  try { localStorage.setItem('ws_theme', next); } catch (e) {}
  applyTheme(next);
});
window.addEventListener('storage', function (e) {
  if (e.key && e.key !== 'ws_theme') return;
  applyTheme(resolve());
});
matchMedia('(prefers-color-scheme: dark)').addEventListener('change', function () {
  var s; try { s = localStorage.getItem('ws_theme'); } catch (e) {}
  if (s !== 'light' && s !== 'dark') applyTheme(resolve());
});
```

协议全文（键名/值域/事件/铁律）见站点仓库 `public/sdk/README.md` 的「统一主题协议 v1」。
`ws_theme` 是平台保留键，publish.py 的存档门禁对它豁免——只接主题不接云存档的项目不会被拦。

## 本地开发

本地 `npm start` 起 WebStoreHouse（http://localhost:4321），在 `/account.html` 注册测试号
（本地空数据时首个账号免邀请码 + 自动 admin）。项目未发布时 ns 自动落到 `<slug>-devlocal`
（带 slug 前缀，多项目本地联调不串档）。不方便起服务就先只走 localStorage 分支。

**Vite/TS 构建型项目**：运行时一律引站点绝对路径（在 `index.html` 里写
`<script src="/sdk/mksync-1.js?v=dev">`，不进构建产物），**只把 `mksync.d.ts` / `mkchip.d.ts`
复制进 `src/vendor/`** 拿类型；`declare const MKSync: MKSyncGlobal` 或
`import type MKSync from './vendor/mksync'` 都行。不要 vendor JS 字节副本——dev 服务器下
`/api/*` 本来就 404、SDK 会自动降级本地，vendor 的唯一效果是把一份会过期的 SDK 焊进你的 bundle。
（`~/.claude/skills/publish-web/sdk/` 那份 JS 只用于 publish.py 的一致性校验与陈旧检测。）

## 排错手册

- **写不进去/毫无反应**：`create({debug: true})` 打开日志；`sync.status().error` 看 code。
- **403 `ns-conflict`**：ns 被另一个项目占用（首次写入记录了来源项目）。确认自己是不是换 id
  重发过（老项目还在画廊时新 id 写不进旧 ns）；要么删掉画廊里的老项目，要么 `create` 时换个 `ns`。
- **413 / 507**：单 ns 超 256KB / 用户配额超 10MB。数据放"进度/设置"这类小 JSON，
  图片音频别往里塞。SDK 对这两类不重试，数据仍在本地。
- **登录后没拉到云端**：确认 `await sync.start()` 在应用初始化时被调过；
  `sync.status().baselineReady` 应为 true。
- **`nsSource === 'devlocal'` 出现在线上**：页面不在 `/p/<id>/` 下（自建路由/自定义域名/被别的页
  嵌套），数据全写进了本地开发命名空间。`create({ns: '<线上项目 id>'})` 显式指定。
- **`readOnly` / `version-newer`**：别的设备用更新版本的项目写过云端。别硬推——刷新页面拿新版本代码；
  真要回滚就先 `reset()` 再重来。
- **`bad-request`（400）**：ns 不合法或写进去的不是 JSON 对象（顶层是数组/字符串会被服务端拒）。
- **换了项目 id，老进度读不到**：`create({adoptNs: ['<旧 id>']})` 一次性迁入。旧项目还在画廊时
  服务端会 403 拒读（归属保护），SDK 静默跳过——先删掉画廊里的老项目再打开新项目。
- **控制台第一行**：SDK 启动会打 `[mksync] mksync-1@x.y.z slug=… ns=… (path)`，
  排查"用户浏览器到底跑的哪个版本"直接看它。

## 底层 HTTP API（不用 SDK 的项目 & 排错用）

```
GET /api/user/me
  → 200 {authed:false} 或 {authed:true, name:"merry", displayName:"美瑞", role:"user"}（永不 401，放心裸调；
    role 为 admin/publisher/user，2026-10 加宽，老服务端没有）

GET    /api/user/data/<ns>          → 数据；从没存过返回 {}（需登录，未登录 401）
       响应带 ETag:"<rev>" 与 X-Mk-Saved-At;请求带 If-None-Match:"<rev>" 未变时 → 304
       ?meta=1 → {exists, rev, savedAt, bytes}（不含正文）
PUT    /api/user/data/<ns>          → 整文档覆盖写（Content-Type: application/json,body 必须是 JSON 对象）
       可选 If-Match:"<rev>"（无档用 "0"）:rev 不符 → 409 {error, rev, savedAt, doc}
       不带 If-Match = last-write-wins;成功 → {ok, rev, savedAt} + ETag
POST   /api/user/data/<ns>          → 同 PUT（给 sendBeacon 用）
DELETE /api/user/data/<ns>          → 删除 → {ok, rev:"0", savedAt:0}
```

- **ns**：`[a-z0-9][a-z0-9_-]{0,31}`。推荐直接从 URL 取项目 id，配合发布 skill 的
  "已上线项目一律 `--update` 原地更新"纪律，id 稳定不变。
- **配额**：单 ns ≤256KB（超 413）、每用户合计 ≤10MB、最多 100 个 ns（超 507）。
- **隔离**：每个用户各存各的。项目之间：ns 首次写入时记录来源项目（Referer），之后其他项目写
  同名 ns 会被 403（响应带 `code:'ns_owner_conflict'`）；原项目已删除/换 id 重发时新来源可接管；
  **无 Referer 的请求放行但不记录归属**。自己发请求记得 `referrerPolicy:'same-origin'`。
- **不缓存**：服务端对 `/api/*` 强制 no-store，不用自己加时间戳防缓存。

## 大量条目 / 图片音视频（/api/vault，2026-10）

**官方客户端 `/sdk/mkvault-1.js`（全局 `MKVault`，ETag 缓存、If-Match 冲突、带进度与压缩的上传），完整指南见同目录 `vault-api.md`**，
新项目直接用它，不必手写下面的 fetch。

整档 256KB 装不下的数据（长文本条目很多、要存图片/音频/视频）改用素材库接口：一条一个 JSON、文件单独存，
需登录、每用户隔离，ns 用项目 id（`/p/<id>/` 里取，规则同 `/api/user/data`）。示例项目：梦想游戏素材库
（`~/…/mc-projects/MaterialCollection`，`js/api.js` 是现成封装，可直接抄）。

```
GET    /api/vault/<ns>/items            → {rev, items}；ETag + If-None-Match → 304
PUT    /api/vault/<ns>/items/<id>       → 整条覆盖，JSON ≤256KB，id 6-40 位 [A-Za-z0-9_-]
DELETE /api/vault/<ns>/items/<id>       → 连带删 item.media[].fileId/thumbId
POST   /api/vault/<ns>/files            → multipart 字段 file → {fileId, kind, mime, bytes}
GET    /api/vault/<ns>/files/<fileId>   → 本人可读，immutable 长缓存，支持 Range（<img>/<video> 直接用）
GET    /api/vault/usage                 → {bytes, quota}
```

- 只认魔数：图片 WEBP/JPEG/PNG/GIF ≤10MB、音频 MP3/M4A/OGG/WAV/FLAC ≤30MB、视频 MP4/MOV/WEBM ≤80MB，每用户 3GB
- 图片务必先在浏览器压缩（canvas → WebP，长边 1600 + 480 缩略图），别传原图
- 这套接口不经 MKSync，publish.py 的存档门禁不管它；小设置仍建议放 MKSync

## 加载进度、分享短链（MKApp，另见 app-api.md）

访客经 play 外壳（`/play/<id>`）打开项目时，外壳先盖一层带进度条的加载遮罩。引 `/sdk/mkapp-1.js?v=dev` 后
`MKApp.progress(done, total)` 报真实加载进度、首屏可交互时 `MKApp.ready()`，遮罩立刻揭开（不再靠"资源安静 700ms"去猜）；
另有 `MKApp.share()`（手机系统分享 / 桌面复制 `/play/<id>` 短链，聊天软件里带封面预览）、`me()`、`login()`、`context()`。
调用一律写成 `window.MKApp && MKApp.ready()`。完整指南见同目录 **`app-api.md`**。

## 多人联机（MKRoom，另见 room-api.md）

项目要让多个玩家实时互动（对战、协作、聊天室、同屏光标）：引 `/sdk/mkroom-1.js?v=dev`，
`MKRoom.create({ slug })` 后 `create()/join(code)` 进房，`send()` 广播/点对点、`setState()` 共享状态、
`isHost` 做权威节点；登录可选、断线自动恢复席位。完整接入指南与限额见同目录 **`room-api.md`**
（公开副本 `/sdk/room-api.md`，自测台 `/sdk/roomdemo.html`）。

## 相关（一般用不到）

- 无账号的老机制仍在：`/api/save/:token`（token 即凭证，知道链接就能读写）与答题日志
  `/api/log/:token`——TeachMath 的 `?sync=` 在用。**新项目一律用 SDK**，别再用这套。
- 账号还能绑定学习口令、家长报告页按账号看：见 WebStoreHouse 仓库 `docs/api.md`「账号」一节。
- 协作文档（多文档、乐观并发、跨用户共享、编辑锁、SSE）：`/api/docs/<ns>/*`，见 WebStoreHouse 仓库 `docs/api.md`。
- 站内已有手写接入的老项目（数学游戏/山海/MortalSaga/Raffle）继续可用（服务端永久兼容），
  迁移到 SDK 时用 `migrate(data, 0)` 接管云端存量裸文档。
