---
name: chensibo-app
description: 在沉思波应用后端（chensibo.cn）上做纯前端小应用。当用户要「做一个留言板 / 报名表 / 展示站 / 记账本」并希望数据存在我们平台、不需要自己买服务器时使用。本文件是完整契约：照它写出来的代码可以直接传到平台跑起来。
version: 1.0.0
---

# 在沉思波应用后端上做一个能跑的应用

这份文件是给 AI 看的说明书。用户把它整份贴给你，后面跟一句需求（「做一个留言板」），
你应该照这里的契约直接写出**能跑**的代码，不要自己发明接口。

## 0. 先把这件事讲清楚

- 平台提供：**数据表读写**、**文件上传**、**静态页面托管**、**域名与 HTTPS**。
- 平台不提供：**服务端代码**。这里没有 Node/Python 运行时、没有环境变量、不能装依赖。
  所以「后端逻辑」必须换一种写法：要么放在浏览器里（前端计算），要么用平台的声明式能力。
- 数据在平台上，用户不需要买服务器、不需要配数据库、不需要备案域名（用平台默认地址）。
- 一切读写都经过 `csb.js`。**不要**让用户自己写 SQL、不要直连数据库、不要发明登录。

## 1. 三条铁律（违反就必须返工）

1. **纯前端。** 只出 `html / css / js / json / svg / png / jpg / webp / woff2 / txt / xml / ico`。
   不要 `package.json`、不要构建步骤、不要 `npm install`。用户要的是复制粘贴就能用。
2. **不自己实现登录。** 身份来自页面已有的登录态。需要令牌时读
   `localStorage.getItem('csb_token')`（SDK 内部已经做了，你一般不用管）。
   不要写注册/登录/找回密码页面。
3. **一切数据走 SDK。** 表名、字段、操作符都要符合第 2、3 节的规则；不符的请求平台直接 400。

## 2. 数据读写：SDK 契约

引入（一行，不需要下载）：

```html
<script src="https://chensibo.cn/apps/csb.js"></script>
```

初始化：`csb.init('__APP_ID__')` —— 应用 ID 用常量放文件顶部，标注「请替换成你的应用 ID」。
（用户在控制台 `#/apps` 概览页点「连应用 ID 一起复制给 AI」，这里就已经填好了。）

| 写法 | 作用 | 返回 |
| --- | --- | --- |
| `csb.from('表名').select({ where, order, limit, offset })` | 查列表 | `{ rows, total, limit, offset }` |
| `csb.from('表名').find(id)` | 按 id 取一条 | 记录对象或 `null` |
| `csb.from('表名').insert(对象)` | 写一条 | 新记录（含 `id` / `created_at`） |
| `csb.from('表名').update(id, 对象)` | 改一条（**整对象覆盖**，局部改请先 `find` 再合并） | — |
| `csb.from('表名').remove(id)` | 删一条 | — |
| `csb.from('表名').count(where)` | 只取总数 | 数字 |
| `csb.files.upload(File)` | 上传文件 | `{ id, name, size, mime }` |
| `csb.files.list()` | 列文件 | 数组 |
| `csb.files.blobUrl(id)` | 带令牌下载，得到 `blob:` 地址（用完 `URL.revokeObjectURL`） | 字符串 |
| `csb.files.remove(id)` | 删文件 | — |
| `csb.signedIn()` | 当前有没有身份 | 布尔 |

### 查询条件怎么写

条件值是「操作符.值」字符串；不写操作符默认 `eq`。

| 想做的事 | 写法 |
| --- | --- |
| 等于 | `{ done: 'eq.false' }` 或 `{ done: false }` |
| 不等于 | `{ status: 'neq.draft' }` |
| 数值比较 | `{ views: 'gte.100' }`、`{ price: 'lt.9.9' }` |
| 文字包含 | `{ title: 'like.周' }` |
| 多值之一 | `{ tag: ['a', 'b', 'c'] }` → 平台收到 `in.(a,b,c)` |
| 空 / 非空 | `{ cover: 'is.null' }`、`{ cover: 'is.notnull' }` |
| 排序 | `order: 'created_at.desc'`，多级 `'done.asc,created_at.desc'` |
| 分页 | `limit` 默认 20、上限 100；`offset` 从 0 开始 |

