# 应用接口（/app/v1）

本文介绍已安装的应用向平台要平台能力的接口：与 `/open/v1` 的区别、谁可以调用、需要声明的范围，以及事件与记录链接两个新路由。

## 区分应用接口与开放接口

平台有两个接口面，约定完全一致（错误信封、`Idempotency-Key`、限流头、`Raise-Environment`、7 天请求日志），用途不同：

| 接口面 | 调用方 | 用途 |
|---|---|---|
| `/open/v1` | 组织授权的集成，或代表组织的应用 | 读写组织的数据 |
| `/app/v1` | 已安装的应用 | 使用平台能力：客户中心、站点访客、经营记账、审计链、成员、事件、记录链接 |

基础地址是 `https://api.${DOMAIN}/app/v1`。调用同样出现在开发者门户应用详情的「日志」页签里（按环境与状态筛选），路由显示为 `/app/v1/…`；命令行 `k3 logs` 看同一份记录，见 [本地开发与测试企业](/developers/guide/local-dev) 的「查看调用日志与错误」。错误码与幂等规则见 [错误码与请求 ID](/developers/guide/errors)。完整的请求与响应形状见规范文件 `app-v1.openapi.yaml`。

Go SDK 用 `client.App`，TypeScript SDK 用 `raise.app`，不要自己拼重试。

## 使用哪种令牌

`/app/v1` 收两种令牌：

- **应用自己的租户令牌**：用应用密钥调用 `POST /open/v1/oauth/token` 换取，见 [认证与凭证](/developers/guide/auth)。令牌里没有人，适合后台任务。
- **页面会话**：正在使用你的应用页面的那个人的会话。你的后端从请求头 `K3-App-Token` 取出，原样作为 `Authorization: Bearer` 转给平台，平台按这个人的身份和权限处理。工作台和 App Bridge 替页面调 `POST /api/apps/{id}/token` 拿到它；自下一个平台版本起，不由平台托管的应用（清单没有 `runtime`）也有页面会话，在这之前这个接口对它们返回 404。开发时页面怎样进到测试企业的工作台，见 [本地开发与测试企业](/developers/guide/local-dev) 的「应用的页面在测试企业的工作台里」。

客户令牌不能调用这里，返回 HTTP 403 与错误码 `forbidden`。`GET /installations` 与 `POST /services/{owner}/{name}/calls` 只收应用自己的令牌，页面会话同样返回 `forbidden`。

## 在清单里声明 uses

每个路由需要一个 `app.*` 范围。范围必须写在应用清单的 `uses` 字段里，随版本审核；应用的令牌在每个安装了它的租户里自带这些范围，不需要租户管理员再授权。缺少范围返回 HTTP 403 与错误码 `scope_required`。

应用要调用 `/open/v1` 的接口（例如 `platform:ping` 换来的 `Platform.Whoami`、`customers:read`），也写在同一个 `uses` 里。安装时管理员在同意页上看到这些范围；升级版本新增的范围要管理员再同意一次。应用的租户令牌带着它们，卸载后令牌在下一次校验时失效。

| 范围 | 路由 |
|---|---|
| `app.customers:resolve` | `POST /customers/resolve`、`POST /customers/touch` |
| `app.customers:read` | `GET /customers/{id}` |
| `app.visitors:read` | `GET /site-visitors`、`GET /site-visitors/{site}/{visitor}` |
| `app.visitors:write` | `PUT /site-visitors/{site}/{visitor}` |
| `app.access:read` | `GET /access`、`GET /unit`、`POST /access/points`（一次最多 100 个权限点） |
| `app.directory:read` | `GET /members` |
| `app.org:read` | `GET /org/members`（按调用者在本应用权限点上的数据范围裁剪的成员及其部门、角色；分页；只有 id、显示名、部门和角色，不含联系方式） |
| `app.ledger:post` | `POST /ledger/posts` |
| `app.audit:write` | `POST /audit` |
| `app.blobs:read`、`app.blobs:write` | `/blobs/…`（本应用文件夹里的文件、预签名地址） |
| `app.notify:send` | `POST /notify` |
| `app.drafts:write`、`app.drafts:read`、`app.drafts:review` | `POST /drafts`（为成员起草）、草稿的读取与审阅、`GET /provenance` |
| `app.outreach:send`、`app.outreach:read` | `/outreach/…` |
| `app.options:read`、`app.options:write` | `/options/{list}` |
| `app.models:use` | `/models/…` |
| `app.intent:parse` | `POST /intent/parse`（页面会话的人的一句话，可带最多 3 张图片，解析成本应用某个操作的输入建议；只收页面会话，应用自己的令牌返回 403；只解析本应用的操作；不写任何东西，调了模型按用量计入企业） |
| `app.pay:read`、`app.pay:write` | `/pay/orders/…` |
| `app.approvals:read`、`app.approvals:write` | `/approvals/…` |
| `app.calendar:write` | `POST /calendar/projections` |
| `app.desk:use` | `GET /peers/desk`、`POST /peers/desk/http`（其他应用的核销台，经平台去问；只收页面会话） |
| `app.till:use` | `GET /peers/till`、`POST /peers/till/http`（其他应用的收银台，经平台去问；只收页面会话） |
| `app.holds:read` | `GET /peers/holds`、`GET /peers/holds/holdable`、`POST /peers/holds/free`（资源占用：治疗室与设备） |
| `app.holds:write` | `POST /peers/holds/hold`、`/move`、`/release` |
| `app.referrals:read` | `GET /peers/referral-codes`、`POST /peers/referral-codes/code`、`/promoter`（推广码） |
| `app.deals:write` | `POST /deals`（把本应用的成交、退款、作废交给平台投递） |
| `app.installations:read` | `GET /installations` |
| `app.outreach:reach` | `GET /outreach/customers/{id}/messages`、`POST /outreach/reachable` |
| `app.customers:tag` | `PUT`、`DELETE /customers/{id}/tags/{tag}` |
| `app.services:call` | `POST /services/{owner}/{name}/calls` |
| `app.events:emit` | `POST /events/emit` |
| `app.links:read` | `GET /links`、`GET /links/resolve` |
| `app.secrets:use` | `POST /secrets/encrypt`、`POST /secrets/decrypt`、`POST /secrets/shred` |
| `app.sites:read` | `POST /sites/summaries` |
| `app.stores:read` | `GET /stores`（可选 `?of=<主体>`） |
| `app.entitlements:read` | `/entitlements/…` 里的读取：模板、发放、预留、核销码、发放方 |
| `app.entitlements:write` | `/entitlements/…` 里的写入：模板、发放、退款、转让、预留、核销，以及 `POST /entitlements/stores/relink` |
| `app.customers:list` | `GET /customers`（页面会话的人在「查看客户」数据范围内的客户） |
| `app.customers:write` | `POST /customers/{id}/rename`、`/links`、`/owner`、`/dealt`、`/merge`、`/erase` |
| `app.customers:consent_events` | 订阅 `customer.consent.changed` 的服务，没有 HTTP 路由；见下文 |
| `app.imports:write` | `POST /imports/{batch}/items` |
| `app.resources:read` | `/access/…` 里的读取：`GET /access/{type}`、`POST /access/{type}/check`、`GET /access/{type}/visible`、`GET /access/{type}/mine`、`GET /access/{type}/{id}`、访问申请的列表 |
| `app.resources:write` | `/access/…` 里的写入：登记与删除资源、授权、继承、通用访问、开关、转让所有者、访问申请的提交与审批 |
| `app.realtime:read` | `/realtime/docs/{id}`、`/state`、`/projection`，`POST /realtime/codecs/{codec}/meta` |
| `app.realtime:write` | 其余 `/realtime/…`：建、删文档，提交编辑，发布主题，撤权与失效连接 |
| `app.errors:report` | `POST /errors` |
| `app.logs:write` | `POST /logs` |
| `app.traces:write` | `POST /traces` |
| `app.analytics:write` | `POST /analytics` |

