merge: maestro/tsk_A1HkEdRbXLGY [tsk_A1HkEdRbXLGY] openapi.yaml 契约(13 操作 + 全量 schema)

This commit is contained in:
wangjia
2026-06-13 02:57:25 +08:00
+999
View File
@@ -0,0 +1,999 @@
openapi: 3.0.3
info:
title: Pangolin VPN — Control-Plane API
description: |
穿山甲 (Pangolin) 控制面 HTTP API,版本 v1。
数据面使用 sing-boxREALITY 主线 + Hysteria2 备线),connect 端点下发用户凭证。
**认证**:除 `auth` 四个端点外,全部操作需在 `Authorization` 头携带 `Bearer <access_token>`JWT RS256)。
**错误体**:所有 4xx/5xx 统一返回 `Error` schema,三字段均 required
`{code: string, message_zh: string, message_en: string}`。
**脱敏口径**:接口文案与日志不记录目的地址 / DNS 查询 / 流量内容;
仅保留字节数与分钟数(`usage_daily`)。
version: "1.0.0"
contact:
email: support@pangolin.vpn
servers:
- url: https://api.pangolin.vpn/v1
description: 生产环境
security:
- bearerAuth: []
paths:
# ── 认证(无需 JWT) ──────────────────────────────────────
/auth/code:
post:
operationId: sendVerificationCode
summary: 发送邮箱验证码
description: |
发送 6 位数字验证码到指定邮箱(Redis 存储,10 分钟有效)。
限流:同一 IP + 同一邮箱 1 次/分钟。防一次性邮箱域名黑名单。
tags: [Auth]
security: []
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [email]
properties:
email:
type: string
format: email
example: user@example.com
responses:
"204":
description: 验证码已发送(无响应体)
"400":
$ref: "#/components/responses/BadRequest"
"429":
$ref: "#/components/responses/TooManyRequests"
"500":
$ref: "#/components/responses/Internal"
/auth/register:
post:
operationId: register
summary: 注册账户
description: |
验证邮箱验证码 → 创建账户 → 自动写入 7 天 PRO 试用(`subscriptions(plan=pro, source='trial')`
一个邮箱仅一次)→ 返回 JWTaccess 15min + refresh 30d)。
tags: [Auth]
security: []
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [email, code, password]
properties:
email:
type: string
format: email
code:
type: string
description: 6 位数字验证码
minLength: 6
maxLength: 6
example: "123456"
password:
type: string
format: password
minLength: 8
description: 用户密码,argon2id 存储
responses:
"200":
description: 注册成功,返回 JWT 令牌对
content:
application/json:
schema:
$ref: "#/components/schemas/TokenPair"
"400":
$ref: "#/components/responses/BadRequest"
"409":
$ref: "#/components/responses/Conflict"
"429":
$ref: "#/components/responses/TooManyRequests"
"500":
$ref: "#/components/responses/Internal"
/auth/login:
post:
operationId: login
summary: 登录
description: |
邮箱 + 密码登录,返回 JWT 令牌对(access 15min + refresh 30d)。
登录失败次数过多触发锁定。
tags: [Auth]
security: []
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [email, password]
properties:
email:
type: string
format: email
password:
type: string
format: password
responses:
"200":
description: 登录成功,返回 JWT 令牌对
content:
application/json:
schema:
$ref: "#/components/schemas/TokenPair"
"400":
$ref: "#/components/responses/BadRequest"
"401":
$ref: "#/components/responses/Unauthorized"
"429":
$ref: "#/components/responses/TooManyRequests"
"500":
$ref: "#/components/responses/Internal"
/auth/refresh:
post:
operationId: refreshToken
summary: 刷新 Access Token
description: 用有效的 refresh_token 换取新的令牌对。
tags: [Auth]
security: []
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [refresh_token]
properties:
refresh_token:
type: string
description: 有效的 refresh token30 天有效期)
responses:
"200":
description: 刷新成功,返回新令牌对
content:
application/json:
schema:
$ref: "#/components/schemas/TokenPair"
"400":
$ref: "#/components/responses/BadRequest"
"401":
$ref: "#/components/responses/Unauthorized"
"429":
$ref: "#/components/responses/TooManyRequests"
"500":
$ref: "#/components/responses/Internal"
# ── 账户 ──────────────────────────────────────────────────
/me:
get:
operationId: getMe
summary: 获取当前账户信息
description: 返回账户基本信息 + 当前订阅状态 + 今日用量摘要。
tags: [Account]
responses:
"200":
description: 账户详情
content:
application/json:
schema:
$ref: "#/components/schemas/Me"
"401":
$ref: "#/components/responses/Unauthorized"
"500":
$ref: "#/components/responses/Internal"
/me/devices:
get:
operationId: listDevices
summary: 获取设备列表
description: 返回当前用户下所有已注册设备。
tags: [Account]
responses:
"200":
description: 设备列表
content:
application/json:
schema:
type: object
required: [devices]
properties:
devices:
type: array
items:
$ref: "#/components/schemas/Device"
"401":
$ref: "#/components/responses/Unauthorized"
"500":
$ref: "#/components/responses/Internal"
/me/devices/{id}:
delete:
operationId: deleteDevice
summary: 移除设备
description: |
从账户移除指定设备,同步通过 gRPC 回收该设备在所有节点侧的凭证(数据面 UUID 无效化)。
tags: [Account]
parameters:
- name: id
in: path
required: true
schema:
type: string
format: uuid
description: 设备 UUID
responses:
"204":
description: 设备已移除(无响应体)
"401":
$ref: "#/components/responses/Unauthorized"
"403":
$ref: "#/components/responses/Forbidden"
"404":
$ref: "#/components/responses/NotFound"
"500":
$ref: "#/components/responses/Internal"
# ── 商业闭环 ─────────────────────────────────────────────
/redeem:
post:
operationId: redeemCode
summary: 兑换激活码
description: |
兑换激活码,订阅顺延(叠加非覆盖)。操作幂等:同一用户重复提交同一码返回首次结果。
失败风控:单用户兑换失败 5 次/小时后锁 1 小时。所有兑换操作写入 audit_log。
tags: [Commerce]
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [code]
properties:
code:
type: string
description: Crockford Base32 激活码,16 位含校验位
example: "ABCD-EFGH-JKLM-NP00"
responses:
"200":
description: 兑换成功
content:
application/json:
schema:
$ref: "#/components/schemas/RedeemResult"
"400":
$ref: "#/components/responses/BadRequest"
"401":
$ref: "#/components/responses/Unauthorized"
"404":
$ref: "#/components/responses/NotFound"
"409":
$ref: "#/components/responses/Conflict"
"429":
$ref: "#/components/responses/TooManyRequests"
"500":
$ref: "#/components/responses/Internal"
/ads/unlock:
post:
operationId: adsUnlock
summary: 激励视频广告解锁当日时长
description: |
免费版用户完成激励视频广告后调用。服务端向广告平台(AdMob/Unity)校验 ad_token 真伪,
通过后记录 `usage_daily.ad_unlocked_at`,当日 connect 接口方可放行。
tags: [Commerce]
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [device_id, ad_token]
properties:
device_id:
type: string
format: uuid
description: 发起广告的设备 UUID
ad_token:
type: string
description: 广告 SDK 签发的服务端回执 token
responses:
"204":
description: 广告解锁成功(无响应体)
"400":
$ref: "#/components/responses/BadRequest"
"401":
$ref: "#/components/responses/Unauthorized"
"403":
$ref: "#/components/responses/Forbidden"
"429":
$ref: "#/components/responses/TooManyRequests"
"500":
$ref: "#/components/responses/Internal"
/plans:
get:
operationId: listPlans
summary: 获取套餐目录
description: |
返回全部套餐定义。客户端用于展示套餐页与价格对比。
套餐口径:免费版(每日 10 分钟,须看广告,1 台设备);
PRO(不限时长,80+ 线路,5 台设备);团队版(10 席位)。
tags: [Commerce]
responses:
"200":
description: 套餐列表
content:
application/json:
schema:
type: object
required: [plans]
properties:
plans:
type: array
items:
$ref: "#/components/schemas/Plan"
"401":
$ref: "#/components/responses/Unauthorized"
"500":
$ref: "#/components/responses/Internal"
# ── 节点 ─────────────────────────────────────────────────
/nodes:
get:
operationId: listNodes
summary: 获取节点目录
description: |
返回当前用户套餐可用的节点列表。响应体带全局 `version` 整数字段。
客户端携带 `if_version=N` 时,若服务端版本未变则返回 304(无响应体)——
这是节点秒级灰度的基础:节点任何变更 bump 全局 version,被封节点即时从目录消失。
**注意:目录不含任何密钥参数**reality_pbk/sni 等),密钥仅通过 connect 端点下发。
tags: [Nodes]
parameters:
- name: if_version
in: query
required: false
schema:
type: integer
format: int64
description: 客户端已缓存的目录版本号;命中则返回 304
responses:
"200":
description: 节点目录(版本有变化或首次请求)
content:
application/json:
schema:
$ref: "#/components/schemas/NodeDirectory"
"304":
description: 节点目录未变化,客户端应使用缓存(无响应体)
"401":
$ref: "#/components/responses/Unauthorized"
"500":
$ref: "#/components/responses/Internal"
/nodes/{id}/connect:
post:
operationId: connectNode
summary: 连接节点 — 下发连接凭证
description: |
校验订阅有效性与设备数量上限(免费版额外校验 ad_unlocked_at + 剩余分钟)→
通过 gRPC 确保用户 UUID 已在节点 sing-box 用户表 →
返回 sing-box 连接凭证(REALITY 主线 + Hysteria2 备线)。
凭证模型:每用户一个数据面 UUID(与账号解耦,可独立轮换),节点侧只见 UUID。
免费版凭证 TTL = 当日剩余分钟数,到时 agent 自动回收;付费版 TTL 24h,在线自动续期。
**注意**`ConnectCredential` 的具体字段可能随数据面树(任务 #5)最终敲定后 bump,
以该任务产出为准。
tags: [Nodes]
parameters:
- name: id
in: path
required: true
schema:
type: string
format: uuid
description: 节点 UUID
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [device_id]
properties:
device_id:
type: string
format: uuid
description: 发起连接的设备 UUID
responses:
"200":
description: 连接凭证(含 sing-box 参数)
content:
application/json:
schema:
$ref: "#/components/schemas/ConnectCredential"
"400":
$ref: "#/components/responses/BadRequest"
"401":
$ref: "#/components/responses/Unauthorized"
"403":
$ref: "#/components/responses/Forbidden"
"404":
$ref: "#/components/responses/NotFound"
"429":
$ref: "#/components/responses/TooManyRequests"
"500":
$ref: "#/components/responses/Internal"
/nodes/{id}/disconnect:
post:
operationId: disconnectNode
summary: 断开节点连接
description: |
通知服务端客户端主动断开。服务端可提前回收免费版凭证以释放配额,
但凭证 TTL 到期时也会被 agent 自动回收。
tags: [Nodes]
parameters:
- name: id
in: path
required: true
schema:
type: string
format: uuid
description: 节点 UUID
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [device_id]
properties:
device_id:
type: string
format: uuid
description: 发起断开的设备 UUID
responses:
"204":
description: 已断开(无响应体)
"400":
$ref: "#/components/responses/BadRequest"
"401":
$ref: "#/components/responses/Unauthorized"
"404":
$ref: "#/components/responses/NotFound"
"500":
$ref: "#/components/responses/Internal"
# ── 用量与公告 ────────────────────────────────────────────
/usage:
get:
operationId: getUsage
summary: 获取用量曲线
description: 返回最近 N 天的每日用量数据,用于统计页折线图 / 柱状图展示。
tags: [Usage]
parameters:
- name: days
in: query
required: false
schema:
type: integer
minimum: 1
maximum: 90
default: 7
description: 查询最近几天,默认 7,最大 90
responses:
"200":
description: 用量数据点列表
content:
application/json:
schema:
type: object
required: [points]
properties:
points:
type: array
items:
$ref: "#/components/schemas/UsagePoint"
"400":
$ref: "#/components/responses/BadRequest"
"401":
$ref: "#/components/responses/Unauthorized"
"500":
$ref: "#/components/responses/Internal"
/notices:
get:
operationId: listNotices
summary: 获取公告列表
description: |
返回当前有效公告。同一份内容亦以多镜像签名静态 JSON 发布,客户端优先拉静态副本,
失败时回落到本端点。
tags: [Notices]
responses:
"200":
description: 公告列表
content:
application/json:
schema:
type: object
required: [notices]
properties:
notices:
type: array
items:
$ref: "#/components/schemas/Notice"
"401":
$ref: "#/components/responses/Unauthorized"
"500":
$ref: "#/components/responses/Internal"
# ── 组件定义 ──────────────────────────────────────────────────
components:
securitySchemes:
bearerAuth:
type: http
scheme: bearer
bearerFormat: JWT
description: JWT RS256`Authorization: Bearer <access_token>`
# ── 统一错误响应 ────────────────────────────────────────────
responses:
BadRequest:
description: 请求参数非法或业务校验失败
content:
application/json:
schema:
$ref: "#/components/schemas/Error"
Unauthorized:
description: 未提供有效的访问令牌,或令牌已过期
content:
application/json:
schema:
$ref: "#/components/schemas/Error"
Forbidden:
description: 令牌有效但权限不足(如免费版访问付费节点、设备不属于当前用户)
content:
application/json:
schema:
$ref: "#/components/schemas/Error"
NotFound:
description: 目标资源不存在
content:
application/json:
schema:
$ref: "#/components/schemas/Error"
Conflict:
description: 资源冲突(如邮箱已注册、激活码已被兑换)
content:
application/json:
schema:
$ref: "#/components/schemas/Error"
TooManyRequests:
description: 请求频率超过限制,稍后重试
headers:
Retry-After:
description: 客户端应等待的秒数后再重试
schema:
type: integer
example: 60
content:
application/json:
schema:
$ref: "#/components/schemas/Error"
Internal:
description: 服务端内部错误
content:
application/json:
schema:
$ref: "#/components/schemas/Error"
# ── Schema ────────────────────────────────────────────────
schemas:
# ── 通用错误体 ─────────────────────────────────────────
Error:
type: object
description: 统一错误响应体,三字段均 required。文案遵守脱敏口径(不含红线词)。
required: [code, message_zh, message_en]
properties:
code:
type: string
description: 机器可读错误码,例如 `auth.code_expired` / `redeem.already_redeemed`
example: auth.code_expired
message_zh:
type: string
description: 中文错误文案(面向用户展示)
example: 验证码已过期,请重新获取
message_en:
type: string
description: 英文错误文案(面向用户展示)
example: Verification code has expired. Please request a new one.
# ── 认证 ───────────────────────────────────────────────
TokenPair:
type: object
required: [access_token, refresh_token, expires_in]
properties:
access_token:
type: string
description: JWT access token,有效期 15 分钟
refresh_token:
type: string
description: Refresh token,有效期 30 天
expires_in:
type: integer
description: access_token 有效秒数(固定 900
example: 900
# ── 账户 ───────────────────────────────────────────────
Me:
type: object
required: [user, subscription, today_usage]
properties:
user:
$ref: "#/components/schemas/UserInfo"
subscription:
$ref: "#/components/schemas/SubscriptionInfo"
today_usage:
$ref: "#/components/schemas/TodayUsageSummary"
UserInfo:
type: object
required: [uuid, email, status]
properties:
uuid:
type: string
format: uuid
description: 用户全局唯一标识(对外口径,永不暴露自增 ID)
email:
type: string
format: email
status:
type: string
enum: [active, suspended]
description: 账户状态
SubscriptionInfo:
type: object
required: [plan_code, source]
properties:
plan_code:
type: string
enum: [free, pro, team]
description: 当前套餐代码
expires_at:
type: string
format: date-time
nullable: true
description: 订阅到期时间(UTC ISO-8601),免费版无到期时间时为 null
source:
type: string
enum: [trial, code, admin]
description: 订阅来源(试用 / 激活码 / 管理员)
TodayUsageSummary:
type: object
required: [minutes_used, minutes_remaining]
properties:
minutes_used:
type: integer
description: 今日已使用分钟数
example: 3
minutes_remaining:
type: integer
nullable: true
description: 今日剩余分钟数,付费无限制时为 null
example: 7
ad_unlocked:
type: boolean
description: 免费版今日是否已完成激励广告解锁
example: false
Device:
type: object
required: [uuid, name, platform, last_seen]
properties:
uuid:
type: string
format: uuid
description: 设备 UUID
name:
type: string
description: 设备名称(客户端上报,如「iPhone 15 Pro」)
example: iPhone 15 Pro
platform:
type: string
enum: [ios, android, macos, windows, linux]
description: 设备平台
last_seen:
type: string
format: date-time
description: 最后活跃时间(UTC ISO-8601
# ── 商业 ───────────────────────────────────────────────
Plan:
type: object
required: [code, max_devices, ad_gate]
properties:
code:
type: string
enum: [free, pro, team]
description: 套餐唯一代码
max_devices:
type: integer
description: 最大同时在线设备数(free=1, pro=5, team=10
example: 5
daily_minutes:
type: integer
nullable: true
description: 每日可用分钟数上限;null 表示无限制(pro/team
example: 10
ad_gate:
type: boolean
description: 首次连接是否需要激励广告解锁(仅 free=true)
example: false
RedeemResult:
type: object
description: |
激活码兑换结果。幂等设计:同一用户重复提交同一码返回首次结果(非报错)。
required: [plan_code, extended_days, expires_at]
properties:
plan_code:
type: string
enum: [free, pro, team]
description: 本次兑换激活的套餐代码
extended_days:
type: integer
description: 本次顺延的天数(叠加而非覆盖)
example: 30
expires_at:
type: string
format: date-time
description: 兑换后的订阅到期时间(UTC ISO-8601
# ── 节点目录 ────────────────────────────────────────────
NodeDirectory:
type: object
required: [version, nodes]
properties:
version:
type: integer
format: int64
description: 目录全局版本号;任何节点变更 bump 此值,客户端据此决定是否刷新缓存
example: 42
nodes:
type: array
items:
$ref: "#/components/schemas/Node"
Node:
type: object
description: |
节点目录条目。**不含任何密钥或连接参数**reality_pbk/sni/password 等),
这些信息仅在 POST /nodes/{id}/connect 成功后下发。
required: [uuid, region, name_zh, name_en, tier, signal, load]
properties:
uuid:
type: string
format: uuid
description: 节点 UUIDconnect 时作为 path 参数)
region:
type: string
description: 地区代码,2 字母大写(HK/JP/SG/US …)
example: HK
name_zh:
type: string
description: 节点中文名称
example: 香港 01
name_en:
type: string
description: 节点英文名称
example: Hong Kong 01
tier:
type: string
enum: [free, pro]
description: 节点等级(free 节点免费版可用,pro 节点需付费订阅)
signal:
type: integer
minimum: 0
maximum: 4
description: 信号格数(0~4),由 agent 上报延迟换算,客户端展示信号条
example: 3
load:
type: integer
minimum: 0
maximum: 100
description: 节点当前负载百分比(0~100),由 agent 每 30s 上报到 Redis
example: 45
# ── 连接凭证 ────────────────────────────────────────────
ConnectCredential:
type: object
description: |
sing-box 连接凭证,支持 REALITY 主线 + Hysteria2 备线。
每用户一个数据面 UUID(dp_uuid),与账号 UUID 解耦、可独立轮换;
节点侧只见 dp_uuid,零账号信息,单节点被抄不泄露用户库。
**字段稳定性**:本 schema 可能随数据面树(任务 #5)最终敲定后 bump,
在正式实现前以该任务产出为权威依据。
required: [protocol, server, port, uuid, ttl_seconds]
properties:
protocol:
type: string
enum: [reality, hysteria2]
description: 优先协议(客户端按网络状况自动选;服务端返回当前最优)
server:
type: string
description: 节点服务器地址(IP 或域名)
example: hk01.node.pangolin.vpn
port:
type: integer
minimum: 1
maximum: 65535
description: 主协议端口(REALITY 用 TCP 443Hysteria2 用 UDP 443
example: 443
uuid:
type: string
format: uuid
description: |
用户数据面 UUIDdp_uuid),即 VLESS uuid 字段。
Hysteria2 auth password 由同一 UUID 派生(客户端无需单独字段)。
flow:
type: string
description: VLESS flow 参数,REALITY 协议时使用(通常为 `xtls-rprx-vision`
example: xtls-rprx-vision
reality:
$ref: "#/components/schemas/RealityParams"
hysteria2:
$ref: "#/components/schemas/Hysteria2Params"
ttl_seconds:
type: integer
description: |
凭证有效秒数。免费版 = 当日剩余分钟数 × 60;付费版 = 86400(24h,在线自动续期)。
example: 86400
RealityParams:
type: object
required: [public_key, short_id, sni]
description: REALITY 协议参数(仅 protocol=reality 时有意义)
properties:
public_key:
type: string
description: REALITY 服务端公钥(x25519
example: ABC123...
short_id:
type: string
description: REALITY short ID(十六进制字符串)
example: "a1b2c3d4"
sni:
type: string
description: TLS SNI 伪装域名
example: www.cloudflare.com
Hysteria2Params:
type: object
required: [port, password]
description: Hysteria2 备线参数(仅 protocol=hysteria2 或客户端协议降级时使用)
properties:
port:
type: integer
minimum: 1
maximum: 65535
description: Hysteria2 UDP 端口(通常 443
example: 443
password:
type: string
description: Hysteria2 auth password(由 dp_uuid 派生,服务端同源生成)
obfs:
type: string
nullable: true
description: 混淆模式(可选,如 `salamander`);null 表示不启用混淆
example: null
# ── 用量 ────────────────────────────────────────────────
UsagePoint:
type: object
required: [date, bytes_up, bytes_down, minutes_used]
description: 单日用量数据点(无目的地/DNS/流量内容,仅字节数与分钟数,符合无日志承诺)
properties:
date:
type: string
format: date
description: 日期(UTCYYYY-MM-DD
example: "2026-06-13"
bytes_up:
type: integer
format: int64
description: 当日上行字节数
example: 10485760
bytes_down:
type: integer
format: int64
description: 当日下行字节数
example: 104857600
minutes_used:
type: integer
description: 当日已用分钟数
example: 7
# ── 公告 ────────────────────────────────────────────────
Notice:
type: object
required: [id, title_zh, title_en, body_zh, body_en, published_at]
properties:
id:
type: string
format: uuid
description: 公告 UUID
title_zh:
type: string
description: 公告中文标题
example: 线路维护通知
title_en:
type: string
description: 公告英文标题
example: Line Maintenance Notice
body_zh:
type: string
description: 公告中文正文(Markdown
body_en:
type: string
description: 公告英文正文(Markdown
published_at:
type: string
format: date-time
description: 发布时间(UTC ISO-8601
tags:
- name: Auth
description: 认证相关(发验证码、注册、登录、刷新 Token)——无需 JWT
- name: Account
description: 账户与设备管理
- name: Commerce
description: 商业闭环(激活码兑换、广告解锁、套餐目录)
- name: Nodes
description: 节点目录与连接凭证下发(数据面入口)
- name: Usage
description: 用量统计
- name: Notices
description: 公告