操作符全集：`eq / neq / gt / gte / lt / lte / like / in / is`。大小写不敏感。
字段名只能是字母、数字、下划线，且不能以数字开头。值一律参数化，不会被注入——但你也别拼 SQL。

## 3. 表与字段规则

- 表名：2–32 位，**小写字母开头**，只能含小写字母、数字、`_`、`-`。
- 一张表**不需要先建**：第一次 `insert` 会按你给的字段自动建。也可以在控制台
  「我的应用 → 数据」里先建表并选策略。
- 表策略三选一：`private`（仅自己）/ `user`（登录用户各自看自己的）/ `public`（公开只读，写入仍被挡）。
  **访客（未登录）只能读 `public` 表**，其它策略一律 403。
- 一条记录就一个 JSON 对象，字段随你定，**单条 ≤16KB**。平台自动补
  `id` / `user_id` / `created_at` / `updated_at`，这四个字段别自己写。
- 额度（首期，够做小应用）：每应用最多 10 张表、1000 条记录、文件 5MB（单文件 ≤256KB）、
  单次发布 ≤200 个文件 / 单文件 512KB / 合计 5MB。

## 4. 需要直连接口时（SDK 覆盖不到）

```
GET    /api/platform/apps/:应用ID/tables/:表名/rows?<筛选>&order=&limit=&offset=
POST   /api/platform/apps/:应用ID/tables/:表名/rows          body: { data: {...} }
PATCH  /api/platform/apps/:应用ID/tables/:表名/rows/:记录ID   body: { data: {...} }
DELETE /api/platform/apps/:应用ID/tables/:表名/rows/:记录ID
GET    /api/platform/public/apps/:应用ID/tables/:表名/rows   // 匿名可读，仅 public 表
```

身份：`Authorization: Bearer <令牌>`。令牌来源两种——站点登录态
`localStorage.getItem('csb_token')`，或用 `POST /api/platform/apps/:应用ID/session`
换一个 1 小时的应用令牌（`csb_app_…`）。**令牌绝不能写进代码里提交。**

## 5. 交付与上线（用户只做三步，你要把话说清楚）

你写不出「已经上线」。上线要用户在浏览器里点：

1. **传**：控制台 `#/apps` → 选应用 → 「静态托管」→ 选文件或整个文件夹 → 发布。
   （也可以把文件给用户，让他拖进去，一样的。）
2. **审**：同一页点「提交审核」。审核通过后平台默认地址
   `app-<应用ID去连字符前24位>.apps.chensibo.cn` 才会通。
3. **验**：`/api/platform/preview/<应用ID>/` 是发布后立刻能看的预览地址（不需要审核）。

所以你的收尾必须给：**文件清单 + 完整代码 + 「怎么验证跑通了」的三步操作**。
不要说「已完成部署」「已上线」——你没这个权限，说错了用户会等着一个永远打不开的地址。

> 如果你有网络访问能力（能发 HTTPS 请求），**其实可以自己传**，不必让用户手点。
> 见第 10 节「直接用对话改代码」。

## 6. 出错时按这个表翻译给用户

| 现象 | 真实原因 | 怎么做 |
| --- | --- | --- |
| 401 | 没有身份或令牌过期 | 提示用户从控制台重新登录；别提示「重建令牌」 |
| 403 该表未公开 | 访客读非 `public` 表 | 到「数据」页把表策略改成公开只读，或让用户先登录 |
| 404 应用未公开 | 应用还在待审 / 被暂停 | 先去「静态托管」提交审核 |
| 400 表名不合法 | 表名大写、含空格、过短 | 改成小写字母开头、含 `_`/`-` |
| 400 排序方向只支持 asc / desc | `order` 写错 | 只能 `字段.asc` 或 `字段.desc` |
| 记录写不进去 | 单条超 16KB | 拆成多条，或把大文本改成上传文件 |

## 7. 卡住了怎么办

