应用接口(/app/v1)
本文介绍已安装的应用向平台要平台能力的接口:与 /open/v1 的区别、谁可以调用、需要声明的范围,以及事件与记录链接两个新路由。
区分应用接口与开放接口
平台有两个接口面,约定完全一致(错误信封、Idempotency-Key、限流头、Raise-Environment、7 天请求日志),用途不同:
| 接口面 | 调用方 | 用途 |
|---|---|---|
/open/v1 | 组织授权的集成,或代表组织的应用 | 读写组织的数据 |
/app/v1 | 已安装的应用 | 使用平台能力:客户中心、站点访客、经营记账、审计链、成员、事件、记录链接 |
基础地址是 https://api.${DOMAIN}/app/v1。调用同样出现在开发者门户应用详情的「日志」页签里(按环境与状态筛选),路由显示为 /app/v1/…;命令行 k3 logs 看同一份记录,见 本地开发与测试企业 的「查看调用日志与错误」。错误码与幂等规则见 错误码与请求 ID。完整的请求与响应形状见规范文件 app-v1.openapi.yaml。
Go SDK 用 client.App,TypeScript SDK 用 raise.app,不要自己拼重试。
使用哪种令牌
/app/v1 收两种令牌:
- 应用自己的租户令牌:用应用密钥调用
POST /open/v1/oauth/token换取,见 认证与凭证。令牌里没有人,适合后台任务。 - 页面会话:正在使用你的应用页面的那个人的会话。你的后端从请求头
K3-App-Token取出,原样作为Authorization: Bearer转给平台,平台按这个人的身份和权限处理。工作台和 App Bridge 替页面调POST /api/apps/{id}/token拿到它;自下一个平台版本起,不由平台托管的应用(清单没有runtime)也有页面会话,在这之前这个接口对它们返回 404。开发时页面怎样进到测试企业的工作台,见 本地开发与测试企业 的「应用的页面在测试企业的工作台里」。
客户令牌不能调用这里,返回 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)
问调用者在本应用一个权限点上的结果与数据范围。
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 里声明。
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 开头。
_, 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 由调用方读完后关闭。键里的 / 保留。
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
}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 的版本更新时覆盖。
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)
按手机号找到或建立客户。
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)
把本应用的一笔成交交给平台投递。
_, 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)
列出企业的成员。
members, err := c.App.Directory.Members(ctx)
if err != nil {
return err
}
for _, m := range members.Items {
fmt.Println(m.UserId, m.Name)
}草稿(drafts)
为成员起草一项改动,由他审阅后应用。
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 重复调用不会多发。
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)
把应用的一次错误报到开发者平台的错误列表。
_, 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(批量、异步、队列满了丢弃并计数)。
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(批量、异步)。
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,不用自己调这个接口。
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 里声明的开放事件,见下文。
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)
上报导入旧记录的一批结果,见下文。
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": []}。
inst, err := c.App.Installations.List(ctx)
if err != nil {
return err
}
fmt.Println(inst.Tenants)意图解析(intent)
把页面会话里那个人的一句话解析成本应用某个操作的输入建议,只收页面会话。
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)
把本应用的一笔收支记进企业的账簿。
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)
在另一个应用里按当前人员的权限搜索记录。
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/…,由调用方给出。
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)const { text } = await raise.app.models.transcribe(recording, 'audio/wav')通知(notify)
给成员发一条站内通知。
_, err := c.App.Notify.Send(ctx, &raise.AppNotifyRequest{
Users: []string{"u_1"},
Title: "新订单 1001",
Link: "/apps/shop-sync/orders/1001",
})选项(options)
读本应用的一个选项列表。
list, err := c.App.Options.Get(ctx, "pickup-points", nil)
if err != nil {
return err
}
fmt.Println(list.Items)组织(org)
列出调用者在本应用权限点上数据范围内的成员,分页。
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 不会多发。
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)
开一张收款单。
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)
经平台问另一个应用哪些房间在某个时段空着。
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[] 里声明种类的共享文档。
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)
在访问内核里批量检查一个人对本应用资源的访问级别,见下文。
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。
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)
启动另一个应用的一次服务运行,只收应用自己的令牌。
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)
读站点的用量摘要。
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)
列出企业的门店。
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)
读一位站点访客对应的客户。
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}. 开头,再调用:
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:
{ "key": "order-1001", "written": true }key 必填,最长 200 字符:同一个键只发一次,重复调用返回 "written": false。未声明或不以应用 ID 开头的类型返回 forbidden。事件的签名、重试与订阅方式见 Webhooks 与事件订阅。
订阅另一个应用的事件
events.emits 里的类型必须以本应用 ID 开头(${APP_ID}.{what},小写词用点连接,最长 64 字符,最多 32 个),不能是平台自己的事件,也不能重复。发出的事件除了送给集成的 webhook(类型是平台登记的开放事件、且带 customer 时),还会送给同一租户里订阅了它的其他应用。订阅方在清单里声明一个服务:
{ "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,没有这个触发器的服务不能带:
{ "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,并声明一个服务:
{ "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=:这位客户在各个渠道收到过的消息,新的在前(limit1 到 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,见 本地开发与测试企业 的「平台根地址: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 <地址> -- <命令>把这个测试企业里平台对应用的调用发到本机的进程,本机进程连你自己起的数据库。步骤见 本地开发与测试企业 的「有自己数据的应用」,本机的库要先建好平台会准备的角色、模式与platform.tenant_visible,见同一页的「在本机准备应用的数据库」。 - 分销、活动、会员、订单、知识库与文档这六个原来的一方应用,现在是托管的目录应用,各在自己的仓库里。它们的应用 ID 由平台保留,要接手维护由平台运营转交,见 本地开发与测试企业 的「接手目录里的应用」;转交之前这些代码只能以你自己的 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列出没有撤销的迁移,其中有破坏性变更时给出警告,回滚之前先看。