租户永远是令牌的租户，响应头 `X-K3-Tenant` 写出令牌实际指向的组织。有归属检查的字段必须是本应用的：`GET /access` 的 `point` 以 `app.${APP_ID}.` 开头，`POST /audit` 的 `action` 以 `${APP_ID}.` 开头，`POST /ledger/posts` 的 `sourceApp` 是本应用，否则返回 `forbidden`。

## 客户的数据范围

应用自己的调用（企业令牌）可以读本企业的任何客户。页面会话里的人（代表某位员工的调用）只能读他在客户应用里「查看客户」（`customer.read`）数据范围内的客户：`GET /customers/{id}` 对范围外的客户返回 404，与不存在一样；`POST /customers/resolve` 找到范围外的客户时，回答只有 `id` 与 `created`，不给 `name`。`POST /customers/touch` 只记一次接触、不回答客户的任何资料，不受范围限制。

## 每个资源的请求示例

每个资源一段示例，用生成的 Go SDK：`c` 是 `raise.New` 建好的客户端，`ctx` 是这次请求的 context。文件与转写另附 TypeScript（`raise` 是 `new Raise({...})`）。所需范围见上表，完整的字段见规范文件。Go 示例的完整函数在 raise-go 模块的 `examples/appapi` 里。

### 权限点（access）

问调用者在本应用一个权限点上的结果与数据范围。

```go
acc, err := c.App.Access.Check(ctx, &raise.AppAccessCheckParams{Point: "app.shop-sync.orders.read"})
if err != nil {
	return err
}
fmt.Println(acc.Allowed, acc.Units)
```

### 审批（approvals）

把本应用的一个对象提交审批，种类在清单的 `approvals` 里声明。

```go
ref, err := c.App.Approvals.Submit(ctx, &raise.AppApprovalSubmitRequest{
	Kind:    "refund",
	Subject: "k3://shop-sync/refund/rf_1",
})
if err != nil {
	return err
}
fmt.Println(ref.Id, ref.Status)
```

### 审计（audit）

在企业的审计链里记一条，`action` 以本应用 ID 开头。

```go
_, err := c.App.Audit.Append(ctx, &raise.AppAuditRequest{
	Action:   "shop-sync.order.cancel",
	Resource: "k3://shop-sync/order/1001",
	Diff:     map[string]any{"status": "cancelled"},
})
```

### 文件（blobs）

把字节存进本应用的文件夹，再读回来。`Put` 收原始字节和长度，`Get` 的 `Body` 由调用方读完后关闭。键里的 `/` 保留。

```go
data := []byte("hello")
if _, err := c.App.Blobs.Put(ctx, "apps/shop-sync/notes/a.txt", bytes.NewReader(data), int64(len(data)), "text/plain"); err != nil {
	return err
}
obj, err := c.App.Blobs.Get(ctx, "apps/shop-sync/notes/a.txt")
if err != nil {
	return err
}
defer obj.Body.Close()
if _, err := io.Copy(os.Stdout, obj.Body); err != nil {
	return err
}
```

```ts
await raise.app.blobs.put('apps/shop-sync/notes/a.txt', new Blob(['hello'], { type: 'text/plain' }))
const res = await raise.app.blobs.get('apps/shop-sync/notes/a.txt')
const text = await res.text()
```

### 日历（calendar）

把本应用的一件事放到负责人的日历上；同一个 `ref` 的版本更新时覆盖。

```go
start := time.Date(2026, 11, 3, 15, 0, 0, 0, time.Local)
_, err := c.App.Calendar.Push(ctx, &raise.AppCalendarPush{
	Ref:     "k3://shop-sync/visit/v_1",
	Version: 1,
	Item: &raise.AppCalendarProjection{
		Ref: "k3://shop-sync/visit/v_1", Owner: "u_1", Title: "到店取货",
		Start: start, End: start.Add(30 * time.Minute),
	},
})
```

### 客户（customers）

按手机号找到或建立客户。

```go
ref, err := c.App.Customers.Resolve(ctx, &raise.AppCustomerResolveRequest{
	Identities: []raise.AppCustomerIdentity{{Kind: "phone", Value: "13800000000"}},
}, nil)
if err != nil {
	return err
}
fmt.Println(ref.Id, ref.Created)
```

### 成交（deals）

把本应用的一笔成交交给平台投递。

```go
_, err := c.App.Deals.Emit(ctx, &raise.AppDealEvent{
	Key:         "sale-1001",
	Kind:        "sale",
	AmountCents: 9900,
	At:          time.Now(),
	Customer:    "cu_1",
	Source:      raise.AppDealSource{App: "shop-sync", Ref: "k3://shop-sync/order/1001"},
})
```

### 成员（directory）

列出企业的成员。

```go
members, err := c.App.Directory.Members(ctx)
if err != nil {
	return err
}
for _, m := range members.Items {
	fmt.Println(m.UserId, m.Name)
}
```