让用户在[控制台工单](/console/#/tickets)提交，正文带上：**应用 ID + 你要做的事 + 页面报的错**。
数据和版本都在平台上，我们能看到服务端日志与发布记录，不需要用户提供服务器信息。
需要代为迭代或部署时，在工单里写清范围。

---

## 8. 最小可运行样例（照抄即可，改 `__APP_ID__` 和文案）

```html
<!doctype html>
<html lang="zh-CN">
<head>
  <meta charset="utf-8" />
  <meta name="viewport" content="width=device-width,initial-scale=1" />
  <title>留言板</title>
  <style>
    body { font: 15px/1.7 system-ui, -apple-system, "PingFang SC", sans-serif; max-width: 640px; margin: 40px auto; padding: 0 16px; color: #1f2328; }
    h1 { font-size: 20px; margin: 0 0 4px; }
    .sub { color: #6a737d; font-size: 13px; margin-bottom: 20px; }
    form { display: flex; gap: 8px; margin-bottom: 24px; }
    input, textarea, button { font: inherit; }
    input[type=text], textarea { flex: 1; padding: 8px 10px; border: 1px solid #d0d7de; border-radius: 8px; }
    button { padding: 8px 16px; border: 0; border-radius: 8px; background: #1a5cff; color: #fff; cursor: pointer; }
    button:disabled { opacity: .5; cursor: default; }
    ul { list-style: none; padding: 0; margin: 0; }
    li { padding: 12px 0; border-top: 1px solid #eaeef2; }
    .meta { color: #6a737d; font-size: 12px; margin-top: 4px; }
    .empty { padding: 32px 0; text-align: center; color: #6a737d; }
    .err { color: #c4291c; margin: 8px 0; }
  </style>
</head>
<body>
  <h1>留言板</h1>
  <p class="sub">说点什么，所有人都能看到。</p>

  <form id="f">
    <input type="text" id="name" placeholder="你的名字" maxlength="20" required />
    <input type="text" id="content" placeholder="留言内容" maxlength="200" required />
    <button type="submit" id="send">发布</button>
  </form>

  <div id="err" class="err" hidden></div>
  <ul id="list"></ul>
  <div id="empty" class="empty" hidden>还没有留言。写第一条吧。</div>

  <script src="https://chensibo.cn/apps/csb.js"></script>
  <script>
    // 请替换成你的应用 ID
    const APP_ID = '__APP_ID__'
    const TABLE = 'messages'
    csb.init(APP_ID)

    const $ = (s) => document.querySelector(s)
    const list = $('#list')
    const empty = $('#empty')
    const errBox = $('#err')

    function showError(e) {
      errBox.textContent = e && e.message ? e.message : '请求失败，请稍后重试'
      errBox.hidden = false
    }
    function clearError() { errBox.hidden = true }

    function render(rows) {
      list.innerHTML = ''
      empty.hidden = rows.length > 0
      for (const r of rows) {
        const li = document.createElement('li')
        const body = document.createElement('div')
        body.textContent = r.data.content || ''
        const meta = document.createElement('div')
        meta.className = 'meta'
        meta.textContent = (r.data.name || '匿名') + ' · ' +
          new Date(r.created_at).toLocaleString('zh-CN')
        li.append(body, meta)
        list.append(li)
      }
    }

    async function load() {
      try {
        clearError()
        const { rows } = await csb.from(TABLE).select({ order: 'created_at.desc', limit: 20 })
        render(rows)
      } catch (e) {
        showError(e)
        empty.hidden = false
        empty.textContent = '读取失败。若是第一次使用，请先在控制台把这张表设为公开只读。'
      }
    }

    $('#f').addEventListener('submit', async (ev) => {
      ev.preventDefault()
      const btn = $('#send')
      const name = $('#name').value.trim()
      const content = $('#content').value.trim()
      if (!name || !content) return
      btn.disabled = true
      try {
        clearError()
        await csb.from(TABLE).insert({ name, content })
        $('#content').value = ''
        await load()
      } catch (e) {
        showError(e)
      } finally {
        btn.disabled = false
      }
    })

    load()
  </script>
</body>
</html>
```

## 9. 什么需求能单点，什么需求要先谈

| 能直接做（数据 + 展示 + 上传） | 要先谈（需要服务端） |
| --- | --- |
| 留言板、报名表、作品展示、通讯录、记账本、需求收集箱、投票 | 在线支付、发邮件/短信、调用付费第三方 API、需要密钥的逻辑、定时任务、文件转码 |
| 一切「填进去—存起来—按条件查出来」 | 一切「必须由服务器悄悄算」的 |

右列不是不能做，是要走另一条线（我们代为部署）。遇到右列需求，老实告诉用户需要人工评估，
不要用「把密钥写进前端」这种办法绕过去。

## 10. 直接用对话改代码（上传 / 下载 / 编辑）

如果你能发 HTTPS 请求（大多数带联网能力的助手都能），就不用让用户手点上传了——
你可以自己读线上文件、改、再发布。**认证走网页端**：用户在我们网站上点一下「确认授权」，
你拿到一个有权限、有期限、可随时撤销的令牌。

### 10.1 拿到令牌（设备授权，三步）

```
① 你先申请：POST https://chensibo.cn/api/cli-auth/device
   body: {"scopes": ["apps:read", "apps:deploy"]}
   → {"device_code": "...", "user_code": "A1B2C3D4E5",
      "verification_uri": "https://chensibo.cn/cli-authorize.html", "expires_in": 600, "interval": 5}

② 请用户做这一步（你做不到，必须让人来）：
   把 user_code 原文发给用户，让他打开
   https://chensibo.cn/cli-authorize.html?code=<user_code>
   登录后核对权限清单，点「确认授权」。

③ 你轮询换令牌（每 5 秒一次，10 分钟内有效）：
   POST https://chensibo.cn/api/cli-auth/token
   body: {"device_code": "..."}
   → 还没批准：{"status": "authorization_pending"}      继续等
   → 被拒绝：  {"status": "access_denied"}              停止，别再问
   → 批准了：  {"access_token": "csb_agent_...", "expires_in": 2592000}
```

拿到 `access_token` 后，所有请求带 `Authorization: Bearer csb_agent_...`。

**令牌纪律（说给你自己听）**：它是用户 30 天的应用读写权限。不要打印到聊天里、不要写进
代码文件、不要提交到 git、不要传给第三方。用户随时可以在同一个页面撤销。

### 10.2 读、改、发

| 动作 | 请求 |
| --- | --- |
| 看现有文件 | `GET /api/platform/apps/<应用ID>/site/tree` |
| 读一个文件 | `GET .../site/file?path=index.html` → `{path, encoding, content}` |
| 写/改一个文件 | `PUT .../site/file` body `{path, content}`（可选 `encoding: "base64"` 传图片） |
| 删一个文件 | `DELETE .../site/file?path=old.html` |
| 发布成新版本 | `POST .../site/publish` body `{note: "改了什么"}` |
| 放弃全部草稿 | `POST .../site/discard` |

要点：

- 这些写的是**草稿区**，线上不受影响。`publish` 才切换版本，自动留历史、可回滚。
- 第一次读草稿区，平台会从线上当前版本复制一份——所以你看到的就是真实在跑的代码。
- 路径规则与发布一致：相对路径、小写字母数字下划线连字符、`/` 分目录、不许 `..`；
  扩展名白名单 `html css js json svg png jpg jpeg webp woff2 txt xml ico`；
  单文件 ≤512KB、整站 ≤5MB、≤200 个文件。
- 发布后让用户打开 `https://chensibo.cn/api/platform/preview/<应用ID>/` 验收；
  公网域名要应用处于「已开放」状态。

### 10.3 典型对话长什么样

> 用户：把我那个记账本首页的标题改成「八月账本」
>
> 你：① `GET tree` → 看到 `index.html`、`app.js`
> ② `GET file?path=index.html` → 找到 `<h1>记账本</h1>`
> ③ `PUT file` 只改这一行
> ④ `POST publish {note:"标题改成八月账本"}`
> ⑤ 回一句「改好了，打开预览地址看一眼；不满意我在两个版本之间回滚」

**不要**为了改一行就重新生成整个站点——那会把用户后来手改的东西覆盖掉。
先读、再精确改、再发。
