跳到正文

应用接口(/app/v1)

查看 Markdown

本文介绍已安装的应用向平台要平台能力的接口:与 /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:resolvePOST /customers/resolve、POST /customers/touch
app.customers:readGET /customers/{id}
app.visitors:readGET /site-visitors、GET /site-visitors/{site}/{visitor}
app.visitors:writePUT /site-visitors/{site}/{visitor}
app.access:readGET /access、GET /unit、POST /access/points(一次最多 100 个权限点)
app.directory:readGET /members
app.org:readGET /org/members(按调用者在本应用权限点上的数据范围裁剪的成员及其部门、角色;分页;只有 id、显示名、部门和角色,不含联系方式)
app.ledger:postPOST /ledger/posts
app.audit:writePOST /audit
app.blobs:read、app.blobs:write/blobs/…(本应用文件夹里的文件、预签名地址)
app.notify:sendPOST /notify
app.drafts:write、app.drafts:read、app.drafts:reviewPOST /drafts(为成员起草)、草稿的读取与审阅、GET /provenance
app.outreach:send、app.outreach:read/outreach/…
app.options:read、app.options:write/options/{list}
app.models:use/models/…
app.intent:parsePOST /intent/parse(页面会话的人的一句话,可带最多 3 张图片,解析成本应用某个操作的输入建议;只收页面会话,应用自己的令牌返回 403;只解析本应用的操作;不写任何东西,调了模型按用量计入企业)
app.pay:read、app.pay:write/pay/orders/…
app.approvals:read、app.approvals:write/approvals/…
app.calendar:writePOST /calendar/projections
app.desk:useGET /peers/desk、POST /peers/desk/http(其他应用的核销台,经平台去问;只收页面会话)
app.till:useGET /peers/till、POST /peers/till/http(其他应用的收银台,经平台去问;只收页面会话)
app.holds:readGET /peers/holds、GET /peers/holds/holdable、POST /peers/holds/free(资源占用:治疗室与设备)
app.holds:writePOST /peers/holds/hold、/move、/release
app.referrals:readGET /peers/referral-codes、POST /peers/referral-codes/code、/promoter(推广码)
app.deals:writePOST /deals(把本应用的成交、退款、作废交给平台投递)
app.installations:readGET /installations
app.outreach:reachGET /outreach/customers/{id}/messages、POST /outreach/reachable
app.customers:tagPUT、DELETE /customers/{id}/tags/{tag}
app.services:callPOST /services/{owner}/{name}/calls
app.events:emitPOST /events/emit
app.links:readGET /links、GET /links/resolve
app.secrets:usePOST /secrets/encrypt、POST /secrets/decrypt、POST /secrets/shred
app.sites:readPOST /sites/summaries
app.stores:readGET /stores(可选 ?of=<主体>)
app.entitlements:read/entitlements/… 里的读取:模板、发放、预留、核销码、发放方
app.entitlements:write/entitlements/… 里的写入:模板、发放、退款、转让、预留、核销,以及 POST /entitlements/stores/relink
app.customers:listGET /customers(页面会话的人在「查看客户」数据范围内的客户)
app.customers:writePOST /customers/{id}/rename、/links、/owner、/dealt、/merge、/erase
app.customers:consent_events订阅 customer.consent.changed 的服务,没有 HTTP 路由;见下文
app.imports:writePOST /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:reportPOST /errors
app.logs:writePOST /logs
app.traces:writePOST /traces
app.analytics:writePOST /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)

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

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=:这位客户在各个渠道收到过的消息,新的在前(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,见 本地开发与测试企业 的「平台根地址: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-designraise-platformraise-app-kitraise-doc-kitraise-voucher-deskraise-office-engineraise-pages-uiraise-bridgeraise-app-sdk
0.7.8 (unreleased)1.2.00.3.60.2.30.5.00.3.20.2.70.1.00.2.21.1.11.3.0
0.7.71.2.00.2.30.2.20.4.10.3.10.2.60.1.00.2.11.1.01.2.0
0.7.61.2.00.2.20.2.10.4.00.3.00.2.50.1.00.2.01.1.01.2.0
0.7.51.0.00.2.20.2.10.4.00.2.50.2.50.1.00.1.41.1.01.2.0
0.7.41.0.00.2.20.2.10.2.20.2.20.2.20.1.00.1.11.0.11.0.1
0.7.31.0.00.2.10.2.10.2.10.2.10.2.10.1.00.1.01.0.11.0.1
0.7.21.0.00.2.00.2.00.2.00.2.00.2.00.1.00.1.01.0.01.0.0
0.7.11.0.00.1.00.2.00.2.00.1.00.1.0——1.0.01.0.0
0.7.01.0.00.1.00.2.00.2.00.1.00.1.0——1.0.01.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 列出没有撤销的迁移,其中有破坏性变更时给出警告,回滚之前先看。

本页目录