### 草稿（drafts）

为成员起草一项改动，由他审阅后应用。

```go
draft, err := c.App.Drafts.Create(ctx, &raise.AppDraftCreateRequest{
	Summary: "补录一笔订单",
	Items: []raise.AppDraftItemInput{
		{Op: "shop-sync.create_order", Proposed: map[string]any{"customer": "cu_1", "amountCents": 9900}},
	},
})
if err != nil {
	return err
}
fmt.Println(draft.Id, draft.Status)
```

新应用的草稿都走 `/app/v1/drafts…`：上面的 `c.App.Drafts`，以及 `appkit` 的 `cl.Drafts()` 和 `appkit.MountDrafts`，调的都是它。平台上还有一组早于 `/app/v1` 的地址 `/k3/app/drafts…`：它不在 `/app/v1` 的接口描述里，公开的 SDK `raise-go` 不调用它，新应用不要用。

### 权益（entitlements）

按模板给客户发放权益；同一个 `key` 重复调用不会多发。

```go
res, err := c.App.Entitlements.Grant(ctx, &raise.AppEntitlementGrantRequest{
	Template: "tpl_1",
	Holder:   "cu_1",
	Key:      "order-1001",
})
if err != nil {
	return err
}
fmt.Println(len(res.Grants), res.Replay)
```

### 错误上报（errors）

把应用的一次错误报到开发者平台的错误列表。

```go
_, err := c.App.Errors.Report(ctx, &raise.AppErrorReport{
	Errors: []raise.AppErrorOccurrence{{Kind: "panic", Message: "nil map", Route: "/api/orders", At: time.Now()}},
})
```

### 结构化日志（logs）

写给开发者看的结构化日志行，开发者用 `k3 tail` 实时读。平台保存前按企业脱敏：开发者自己的测试企业保留原文（只去掉密钥），其他企业的消息和字段值都换成占位符，字段名保留。常驻进程用 `appkit.Logger`（批量、异步、队列满了丢弃并计数）。

```go
res, err := c.App.Logs.Write(ctx, &raise.AppLogBatch{
	Version: "1.4.0",
	Lines:   []raise.AppLogLine{{Level: "warn", Message: "order sync slow", Fields: map[string]string{"order": "1001", "ms": "2300"}}},
})
if err != nil {
	return err
}
fmt.Println(res.Accepted, res.Dropped)
```

### 自定义分析（analytics）

写本应用自定义的分析数据点，开发者用 `k3 analytics query` 或开发者控制台查询（ADR-0155 §1）。数据集要先在清单的 `analytics` 里登记（`name`、`blobs`、`doubles` 的字段名），没登记的、不合登记的点丢弃并计数。一个点最多 1 个索引、20 个字符串、20 个数值；每个应用每秒最多 200 个点。平台在写入前按企业脱敏：测试企业保留原文，其他企业的字符串换成占位符；留 31 天。常驻进程用 `appkit.Analytics`（批量、异步）。

```go
res, err := c.App.Analytics.Write(ctx, &raise.AppAnalyticsBatch{
	Version: "1.4.0",
	Points:  []raise.AppAnalyticsPoint{{Dataset: "orders", Index: "sku-1", Blobs: []string{"cn"}, Doubles: []float64{12.5}}},
})
if err != nil {
	return err
}
fmt.Println(res.Accepted, res.Dropped)
```

### 请求追踪（traces）

把应用自己的段加进平台的请求追踪（ADR-0155 §2），开发者用 `k3 traces` 或开发者控制台看瀑布图。平台的应用网关给每个请求开一个追踪，交给应用 `traceparent` 和带平台标记的 `tracestate`（`raise=…`）；段要带上这个标记，否则不收。只收登记过的属性名，值按企业脱敏。常驻进程用 `appkit.Tracer` 的中间件和 `Trace.Span`，不用自己调这个接口。

```go
res, err := c.App.Traces.Write(ctx, &raise.AppTraceSpanBatch{
	Version: "1.4.0",
	Spans: []raise.AppTraceSpan{{TraceId: "4bf92f3577b34da6a3ce929d0e0e4736", SpanId: "00f067aa0ba902b7", ParentId: "b7ad6b7169203331",
		Mark: "<the tracestate's raise= value>", Name: "load order", Kind: "internal", Start: time.Now(), DurationUs: 1800,
		Attrs: map[string]string{"db.operation": "select"}}},
})
if err != nil {
	return err
}
fmt.Println(res.Accepted, res.Refused)
```

### 事件（events）

发出清单 `events.emits` 里声明的开放事件，见下文。

```go
ack, err := c.App.Events.Emit(ctx, &raise.AppEmitRequest{
	Type: "shop-sync.order.created",
	Key:  "order-1001",
	Data: map[string]any{"orderId": "1001"},
})
if err != nil {
	return err
}
fmt.Println(ack.Written)
```

### 导入（imports）

上报导入旧记录的一批结果，见下文。

```go
batch, err := c.App.Imports.Report(ctx, "batch-2026-11", &raise.AppImportReport{
	Items: []raise.AppImportReportItem{{
		Key: "row-1", Kind: "order", Status: "ok", Digest: "sha256:…",
		RecordId: "1001", BusinessAt: time.Now(),
	}},
})
if err != nil {
	return err
}
fmt.Println(batch.RowsOk, batch.RowsFailed)
```

### 安装（installations）

列出装了本应用的租户，只收应用自己的令牌。测试凭证的令牌列出装了本应用的测试企业，不论它属于哪个开发者组织；正式凭证的令牌只列真实组织，两个环境不混。
列出装了本应用的租户，只收应用自己的令牌。回答随令牌的环境：测试环境的令牌（`kwo_test_` 密钥换来的）列出你自己组织里装了本应用的测试企业，正式环境的令牌列出装了它的真实企业。这从下一个平台版本起；在这之前，测试环境的令牌得到空列表 `{"tenants": []}`。

```go
inst, err := c.App.Installations.List(ctx)
if err != nil {
	return err
}
fmt.Println(inst.Tenants)
```

### 意图解析（intent）

把页面会话里那个人的一句话解析成本应用某个操作的输入建议，只收页面会话。

```go
res, err := c.App.Intent.Parse(ctx, &raise.AppIntentRequest{
	Op:   "shop-sync.create_order",
	Text: "张三明天下午买两盒，99 元",
})
if err != nil {
	return err
}
fmt.Println(res.Input, res.Missing)
```

