开发者自助 OAuth 应用 API
面向普通开发者(非管理员)的自助 OAuth 应用接口:创建自己的 OAuth App、以开发模式自测、 提交人工审核、审核通过后发布为公众版本。路由前缀为 /user/oauth-apps,源码位于 apps/backend/src/routes/user/oauth-apps.ts(挂载点 apps/backend/src/app.ts)。
鉴权与安全边界
- 只接受网站 JWT(
Authorization: Bearer <JWT>):API Key、OAuth Token 不代表"这个人", 匿名 JWT 同样被拒绝;凭证缺失或无效返回401,认证方式不符返回403 self_service_requires_user_session。 - 自助入口只能看到并操作
management_mode = 'self_service'且owner_user_id为本人的 App;即使当前用户是管理员,也看不到admin_managedApp(那条路径在/admin/oauth-apps)。 - 相关数据表为 service-only RLS,读写一律经 service-role 客户端;归属隔离由路由层的
owner_user_id + management_mode双重过滤与各 RPC 内部的 owner 校验共同保证,路由文件 本身不直接写任何业务表。 - 数据库
RAISE EXCEPTION抛出的 snake_case 业务码是前后端共享的稳定契约;下文错误表中 的错误码均按apps/backend/src/utils/oauth-self-service-errors.ts的统一映射选择 HTTP 状态码。
重要:写操作已迁移至 Team 工作区
新建、编辑、审核和发布已统一迁移到 Team 工作区(/teams/:teamId/oauth-apps/...)。为避免 滚动发布期间旧前端继续制造没有 Team/Project 归属的数据,本模块对所有非 GET/HEAD 请求 统一返回 409 oauth_app_team_context_required(在通过 JWT 校验之后拦截)。
因此下文中的 POST / PATCH / DELETE 路由的契约仍按实现保留描述,但当前调用会先命中该 409 拦截;查询类路由(GET)不受影响。
通用请求头
| 头 | 适用路由 | 说明 |
|---|---|---|
Authorization: Bearer <JWT> | 全部 | 必填,仅接受非匿名 JWT |
Idempotency-Key | 幂等写路由 | 可选,≤128 字符;缺省时后端生成 UUID。同键重试不会重复计次/扣款 |
通用错误
| HTTP 状态 | 错误码 | 说明 |
|---|---|---|
| 401 | Missing or invalid authorization header | 未携带或凭证无效 |
| 403 | self_service_requires_user_session | 使用了 API Key / OAuth Token / 匿名 JWT |
| 409 | oauth_app_team_context_required | 非 GET/HEAD 请求被 Team 迁移拦截(见上文) |
| 500 | 操作失败,请稍后重试 | 服务端异常(已脱敏) |
应用与草稿字段
创建/保存草稿的 payload 由数据库 RPC create_self_service_oauth_app / save_self_service_oauth_app_draft 校验,允许的字段固定为以下八个(含未知字段会被 400 invalid_self_service_oauth_app_payload 拒绝):
| 字段 | 类型 | 约束 |
|---|---|---|
name | string | 必填(草稿更新时可选提供),trim 后 1–120 字节 |
description | string | null | 可选,≤4000 字节 |
logo_url | string | null | 可选,须为合法 URI |
homepage_url | string | null | 可选,须为合法 URI |
redirect_uris | string[] | 必填,非空数组且每项为非空字符串 |
scopes | string[] | 必填,非空数组且每项为非空字符串 |
authorization_config | object | 可选,序列化后 ≤32KB |
test_evidence | object | 可选,序列化后 ≤64KB |
路由总览
| 方法 | 路径 | 说明 |
|---|---|---|
| GET | /user/oauth-apps | 我的自助 OAuth 应用列表 |
| POST | /user/oauth-apps | 创建自助 OAuth 应用(幂等,当前被 409 拦截) |
| GET | /user/oauth-apps/:id | 应用详情(草稿 / 公众版本 / 审核记录) |
| PATCH | /user/oauth-apps/:id | 保存草稿(乐观锁,当前被 409 拦截) |
| DELETE | /user/oauth-apps/:id | 软删除(当前被 409 拦截) |
| GET | /user/oauth-apps/limits | 自助限额与审核费用配置 |
| GET | /user/oauth-apps/products | 我的产品级 OAuth App 列表 |
| GET | /user/oauth-apps/products/:productId | 产品与客户端列表 |
| GET | /user/oauth-apps/legacy-products | 待迁移的历史 OAuth Product |
| POST | /user/oauth-apps/products/:productId/clients | 在产品内新建客户端(当前被 409 拦截) |
| POST | /user/oauth-apps/products/:productId/clients/attach | 关联既有单客户端 App(当前被 409 拦截) |
| PATCH | /user/oauth-apps/products/:productId/clients/:clientRowId | 修改客户端标签(当前被 409 拦截) |
| POST | /user/oauth-apps/:id/development-ticket | 签发一次性开发模式票据(当前被 409 拦截) |
| POST | /user/oauth-apps/:id/review | 提交人工审核(当前被 409 拦截) |
| POST | /user/oauth-apps/:id/review/withdraw | 撤回进行中的审核(当前被 409 拦截) |
| GET | /user/oauth-apps/:id/review/precheck | 提交审核前预检(只读) |
注:/limits、/products、/legacy-products 等静态子路径在源码中声明于 /:id 之前, 避免被 App 详情路由吞掉。
GET /user/oauth-apps
返回当前用户名下全部自助 OAuth App,按 created_at 倒序。每条记录附带草稿版本摘要与 进行中的审核单摘要。
GET /user/oauth-apps
Authorization: Bearer <JWT>成功响应
{
"success": true,
"apps": [
{
"id": "0f2f7d3e-1111-4a2b-9c3d-000000000001",
"product_id": "0f2f7d3e-1111-4a2b-9c3d-000000000001",
"client_id": "Ab3fK9pQ2wSxYz0aBcDeFgHiJkLmNoPq",
"client_label": null,
"name": "示例应用",
"current_public_revision_id": null,
"disabled_at": null,
"created_at": "2026-08-20T08:00:00.000Z",
"updated_at": "2026-08-20T08:00:00.000Z",
"published": false,
"draft_revision": {
"id": "3c1a...-revision-id",
"app_id": "0f2f7d3e-1111-4a2b-9c3d-000000000001",
"revision_number": 1,
"name": "示例应用",
"updated_at": "2026-08-20T08:00:00.000Z"
},
"active_submission": null
}
]
}published 由 current_public_revision_id 派生;active_submission 仅在存在 queued / in_review 状态的审核单时非空。没有应用时返回 { "success": true, "apps": [] }。
常见错误
| HTTP 状态 | 错误码 | 说明 |
|---|---|---|
| 401 / 403 | 见「通用错误」 | 认证失败或认证方式不符 |
POST /user/oauth-apps
创建自助 OAuth App(幂等)。client_id 由后端从「用户 + 幂等键」确定性派生,客户端不能 指定,防止抢注他人已知的 client_id。首次创建返回 201;同一 Idempotency-Key 且内容 一致的重放返回 200 且 idempotent: true;同键但内容不同会被拒绝。
当前状态:该写接口被 Team 迁移中间件拦截,直接返回 409 oauth_app_team_context_required, 契约描述如下(供迁移前的客户端与兼容层参考)。
POST /user/oauth-apps
Authorization: Bearer <JWT>
Idempotency-Key: 8f14e45f-ea9a-4b3c-9d2e-000000000001
Content-Type: application/json请求体
见「应用与草稿字段」一节的八个字段,其中 name、redirect_uris、scopes 必填。
{
"name": "示例应用",
"description": "一个示例应用",
"logo_url": "https://example.com/logo.png",
"homepage_url": "https://example.com",
"redirect_uris": ["https://example.com/callback"],
"scopes": ["profile:read"],
"authorization_config": {},
"test_evidence": {}
}成功响应
{
"success": true,
"app": {
"id": "0f2f7d3e-1111-4a2b-9c3d-000000000002",
"product_id": "0f2f7d3e-1111-4a2b-9c3d-000000000002",
"client_id": "Bc4gD0qR3xTzA1bCdEfGhIjKlMnOpQrS",
"draft_revision_id": "3c1a...-revision-id",
"idempotent": false
}
}单客户端时代产品行与 App 行同源,故 product_id 回退为 id。
常见错误
| HTTP 状态 | 错误码 | 说明 |
|---|---|---|
| 400 | invalid_self_service_oauth_app_create | 请求体不是 JSON 对象 |
| 400 | invalid_self_service_oauth_app_payload | 字段缺失、类型不符或含未知字段 |
| 400 | oauth_self_service_redirect_uri_invalid | redirect_uri / logo_url / homepage_url 不合法 |
| 409 | oauth_self_service_app_limit_exceeded | 活跃 App 数超过 max_active_apps |
| 409 | oauth_self_service_create_idempotency_conflict | 同幂等键但请求内容不同 |
| 429 | oauth_self_service_create_rate_limited | 命中 create_per_hour 防滥用窗口 |
| 503 | oauth_self_service_input_limits_invalid | 输入超长或站点限额配置损坏 |
| 409 | oauth_app_team_context_required | 当前写操作被 Team 迁移拦截 |
GET /user/oauth-apps/:id
读取单个自助 App 的完整详情:App 行、草稿版本、当前公众版本、全部版本历史与最近 20 条 审核提交记录。
GET /user/oauth-apps/:id
Authorization: Bearer <JWT>路径参数
| 参数 | 类型 | 说明 |
|---|---|---|
id | UUID | App(oauth_apps.id)ID;须属于当前用户且 management_mode = 'self_service' |
成功响应
{
"success": true,
"app": {
"id": "0f2f7d3e-1111-4a2b-9c3d-000000000001",
"product_id": "0f2f7d3e-1111-4a2b-9c3d-000000000001",
"client_id": "Ab3fK9pQ2wSxYz0aBcDeFgHiJkLmNoPq",
"client_label": null,
"name": "示例应用",
"management_mode": "self_service",
"owner_user_id": "1e0e...-user-id",
"current_public_revision_id": null,
"disabled_at": null,
"deleted_at": null,
"created_at": "2026-08-20T08:00:00.000Z",
"updated_at": "2026-08-20T08:00:00.000Z"
},
"draft_revision": {
"id": "3c1a...-revision-id",
"revision_number": 1,
"is_draft": true,
"is_frozen": false,
"name": "示例应用",
"description": "一个示例应用",
"logo_url": "https://example.com/logo.png",
"homepage_url": "https://example.com",
"redirect_uris": ["https://example.com/callback"],
"scopes": ["profile:read"],
"authorization_config": {},
"test_evidence": {},
"frozen_at": null,
"created_at": "2026-08-20T08:00:00.000Z",
"updated_at": "2026-08-20T08:00:00.000Z"
},
"public_revision": null,
"revisions": [],
"submissions": []
}revisions 按 revision_number 倒序返回全部版本;submissions 按 created_at 倒序最多 20 条,字段为 id, revision_id, status, started_at, decision_reason, decided_at, quota_period, created_at, updated_at。 无草稿时 draft_revision 为 null;未发布时 public_revision 为 null。
常见错误
| HTTP 状态 | 错误码 | 说明 |
|---|---|---|
| 404 | oauth_app_not_found | App 不存在、已删除或不属于当前用户(含非 self_service) |
PATCH /user/oauth-apps/:id
保存草稿,使用乐观锁:客户端必须回传它看到的草稿 expected_updated_at,与数据库当前值 不一致时拒绝,避免覆盖并发编辑。
当前状态:被 Team 迁移中间件拦截,返回 409 oauth_app_team_context_required。
PATCH /user/oauth-apps/:id
Authorization: Bearer <JWT>
Content-Type: application/json请求体
| 字段 | 类型 | 约束 |
|---|---|---|
expected_updated_at | string(ISO 8601) | 必填;缺失或不可解析直接 400 |
| 其余字段 | 见「应用与草稿字段」 | 至少一个草稿字段;空对象、未知字段会被拒绝 |
{
"expected_updated_at": "2026-08-20T08:00:00.000Z",
"name": "示例应用(改)",
"description": "更新后的描述"
}成功响应
{
"success": true,
"draft": {
"id": "3c1a...-revision-id",
"app_id": "0f2f7d3e-1111-4a2b-9c3d-000000000001",
"revision_number": 1,
"is_draft": true,
"name": "示例应用(改)",
"updated_at": "2026-08-21T09:30:00.000Z"
}
}draft 为更新后的完整草稿版本行。
常见错误
| HTTP 状态 | 错误码 | 说明 |
|---|---|---|
| 400 | invalid_self_service_oauth_app_payload | 请求体不是对象、为空对象或含未知字段 |
| 400 | oauth_app_draft_conflict | 缺少 expected_updated_at 或不可解析 |
| 404 | oauth_app_not_found | App 不存在或不属于当前用户 |
| 409 | oauth_app_draft_conflict | 乐观锁不匹配(updated_at 已被并发修改) |
| 409 | oauth_app_draft_changed_retry | 草稿并发变更,需刷新后重试 |
| 409 | oauth_app_team_context_required | 当前写操作被 Team 迁移拦截 |
DELETE /user/oauth-apps/:id
软删除自助 App:作废未消费授权码、撤销全部开发模式 token,并写入审计日志与通知 outbox。 存在进行中的审核单(queued / pending / in_review)时必须先撤回。
当前状态:被 Team 迁移中间件拦截,返回 409 oauth_app_team_context_required。
DELETE /user/oauth-apps/:id
Authorization: Bearer <JWT>
Content-Type: application/json请求体
| 字段 | 类型 | 约束 |
|---|---|---|
reason | string | 数据库要求非空且 ≤4000 字符;缺失会被 400 oauth_app_delete_request_invalid 拒绝 |
{
"reason": "不再需要该应用"
}成功响应
{
"success": true,
"result": {
"app_id": "0f2f7d3e-1111-4a2b-9c3d-000000000001",
"deleted": true,
"consumed_code_count": 2,
"revoked_token_count": 1,
"idempotent": false
}
}常见错误
| HTTP 状态 | 错误码 | 说明 |
|---|---|---|
| 400 | oauth_app_delete_request_invalid | 缺少 reason 或超长(>4000 字符) |
| 404 | oauth_app_not_found | App 不存在、已删除或不属于当前用户 |
| 409 | oauth_app_review_active_withdraw_required | 存在进行中的审核单,需先撤回 |
| 409 | oauth_self_service_app_already_deleted | App 已被删除 |
| 409 | oauth_product_has_clients | 该 App 是产品主客户端且还有其它客户端 |
| 409 | oauth_app_team_context_required | 当前写操作被 Team 迁移拦截 |
GET /user/oauth-apps/limits
返回自助限额与费用配置,读取自 site_settings.oauth_app_self_service_limits。配置缺失或 字段非法时直接报错(不给默认值兜底),保证计费口径不会静默漂移。
GET /user/oauth-apps/limits
Authorization: Bearer <JWT>成功响应
{
"success": true,
"limits": {
"max_active_apps": 5,
"create_per_hour": 10,
"development_tickets_per_hour": 20,
"free_reviews_per_month": 2,
"review_fee_milli_credits": 1000000,
"development_ticket_ttl_seconds": 600,
"timezone": "Asia/Shanghai"
}
}| 字段 | 说明 |
|---|---|
max_active_apps | 单用户最多活跃 App 数 |
create_per_hour | 每小时创建次数上限 |
development_tickets_per_hour | 每小时开发票据签发上限 |
free_reviews_per_month | 每月免费审核次数(按 timezone 自然月计) |
review_fee_milli_credits | 免费额度用尽后的单次审核费用(毫积分) |
development_ticket_ttl_seconds | 开发票据有效期(秒) |
timezone | 免费额度结算时区 |
常见错误
| HTTP 状态 | 错误码 | 说明 |
|---|---|---|
| 503 | oauth_app_self_service_limits_invalid | 站点限额配置缺失或非法,重试无效,需管理员修复 |
GET /user/oauth-apps/products
返回当前用户的产品级 OAuth App 聚合视图:每个产品(oauth_products,self_service 且 未删除)附其名下全部客户端摘要。没有任何活跃客户端的产品一律隐藏。产品按 created_at 倒序,客户端按 created_at、id 升序。
GET /user/oauth-apps/products
Authorization: Bearer <JWT>成功响应
{
"success": true,
"products": [
{
"id": "5d9c...-product-id",
"primary_client_id": "0f2f7d3e-1111-4a2b-9c3d-000000000001",
"owner_user_id": "1e0e...-user-id",
"team_id": null,
"management_mode": "self_service",
"created_at": "2026-08-20T08:00:00.000Z",
"updated_at": "2026-08-20T08:00:00.000Z",
"name": "示例应用",
"client_count": 2,
"primary_client": {
"id": "0f2f7d3e-1111-4a2b-9c3d-000000000001",
"product_id": "5d9c...-product-id",
"client_id": "Ab3fK9pQ2wSxYz0aBcDeFgHiJkLmNoPq",
"client_label": "主客户端",
"name": "示例应用",
"current_public_revision_id": null,
"disabled_at": null,
"created_at": "2026-08-20T08:00:00.000Z",
"updated_at": "2026-08-20T08:00:00.000Z",
"published": false,
"draft_revision": null,
"active_submission": null
},
"clients": []
}
]
}产品 name 取主客户端(primary_client_id 命中者,缺省取第一个)的 name; clients 数组元素结构与 primary_client 相同。
常见错误
| HTTP 状态 | 错误码 | 说明 |
|---|---|---|
| 401 / 403 | 见「通用错误」 | 认证失败或认证方式不符 |
GET /user/oauth-apps/products/:productId
返回单个产品及其客户端列表,聚合逻辑与列表接口一致。
GET /user/oauth-apps/products/:productId
Authorization: Bearer <JWT>路径参数
| 参数 | 类型 | 说明 |
|---|---|---|
productId | UUID | 产品(oauth_products.id)ID;须属于当前用户且 self_service、未删除 |
成功响应
{
"success": true,
"product": {
"id": "5d9c...-product-id",
"primary_client_id": "0f2f7d3e-1111-4a2b-9c3d-000000000001",
"owner_user_id": "1e0e...-user-id",
"team_id": null,
"management_mode": "self_service",
"created_at": "2026-08-20T08:00:00.000Z",
"updated_at": "2026-08-20T08:00:00.000Z",
"name": "示例应用",
"client_count": 1,
"primary_client": {},
"clients": []
}
}product 的字段结构同列表接口(此处 primary_client / clients 内容省略)。
常见错误
| HTTP 状态 | 错误码 | 说明 |
|---|---|---|
| 404 | oauth_product_not_found | 产品不存在、不属于当前用户、非 self_service 或已删除 |
GET /user/oauth-apps/legacy-products
返回当前用户 ownership_state = 'legacy_unassigned' 且未删除的历史 OAuth Product,供前端 提示迁移到 Team 工作区。按 created_at 倒序。
GET /user/oauth-apps/legacy-products
Authorization: Bearer <JWT>成功响应
{
"success": true,
"products": [
{
"id": "5d9c...-product-id",
"primary_client_id": "0f2f7d3e-1111-4a2b-9c3d-000000000001",
"owner_user_id": "1e0e...-user-id",
"created_at": "2026-08-18T08:00:00.000Z",
"updated_at": "2026-08-18T08:00:00.000Z"
}
],
"migration_endpoint": "/teams/:teamId/oauth-apps/products/:productId/migrate"
}migration_endpoint 指示迁移目标端点(Team 工作区接口),:teamId / :productId 由 客户端替换。
常见错误
| HTTP 状态 | 错误码 | 说明 |
|---|---|---|
| 401 / 403 | 见「通用错误」 | 认证失败或认证方式不符 |
POST /user/oauth-apps/products/:productId/clients
在既有产品内新建一个独立 OAuth 客户端。幂等键同时绑定 owner 与 product(后端对 Idempotency-Key 做 scope 派生),避免两个产品复用同一 Key 时撞上 owner 级唯一约束。
当前状态:被 Team 迁移中间件拦截,返回 409 oauth_app_team_context_required。
POST /user/oauth-apps/products/:productId/clients
Authorization: Bearer <JWT>
Idempotency-Key: 2b7e...-idempotency-key
Content-Type: application/json路径参数
| 参数 | 类型 | 说明 |
|---|---|---|
productId | UUID | 目标产品 ID,须属于当前用户且 self_service |
请求体
| 字段 | 类型 | 约束 |
|---|---|---|
client_label | string | 必填,trim 后 1–80 字符 |
| 其余字段 | 见「应用与草稿字段」 | 作为新客户端初始草稿 payload(name、redirect_uris、scopes 必填) |
{
"client_label": "测试客户端",
"name": "示例应用·测试端",
"redirect_uris": ["https://staging.example.com/callback"],
"scopes": ["profile:read"]
}成功响应
首次创建 201 Created,幂等重放 200 OK:
{
"success": true,
"client": {
"id": "0f2f7d3e-1111-4a2b-9c3d-000000000003",
"client_id": "Cd5hE1sT4yUaB2dEeFfGgHiJkLmOnPqR",
"draft_revision_id": "3c1a...-revision-id",
"product_id": "5d9c...-product-id",
"client_label": "测试客户端",
"idempotent": false
}
}常见错误
| HTTP 状态 | 错误码 | 说明 |
|---|---|---|
| 400 | invalid_oauth_product_client_create | 请求体不是对象或 client_label 缺失/为空 |
| 400 | invalid_self_service_oauth_app_payload | 初始 payload 字段不合法 |
| 404 | oauth_product_not_found | 目标产品不存在或不属于当前用户 |
| 409 | oauth_product_client_idempotency_conflict | 同幂等键但内容(含 client_label)不同 |
| 409 | oauth_self_service_app_limit_exceeded | 超过 max_active_apps |
| 429 | oauth_self_service_create_rate_limited | 命中 create_per_hour 窗口 |
| 409 | oauth_app_team_context_required | 当前写操作被 Team 迁移拦截 |
POST /user/oauth-apps/products/:productId/clients/attach
把一个既有的单客户端自助 App(产品行与 App 行同源的那种)并入目标产品,成为它的一个 客户端,并打上展示标签。
当前状态:被 Team 迁移中间件拦截,返回 409 oauth_app_team_context_required。
POST /user/oauth-apps/products/:productId/clients/attach
Authorization: Bearer <JWT>
Content-Type: application/json路径参数
| 参数 | 类型 | 说明 |
|---|---|---|
productId | UUID | 目标产品 ID,须属于当前用户且 self_service |
请求体
| 字段 | 类型 | 约束 |
|---|---|---|
source_client_row_id | string(UUID) | 必填,待并入的单客户端 App 的 oauth_apps.id |
client_label | string | 必填,trim 后 1–80 字符 |
{
"source_client_row_id": "0f2f7d3e-1111-4a2b-9c3d-000000000004",
"client_label": "并入的旧客户端"
}成功响应
{
"success": true,
"client": {
"product_id": "5d9c...-product-id",
"client_id": "0f2f7d3e-1111-4a2b-9c3d-000000000004",
"client_label": "并入的旧客户端",
"idempotent": false
}
}常见错误
| HTTP 状态 | 错误码 | 说明 |
|---|---|---|
| 400 | invalid_oauth_product_client_attach | 请求体字段缺失或 client_label 为空 |
| 404 | oauth_product_not_found / oauth_app_not_found | 目标产品或来源 App 不存在/不属于当前用户 |
| 409 | oauth_product_source_not_standalone | 来源不是单客户端 App,无法并入 |
| 409 | oauth_product_client_idempotency_conflict | 同幂等键但内容不同 |
| 409 | oauth_app_team_context_required | 当前写操作被 Team 迁移拦截 |
PATCH /user/oauth-apps/products/:productId/clients/:clientRowId
修改产品内某个客户端的展示标签。
当前状态:被 Team 迁移中间件拦截,返回 409 oauth_app_team_context_required。
PATCH /user/oauth-apps/products/:productId/clients/:clientRowId
Authorization: Bearer <JWT>
Content-Type: application/json路径参数
| 参数 | 类型 | 说明 |
|---|---|---|
productId | UUID | 客户端所属产品 ID |
clientRowId | UUID | 客户端行 ID(oauth_apps.id),须属于该产品且属于当前用户 |
请求体
| 字段 | 类型 | 约束 |
|---|---|---|
client_label | string | 必填,trim 后 1–80 字符 |
{
"client_label": "新标签"
}成功响应
{
"success": true,
"client": {
"product_id": "5d9c...-product-id",
"client_id": "0f2f7d3e-1111-4a2b-9c3d-000000000003",
"client_label": "新标签"
}
}常见错误
| HTTP 状态 | 错误码 | 说明 |
|---|---|---|
| 400 | invalid_oauth_product_client_label | client_label 缺失、为空或超长 |
| 404 | oauth_app_not_found | 客户端不存在、不在该产品下或不属于当前用户 |
| 409 | oauth_app_team_context_required | 当前写操作被 Team 迁移拦截 |
POST /user/oauth-apps/:id/development-ticket
为未首次发布的 App 签发一次性开发模式票据:未发布 App 只有 owner 本人能走授权流程, 凭据就是这张票据。后端只存票据 hash,明文仅在本次响应中返回一次,不落任何日志。票据需 带到 /oauth/authorize 使用。
有效期上限由 limits.development_ticket_ttl_seconds 下发,后端额外预留 5 秒时钟偏差 余量。scopes 会被后端去重并按字典序排序(数据库按数组顺序比对,授权端同样会归一化, 避免书写顺序差异被误报为"票据已失效")。
当前状态:被 Team 迁移中间件拦截,返回 409 oauth_app_team_context_required。
POST /user/oauth-apps/:id/development-ticket
Authorization: Bearer <JWT>
Idempotency-Key: 9a2b...-idempotency-key
Content-Type: application/json路径参数
| 参数 | 类型 | 说明 |
|---|---|---|
id | UUID | App ID,须属于当前用户且 self_service、未删除未禁用 |
请求体
| 字段 | 类型 | 约束 |
|---|---|---|
redirect_uri | string | 必填,必须是当前草稿 redirect_uris 之一 |
scopes | string[] | 必填字符串数组;须为草稿 scopes 子集且属于允许自助申请的 scope |
{
"redirect_uri": "https://example.com/callback",
"scopes": ["profile:read"]
}成功响应
{
"success": true,
"development_ticket": "wn_dev_一次性明文票据(仅此一次返回)",
"expires_at": "2026-08-29T08:10:00.000Z",
"result": {
"ticket_id": "7f3d...-ticket-id",
"app_id": "0f2f7d3e-1111-4a2b-9c3d-000000000001",
"revision_id": "3c1a...-revision-id",
"redirect_uri": "https://example.com/callback",
"scopes": ["profile:read"],
"expires_at": "2026-08-29T08:10:00.000Z",
"idempotent": false
}
}常见错误
| HTTP 状态 | 错误码 | 说明 |
|---|---|---|
| 400 | oauth_development_ticket_invalid | 请求体不是对象、redirect_uri / scopes 形状不合法,或不在草稿 URI 与 scope 范围内 |
| 400 | oauth_self_service_scope_not_allowed | 含不允许自助申请的 scope |
| 400 | oauth_development_ticket_expiry_invalid | 过期时间越界 |
| 409 | oauth_development_ticket_idempotency_conflict | 同幂等键但请求内容不同 |
| 429 | oauth_development_ticket_rate_limited | 命中 development_tickets_per_hour 窗口 |
| 503 | oauth_app_self_service_limits_invalid | 站点限额配置损坏 |
| 409 | oauth_app_team_context_required | 当前写操作被 Team 迁移拦截 |
POST /user/oauth-apps/:id/review
把当前草稿提交人工审核。免费额度未用完时直接入队;用完时客户端必须带上 confirm_paid=true 与预检(GET /:id/review/precheck)给出的费用和报价指纹——指纹对不上 说明用户看到的报价已过期,服务端拒绝并要求重新预检,保证「用户确认的金额」与「实际扣的 金额」永远一致。
当前状态:被 Team 迁移中间件拦截,返回 409 oauth_app_team_context_required。
POST /user/oauth-apps/:id/review
Authorization: Bearer <JWT>
Idempotency-Key: 6c8e...-idempotency-key
Content-Type: application/json路径参数
| 参数 | 类型 | 说明 |
|---|---|---|
id | UUID | App ID,须属于当前用户且 self_service |
请求体
| 字段 | 类型 | 约束 |
|---|---|---|
confirm_paid | boolean | 可选;付费审核时必须为 true |
expected_fee_milli_credits | number | confirm_paid=true 时必填,安全正整数 |
expected_paid_quote_fingerprint | string | confirm_paid=true 时必填,预检返回的报价指纹 |
{
"confirm_paid": true,
"expected_fee_milli_credits": 1000000,
"expected_paid_quote_fingerprint": "sha256-hex-fingerprint"
}成功响应
201 Created(幂等重放同样返回 201,凭 idempotent 区分):
{
"success": true,
"submission": {
"submission_id": "8b4f...-submission-id",
"revision_id": "3c1a...-revision-id",
"allocation_id": "9c5a...-allocation-id",
"funding_type": "free",
"status": "queued",
"idempotent": false
}
}funding_type 为 free(免费额度)或 paid(付费)。
常见错误
| HTTP 状态 | 错误码 | 说明 |
|---|---|---|
| 400 | oauth_review_fee_invalid | confirm_paid=true 但费用缺失、非正整数 |
| 400 | oauth_review_paid_confirmation_context_invalid | 缺少报价指纹或指纹为空 |
| 402 | insufficient_credits | 余额不足,先充值 |
| 404 | oauth_app_not_found | App 不存在或不属于当前用户 |
| 404 | balance_not_found | 付费审核缺少可用计费账户 |
| 409 | quota_exhausted_requires_confirmation | 免费额度用尽但未带付费确认 |
| 409 | oauth_review_fee_changed / oauth_review_paid_confirmation_stale | 报价已过期,需重新预检 |
| 409 | oauth_app_review_already_pending | 已有进行中的审核单 |
| 409 | oauth_app_not_reviewable | App 当前状态不可提交审核(如无可用草稿) |
| 409 | oauth_review_submit_idempotency_conflict | 同幂等键但提交内容不同 |
| 409 | oauth_app_team_context_required | 当前写操作被 Team 迁移拦截 |
POST /user/oauth-apps/:id/review/withdraw
撤回一个进行中的审核(queued / in_review)。归属双重校验:审核单必须属于该 App,且 该 App 必须属于当前用户。
当前状态:被 Team 迁移中间件拦截,返回 409 oauth_app_team_context_required。
POST /user/oauth-apps/:id/review/withdraw
Authorization: Bearer <JWT>
Idempotency-Key: 4d7a...-idempotency-key
Content-Type: application/json路径参数
| 参数 | 类型 | 说明 |
|---|---|---|
id | UUID | App ID,须属于当前用户且 self_service |
请求体
| 字段 | 类型 | 约束 |
|---|---|---|
submission_id | string(UUID) | 必填,要撤回的审核单 ID |
{
"submission_id": "8b4f...-submission-id"
}成功响应
{
"success": true,
"result": {
"id": "8b4f...-submission-id",
"app_id": "0f2f7d3e-1111-4a2b-9c3d-000000000001",
"revision_id": "3c1a...-revision-id",
"status": "withdrawn",
"idempotent": false
}
}result 为撤回后的审核单完整记录附加 idempotent 标记(幂等重放时为 true)。
常见错误
| HTTP 状态 | 错误码 | 说明 |
|---|---|---|
| 400 | oauth_review_submission_not_found | 请求体缺失 submission_id |
| 404 | oauth_app_not_found | App 不存在或不属于当前用户 |
| 404 | oauth_review_submission_not_found | 审核单不存在 |
| 409 | oauth_review_submission_not_withdrawable | 审核单不在可撤回状态 |
| 409 | oauth_review_withdraw_idempotency_conflict | 同幂等键但内容不同 |
| 409 | oauth_app_team_context_required | 当前写操作被 Team 迁移拦截 |
GET /user/oauth-apps/:id/review/precheck
提交审核前的只读预检:返回本月剩余免费次数、本次费用、当前余额与扣后余额,供前端在收费 前让用户显式确认。预检不建审核单、不计次、不扣款,也绝不隐式创建个人计费账户。
GET /user/oauth-apps/:id/review/precheck
Authorization: Bearer <JWT>路径参数
| 参数 | 类型 | 说明 |
|---|---|---|
id | UUID | App ID,须属于当前用户且 self_service、未删除未禁用,且有未冻结草稿 |
成功响应
{
"success": true,
"precheck": {
"app_id": "0f2f7d3e-1111-4a2b-9c3d-000000000001",
"draft_revision_id": "3c1a...-revision-id",
"period_start": "2026-08-01",
"free_limit": 2,
"free_used": 1,
"free_remaining": 1,
"requires_paid_confirmation": false,
"review_fee_milli_credits": 1000000,
"paid_confirmation": null,
"billing": {
"status": "ready"
}
}
}requires_paid_confirmation 为 true(免费额度用尽)且计费上下文就绪时, paid_confirmation 包含完整报价与指纹,结构如下(提交时须原样回传费用与指纹):
{
"quote": {
"schema_version": 1,
"user_id": "1e0e...-user-id",
"app_id": "0f2f7d3e-1111-4a2b-9c3d-000000000001",
"draft_revision_id": "3c1a...-revision-id",
"draft_updated_at": "2026-08-20T08:00:00.000000Z",
"policy_config": {},
"policy_timezone": "Asia/Shanghai",
"policy_period": "2026-08-01",
"free_used": 2,
"free_limit": 2,
"review_fee_milli_credits": 1000000,
"billing_project_id": "2f1f...-project-id",
"billing_project_updated_at": "2026-08-20T08:00:00.000000Z",
"billing_team_id": "3e2e...-team-id",
"billing_team_updated_at": "2026-08-20T08:00:00.000000Z",
"billing_balance_id": "4f3f...-balance-id",
"billing_balance_updated_at": "2026-08-20T08:00:00.000000Z",
"balance_milli_credits": 5000000,
"grant_balance_milli_credits": 2000000,
"purchase_balance_milli_credits": 3000000,
"balance_after_milli_credits": 4000000
},
"fingerprint": "sha256-hex-fingerprint"
}常见错误
| HTTP 状态 | 错误码 | 说明 |
|---|---|---|
| 404 | oauth_app_not_found | App 不存在、不属于当前用户或没有可用草稿 |
| 404 | balance_not_found | 缺少计费账户(付费场景) |
| 409 | oauth_app_review_already_pending | 已有进行中的审核单,先撤回 |
| 422 | balance_inconsistent 等 | 计费数据一致性异常,需人工介入 |
| 503 | oauth_app_self_service_limits_invalid | 站点限额配置损坏 |