openapi: 3.1.0 info: title: Raise Open API version: 1.2.0 description: | 给应用后端用的开放接口(主版本 v1)。调用根地址是 `https://api.{域名}/open/v1`(`https://app.{域名}/open/v1` 同样可用)。 本文件里的路径已经去掉了这个前缀:`GET /whoami` 就是 `GET https://api.{域名}/open/v1/whoami`。 ## 第一次调用 1. 在开发者平台创建应用,拿到 `client_id` 和密钥(测试环境以 `kwo_test_` 开头,正式以 `kwo_live_` 开头;旧密钥 `kwo_` 也是正式)。 2. `POST /oauth/token`,`grant_type=client_credentials`。商店应用(应用级凭证)必须带 `tenant`;租户自建应用(绑定在一个组织上)不要带,或只能带它自己的组织。 3. 用返回的 `access_token` 调 `GET /whoami` 或 `GET /ping`。令牌 300 秒过期,SDK 会在到期前 60 秒自动换。 代表一位终端客户(小程序用户)调用 `/me…` 时,用 `grant_type=urn:raise:grant-type:customer`,带上后台已经验证过的 `phone`。 ## 约定 * **认证** `Authorization: Bearer `。换令牌本身用 HTTP Basic(`client_id:client_secret`)或把这两个字段放在正文里。 * **错误** 状态 ≥ 400 的正文都是 `{"error":{"code","message","requestId","docUrl","details"}}`。`code` 稳定,用它分支;`message` 给人看。`details` 没内容时是 `{}`。`requestId` 与响应头 `X-Request-Id` 相同,报问题时带上。 * **列表** `?limit=`(默认 20,最大 100)`&cursor=` → `{"data":[…],"nextCursor":"…" 或 null}`。没有下一页时 `nextCursor` 为 `null`。会员 `/me` 下的短列表(自己的会员、课包等)不翻页,字段名见各操作。 * **幂等** 除 `/oauth/token` 外每个 `POST` 收 `Idempotency-Key`(1–255 字符)。范围是应用 + 组织(客户令牌再加上客户)。保存 24 小时。同键同方法同路径同正文 → 重放第一次的回答,并带 `Idempotent-Replayed: true`。同键不同正文 → 409 `idempotency_conflict`。第一次还在处理 → 409 `idempotency_in_progress`。状态 ≥ 500 的回答不保存。SDK 会在 POST 上自动生成 uuid v4。 * **限流** 认证过的调用都带 `X-RateLimit-Limit`、`X-RateLimit-Remaining`、`X-RateLimit-Reset`。429 另带 `Retry-After`(秒)。`Raise-Environment: test|live` 说明这把令牌属于哪边。 * **时间** RFC 3339 UTC。**金额** 整数最小货币单位 + `currency`。JSON 字段 camelCase;令牌接口的成功正文用 RFC 6749 的名字(`access_token`、`expires_in`…)。 用 Go 模块 `github.com/21stware/raise-go` 或 npm 包 `@21stware/raise-sdk`,不要自己拼签名和重试。 servers: - url: https://api.{domain}/open/v1 variables: domain: default: example.com description: 平台域名 tags: - {name: oauth, description: 用应用密钥换短令牌。SDK 内部调用,应用代码不要自己打这条} - {name: platform, description: 令牌身份与连通性} - {name: tables, description: 多维表只读(必须经视图;个人字段永远不返回)} - {name: customers, description: 客户档案。电话按员工界面打码;customer:write 可按手机号创建或找到,并改姓名与标签} - {name: org, description: 组织通讯录(成员与组织名)。电话与邮箱只在同时持有 org.contacts:read 时返回} - {name: me, description: "会员客户模式:这位客户自己的会员、课包、预约和待确认核销。新地址是会员应用自己的域名 `https://membership.apps.{domain}/open/v1/me…`(同一组路由、同一把客户令牌);`api.{domain}` 上的旧地址照常应答到 2027-05-01,应答带 `Deprecation`、`Sunset` 与指向新地址的 `Link: rel=successor-version`"} - {name: events, description: 平台推到你 https 地址的事件(同一信封、同一签名)} security: - bearer: [] paths: /oauth/token: post: operationId: createToken tags: [oauth] summary: 用应用密钥换一把短令牌(租户令牌或客户令牌) description: | 这条接口不要用 Bearer,也不要带 Idempotency-Key。SDK 的传输层会调它。 认证:HTTP Basic(`client_id:client_secret`)或把 `client_id`、`client_secret` 放进正文。未知的 id 和错误的密钥都回答 401 `invalid_client`,并带 `WWW-Authenticate: Basic`。 正文 `application/x-www-form-urlencoded` 或 `application/json` 都可以。 `grant_type=client_credentials`:换租户令牌,300 秒。`tenant` 对应用级凭证(商店应用、凭证不绑在一个组织上)是必填;对租户绑定凭证必须省略或等于它自己的组织,否则 400 `tenant_required`。`scope` 可选,空格分隔,只能是已经授予的子集。 `grant_type=urn:raise:grant-type:customer`:换客户令牌,900 秒。`phone` 必填(后台自己验证过的号码,平台不再验)。`customer_id` 可选,在 409 `ambiguous_customer` 之后用来指定是哪一位。需要范围 `customer:assert`。没有对应会员时 200 `{"matched":false}`,不发令牌、不建客户。 `grant_type=authorization_code`:员工授权(代某位员工,在工作台之外:手机、桌面、命令行、外部 agent)。先把人带到身份服务的授权地址(client_id = 应用的 OIDC 客户端,code_challenge + `S256`,redirect_uri 必须在开发者平台登记过);人登录、在同意页看过范围并同意后,回调带回 `code`。再到这里换:`code`、code_verifier、redirect_uri、`client_id` 必填;机密客户端另用应用密钥认证(HTTP Basic 或 `client_secret`),公开客户端不带密钥。得到员工令牌(600 秒,`sub` 是这个人)与 refresh_token。人没同意过是 403 `consent_required`。 `grant_type=refresh_token`:用 refresh_token 续员工令牌。每用一次换一把新的(30 天滚动);用过的再用一次,整条链作废(400 `invalid_grant`)。人或管理员吊销授权、应用停用、凭证吊销后都是 400 `invalid_grant`,已发出的员工令牌 30 秒内失效(401 `consent_revoked`)。员工令牌能做的 = 人同意的范围 ∩ 组织此刻授予应用的范围 ∩ 这个人此刻的角色与数据范围。 测试密钥只能打测试企业;正式密钥不能打测试企业。否则 403 `wrong_environment`。应用没装、关掉、组织停用是 403 `not_installed`。一把范围都没授是 403 `no_scope`。密钥吊销或过期是 401 `credential_ended`。来源 IP 不在允许名单是 403 `address_not_allowed`。 同一地址失败太多次是 429 `too_many_attempts`(`Retry-After: 60`)。换令牌本身也有次数限制(429 `rate_limited`)。 x-sdk: {internal: true} security: - basic: [] - {} requestBody: required: true content: application/x-www-form-urlencoded: schema: {$ref: '#/components/schemas/TokenRequest'} application/json: schema: {$ref: '#/components/schemas/TokenRequest'} responses: "200": description: | 换到令牌:`access_token`、`token_type=Bearer`、`expires_in`、`scope`、`tenant`、`environment`。客户授权另带 `customer`。 客户授权找不到人:`{"matched":false}`,没有令牌。 headers: X-Request-Id: {$ref: '#/components/headers/XRequestId'} X-RateLimit-Limit: {$ref: '#/components/headers/XRateLimitLimit'} X-RateLimit-Remaining: {$ref: '#/components/headers/XRateLimitRemaining'} X-RateLimit-Reset: {$ref: '#/components/headers/XRateLimitReset'} content: application/json: schema: oneOf: - {$ref: '#/components/schemas/Token'} - {$ref: '#/components/schemas/CustomerMiss'} "400": {$ref: '#/components/responses/BadRequest'} "401": {$ref: '#/components/responses/InvalidClient'} "403": {$ref: '#/components/responses/Forbidden'} "409": {$ref: '#/components/responses/Conflict'} "422": {$ref: '#/components/responses/Unprocessable'} "429": {$ref: '#/components/responses/RateLimited'} "500": {$ref: '#/components/responses/Internal'} "501": {$ref: '#/components/responses/NotImplemented'} "503": {$ref: '#/components/responses/Unavailable'} /whoami: get: operationId: getWhoami tags: [platform] summary: 这把令牌现在是谁:组织、应用、凭证和仍然有效的范围 description: | 不需要特定范围。任何还活着的集成令牌都可以调。返回的 `scopes` 是令牌里的范围与此刻授予的交集:管理员收回的范围最多 30 秒内从这里消失。 用它确认换到的令牌打的是哪个组织和哪把凭证。 x-sdk: {resource: platform, method: whoami, audience: tenant} security: [{bearer: []}] responses: "200": description: "{tenant, app, credential, scopes}" headers: X-Request-Id: {$ref: '#/components/headers/XRequestId'} X-RateLimit-Limit: {$ref: '#/components/headers/XRateLimitLimit'} X-RateLimit-Remaining: {$ref: '#/components/headers/XRateLimitRemaining'} X-RateLimit-Reset: {$ref: '#/components/headers/XRateLimitReset'} Raise-Environment: {$ref: '#/components/headers/RaiseEnvironment'} content: application/json: schema: {$ref: '#/components/schemas/Whoami'} "401": {$ref: '#/components/responses/Unauthorized'} "429": {$ref: '#/components/responses/RateLimited'} "503": {$ref: '#/components/responses/Unavailable'} /ping: get: operationId: getPing tags: [platform] summary: 连通性探测,需要范围 platform:ping,正文只有 {ok:true} description: | 需要范围 `platform:ping`。不返回组织里的任何数据,只说明这把令牌还能用。没有该范围是 403 `scope_required`(这次调用已经计入限流)。 x-sdk: {resource: platform, method: ping, audience: tenant} security: [{bearer: []}] responses: "200": description: "{ok: true}" headers: X-Request-Id: {$ref: '#/components/headers/XRequestId'} X-RateLimit-Limit: {$ref: '#/components/headers/XRateLimitLimit'} X-RateLimit-Remaining: {$ref: '#/components/headers/XRateLimitRemaining'} X-RateLimit-Reset: {$ref: '#/components/headers/XRateLimitReset'} Raise-Environment: {$ref: '#/components/headers/RaiseEnvironment'} content: application/json: schema: {$ref: '#/components/schemas/Ping'} "401": {$ref: '#/components/responses/Unauthorized'} "403": {$ref: '#/components/responses/Forbidden'} "429": {$ref: '#/components/responses/RateLimited'} "503": {$ref: '#/components/responses/Unavailable'} /tables/{table}/views/{view}/rows: get: operationId: listTableRows tags: [tables] summary: 经视图列出一张多维表的行(不含个人字段) description: | 需要这张表的范围:租户自建表是 `tables.base:read`;系统表 `sys_` 是 `tables.:read`。表还必须「开放给项目」。缺范围是 403 `scope_required`(消息里会写出缺的那条,方便你去要)。 后台模式令牌(租户令牌)按视图自己的筛选读行,员工专属视图也可以。客户模式令牌按视图的行规则来(own-read / own-write 只看见断言客户的行)。个人(PII)列永远不返回。 表、视图不存在,或表未开放,或这条令牌进不了这个视图:一律 404 `not_found`,避免探测。 查询与造物的 `data.list` 相同:`limit`(默认 20,1–100)、`cursor`、`sort`(字段 id,前面加 `-` 表示降序,逗号分隔)、`filter[字段][运算符]=值`。运算符:`eq`、`ne`、`in`、`lt`、`lte`、`gt`、`gte`、`contains`、`between`。只能筛、只能排视图里出现的字段,否则 400 `invalid_request`。 列表形状是 `data` + `nextCursor`。每一行是 `{id, version, fields, createdAt}`,`fields` 只有视图里的列。 x-sdk: {resource: tables, method: listRows, audience: tenant} security: [{bearer: []}] parameters: - {$ref: '#/components/parameters/Table'} - {$ref: '#/components/parameters/View'} - {$ref: '#/components/parameters/Limit'} - {$ref: '#/components/parameters/Cursor'} - name: sort in: query description: 字段 id,逗号分隔;前面加 - 表示降序。只能是视图里的字段。 schema: {type: string} - name: filter in: query style: deepObject explode: true description: | 只按视图里的字段筛选。写成 filter[字段][运算符]=值。 例:filter[status][eq]=active。in 的值用逗号分隔;between 的值是 lo,hi。 schema: type: object additionalProperties: type: object additionalProperties: {type: string} responses: "200": description: 一页行 headers: X-Request-Id: {$ref: '#/components/headers/XRequestId'} X-RateLimit-Limit: {$ref: '#/components/headers/XRateLimitLimit'} X-RateLimit-Remaining: {$ref: '#/components/headers/XRateLimitRemaining'} X-RateLimit-Reset: {$ref: '#/components/headers/XRateLimitReset'} Raise-Environment: {$ref: '#/components/headers/RaiseEnvironment'} content: application/json: schema: {$ref: '#/components/schemas/RowPage'} "400": {$ref: '#/components/responses/BadRequest'} "401": {$ref: '#/components/responses/Unauthorized'} "403": {$ref: '#/components/responses/Forbidden'} "404": {$ref: '#/components/responses/NotFound'} "429": {$ref: '#/components/responses/RateLimited'} "503": {$ref: '#/components/responses/Unavailable'} /tables/{table}/views/{view}/rows/{id}: get: operationId: getTableRow tags: [tables] summary: 经视图读取多维表的一行 description: | 范围和可见性与列表相同。行不在视图里、不是这位客户的、或 id 不存在:404 `not_found`。个人字段不返回。 x-sdk: {resource: tables, method: getRow, audience: tenant} security: [{bearer: []}] parameters: - {$ref: '#/components/parameters/Table'} - {$ref: '#/components/parameters/View'} - name: id in: path required: true schema: {type: string} description: 行 id responses: "200": description: 这一行 headers: X-Request-Id: {$ref: '#/components/headers/XRequestId'} X-RateLimit-Limit: {$ref: '#/components/headers/XRateLimitLimit'} X-RateLimit-Remaining: {$ref: '#/components/headers/XRateLimitRemaining'} X-RateLimit-Reset: {$ref: '#/components/headers/XRateLimitReset'} Raise-Environment: {$ref: '#/components/headers/RaiseEnvironment'} content: application/json: schema: {$ref: '#/components/schemas/Row'} "401": {$ref: '#/components/responses/Unauthorized'} "403": {$ref: '#/components/responses/Forbidden'} "404": {$ref: '#/components/responses/NotFound'} "429": {$ref: '#/components/responses/RateLimited'} "503": {$ref: '#/components/responses/Unavailable'} /me: get: operationId: listMeMembers tags: [me] summary: 这把客户令牌对应的会员(id、编号、姓名、状态) description: | 需要范围 `membership.me:read`。只要客户令牌(租户令牌是 403 `customer_token_required`)。读操作不检查断言有多老。 `members` 是这个组织里 `customer_id` 为令牌客户的全部会员,按创建时间,不含已合并、已关闭。一个号码在多家店可以是多条会员,只要客户 id 相同。没有会员时是 `[]`。 不返回:电话、备注、标签、教练、核销码、金额、病历。 x-sdk: {resource: me, method: listMemberships, audience: customer} security: [{bearer: []}] responses: "200": description: "{members: [MeMember]}" headers: X-Request-Id: {$ref: '#/components/headers/XRequestId'} X-RateLimit-Limit: {$ref: '#/components/headers/XRateLimitLimit'} X-RateLimit-Remaining: {$ref: '#/components/headers/XRateLimitRemaining'} X-RateLimit-Reset: {$ref: '#/components/headers/XRateLimitReset'} Raise-Environment: {$ref: '#/components/headers/RaiseEnvironment'} content: application/json: schema: {$ref: '#/components/schemas/MeMembers'} "401": {$ref: '#/components/responses/Unauthorized'} "403": {$ref: '#/components/responses/Forbidden'} "429": {$ref: '#/components/responses/RateLimited'} "500": {$ref: '#/components/responses/Internal'} "503": {$ref: '#/components/responses/Unavailable'} /me/packages: get: operationId: listMePackages tags: [me] summary: 这位客户名下会员的有效或冻结课包 description: | 需要范围 `membership.me:read`。认证与 GET /me 相同。 只列出存储状态为 `active` 或 `frozen` 的课包(已退、作废、已转让不出现)。`status` 是读的时候算出来的,存储仍是 `active` 时也可能是 `not_started`、`expired`、`used_up`。`unlimited` 仅在 kind 为 `period`(期卡)时为 true。`courses` 为空表示适用全部课程。 不返回:备注、金额、销售、流水、券、每日/每周上限。 x-sdk: {resource: me, method: listPackages, audience: customer} security: [{bearer: []}] responses: "200": description: "{packages: [MePackage]}" headers: X-Request-Id: {$ref: '#/components/headers/XRequestId'} X-RateLimit-Limit: {$ref: '#/components/headers/XRateLimitLimit'} X-RateLimit-Remaining: {$ref: '#/components/headers/XRateLimitRemaining'} X-RateLimit-Reset: {$ref: '#/components/headers/XRateLimitReset'} Raise-Environment: {$ref: '#/components/headers/RaiseEnvironment'} content: application/json: schema: {$ref: '#/components/schemas/MePackages'} "401": {$ref: '#/components/responses/Unauthorized'} "403": {$ref: '#/components/responses/Forbidden'} "429": {$ref: '#/components/responses/RateLimited'} "500": {$ref: '#/components/responses/Internal'} "503": {$ref: '#/components/responses/Unavailable'} /me/bookings: get: operationId: listMeBookings tags: [me] summary: 这位客户在一个日期窗口里的预约,并给出此刻取消要扣几次 description: | 需要范围 `membership.me:read`。`from`、`to` 是门店时区的日期 `YYYY-MM-DD`,两端都包含。省略 `from` 为昨天;省略 `to` 为 `from` 加 62 天。`to` 早于 `from`、不是日期、或跨度超过 124 天:422 `validation_failed`。 `status` 有值时按精确状态过滤(未知值得到空列表,不是 422)。最多 200 条,按开始时间再按 id。 `cancelCharge` 只在 `status` 为 `booked` 时计算(免费取消窗口内是 0,否则是门店的晚取消扣次,0 或 1)。`coachName` 是员工显示名,没有则省略。 不返回:备注、会员姓名或编号、教练 id、金额、颜色、课包 id、核销 id。 x-sdk: {resource: me, method: listBookings, audience: customer} security: [{bearer: []}] parameters: - name: from in: query schema: {type: string} description: YYYY-MM-DD,门店时区。默认昨天 - name: to in: query schema: {type: string} description: YYYY-MM-DD,包含。默认 from 加 62 天。跨度最多 124 天 - name: status in: query schema: {type: string} description: 精确预约状态。未知值得到空列表 responses: "200": description: "{bookings: [MeBooking]}" headers: X-Request-Id: {$ref: '#/components/headers/XRequestId'} X-RateLimit-Limit: {$ref: '#/components/headers/XRateLimitLimit'} X-RateLimit-Remaining: {$ref: '#/components/headers/XRateLimitRemaining'} X-RateLimit-Reset: {$ref: '#/components/headers/XRateLimitReset'} Raise-Environment: {$ref: '#/components/headers/RaiseEnvironment'} content: application/json: schema: {$ref: '#/components/schemas/MeBookings'} "401": {$ref: '#/components/responses/Unauthorized'} "403": {$ref: '#/components/responses/Forbidden'} "422": {$ref: '#/components/responses/Unprocessable'} "429": {$ref: '#/components/responses/RateLimited'} "500": {$ref: '#/components/responses/Internal'} "503": {$ref: '#/components/responses/Unavailable'} post: operationId: createMeBooking tags: [me] summary: 以这位客户的一名会员预约一节未开始的开放课(满员进候补) description: | 需要范围 `membership.booking:write`。这是写操作:客户断言超过 10 分钟是 401 `assertion_stale`,请重新断言。 正文 `{memberId, sessionId}`,都必填,否则 422 `validation_failed`。`memberId` 必须是 GET /me 里的会员,否则 404 `not_found`。课次不存在是 422,不是 404。走门店自己的预约规则(会员状态、课次开放且未开始、预约窗口、时间重叠、可用课包、容量)。满员时 201 且 `status` 为 `waitlist`。 已经预约过是 422;并发下唯一约束冲突是 409 `conflict`。请带 `Idempotency-Key`。 x-sdk: {resource: me, method: createBooking, audience: customer} security: [{bearer: []}] parameters: - {$ref: '#/components/parameters/IdempotencyKey'} requestBody: required: true content: application/json: schema: {$ref: '#/components/schemas/CreateBookingBody'} responses: "201": description: 预约本身(MeBooking),不包裹。status 为 booked 或 waitlist headers: X-Request-Id: {$ref: '#/components/headers/XRequestId'} X-RateLimit-Limit: {$ref: '#/components/headers/XRateLimitLimit'} X-RateLimit-Remaining: {$ref: '#/components/headers/XRateLimitRemaining'} X-RateLimit-Reset: {$ref: '#/components/headers/XRateLimitReset'} Raise-Environment: {$ref: '#/components/headers/RaiseEnvironment'} Idempotent-Replayed: {$ref: '#/components/headers/IdempotentReplayed'} content: application/json: schema: {$ref: '#/components/schemas/MeBooking'} "400": {$ref: '#/components/responses/BadRequest'} "401": {$ref: '#/components/responses/Unauthorized'} "403": {$ref: '#/components/responses/Forbidden'} "404": {$ref: '#/components/responses/NotFound'} "409": {$ref: '#/components/responses/Conflict'} "413": {$ref: '#/components/responses/PayloadTooLarge'} "422": {$ref: '#/components/responses/Unprocessable'} "429": {$ref: '#/components/responses/RateLimited'} "500": {$ref: '#/components/responses/Internal'} "503": {$ref: '#/components/responses/Unavailable'} /me/bookings/{id}/cancel: post: operationId: cancelMeBooking tags: [me] summary: 取消这位客户的预约;晚取消且要扣次时必须 acceptCharge description: | 需要范围 `membership.booking:write`。写操作(10 分钟后 `assertion_stale`)。`{id}` 必须是这位客户的预约,否则 404 `not_found`。 正文可选 `{acceptCharge, reason}`。可以不带正文。`reason` 最多 200 字,否则 422 `validation_failed`。 预约是 `booked` 且现在取消要扣次(与列表里的 `cancelCharge` 相同)且 `acceptCharge` 不是 true:409 `conflict`,什么都不取消。候补的 `cancelCharge` 为 0,不需要 `acceptCharge`。已到场或已完成不能取消(422)。 200 里的 `charged` 是实际扣掉的次数,即使带了 `acceptCharge: true` 也可能是 0(没有可扣的课包,或期卡)。 x-sdk: {resource: me, method: cancelBooking, audience: customer} security: [{bearer: []}] parameters: - name: id in: path required: true schema: {type: string} description: 预约 id - {$ref: '#/components/parameters/IdempotencyKey'} requestBody: required: false content: application/json: schema: {$ref: '#/components/schemas/CancelBookingBody'} responses: "200": description: "{cancelled: true, charged}。charged 是实际扣次,可以为 0" headers: X-Request-Id: {$ref: '#/components/headers/XRequestId'} X-RateLimit-Limit: {$ref: '#/components/headers/XRateLimitLimit'} X-RateLimit-Remaining: {$ref: '#/components/headers/XRateLimitRemaining'} X-RateLimit-Reset: {$ref: '#/components/headers/XRateLimitReset'} Raise-Environment: {$ref: '#/components/headers/RaiseEnvironment'} Idempotent-Replayed: {$ref: '#/components/headers/IdempotentReplayed'} content: application/json: schema: {$ref: '#/components/schemas/CancelBookingResult'} "400": {$ref: '#/components/responses/BadRequest'} "401": {$ref: '#/components/responses/Unauthorized'} "403": {$ref: '#/components/responses/Forbidden'} "404": {$ref: '#/components/responses/NotFound'} "409": {$ref: '#/components/responses/Conflict'} "413": {$ref: '#/components/responses/PayloadTooLarge'} "422": {$ref: '#/components/responses/Unprocessable'} "429": {$ref: '#/components/responses/RateLimited'} "500": {$ref: '#/components/responses/Internal'} "503": {$ref: '#/components/responses/Unavailable'} /me/sessions: get: operationId: listMeSessions tags: [me] summary: 这位客户还能预约的开放课次,尚未开始,最多 31 天 description: | 需要范围 `membership.me:read`。`from`、`to` 是门店时区日期,两端包含。省略 `from` 为今天;省略 `to` 为 `from` 加 6 天。日期不合法、`to` 早于 `from`、或超过 31 天:422 `validation_failed`。 只返回 `status=open` 且开始时间仍在未来的课次(今天已经开始的不算)。可选 `course` 为课程 id(未知 id 得到空列表)。最多 500 条,按开始时间再按课程名。 `spotsLeft` 为 max(0, 容量 − booked/attended/completed)。0 表示再预约会进候补。候补不占 `spotsLeft`。 x-sdk: {resource: me, method: listSessions, audience: customer} security: [{bearer: []}] parameters: - name: from in: query schema: {type: string} description: YYYY-MM-DD。默认今天(门店时区) - name: to in: query schema: {type: string} description: YYYY-MM-DD,包含。默认 from 加 6 天。跨度最多 31 天 - name: course in: query schema: {type: string} description: 课程 id。精确过滤 responses: "200": description: "{sessions: [MeSession]}" headers: X-Request-Id: {$ref: '#/components/headers/XRequestId'} X-RateLimit-Limit: {$ref: '#/components/headers/XRateLimitLimit'} X-RateLimit-Remaining: {$ref: '#/components/headers/XRateLimitRemaining'} X-RateLimit-Reset: {$ref: '#/components/headers/XRateLimitReset'} Raise-Environment: {$ref: '#/components/headers/RaiseEnvironment'} content: application/json: schema: {$ref: '#/components/schemas/MeSessions'} "401": {$ref: '#/components/responses/Unauthorized'} "403": {$ref: '#/components/responses/Forbidden'} "422": {$ref: '#/components/responses/Unprocessable'} "429": {$ref: '#/components/responses/RateLimited'} "500": {$ref: '#/components/responses/Internal'} "503": {$ref: '#/components/responses/Unavailable'} /me/redemption-requests: get: operationId: listMeRedemptionRequests tags: [me] summary: 前台请这位客户确认的核销(轮询;没有推送) description: | 需要范围 `membership.me:read`。没有推送,小程序轮询这一条。 `state` 省略或 `pending`:仍待确认且未过期。`all`:最近 30 天,最新在前;过了 `expiresAt` 的 pending 读出来是 `expired`(当场计算,后台没有定时任务)。其他 `state` 是 422 `validation_failed`。最多 100 条。 不返回:备注、预约 id、课程 id、课包 id、会员编号、谁发起、谁决定、拒绝原因、核销 id。 x-sdk: {resource: me, method: listRedemptionRequests, audience: customer} security: [{bearer: []}] parameters: - name: state in: query schema: {type: string, enum: [pending, all]} description: 默认 pending(未过期)。all 是最近 30 天,含计算出来的 expired responses: "200": description: "{requests: [MeRedemptionRequest]}" headers: X-Request-Id: {$ref: '#/components/headers/XRequestId'} X-RateLimit-Limit: {$ref: '#/components/headers/XRateLimitLimit'} X-RateLimit-Remaining: {$ref: '#/components/headers/XRateLimitRemaining'} X-RateLimit-Reset: {$ref: '#/components/headers/XRateLimitReset'} Raise-Environment: {$ref: '#/components/headers/RaiseEnvironment'} content: application/json: schema: {$ref: '#/components/schemas/MeRedemptionRequests'} "401": {$ref: '#/components/responses/Unauthorized'} "403": {$ref: '#/components/responses/Forbidden'} "422": {$ref: '#/components/responses/Unprocessable'} "429": {$ref: '#/components/responses/RateLimited'} "500": {$ref: '#/components/responses/Internal'} "503": {$ref: '#/components/responses/Unavailable'} /me/redemption-requests/{id}/confirm: post: operationId: confirmMeRedemption tags: [me] summary: 确认前台的核销申请;次数只在此刻扣,记在发起的员工名下 description: | 需要范围 `membership.redemption:confirm`。写操作(10 分钟后 `assertion_stale`)。没有正文。 `{id}` 必须是这位客户的申请,否则 404。只有仍 `pending`(且未过期)能确认;否则 409 `conflict`。第二次确认也是 409,不会把第一次的 JSON 再给一遍。 扣次就是前台自己的核销,同一事务,幂等键 `rr:<申请 id>`,核销记在发起员工名下。200 是 `{confirmed: true, remaining, notices}`。`remaining` 是扣完后的课包余额;期卡扣 0,所以这不是到店次数。`notices` 可能为空。 与前台核销相同的拒绝是 422 `validation_failed`(候补、已取消、缺席、课次取消、不是上课当天、超过 7 天、会员冻结/过期/关闭、课包不可用、剩余次数不足)。 x-sdk: {resource: me, method: confirmRedemption, audience: customer} security: [{bearer: []}] parameters: - name: id in: path required: true schema: {type: string} description: 核销申请 id - {$ref: '#/components/parameters/IdempotencyKey'} responses: "200": description: "{confirmed: true, remaining, notices}" headers: X-Request-Id: {$ref: '#/components/headers/XRequestId'} X-RateLimit-Limit: {$ref: '#/components/headers/XRateLimitLimit'} X-RateLimit-Remaining: {$ref: '#/components/headers/XRateLimitRemaining'} X-RateLimit-Reset: {$ref: '#/components/headers/XRateLimitReset'} Raise-Environment: {$ref: '#/components/headers/RaiseEnvironment'} Idempotent-Replayed: {$ref: '#/components/headers/IdempotentReplayed'} content: application/json: schema: {$ref: '#/components/schemas/ConfirmRedemptionResult'} "401": {$ref: '#/components/responses/Unauthorized'} "403": {$ref: '#/components/responses/Forbidden'} "404": {$ref: '#/components/responses/NotFound'} "409": {$ref: '#/components/responses/Conflict'} "422": {$ref: '#/components/responses/Unprocessable'} "429": {$ref: '#/components/responses/RateLimited'} "500": {$ref: '#/components/responses/Internal'} "503": {$ref: '#/components/responses/Unavailable'} /me/redemption-requests/{id}/decline: post: operationId: declineMeRedemption tags: [me] summary: 拒绝前台的核销申请;不扣次 description: | 需要范围 `membership.redemption:confirm`。写操作(10 分钟后 `assertion_stale`)。 正文可选 `{reason}`,最多 200 字,否则 422 `validation_failed`。可以不带正文。404 / 409 与确认相同(只有仍 pending 的申请)。不扣次。 x-sdk: {resource: me, method: declineRedemption, audience: customer} security: [{bearer: []}] parameters: - name: id in: path required: true schema: {type: string} description: 核销申请 id - {$ref: '#/components/parameters/IdempotencyKey'} requestBody: required: false content: application/json: schema: {$ref: '#/components/schemas/DeclineRedemptionBody'} responses: "200": description: "{declined: true}" headers: X-Request-Id: {$ref: '#/components/headers/XRequestId'} X-RateLimit-Limit: {$ref: '#/components/headers/XRateLimitLimit'} X-RateLimit-Remaining: {$ref: '#/components/headers/XRateLimitRemaining'} X-RateLimit-Reset: {$ref: '#/components/headers/XRateLimitReset'} Raise-Environment: {$ref: '#/components/headers/RaiseEnvironment'} Idempotent-Replayed: {$ref: '#/components/headers/IdempotentReplayed'} content: application/json: schema: {$ref: '#/components/schemas/DeclineRedemptionResult'} "400": {$ref: '#/components/responses/BadRequest'} "401": {$ref: '#/components/responses/Unauthorized'} "403": {$ref: '#/components/responses/Forbidden'} "404": {$ref: '#/components/responses/NotFound'} "409": {$ref: '#/components/responses/Conflict'} "413": {$ref: '#/components/responses/PayloadTooLarge'} "422": {$ref: '#/components/responses/Unprocessable'} "429": {$ref: '#/components/responses/RateLimited'} "500": {$ref: '#/components/responses/Internal'} "503": {$ref: '#/components/responses/Unavailable'} /customers: get: operationId: listCustomers tags: [customers] summary: 列出组织里的客户(电话已打码) description: | 需要范围 `customer:read`。租户令牌。列表形状是 `data` + `nextCursor`。默认 20 条,最大 100。 `phone` 按客户中心的查找:整段号码(大陆 11 位或带国家码)走身份查找;也可以是屏幕上打码后仍能看见的数字。`updatedSince` 是 RFC 3339,只留下 `lastTouchAt`(没有则 `createdAt`)不早于该时刻的客户。 `phone` 是员工列表上的打码形式(与 `customer:write` 无关;揭开号码仍是员工在客户应用里的 reveal)。`tags` 是标签 id。不返回明文号码、邮箱、备注(客户中心没有备注字段)。 x-sdk: {resource: customers, method: list, audience: tenant} security: [{bearer: []}] parameters: - {$ref: '#/components/parameters/Limit'} - {$ref: '#/components/parameters/Cursor'} - name: phone in: query schema: {type: string} description: 按号码查找(整段或打码后仍可见的数字) - name: updatedSince in: query schema: {type: string, format: date-time} description: RFC 3339 UTC。只列出该时刻及之后有过接触(或新建)的客户 responses: "200": description: 一页客户。没有下一页时 nextCursor 为 null headers: X-Request-Id: {$ref: '#/components/headers/XRequestId'} X-RateLimit-Limit: {$ref: '#/components/headers/XRateLimitLimit'} X-RateLimit-Remaining: {$ref: '#/components/headers/XRateLimitRemaining'} X-RateLimit-Reset: {$ref: '#/components/headers/XRateLimitReset'} Raise-Environment: {$ref: '#/components/headers/RaiseEnvironment'} content: application/json: schema: {$ref: '#/components/schemas/CustomerPage'} "400": {$ref: '#/components/responses/BadRequest'} "401": {$ref: '#/components/responses/Unauthorized'} "403": {$ref: '#/components/responses/Forbidden'} "429": {$ref: '#/components/responses/RateLimited'} "500": {$ref: '#/components/responses/Internal'} "503": {$ref: '#/components/responses/Unavailable'} post: operationId: createCustomer tags: [customers] summary: 按手机号创建客户,或返回已经存在的那位 description: | 需要范围 `customer:write`。租户令牌。`phone` 必填,否则 422。走客户中心的 Resolve:号码已有客户则 200 并返回那位(不改姓名、不加标签、不发事件);没有则 201 并写入 `customer.created`。 新建时可选 `name`、`tags`(标签 id)。请带 Idempotency-Key。返回的 `phone` 已打码。 x-sdk: {resource: customers, method: create, audience: tenant} security: [{bearer: []}] parameters: - {$ref: '#/components/parameters/IdempotencyKey'} requestBody: required: true content: application/json: schema: {$ref: '#/components/schemas/CreateCustomerBody'} responses: "200": description: 这个号码已经有客户,返回那位(未改) headers: X-Request-Id: {$ref: '#/components/headers/XRequestId'} X-RateLimit-Limit: {$ref: '#/components/headers/XRateLimitLimit'} X-RateLimit-Remaining: {$ref: '#/components/headers/XRateLimitRemaining'} X-RateLimit-Reset: {$ref: '#/components/headers/XRateLimitReset'} Raise-Environment: {$ref: '#/components/headers/RaiseEnvironment'} Idempotent-Replayed: {$ref: '#/components/headers/IdempotentReplayed'} content: application/json: schema: {$ref: '#/components/schemas/Customer'} "201": description: 新建的客户 headers: X-Request-Id: {$ref: '#/components/headers/XRequestId'} X-RateLimit-Limit: {$ref: '#/components/headers/XRateLimitLimit'} X-RateLimit-Remaining: {$ref: '#/components/headers/XRateLimitRemaining'} X-RateLimit-Reset: {$ref: '#/components/headers/XRateLimitReset'} Raise-Environment: {$ref: '#/components/headers/RaiseEnvironment'} Idempotent-Replayed: {$ref: '#/components/headers/IdempotentReplayed'} content: application/json: schema: {$ref: '#/components/schemas/Customer'} "400": {$ref: '#/components/responses/BadRequest'} "401": {$ref: '#/components/responses/Unauthorized'} "403": {$ref: '#/components/responses/Forbidden'} "409": {$ref: '#/components/responses/Conflict'} "413": {$ref: '#/components/responses/PayloadTooLarge'} "422": {$ref: '#/components/responses/Unprocessable'} "429": {$ref: '#/components/responses/RateLimited'} "500": {$ref: '#/components/responses/Internal'} "503": {$ref: '#/components/responses/Unavailable'} /customers/{id}: get: operationId: getCustomer tags: [customers] summary: 读取一位客户(电话已打码) description: | 需要范围 `customer:read`。不存在是 404 `not_found`。`phone` 与 `identities[].masked` 是打码形式;`identities` 不含明文 `value`。揭开号码不是这个接口的事。 x-sdk: {resource: customers, method: get, audience: tenant} security: [{bearer: []}] parameters: - name: id in: path required: true schema: {type: string} description: 客户中心 id responses: "200": description: 这位客户 headers: X-Request-Id: {$ref: '#/components/headers/XRequestId'} X-RateLimit-Limit: {$ref: '#/components/headers/XRateLimitLimit'} X-RateLimit-Remaining: {$ref: '#/components/headers/XRateLimitRemaining'} X-RateLimit-Reset: {$ref: '#/components/headers/XRateLimitReset'} Raise-Environment: {$ref: '#/components/headers/RaiseEnvironment'} content: application/json: schema: {$ref: '#/components/schemas/Customer'} "401": {$ref: '#/components/responses/Unauthorized'} "403": {$ref: '#/components/responses/Forbidden'} "404": {$ref: '#/components/responses/NotFound'} "429": {$ref: '#/components/responses/RateLimited'} "500": {$ref: '#/components/responses/Internal'} "503": {$ref: '#/components/responses/Unavailable'} patch: operationId: updateCustomer tags: [customers] summary: 改客户的姓名或标签 description: | 需要范围 `customer:write`。可改 `name`、`tags`(标签 id 列表;空数组表示去掉全部可改的标签)。带上 `phone` 是 422(号码不能改)。带上 `notes` 是 422(客户中心没有备注字段)。其它未知字段忽略。 改了字段时写入 `customer.updated`。企业微信等外来标签不能在这里摘掉,会 422。 x-sdk: {resource: customers, method: update, audience: tenant} security: [{bearer: []}] parameters: - name: id in: path required: true schema: {type: string} description: 客户中心 id requestBody: required: true content: application/json: schema: {$ref: '#/components/schemas/UpdateCustomerBody'} responses: "200": description: 改完后的客户 headers: X-Request-Id: {$ref: '#/components/headers/XRequestId'} X-RateLimit-Limit: {$ref: '#/components/headers/XRateLimitLimit'} X-RateLimit-Remaining: {$ref: '#/components/headers/XRateLimitRemaining'} X-RateLimit-Reset: {$ref: '#/components/headers/XRateLimitReset'} Raise-Environment: {$ref: '#/components/headers/RaiseEnvironment'} content: application/json: schema: {$ref: '#/components/schemas/Customer'} "400": {$ref: '#/components/responses/BadRequest'} "401": {$ref: '#/components/responses/Unauthorized'} "403": {$ref: '#/components/responses/Forbidden'} "404": {$ref: '#/components/responses/NotFound'} "422": {$ref: '#/components/responses/Unprocessable'} "429": {$ref: '#/components/responses/RateLimited'} "500": {$ref: '#/components/responses/Internal'} "503": {$ref: '#/components/responses/Unavailable'} /members: get: operationId: listMembers tags: [org] summary: 列出组织通讯录里的成员 description: | 需要范围 `org.members:read`。租户令牌。返回 `{data:[{id, name, title, departments, status}], nextCursor}`。默认 20 条,最大 100。按账号 id。 `email` 与 `phone` 只在同时持有 `org.contacts:read` 时出现。生产环境的通讯录是在职的人(不含机器账号);状态以目录为准。 x-sdk: {resource: org, method: listMembers, audience: tenant} security: [{bearer: []}] parameters: - {$ref: '#/components/parameters/Limit'} - {$ref: '#/components/parameters/Cursor'} responses: "200": description: 一页成员。没有下一页时 nextCursor 为 null headers: X-Request-Id: {$ref: '#/components/headers/XRequestId'} X-RateLimit-Limit: {$ref: '#/components/headers/XRateLimitLimit'} X-RateLimit-Remaining: {$ref: '#/components/headers/XRateLimitRemaining'} X-RateLimit-Reset: {$ref: '#/components/headers/XRateLimitReset'} Raise-Environment: {$ref: '#/components/headers/RaiseEnvironment'} content: application/json: schema: {$ref: '#/components/schemas/MemberPage'} "400": {$ref: '#/components/responses/BadRequest'} "401": {$ref: '#/components/responses/Unauthorized'} "403": {$ref: '#/components/responses/Forbidden'} "429": {$ref: '#/components/responses/RateLimited'} "500": {$ref: '#/components/responses/Internal'} "503": {$ref: '#/components/responses/Unavailable'} /org: get: operationId: getOrg tags: [org] summary: 当前令牌所在组织的 id 与名称 description: | 需要范围 `org.members:read`。返回 `{tenant, name}`。名称来自组织档案;没有名称时是空字符串。 x-sdk: {resource: org, method: get, audience: tenant} security: [{bearer: []}] responses: "200": description: "{tenant, name}" headers: X-Request-Id: {$ref: '#/components/headers/XRequestId'} X-RateLimit-Limit: {$ref: '#/components/headers/XRateLimitLimit'} X-RateLimit-Remaining: {$ref: '#/components/headers/XRateLimitRemaining'} X-RateLimit-Reset: {$ref: '#/components/headers/XRateLimitReset'} Raise-Environment: {$ref: '#/components/headers/RaiseEnvironment'} content: application/json: schema: {$ref: '#/components/schemas/Org'} "401": {$ref: '#/components/responses/Unauthorized'} "403": {$ref: '#/components/responses/Forbidden'} "429": {$ref: '#/components/responses/RateLimited'} "500": {$ref: '#/components/responses/Internal'} "503": {$ref: '#/components/responses/Unavailable'} webhooks: app.installed: post: operationId: receiveAppInstalled tags: [events] summary: 一个组织装上了这个应用 description: | 只发给这个应用自己的开发者订阅,不需要范围。`data` 是 `{version, scopes}`。信封带 `tenant`(正式环境也会带:租户装了你的应用,你要用这个 id 去换该组织的令牌)、`environment`、`app`。 用 `X-Kwork-Signature: t=,v1=.")>` 验签,拒绝过期的时间戳。另有 `X-Kwork-Event`、`X-Kwork-Delivery`。 x-sdk: {internal: true} parameters: - {$ref: '#/components/parameters/WebhookSignature'} - {$ref: '#/components/parameters/WebhookEvent'} - {$ref: '#/components/parameters/WebhookDelivery'} requestBody: required: true content: application/json: schema: allOf: - {$ref: '#/components/schemas/Event'} - type: object properties: type: {type: string, const: app.installed} data: {$ref: '#/components/schemas/AppInstalledData'} responses: "200": {description: 接收方已收下。非 2xx 会按退避重投} app.uninstalled: post: operationId: receiveAppUninstalled tags: [events] summary: 一个组织卸载了这个应用 description: 只发给这个应用自己的开发者订阅,不需要范围。`data` 是 `{}`。验签同 app.installed。 x-sdk: {internal: true} parameters: - {$ref: '#/components/parameters/WebhookSignature'} - {$ref: '#/components/parameters/WebhookEvent'} - {$ref: '#/components/parameters/WebhookDelivery'} requestBody: required: true content: application/json: schema: allOf: - {$ref: '#/components/schemas/Event'} - type: object properties: type: {type: string, const: app.uninstalled} data: {$ref: '#/components/schemas/AppUninstalledData'} responses: "200": {description: 接收方已收下} app.upgraded: post: operationId: receiveAppUpgraded tags: [events] summary: 一个组织把这个应用升到了新版本 description: 只发给这个应用自己的开发者订阅。`data` 是 `{from, to, scopes}`。 x-sdk: {internal: true} parameters: - {$ref: '#/components/parameters/WebhookSignature'} - {$ref: '#/components/parameters/WebhookEvent'} - {$ref: '#/components/parameters/WebhookDelivery'} requestBody: required: true content: application/json: schema: allOf: - {$ref: '#/components/schemas/Event'} - type: object properties: type: {type: string, const: app.upgraded} data: {$ref: '#/components/schemas/AppUpgradedData'} responses: "200": {description: 接收方已收下} app.scopes_changed: post: operationId: receiveAppScopesChanged tags: [events] summary: 管理员改了同意给这个应用的权限范围 description: 只发给这个应用自己的开发者订阅。`data` 是 `{scopes, added, removed}`。 x-sdk: {internal: true} parameters: - {$ref: '#/components/parameters/WebhookSignature'} - {$ref: '#/components/parameters/WebhookEvent'} - {$ref: '#/components/parameters/WebhookDelivery'} requestBody: required: true content: application/json: schema: allOf: - {$ref: '#/components/schemas/Event'} - type: object properties: type: {type: string, const: app.scopes_changed} data: {$ref: '#/components/schemas/AppScopesChangedData'} responses: "200": {description: 接收方已收下} dev.version.approved: post: operationId: receiveDevVersionApproved tags: [events] summary: 平台审核通过了一个版本,可以上架了 description: | 开发者订阅,不需要租户授予的范围。`data` 是 `{app, version, decision}`,`decision` 为 `approved`。同一审核结果的 id 在每次重试、每个订阅上都相同,接收方按 id 去重。 x-sdk: {internal: true} parameters: - {$ref: '#/components/parameters/WebhookSignature'} - {$ref: '#/components/parameters/WebhookEvent'} - {$ref: '#/components/parameters/WebhookDelivery'} requestBody: required: true content: application/json: schema: allOf: - {$ref: '#/components/schemas/Event'} - type: object properties: type: {type: string, const: dev.version.approved} data: {$ref: '#/components/schemas/DevVersionData'} responses: "200": {description: 接收方已收下} dev.version.rejected: post: operationId: receiveDevVersionRejected tags: [events] summary: 平台审核驳回了一个版本 description: 开发者订阅。`data` 是 `{app, version, decision, reason?}`,`decision` 为 `rejected`,`reason` 是审核意见(改好后重新提交)。 x-sdk: {internal: true} parameters: - {$ref: '#/components/parameters/WebhookSignature'} - {$ref: '#/components/parameters/WebhookEvent'} - {$ref: '#/components/parameters/WebhookDelivery'} requestBody: required: true content: application/json: schema: allOf: - {$ref: '#/components/schemas/Event'} - type: object properties: type: {type: string, const: dev.version.rejected} data: {$ref: '#/components/schemas/DevVersionData'} responses: "200": {description: 接收方已收下} membership.redeem_request.created: post: operationId: receiveMembershipRedeemRequestCreated tags: [events] summary: 前台请客户确认一次核销 description: | 需要范围 `membership.redemption:confirm`。租户集成订阅与商店应用的开发者订阅(按安装且授予了该范围的组织)都会收到。 `data` 含 `customer`(客户中心 id)、`member`、`request`、`course`(课程名)、`times`、`expiresAt`。没有电话、没有姓名。客户该去小程序里确认。 x-sdk: {internal: true} parameters: - {$ref: '#/components/parameters/WebhookSignature'} - {$ref: '#/components/parameters/WebhookEvent'} - {$ref: '#/components/parameters/WebhookDelivery'} requestBody: required: true content: application/json: schema: allOf: - {$ref: '#/components/schemas/Event'} - type: object properties: type: {type: string, const: membership.redeem_request.created} data: {$ref: '#/components/schemas/RedeemRequestCreatedData'} responses: "200": {description: 接收方已收下} membership.redeem_request.cancelled: post: operationId: receiveMembershipRedeemRequestCancelled tags: [events] summary: 前台取消了请客户确认的核销 description: 需要范围 `membership.redemption:confirm`。`data` 含 `customer`、`member`、`request`。客户不用再确认。 x-sdk: {internal: true} parameters: - {$ref: '#/components/parameters/WebhookSignature'} - {$ref: '#/components/parameters/WebhookEvent'} - {$ref: '#/components/parameters/WebhookDelivery'} requestBody: required: true content: application/json: schema: allOf: - {$ref: '#/components/schemas/Event'} - type: object properties: type: {type: string, const: membership.redeem_request.cancelled} data: {$ref: '#/components/schemas/RedeemRequestCancelledData'} responses: "200": {description: 接收方已收下} membership.booking.created: post: operationId: receiveMembershipBookingCreated tags: [events] summary: 客户有了新的预约或候补 description: 需要范围 `membership.me:read`。前台或客户自己预约都会发。`data` 含 `customer`、`member`、`booking`、`session`、`course`(课程名)、`startsAt`、`status`(booked 或 waitlist)。 x-sdk: {internal: true} parameters: - {$ref: '#/components/parameters/WebhookSignature'} - {$ref: '#/components/parameters/WebhookEvent'} - {$ref: '#/components/parameters/WebhookDelivery'} requestBody: required: true content: application/json: schema: allOf: - {$ref: '#/components/schemas/Event'} - type: object properties: type: {type: string, const: membership.booking.created} data: {$ref: '#/components/schemas/BookingCreatedData'} responses: "200": {description: 接收方已收下} membership.booking.cancelled: post: operationId: receiveMembershipBookingCancelled tags: [events] summary: 客户的预约或候补被取消 description: 需要范围 `membership.me:read`。前台、客户自己或课次取消都会发。`data` 含 `customer`、`member`、`booking`、`session`、`course`、`charged`(实际扣次)。 x-sdk: {internal: true} parameters: - {$ref: '#/components/parameters/WebhookSignature'} - {$ref: '#/components/parameters/WebhookEvent'} - {$ref: '#/components/parameters/WebhookDelivery'} requestBody: required: true content: application/json: schema: allOf: - {$ref: '#/components/schemas/Event'} - type: object properties: type: {type: string, const: membership.booking.cancelled} data: {$ref: '#/components/schemas/BookingCancelledData'} responses: "200": {description: 接收方已收下} membership.redemption.created: post: operationId: receiveMembershipRedemptionCreated tags: [events] summary: 客户有一次核销(到店核销,或客户确认了申请) description: 需要范围 `membership.me:read`。`data` 含 `customer`、`member`、`redemption`、`course`、`times`、`remaining`(扣完后的课包余额)。 x-sdk: {internal: true} parameters: - {$ref: '#/components/parameters/WebhookSignature'} - {$ref: '#/components/parameters/WebhookEvent'} - {$ref: '#/components/parameters/WebhookDelivery'} requestBody: required: true content: application/json: schema: allOf: - {$ref: '#/components/schemas/Event'} - type: object properties: type: {type: string, const: membership.redemption.created} data: {$ref: '#/components/schemas/RedemptionCreatedData'} responses: "200": {description: 接收方已收下} customer.created: post: operationId: receiveCustomerCreated tags: [events] summary: 组织里新建了一位客户 description: | 需要范围 `customer:read`。本批只在开放接口 POST /customers 新建时发出(员工在客户应用里新建不走这条)。 `data` 是 `{customer, id}`(同一客户中心 id),没有电话、没有姓名。 x-sdk: {internal: true} parameters: - {$ref: '#/components/parameters/WebhookSignature'} - {$ref: '#/components/parameters/WebhookEvent'} - {$ref: '#/components/parameters/WebhookDelivery'} requestBody: required: true content: application/json: schema: allOf: - {$ref: '#/components/schemas/Event'} - type: object properties: type: {type: string, const: customer.created} data: {$ref: '#/components/schemas/CustomerCreatedData'} responses: "200": {description: 接收方已收下} customer.updated: post: operationId: receiveCustomerUpdated tags: [events] summary: 一位客户的姓名或标签改了 description: | 需要范围 `customer:read`。本批只在开放接口 PATCH /customers/{id} 改了字段时发出。 `data` 是 `{customer, id, fields}`,`fields` 为 `name`、`tags` 中实际改过的项。没有电话、没有姓名。 x-sdk: {internal: true} parameters: - {$ref: '#/components/parameters/WebhookSignature'} - {$ref: '#/components/parameters/WebhookEvent'} - {$ref: '#/components/parameters/WebhookDelivery'} requestBody: required: true content: application/json: schema: allOf: - {$ref: '#/components/schemas/Event'} - type: object properties: type: {type: string, const: customer.updated} data: {$ref: '#/components/schemas/CustomerUpdatedData'} responses: "200": {description: 接收方已收下} components: securitySchemes: bearer: type: http scheme: bearer description: "POST /oauth/token 换到的短令牌。Authorization: Bearer " basic: type: http scheme: basic description: 换令牌时的应用密钥。用户名是 client_id,密码是 client_secret。也可以把这两个字段放进正文。 parameters: Table: name: table in: path required: true schema: {type: string} description: 表名。系统表以 sys_ 开头;否则是租户自建表 View: name: view in: path required: true schema: {type: string} description: 视图 id Limit: name: limit in: query schema: {type: integer, minimum: 1, maximum: 100, default: 20} description: 每页条数,默认 20,最大 100 Cursor: name: cursor in: query schema: {type: string} description: 上一页返回的 nextCursor。第一页省略 IdempotencyKey: name: Idempotency-Key in: header schema: {type: string, minLength: 1, maxLength: 255} description: 除 /oauth/token 外每个 POST 都收。SDK 未传入时会生成 uuid v4。24 小时内同键同正文重放第一次的回答 WebhookSignature: name: X-Kwork-Signature in: header required: true schema: {type: string} description: t=,v1=.")> WebhookEvent: name: X-Kwork-Event in: header required: true schema: {type: string} description: 事件类型,与正文 type 相同 WebhookDelivery: name: X-Kwork-Delivery in: header required: true schema: {type: string} description: 这一次投递的 id(重投会变;事件 id 在正文里,重试不变) headers: XRequestId: schema: {type: string} description: 这次调用的 id,与错误信封里的 requestId 相同。报问题时带上 XRateLimitLimit: schema: {type: integer} description: 这把凭证的突发上限(2×rps) XRateLimitRemaining: schema: {type: integer} description: 此刻还剩几次 XRateLimitReset: schema: {type: integer} description: 多少秒后桶会加满 RetryAfter: schema: {type: integer} description: 再试之前要等的秒数 IdempotentReplayed: schema: {type: string, enum: ["true"]} description: 这次回答是 24 小时内同一把 Idempotency-Key 的重放 RaiseEnvironment: schema: {type: string, enum: [test, live]} description: 这把令牌属于测试还是正式 WWWAuthenticate: schema: {type: string} description: 'Basic realm="kwork open platform"(invalid_client 时)' responses: BadRequest: description: | 400 `invalid_request`:查询或正文读不出来。换令牌时还有 `unsupported_grant_type`(不认识的 grant_type)和 `tenant_required`(应用级凭证必须带 tenant;或带了但不是这个凭证能用的组织)。多维表筛/排了视图里没有的字段也是这一条。 headers: X-Request-Id: {$ref: '#/components/headers/XRequestId'} content: application/json: schema: {$ref: '#/components/schemas/Error'} Unauthorized: description: | 401。`token_required`:没带 Bearer,先 POST /oauth/token。`invalid_token`:令牌无效或过期,重新换(SDK 会自动换一次再重试)。`credential_ended`:凭证吊销、过期,或应用关掉、范围被收光。写 /me 时还有 `assertion_stale`:客户断言超过 10 分钟,重新断言再写。 headers: X-Request-Id: {$ref: '#/components/headers/XRequestId'} content: application/json: schema: {$ref: '#/components/schemas/Error'} InvalidClient: description: | 401 `invalid_client`:client_id 不存在或 client_secret 不对。两种情况同一句,避免探测。带 `WWW-Authenticate: Basic`。密钥已吊销或过期是 `credential_ended`(也是 401)。 headers: X-Request-Id: {$ref: '#/components/headers/XRequestId'} WWW-Authenticate: {$ref: '#/components/headers/WWWAuthenticate'} content: application/json: schema: {$ref: '#/components/schemas/Error'} Forbidden: description: | 403。`scope_required`:缺了操作描述里写的范围,去开发者平台或请管理员勾。`customer_token_required`:/me 只要客户令牌,用客户授权再调。`wrong_environment`:测试密钥打了正式组织,或正式密钥打了测试企业。`not_installed`:应用没装、关掉,或组织停用。`no_scope`:一把范围都没授。`address_not_allowed`:来源 IP 不在凭证允许名单。 headers: X-Request-Id: {$ref: '#/components/headers/XRequestId'} content: application/json: schema: {$ref: '#/components/schemas/Error'} NotFound: description: | 404 `not_found`:不存在,或你不该知道它存在(别人的会员、未开放的表、进不了的视图)。不要靠 403 去区分「没有」和「没权限」。 headers: X-Request-Id: {$ref: '#/components/headers/XRequestId'} content: application/json: schema: {$ref: '#/components/schemas/Error'} Conflict: description: | 409。`conflict`:和当前状态冲突(已经预约、申请已经处理、晚取消未带 acceptCharge)。`ambiguous_customer`:同一号码对应多个客户,`details.candidates` 列出 id,下次带上 `customer_id`。`idempotency_conflict`:同一 Idempotency-Key 不同正文。`idempotency_in_progress`:第一次还在处理,稍后再读或换新键。 headers: X-Request-Id: {$ref: '#/components/headers/XRequestId'} content: application/json: schema: {$ref: '#/components/schemas/Error'} PayloadTooLarge: description: 413 `payload_too_large`:正文太大。会员写接口上限 1 MiB。 headers: X-Request-Id: {$ref: '#/components/headers/XRequestId'} content: application/json: schema: {$ref: '#/components/schemas/Error'} Unprocessable: description: | 422。`validation_failed`:字段或门店规则不接受(日期窗口、课次不存在、会员状态、剩余次数不足、原因超长)。换客户令牌时电话读不出来是 `invalid_phone`:带国家码,或按大陆手机号发。 headers: X-Request-Id: {$ref: '#/components/headers/XRequestId'} content: application/json: schema: {$ref: '#/components/schemas/Error'} RateLimited: description: | 429。`rate_limited`:这把凭证的调用预算用完,看 `Retry-After` 和 `X-RateLimit-*`。`too_many_attempts`:这个地址失败太多次,等 `Retry-After`(换令牌时常见 60 秒)。 headers: X-Request-Id: {$ref: '#/components/headers/XRequestId'} Retry-After: {$ref: '#/components/headers/RetryAfter'} X-RateLimit-Limit: {$ref: '#/components/headers/XRateLimitLimit'} X-RateLimit-Remaining: {$ref: '#/components/headers/XRateLimitRemaining'} X-RateLimit-Reset: {$ref: '#/components/headers/XRateLimitReset'} content: application/json: schema: {$ref: '#/components/schemas/Error'} Internal: description: 500 `internal`:平台出错。带上 `X-Request-Id` 报给我们。不要立刻换一种写法重试同一把幂等键。 headers: X-Request-Id: {$ref: '#/components/headers/XRequestId'} content: application/json: schema: {$ref: '#/components/schemas/Error'} NotImplemented: description: 501 `not_implemented`。客户授权在这个部署不可用时会出现(代码仍可能是 `unavailable`)。 headers: X-Request-Id: {$ref: '#/components/headers/XRequestId'} content: application/json: schema: {$ref: '#/components/schemas/Error'} Unavailable: description: 503 `unavailable`:暂时不可用(读凭证、读授权、读库失败)。按 `Retry-After` 重试;没有该头时 SDK 用指数退避。 headers: X-Request-Id: {$ref: '#/components/headers/XRequestId'} Retry-After: {$ref: '#/components/headers/RetryAfter'} content: application/json: schema: {$ref: '#/components/schemas/Error'} schemas: ErrorCode: type: string description: 稳定错误码。用它分支,不要解析 message。 enum: - invalid_request - invalid_grant - consent_required - consent_revoked - person_gone - unauthorized - forbidden - not_found - method_not_allowed - conflict - payload_too_large - validation_failed - rate_limited - internal - not_implemented - unavailable - token_required - invalid_token - invalid_credential - invalid_client - unsupported_grant_type - tenant_required - wrong_environment - credential_ended - address_not_allowed - not_installed - no_scope - scope_required - customer_token_required - assertion_stale - invalid_phone - ambiguous_customer - too_many_attempts - idempotency_conflict - idempotency_in_progress - condition_failed ErrorBody: type: object required: [code, message] properties: code: {$ref: '#/components/schemas/ErrorCode'} message: {type: string, description: 给人看的说明,并写出下一步} requestId: {type: string, description: 与 X-Request-Id 相同} docUrl: {type: string, description: "这条错误的文档,形如 https://dev.{domain}/docs/errors#scope_required"} details: type: object additionalProperties: true description: 没有内容时是 {}。ambiguous_customer 时含 candidates(客户 id 列表) Error: type: object required: [error] description: 每个状态 ≥ 400 的正文 properties: error: {$ref: '#/components/schemas/ErrorBody'} Page: type: object required: [data, nextCursor] description: 游标列表。limit 默认 20,最大 100 properties: data: {type: array, items: {}} nextCursor: type: [string, "null"] description: 下一页游标。没有下一页时为 null TokenRequest: type: object required: [grant_type] properties: grant_type: type: string enum: [client_credentials, "urn:raise:grant-type:customer", authorization_code, refresh_token] description: "client_credentials 换租户令牌;urn:raise:grant-type:customer 换客户令牌;authorization_code / refresh_token 换员工令牌" client_id: type: string description: 未用 HTTP Basic 时必填 client_secret: type: string description: 未用 HTTP Basic 时必填 tenant: type: string description: 应用级凭证必填。租户绑定凭证必须省略或等于它自己的组织 scope: type: string description: 空格分隔的范围子集。省略则给已授予的全部 phone: type: string description: 客户授权必填。数字,可带前导 +。空格和短横忽略。大陆手机也可写成 86… / +86… customer_id: type: string description: 客户授权可选。在 ambiguous_customer 之后指定是哪一位 code: type: string description: authorization_code 必填。身份服务回调带回的授权码 code_verifier: type: string description: authorization_code 必填。PKCE 原文(S256) redirect_uri: type: string description: authorization_code 必填。与授权请求里的一致,且在开发者平台登记过 refresh_token: type: string description: refresh_token 必填。kwr_ 开头,只能用一次 Token: type: object required: [access_token, token_type, expires_in, scope, tenant, environment] description: RFC 6749 成功正文。customer 只在客户授权时出现 properties: access_token: {type: string} token_type: {type: string, const: Bearer} expires_in: {type: integer, description: 秒。租户令牌 300,客户令牌 900,员工令牌 600} scope: {type: string, description: 空格分隔。客户令牌不含 customer:assert} tenant: {type: string} environment: {type: string, enum: [test, live]} customer: {type: string, description: 客户中心 id。仅客户授权} refresh_token: {type: string, description: 仅员工授权。下一次续期用它,30 天不用就过期} user: {type: string, description: 仅员工授权。这位员工的用户 id} CustomerMiss: type: object required: [matched] additionalProperties: false description: 客户授权找不到这个号码下的会员。不发令牌、不建客户 properties: matched: {type: boolean, const: false} Whoami: type: object required: [tenant, app, credential, scopes] properties: tenant: {type: string} app: {type: string} credential: {type: string, description: 换出这把令牌的 client_id} scopes: type: array items: {type: string} description: 令牌范围与此刻授予的交集 user: {type: string, description: 员工令牌的员工;租户令牌为空} roles: type: array items: {type: string} description: 员工令牌:这个人此刻在组织里的角色(每次调用现读);租户令牌为空 environment: {type: string, enum: [test, live]} Ping: type: object required: [ok] properties: ok: {type: boolean, const: true} Row: type: object required: [id, version, fields] description: 多维表一行。fields 只有视图里的列,永远不含个人字段 properties: id: {type: string} version: {type: integer, description: 乐观并发版本} fields: type: object additionalProperties: true createdAt: {type: string, format: date-time} RowPage: type: object required: [data, nextCursor] properties: data: type: array items: {$ref: '#/components/schemas/Row'} nextCursor: type: [string, "null"] MeMember: type: object required: [id, no, name, status] properties: id: {type: string} no: {type: string, description: 会员编号} name: {type: string} status: {type: string, enum: [active, frozen, expired], description: merged 和 closed 不会出现} MeMembers: type: object required: [members] properties: members: type: array items: {$ref: '#/components/schemas/MeMember'} MePackage: type: object required: [id, memberId, name, kind, unlimited, totalTimes, remaining, courses, state, status] properties: id: {type: string} memberId: {type: string} name: {type: string} kind: {type: string, enum: [count, period, count_period]} unlimited: {type: boolean, description: 仅 kind 为 period 时为 true} totalTimes: {type: integer} remaining: {type: integer} courses: type: array items: {type: string} description: 课程 id。空表示全部课程 startsOn: {type: string, description: YYYY-MM-DD。未设则省略} endsOn: {type: string, description: YYYY-MM-DD。未设则省略} state: {type: string, enum: [active, frozen], description: 存储状态。只列出这两种} status: {type: string, enum: [active, not_started, frozen, expired, used_up], description: 读的时候算出来} MePackages: type: object required: [packages] properties: packages: type: array items: {$ref: '#/components/schemas/MePackage'} MeBooking: type: object required: [id, memberId, sessionId, courseName, startsAt, endsAt, room, status, cancelCharge] properties: id: {type: string} memberId: {type: string} sessionId: {type: string} courseName: {type: string} coachName: {type: string, description: 员工显示名。没有则省略} startsAt: {type: string, format: date-time} endsAt: {type: string, format: date-time} room: {type: string, description: 总是出现,可能是空字符串} status: {type: string, enum: [booked, waitlist, attended, completed, cancelled, no_show]} cancelCharge: {type: integer, description: 现在取消要扣几次。非 booked 为 0。门店设置为 0 或 1} MeBookings: type: object required: [bookings] properties: bookings: type: array items: {$ref: '#/components/schemas/MeBooking'} CreateBookingBody: type: object required: [memberId, sessionId] properties: memberId: {type: string, description: GET /me 返回的会员 id} sessionId: {type: string, description: GET /me/sessions 返回的课次 id} CancelBookingBody: type: object properties: acceptCharge: {type: boolean, description: 列表里 cancelCharge 大于 0 时必须为 true} reason: {type: string, description: 最多 200 字} CancelBookingResult: type: object required: [cancelled, charged] properties: cancelled: {type: boolean, const: true} charged: {type: integer, description: 实际扣次,可以为 0} MeSession: type: object required: [id, courseId, courseName, startsAt, endsAt, room, spotsLeft] properties: id: {type: string} courseId: {type: string} courseName: {type: string} coachName: {type: string, description: 员工显示名。没有则省略} startsAt: {type: string, format: date-time} endsAt: {type: string, format: date-time} room: {type: string, description: 总是出现,可能是空字符串} spotsLeft: {type: integer, description: "max(0, 容量 − booked/attended/completed)。0 表示再预约会进候补"} MeSessions: type: object required: [sessions] properties: sessions: type: array items: {$ref: '#/components/schemas/MeSession'} MeRedemptionRequest: type: object required: [id, memberId, memberName, times, state, expiresAt, createdAt] properties: id: {type: string} memberId: {type: string} memberName: {type: string} courseName: {type: string, description: 课程没有名字则省略} times: {type: integer} state: {type: string, enum: [pending, confirmed, declined, cancelled, expired], description: expired 是读的时候算的} expiresAt: {type: string, format: date-time} createdAt: {type: string, format: date-time} MeRedemptionRequests: type: object required: [requests] properties: requests: type: array items: {$ref: '#/components/schemas/MeRedemptionRequest'} ConfirmRedemptionResult: type: object required: [confirmed, remaining, notices] properties: confirmed: {type: boolean, const: true} remaining: {type: integer, description: 扣完后的课包余额。期卡扣 0} notices: type: array items: {type: string} description: 可能为空。否则是用完、只剩 N 次、今天到期、N 天后到期 DeclineRedemptionBody: type: object properties: reason: {type: string, description: 最多 200 字} DeclineRedemptionResult: type: object required: [declined] properties: declined: {type: boolean, const: true} Event: type: object required: [id, type, createdAt, apiVersion, data] description: 每一种订阅都用这个信封 properties: id: {type: string, description: evt_…,同一事件每次重试、每个订阅都相同,按它去重} type: {type: string} createdAt: {type: string, format: date-time} apiVersion: {type: string, const: "2026-10-28"} tenant: {type: string, description: 租户范围的事件有。发给商店应用的正式事件也会带(租户装了你的应用)} environment: {type: string, enum: [test, live]} app: {type: string} data: type: object additionalProperties: true AppInstalledData: type: object required: [version, scopes] properties: version: {type: string} scopes: type: array items: {type: string} AppUninstalledData: type: object additionalProperties: false AppUpgradedData: type: object required: [from, to, scopes] properties: from: {type: string} to: {type: string} scopes: type: array items: {type: string} AppScopesChangedData: type: object required: [scopes, added, removed] properties: scopes: type: array items: {type: string} added: type: array items: {type: string} removed: type: array items: {type: string} DevVersionData: type: object required: [app, version, decision] properties: app: {type: string} version: {type: string} decision: {type: string, enum: [approved, rejected]} reason: {type: string, description: 驳回时的审核意见} RedeemRequestCreatedData: type: object required: [customer, member, request, times] properties: customer: {type: string, description: 客户中心 id} member: {type: string} request: {type: string} course: {type: string, description: 课程名} times: {type: integer} expiresAt: {type: string, format: date-time} RedeemRequestCancelledData: type: object required: [customer, member, request] properties: customer: {type: string} member: {type: string} request: {type: string} BookingCreatedData: type: object required: [customer, member, booking, session, course, startsAt, status] properties: customer: {type: string} member: {type: string} booking: {type: string} session: {type: string} course: {type: string, description: 课程名} startsAt: {type: string, format: date-time} status: {type: string, description: booked 或 waitlist} BookingCancelledData: type: object required: [customer, member, booking, session, course, charged] properties: customer: {type: string} member: {type: string} booking: {type: string} session: {type: string} course: {type: string} charged: {type: integer} RedemptionCreatedData: type: object required: [customer, member, redemption, times, remaining] properties: customer: {type: string} member: {type: string} redemption: {type: string} course: {type: string} times: {type: integer} remaining: {type: integer} Customer: type: object required: [id, name, stage, tags, createdAt, lastTouchAt] description: 开放接口里的一位客户。phone 是打码形式 properties: id: {type: string} name: {type: string} phone: {type: string, description: 员工列表上的打码形式。没有电话则省略} stage: {type: string, description: lead、customer 或 dormant(按最近接触算)} tags: type: array items: {type: string} description: 标签 id createdAt: {type: string, format: date-time} lastTouchAt: {type: string, format: date-time} identities: type: array items: {$ref: '#/components/schemas/CustomerIdentity'} description: 详情才有。masked 已打码,没有明文 value CustomerIdentity: type: object required: [kind, masked, verified] properties: kind: {type: string, description: phone、email、openid 等} masked: {type: string} verified: {type: boolean} CustomerPage: type: object required: [data, nextCursor] properties: data: type: array items: {$ref: '#/components/schemas/Customer'} nextCursor: type: [string, "null"] CreateCustomerBody: type: object required: [phone] properties: phone: {type: string, description: 必填。大陆 11 位或带国家码} name: {type: string, description: 新建时使用。号码已有客户时忽略} tags: type: array items: {type: string} description: 标签 id。只在新建时加上 UpdateCustomerBody: type: object properties: name: {type: string} tags: type: array items: {type: string} description: 标签 id。空数组表示去掉全部可改的标签 Member: type: object required: [id, name, title, departments, status] description: 通讯录里的一个人。email、phone 只在持有 org.contacts:read 时出现 properties: id: {type: string, description: 账号 id} name: {type: string} title: {type: string, description: 职务。没有则是空字符串} departments: type: array items: {type: string} description: 部门名称,主部门在前 status: {type: string, description: 目录里的状态,如 active、invited} email: {type: string} phone: {type: string} MemberPage: type: object required: [data, nextCursor] properties: data: type: array items: {$ref: '#/components/schemas/Member'} nextCursor: type: [string, "null"] Org: type: object required: [tenant, name] properties: tenant: {type: string} name: {type: string, description: 组织显示名。没有则是空字符串} CustomerCreatedData: type: object required: [customer, id] properties: customer: {type: string, description: 客户中心 id} id: {type: string, description: 与 customer 相同} CustomerUpdatedData: type: object required: [customer, id, fields] properties: customer: {type: string, description: 客户中心 id} id: {type: string, description: 与 customer 相同} fields: type: array items: {type: string} description: 实际改过的字段,name 和/或 tags}