### 记账（ledger）

把本应用的一笔收支记进企业的账簿。

```go
posted, err := c.App.Ledger.Post(ctx, &raise.AppLedgerPostRequest{
	SourceApp:   "shop-sync",
	SourceRef:   "k3://shop-sync/order/1001",
	Kind:        "purchase",
	Direction:   "in",
	AmountCents: 9900,
	Method:      "wechat",
})
if err != nil {
	return err
}
fmt.Println(posted.Key)
```

### 记录链接（links）

在另一个应用里按当前人员的权限搜索记录。

```go
found, err := c.App.Links.Search(ctx, &raise.AppLinksSearchParams{App: "wiki", Q: "退货"})
if err != nil {
	return err
}
for _, l := range found.Items {
	fmt.Println(l.Ref, l.Title)
}
```

### 模型（models）

调用企业的服务模型，以及把一段录音转成文字。转写的 `Content-Type` 必须是 `audio/…`，由调用方给出。

```go
out, err := c.App.Models.Complete(ctx, &raise.AppModelRequest{Scene: "order-summary", User: "把这条留言缩成一句话：……"})
if err != nil {
	return err
}
fmt.Println(out.Text)

audio, err := os.Open("note.wav")
if err != nil {
	return err
}
defer audio.Close()
heard, err := c.App.Models.Transcribe(ctx, audio, -1, "audio/wav")
if err != nil {
	return err
}
fmt.Println(heard.Text)
```

```ts
const { text } = await raise.app.models.transcribe(recording, 'audio/wav')
```

### 通知（notify）

给成员发一条站内通知。

```go
_, err := c.App.Notify.Send(ctx, &raise.AppNotifyRequest{
	Users: []string{"u_1"},
	Title: "新订单 1001",
	Link:  "/apps/shop-sync/orders/1001",
})
```

### 选项（options）

读本应用的一个选项列表。

```go
list, err := c.App.Options.Get(ctx, "pickup-points", nil)
if err != nil {
	return err
}
fmt.Println(list.Items)
```

### 组织（org）

列出调用者在本应用权限点上数据范围内的成员，分页。

```go
page, err := c.App.Org.Members(ctx, &raise.AppOrgMembersParams{Point: "app.shop-sync.orders.read"})
if err != nil {
	return err
}
for _, m := range page.Items {
	fmt.Println(m.Id, m.Name, m.Units)
}
```

### 触达（outreach）

用模板给客户发一条消息；同一个 `key` 不会多发。

```go
msg, err := c.App.Outreach.Send(ctx, &raise.AppOutreachSendRequest{
	Customer: "cu_1",
	Channel:  "sms",
	Purpose:  "service",
	Template: "tpl_pickup",
	Key:      "pickup-1001",
	Vars:     map[string]string{"order": "1001"},
})
if err != nil {
	return err
}
fmt.Println(msg.Id, msg.Existing)
```

### 收款（pay）

开一张收款单。

```go
order, err := c.App.Pay.Create(ctx, &raise.AppPayCreateRequest{
	Channel:     "wechat",
	AmountCents: 9900,
	Subject:     "订单 1001",
	SourceRef:   "k3://shop-sync/order/1001",
})
if err != nil {
	return err
}
fmt.Println(order.Id, order.Status, order.CodeUrl)
```

### 其他应用（peers）

经平台问另一个应用哪些房间在某个时段空着。

```go
from := time.Date(2026, 11, 3, 14, 0, 0, 0, time.Local)
free, err := c.App.Peers.HoldsFree(ctx, &raise.AppPeerHoldsFreeRequest{
	Items: []string{"room-1", "room-2"},
	From:  from,
	To:    from.Add(time.Hour),
})
if err != nil {
	return err
}
for _, it := range free.Items {
	fmt.Println(it.Item, it.Free)
}
```

### 实时文档（realtime）

建一个清单 `realtime.docs[]` 里声明种类的共享文档。

```go
doc, err := c.App.Realtime.CreateDoc(ctx, &raise.AppRealtimeDocCreate{
	Kind:     "note",
	Resource: "nb_1",
	Data:     map[string]any{"title": "周会"},
})
if err != nil {
	return err
}
fmt.Println(doc.Id, doc.HeadSeq)
```

### 资源访问（resources）

在访问内核里批量检查一个人对本应用资源的访问级别，见下文。

```go
ans, err := c.App.Resources.Check(ctx, "shop-sync.list", &raise.AppResourceCheckRequest{
	Ids:   []string{"ls_1", "ls_2"},
	Level: "edit",
})
if err != nil {
	return err
}
fmt.Println(ans.Levels)
```

### 密钥（secrets）

为本应用的一个主体加密一段值，明文是 base64。

```go
sealed, err := c.App.Secrets.Encrypt(ctx, &raise.AppSecretEncryptRequest{
	Subject:   "recipient:f_123",
	Plaintext: "MTM4MDAwMDAwMDA=", // base64
})
if err != nil {
	return err
}
fmt.Println(sealed.Ciphertext)
```

### 服务调用（services）

启动另一个应用的一次服务运行，只收应用自己的令牌。

```go
run, err := c.App.Services.Call(ctx, "campaign", "claimed", &raise.AppServiceCallRequest{
	Input: map[string]any{"customer": "cu_1"},
})
if err != nil {
	return err
}
fmt.Println(run.Id, run.Status)
```

### 站点（sites）

读站点的用量摘要。

```go
sums, err := c.App.Sites.Summaries(ctx, &raise.AppSiteSummariesRequest{
	Requests: []raise.AppSiteUsageRequest{{Site: "site_1", From: time.Now().AddDate(0, 0, -7)}},
})
if err != nil {
	return err
}
fmt.Println(len(sums.Summaries))
```

### 门店（stores）

列出企业的门店。

```go
stores, err := c.App.Stores.List(ctx, nil)
if err != nil {
	return err
}
for _, s := range stores.Stores {
	fmt.Println(s.Id, s.Name)
}
```

### 站点访客（visitors）

读一位站点访客对应的客户。

```go
v, err := c.App.Visitors.Get(ctx, "site_1", "vis_1")
if err != nil {
	return err
}
fmt.Println(v.Customer, v.Verified)
```

## 发出开放事件

先在清单的 `events.emits` 里声明事件类型，类型以 `${APP_ID}.` 开头，再调用：

```bash
curl -sS -X POST "https://api.${DOMAIN}/app/v1/events/emit" \
  -H "Authorization: Bearer ${TENANT_TOKEN}" \
  -H "Content-Type: application/json" \
  -d '{"type": "shop-sync.order.created", "key": "order-1001", "data": {"orderId": "1001"}}'
```

成功返回 HTTP 202：

```json
{ "key": "order-1001", "written": true }
```

`key` 必填，最长 200 字符：同一个键只发一次，重复调用返回 `"written": false`。未声明或不以应用 ID 开头的类型返回 `forbidden`。事件的签名、重试与订阅方式见 [Webhooks 与事件订阅](/developers/guide/webhooks)。

### 订阅另一个应用的事件

`events.emits` 里的类型必须以本应用 ID 开头（`${APP_ID}.{what}`，小写词用点连接，最长 64 字符，最多 32 个），不能是平台自己的事件，也不能重复。发出的事件除了送给集成的 webhook（类型是平台登记的开放事件、且带 `customer` 时），还会送给同一租户里订阅了它的其他应用。订阅方在清单里声明一个服务：

```json
{ "id": "claimed", "title": "领券后入场", "trigger": ["event:campaign.coupon.claimed"], "endpoint": "/k3/services/claimed" }
```

平台在发布方发出事件的同一个事务里把它写进发件箱，再启动订阅服务的一次运行，至少一次，带平台断言。幂等键是 `appevt:{发布方}:{发布方的键}`，同一个事件重复送达时键不变，订阅方按它去重。`input` 是 `{id, type, app, tenant, customer, data, createdAt}`。`data` 最多 16 KiB；事件只用 id 指代人和记录（客户用客户中心的 id），不放手机号和姓名。一个类型只由声明它的应用发布：发布方的新版本不再声明它，事件就不再送出；同名的审计动作也不再作为事件送给服务。

Go 的 appkit：发布方用 `Client.Emit(ctx, tenant, appkit.Emit{Type, Key, Customer, Data})`；订阅方用 `appkit.EventService(id, title, kinds...)` 声明服务，实现 `appkit.AppEventHandling`（`AppEvents(d) map[string]appkit.AppEventFunc`），`appkit.Serve` 在声明的端点验证断言后调用。

## 定时运行的服务（schedule）

清单 `services[]` 里触发器是 `schedule` 的服务必须带 `schedule`，没有这个触发器的服务不能带：

```json
{ "id": "round", "title": "推进一轮", "trigger": ["schedule"], "endpoint": "/k3/services/round",
  "schedule": { "cron": "*/10 * * * *", "timezone": "Asia/Shanghai", "missed": "skip" } }
```

- `cron` 是五段：分、时、日、月、星期。可以写数字、`*`、`a-b`、`/n` 和逗号列表；星期 0 和 7 都是周日；日和星期都限定时，满足其一即可。不支持月份和星期的英文名，也不支持 `@hourly` 这类写法。
- `timezone` 是 IANA 时区名，默认 `Asia/Shanghai`。
- `missed` 决定错过的时间（平台重启、故障、服务暂停）怎么处理：`skip`（默认）时，晚了超过 5 分钟的时间不再启动，只计数；`once` 时，只把其中最近的一个补跑一次。

平台每 30 秒检查一次到点的时间，对每个装了并开着本应用的租户各启动一次运行。平台有多个副本时，每个时间也只启动一次。投递方式同服务事件：带平台断言（`K3-Assertion`），至少一次，幂等键是 `schedule:` 加这个时间（UTC 的 RFC 3339）；`input` 是 `{"scheduledAt": "…", "missed": 0}`，`missed` 是之前没有启动的时间个数。回答 2xx 表示完成，5xx、408、429 或没有回答会按退避重试，其他 4xx 拒绝、不再重试；连续失败 3 次服务会暂停，管理员会收到通知。新装、升级、卸载最迟 5 分钟后生效。每次投递都记进开发者的请求日志（方向 out），失败的同时是一条脱敏错误。

Go 的 appkit：`appkit.ScheduleService(id, title, cron, zone, missed)` 声明服务，应用实现 `appkit.Scheduling`（`Schedules(d) map[string]appkit.ScheduleFunc`），`appkit.Serve` 会在声明的端点验证断言，再调用对应的函数。

## 客户的同意变化（customer.consent.changed）

客户退订、撤回或给出同意时（退订链接、回复退订、员工改动、客户被抹除），平台会通知订阅了的应用。应用要在清单的 `uses` 里写 `app.customers:consent_events`，并声明一个服务：

```json
{ "id": "consent", "title": "退订即停", "trigger": ["event:customer.consent.changed"], "endpoint": "/k3/services/consent" }
```

投递方式同服务事件：至少一次，带平台断言，幂等键是变化的 `id`。`input` 是 `{id, type, tenant, customer, channel, purpose, state, at}`。`customer` 是客户中心的 id；`channel` 为 `sms`、`wechat_template`、`wecom` 或 `any`；`purpose` 为 `service` 或 `marketing`；`state` 为 `granted` 或 `withdrawn`。里面没有手机号、姓名，也没有同意的来源。

撤回同意时，平台在写入撤回的同一个事务里先取消这位客户在这个渠道、这个用途下排队未发的消息，再通知应用；应用收到通知时，这些消息已经不会发出。客户被抹除时，每一项仍然有效的同意都各发一次撤回。

Go 的 appkit：`appkit.ConsentService(id, title)` 声明服务，清单 `Uses` 加 `appkit.ScopeConsentEvents`，应用实现 `appkit.ConsentHandling`（`ConsentChanges(d) appkit.ConsentFunc`）。

## 客户标签

- `GET /app/v1/customers/tags`（`app.customers:read`）：企业的客户标签库，每项是 `{id, name, color, source, writable}`。`writable` 为 true 的是手动标签，应用可以打上、去掉；不含每个标签有多少客户。
- `PUT /app/v1/customers/{id}/tags/{tag}` 打上，`DELETE` 同一路径去掉（`app.customers:tag`，单独的范围）：只能是本企业的客户、标签库里的手动标签。应用自己的调用可改本企业任何客户；页面会话里的人只能改他在客户应用里「编辑客户」数据范围内的客户，范围外的与不存在一样返回 404。别的来源（企业微信）的标签返回 422，客户或标签不存在返回 404，客户被合并时作用在主客户上，重复调用结果相同。每次改动在企业的审计链里记一条（`customer.tag` / `customer.untag`，操作者是页面会话的人或应用自己）。一个应用在一个企业里每 10 分钟最多改 3000 次，超过返回 429 与 `Retry-After`。

Go 的 appkit：`Client.CustomerTags()` 的 `Tags(ctx, caller)` 与 `Tag(ctx, caller, customer, tag, on)`。

## 客户的触达记录与可达性

两条都要范围 `app.outreach:reach`（单独的范围），只限本企业的客户：别的 id 返回 404，合并过的客户按主客户读。应用自己的调用可读本企业任何客户；页面会话里的人只能读他在客户应用里「查看客户」数据范围内的客户，范围外的与不存在一样返回 404。

- `GET /app/v1/outreach/customers/{id}/messages?since=&limit=`：这位客户在各个渠道收到过的消息，新的在前（`limit` 1 到 200，默认 50；`since` 是 RFC 3339 时间）。每条只有 `id`、`channel`、`purpose`、`state`、`reason` 和时间（`createdAt`、`sentAt`、`deliveredAt`）；`template` 与 `triggerRef` 只在本应用自己发的消息上给（`mine: true`）。没有消息内容、变量和接收号码。
- `POST /app/v1/outreach/reachable`，请求 `{"customer", "channel", "purpose"}`：发送前问这位客户现在能不能经这个渠道、为这个用途被触达。答 `{"reachable": true}`，或 `{"reachable": false, "reason": "…"}`，原因按发送时的检查顺序给第一个：`channel_off`（渠道没开）、`withdrawn`（撤回了同意）、`no_consent`（营销消息没有同意）、`no_identity`（没有手机号或 openid）、`blocked`（30 天内被服务商拦截过）、`freq_day` / `freq_week`（营销频控）、`tenant_cap`（企业每日营销上限）。能触达但在免打扰时段时，`later` 是消息会发出的时间。这次查询不占频次；真正发送时平台会再查一遍。

Go 的 appkit：`Client.Reach()` 的 `History(ctx, caller, customer, since, limit)` 与 `Reachable(ctx, caller, customer, channel, purpose)`。

## 搜索与解析记录链接

- `GET /app/v1/links?app=${OTHER_APP}&q=关键字&limit=20`：在另一个应用里，按当前人员的权限搜索记录。`limit` 为 1 到 50。响应里的 `installed` 说明这个租户有没有装并开启那个应用，用来区分「没装」和「没找到」。
- `GET /app/v1/links/resolve?ref=k3://...`：解析一个 `k3://` 引用，返回 `{"found": false}` 或 `{"found": true, "link": {...}}`。无权查看和不存在得到同样的回答。

## 托管进程的环境

平台托管运行的应用（清单写了 `runtime`），进程启动时有这些环境变量：

- `K3_APP`、`K3_VERSION`：应用 ID 与正在运行的版本。
- `PORT`：进程要监听的端口（清单 `runtime.port`，默认 8080）。
- `K3_PLATFORM_URL`：平台在内网的地址（例如 `http://kwork:8000`）。`/app/v1`、换令牌的 `/open/v1/oauth/token` 和平台的公钥都从这里调，不经公网。在平台之外运行时（例如 `k3 dev`），平台的根地址是 `RAISE_BASE_URL` 去掉末尾的 `/open/v1`，见 [本地开发与测试企业](/developers/guide/local-dev) 的「平台根地址：RAISE_BASE_URL 与 K3_PLATFORM_URL」。
- `K3_BASE_DOMAIN`：部署的基础域名（应用的公网地址是 `https://${K3_APP}.apps.${K3_BASE_DOMAIN}`）。
- `K3_APP_CLIENT_ID`、`K3_APP_CLIENT_SECRET`：平台运维替应用存好的 live 应用密钥，存了才有。
- `K3_APP_TEST_CLIENT_ID`、`K3_APP_TEST_CLIENT_SECRET`：应用的 test 应用密钥，存了才有。平台在测试企业里不收 live 密钥（`wrong_environment`），开发者自己的测试企业的租户令牌要用这把密钥换。raise-go 的 `appkit.Tokens` 读这两个变量，按平台在断言和 App 令牌里写的 `env` 选密钥（测试企业是 `test`）；没有 test 密钥时所有租户都用 live 密钥。
- `K3_APP_DATABASE_URL`：版本带 `data.migrations` 时，进程以自己的角色 `app_<id>` 连自己模式的数据库连接，由平台交给进程。
- 清单声明了 `egress` 时还有 `HTTPS_PROXY`、`HTTP_PROXY`、`NO_PROXY`：唯一的出网通道。

`K3_` 开头的名字和 `PORT` 由平台设定，清单的 `runtime.env` 不能覆盖。

## 密钥、站点摘要与门店

- `POST /secrets/encrypt`、`/secrets/decrypt`、`/secrets/shred`：为应用自己的主体加密、解密、销毁密钥。主体写作 `<kind>:<id>`，平台存成 `<应用>-<kind>:<租户>:<id>`。`decrypt` 的响应带 `Cache-Control: no-store`；密钥已销毁或密文属于别的主体都返回 HTTP 409，且不说明是哪一种；`decrypt` 的答案不会因 `Idempotency-Key` 被留存。
- `POST /sites/summaries`：站点摘要。
- `GET /stores`：门店列表，可加 `?of=<主体>`。
- `POST /entitlements/stores/relink`，请求 `{"from", "to"}`：把调用方应用自己的券模板和发放里的 `store:<from>` 改成 `store:<to>`，兑换所在的位置一并改；重复调用结果相同。

## 一次问多个权限点

`POST /app/v1/access/points`（`app.access:read`）一次问 1 到 100 个权限点，答案与逐个调用 `GET /access` 相同，按请求的顺序给出，不缓存。超过 100 个分几次问。Go SDK 用 `AccessMany` 或 `Authz.CheckMany`，`AccessBatcher` 是支持批量的 `Access`；生成的客户端是 `client.App.Access.CheckMany`。

## 上报导入的行

应用在自己的进程里导入旧系统的历史记录时，在自己的事务里写完每一行，再调用 `POST /app/v1/imports/{batch}/items`（`app.imports:write`）上报这一批的结果。平台把这一批列在管理员的跨应用导入批次页里，给每条写入的记录写来源（`via` 为导入），「来源」面板读的就是它，这一批也记入审计。只有租户管理员的页面会话可以上报。同一个键已经记为成功后，又作为另一行上报，返回冲突。Go SDK 用 `Client.Imports()` 的 `Report`。

## 站点动作的请求

站点动作的请求里总有 `unit`（站点所属的部门）。规格里写了 `openid` 或 `referrer` 时（`siteActionSpecs[].openid`、`.referrer`），请求里还有 `appid` 与访客的 `openid`（租户的公众号），或带来访客的分销员 `referrer`。两者都需要有访客。访客的 IP 只以哈希（`IPHash`）离开平台。

## 由访问内核保管的分享

应用在清单的 `resources[]` 里声明自己的资源类型：`type` 写作 `<应用>.<种类>`，树形类型可以写 `parent`，还有 `levels`、`switches`、`publishable`、`requestable`、`sensitive`，以及分享对话框用的名字 `name` 与 `nameEn`（各不超过 20 个字符，一行）。

谁能访问的名单由平台的访问内核保管，也只在内核里改。平台的分享对话框（Bridge 的 `access.share(type, id, name?)`、app-kit 的 `ShareButton`，状态用 `bridge.shareState` 即 Bridge 的 `access.state` 读取）用这个人的页面会话读写 `/app/v1/access/{type}/{id}…`。应用自己的令牌调用分享的路由返回 403。移动节点和树的形状只收应用自己的令牌。

| 路由 | 用途 |
|---|---|
| `GET /access/{type}` | 声明的种类：名字、级别、开关、`requestable`、`publishable`、是否树形 |
| `POST /access/{type}/check` | 批量检查，最多 500 个 |
| `GET /access/{type}/visible`、`GET /access/{type}/mine` | 这个人能看到的资源（`mine` 用于树形类型） |
| `POST /access/{type}/{id}`、`DELETE /access/{type}/{id}` | 登记、删除资源；树形类型的形状只收应用自己的令牌 |
| `GET /access/{type}/{id}`、`…/grants` | 名单与授权 |
| `PUT /access/{type}/{id}/inherit` | 树形：停止或恢复继承；停止时现在的名单成为节点自己的 |
| `PUT …/general`、`PUT …/toggles`、`POST …/owner` | 通用访问、开关、转让所有者 |
| `POST /access/{type}/{id}/requests`、`GET /access/{type}/{id}/requests`、`GET /access/requests`、`GET /access/requests/mine`、`POST /access/requests/{rid}/approve`、`POST /access/requests/{rid}/deny`、`DELETE /access/requests/{rid}` | 访问申请 |

名单变化后，平台向资源所属的应用发 `access.changed {type, id}`。应用如果留了名单的派生物（缓存、可见范围的镜像），就在这时从内核重读。应用不要自己做分享界面或访问控制表，门禁会检查。

## 平台的数据事件

应用在清单 `events.receives` 里写要收的平台数据事件，平台在 `POST /k3/events/{kind}` 把它们送给应用，至少一次。进程内的应用与独立进程的应用走同一条路。

| 种类 | 什么时候 | `subject` |
|---|---|---|
| `tenant.deleted` | 企业被删除 | 企业 id |
| `site.deleted` | 企业的一个站点被删除 | 站点 id |
| `customer.merged` | 客户中心把一位客户合并进另一位 | `<被合并的 id>><保留的 id>`，用 `appkit.ParseMergedSubject` 读 |
| `app.uninstalled` | 企业卸载了本应用 | 本应用自己的 id |

- 正文是 `{tenant, subject}`，请求头 `K3-Assertion` 是平台的断言（`via` 为 `event`）。raise-go 的 `appkit.Serve` 会验证断言，只把清单声明了的种类交给 `DataEventHandler`，没声明的回 404。
- 回 2xx 就算完成。失败后平台按退避重试（30 秒起翻倍，最长 6 小时），不放弃。同一个事件可能来不止一次，处理程序要能重复执行。
- 除 `tenant.deleted` 外，事件只发给装在这个企业里的应用（停用的也发）。`app.uninstalled` 只发给被卸载的那个应用。随企业一起删除的安装只发 `tenant.deleted`。
- 存了客户 id 的应用要声明 `customer.merged`，把指向被合并客户的记录挪到保留的客户。
- 卸载不删数据。收到 `app.uninstalled` 后留什么、清什么由应用决定，企业以后可能再装回来。

## 声明需要的平台版本

清单的 `platform` 字段写应用需要的平台 API 版本范围，例如 `"platform": ">=1.1"` 或 `">=1.1, <2"`。不写就是任何版本都可以。

- 平台 API 版本就是 `open-v1.openapi.yaml` 与 `app-v1.openapi.yaml` 的 `info.version`，Go 里是 `k3.PlatformAPI`。应用可以依赖的新增内容会让次版本号加一。
- 范围由逗号或空格分开的条件组成，每一条都要满足。条件可以是 `>=`、`>`、`<=`、`<`、`=`（不写就是 `=`）加 `主.次.修订`（后两段可省，省掉的当 0），也可以是 `^1.1`（同一个主版本、不低于 1.1）或 `~1.1.2`（同一个次版本、不低于 1.1.2）。不支持 `||`。
- 写错的范围在清单校验时就会报错（`k3 lint`、提交版本）。
- 平台不在范围内时，提交版本的自动检查 `platform` 不通过，已审核的版本不能发布，租户也不能安装或升级到这个版本。返回的错误写明应用需要的范围和这个平台的版本，例如 `app demo 1.2.0 needs platform API >=1.2; this platform is 1.1.0`。

### 兼容矩阵

每个 raise-go 版本是在哪个平台 API 版本上发布的，同时发布了哪些套件版本。平台 API 一栏也就是这个 SDK 版本需要的最低平台版本。TypeScript SDK `@21stware/raise-sdk` 与 raise-go 同号发布。这张表由 `tools/sdkpack` 的测试与代码核对：最上面一行必须与仓库里的版本一致。

| raise-go | 平台 API（最低） | raise-design | raise-platform | raise-app-kit | raise-doc-kit | raise-voucher-desk | raise-office-engine | raise-pages-ui | raise-bridge | raise-app-sdk |
|---|---|---|---|---|---|---|---|---|---|---|
| 0.7.8 (unreleased) | 1.2.0 | 0.3.6 | 0.2.3 | 0.5.0 | 0.3.2 | 0.2.7 | 0.1.0 | 0.2.2 | 1.1.1 | 1.3.0 |
| 0.7.7 | 1.2.0 | 0.2.3 | 0.2.2 | 0.4.1 | 0.3.1 | 0.2.6 | 0.1.0 | 0.2.1 | 1.1.0 | 1.2.0 |
| 0.7.6 | 1.2.0 | 0.2.2 | 0.2.1 | 0.4.0 | 0.3.0 | 0.2.5 | 0.1.0 | 0.2.0 | 1.1.0 | 1.2.0 |
| 0.7.5 | 1.0.0 | 0.2.2 | 0.2.1 | 0.4.0 | 0.2.5 | 0.2.5 | 0.1.0 | 0.1.4 | 1.1.0 | 1.2.0 |
| 0.7.4 | 1.0.0 | 0.2.2 | 0.2.1 | 0.2.2 | 0.2.2 | 0.2.2 | 0.1.0 | 0.1.1 | 1.0.1 | 1.0.1 |
| 0.7.3 | 1.0.0 | 0.2.1 | 0.2.1 | 0.2.1 | 0.2.1 | 0.2.1 | 0.1.0 | 0.1.0 | 1.0.1 | 1.0.1 |
| 0.7.2 | 1.0.0 | 0.2.0 | 0.2.0 | 0.2.0 | 0.2.0 | 0.2.0 | 0.1.0 | 0.1.0 | 1.0.0 | 1.0.0 |
| 0.7.1 | 1.0.0 | 0.1.0 | 0.2.0 | 0.2.0 | 0.1.0 | 0.1.0 | — | — | 1.0.0 | 1.0.0 |
| 0.7.0 | 1.0.0 | 0.1.0 | 0.2.0 | 0.2.0 | 0.1.0 | 0.1.0 | — | — | 1.0.0 | 1.0.0 |

## 旧路径

原先 `/k3/app/…` 下的 12 个路径作为别名保留过，2026-11-02 起已删除，请求返回 404。请使用 `/app/v1`。

## 应用的数据与托管

- 有自己数据的应用有自己的模式 `app_<id>` 和同名角色，并托管运行（清单写 `runtime.image`）。带 `data.migrations` 的版本第一次审核通过并发布时，平台建好模式，再运行迁移。
- 数据库连接 `K3_APP_DATABASE_URL`（角色 `app_<id>`）只交给平台运行的进程。开发者拿不到它，在平台之外运行的应用也拿不到数据库连接，数据放在应用自己的存储里。
- 带 `data.migrations` 的版本必须写 `runtime.image`：没写时提交版本的自动检查 `migrations.runtime` 不通过。所以带自己数据的应用不能整个以独立进程跑在平台之外。
- 带托管进程的草稿不能试装进测试企业，只有发布了的版本能装。开发时有两条路：发布后在测试企业里试装发布的版本，看「日志」与「错误」调试（平台不显示托管进程的输出）；或者在测试企业里装着一个版本时，用 `k3 dev --test-enterprise <测试企业 ID> --app <应用 ID> --endpoint <地址> -- <命令>` 把这个测试企业里平台对应用的调用发到本机的进程，本机进程连你自己起的数据库。步骤见 [本地开发与测试企业](/developers/guide/local-dev) 的「有自己数据的应用」，本机的库要先建好平台会准备的角色、模式与 `platform.tenant_visible`，见同一页的「在本机准备应用的数据库」。
- 分销、活动、会员、订单、知识库与文档这六个原来的一方应用，现在是托管的目录应用，各在自己的仓库里。它们的应用 ID 由平台保留，要接手维护由平台运营转交，见 [本地开发与测试企业](/developers/guide/local-dev) 的「接手目录里的应用」；转交之前这些代码只能以你自己的 ID 的分支运行，见同一页的「转交之前运行目录应用的代码」。

## 部署与回滚

每次发布、灰度、比例灰度、回滚、中止和撤回都留一条部署记录，`GET /api/dev/apps/{id}/deployments` 可以查。记录里只有数量，不列租户，只列你们自己的测试企业。

- **回滚**：`POST /api/dev/apps/{id}/deployments/{did}/rollback` 把这次部署之前的版本再部署一次，是一条新记录，不走审核。只有造成当前已发布版本的那次部署能回滚；只能回到发布过且没有被撤回的版本。这次部署移动过的安装会跟着回去，管理员固定到别处的不动。
- **中止灰度**：`POST /api/dev/apps/{id}/deployments/{did}/abort` 让这次灰度移动过的每个租户回到它各自原来的版本，这个版本仍是已批准，可以再灰度或发布。
- **不移动的安装**：关了自动升级的不动。新版本要的权限比安装的版本多（能力、权限点、范围）也不动，等租户管理员同意后自己升级。发布、灰度和回滚的应答在 `stayed` 里按原因给出数量。
- **比例灰度**：`POST /api/dev/apps/{id}/versions/{version}/rollout`，请求体 `{"percent": 10, "autoAbort": {"max5xx": 0.02, "minRequests": 100}}`，把已批准的版本给一定比例的租户。按应用和租户算固定的分桶，同一个比例永远是同一批租户。`POST …/deployments/{did}/advance`，请求体 `{"percent": 50}`，提高比例，只增加租户。`/publish` 就是 100%，同时结束这次灰度；`/abort` 让所有人回去。一个应用同时只能有一个比例灰度，不能和指定租户的灰度同时进行，结束之前也不能发布别的版本。不移动的安装和灰度相同，灰度期间管理员自己选了版本的也不动。新安装拿到的是已发布的版本，下一次提高比例时再按分桶加入。
- **自动退回**：平台每 30 秒看一次新版本最近 10 分钟的 5xx，开始后的前 5 分钟不算，那时进程还在启动。窗口内请求数不少于 `minRequests`（默认 100）且 5xx 占比超过 `max5xx`（默认 2%）时，自动中止，效果和 `POST …/abort` 相同。运行环境显示新版本的进程持续 5 分钟不在运行时，不管流量多少都会中止。请求太少的窗口不会因为错误率中止。记录里 `by` 是 `system`，`reason` 里有当时的数字，你们的组织会收到通知。`GET …/deployments/{did}` 给出比例、移动了多少租户、不动的原因和数量，以及新旧版本在窗口内的计数。
- **迁移不会撤销**：一个应用的所有版本共用一个模式，回到的版本要在现在的模式上运行，所以新版本的迁移要让旧版本还能跑。`GET …/deployments/{did}` 的 `rollback.migrations` 列出没有撤销的迁移，其中有破坏性变更时给出警告，回滚之前先看。
