Files
pangolin/server/api/openapi.yaml
T
wangjia 43c76fb978
ci-pangolin / Redline Scan — 脱敏 (UI 文案) (push) Successful in 25s
ci-pangolin / Lint — shellcheck (push) Successful in 29s
ci-pangolin / Cleartext Scan — Android 禁明文 (push) Successful in 22s
ci-pangolin / OpenAPI Sync Check (push) Successful in 40s
ci-pangolin / Portable SQL — 可移植性 (mysql/sqlite) (push) Successful in 19s
ci-pangolin / Flutter — analyze + test (push) Failing after 4m59s
ci-pangolin / Codegen Drift — token 生成物未漂移 (push) Successful in 1m51s
ci-pangolin / DS-flow — 原型/跨端同源/代码色单源闸 (push) Successful in 5s
ci-pangolin / Go — build + test (push) Failing after 1m33s
ci-pangolin / E2E Smoke — L4 进程级端到端 (push) Failing after 14s
ci-pangolin / Go — integration (mysql/redis testcontainers) (push) Failing after 4m59s
ci-pangolin / Golden — 视觉回归 (全量:components/auth/desktop/tablet) (push) Failing after 4s
Merge remote-tracking branch 'origin/main' into feat/pay-v2-integration
# Conflicts:
#	docs/index.html
#	server/cmd/server/main.go
2026-07-11 16:44:05 +08:00

1497 lines
51 KiB
YAML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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_bonus_minutes` 累加固定分钟(每日封顶),当日免费额度随之上浮。
额度为**全账户共享**(非每设备)。响应返回本次实际加时与最新剩余分钟。
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:
"200":
description: 广告加时成功
content:
application/json:
schema:
type: object
required: [granted_minutes, minutes_remaining]
properties:
granted_minutes:
type: integer
description: 本次广告实际加时分钟(已达每日封顶时为 0)
minutes_remaining:
type: integer
description: 加时后账户当日剩余分钟(全账户共享)
"400":
$ref: "#/components/responses/BadRequest"
"401":
$ref: "#/components/responses/Unauthorized"
"403":
$ref: "#/components/responses/Forbidden"
"409":
$ref: "#/components/responses/Conflict"
"429":
$ref: "#/components/responses/TooManyRequests"
"500":
$ref: "#/components/responses/Internal"
# ── Pay(pay v2 统一支付网关代理;PAY_BASE_URL 未配置时整组不挂载)───
/pay/catalog:
get:
operationId: payCatalog
summary: 可购档位目录
description: |
三档可购套餐(月/季/年),仅展示用(`price_minor` 为 CNY 展示价,单位分)。
实际扣款金额以 pay 侧 `product_prices` / `products.price` 为准,两处人工对齐
(联调 checklist 有价格一致性核对项)。
tags: [Pay]
responses:
"200":
description: 目录列表
content:
application/json:
schema:
type: object
required: [items]
properties:
items:
type: array
items:
$ref: "#/components/schemas/PayCatalogItem"
"401":
$ref: "#/components/responses/Unauthorized"
"500":
$ref: "#/components/responses/Internal"
/pay/orders:
post:
operationId: payCreateOrder
summary: 下单(代理 pay v2 create
description: |
创建支付订单。**客户端只传 sku + method + 端型 metadata,永远不传金额**——
金额由服务端按 sku 查 pay 侧目录后代下单,`biz_ref` 由服务端从当前 JWT 用户
映射(`users.uuid`),客户端无感知。
下单成功后台账(`pay_purchases`)写入失败不影响本次下单结果(webhook 按
`biz_ref` 兜底补建),仅记服务端日志。
tags: [Pay]
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [sku, method]
properties:
sku:
type: string
description: 档位代码,取自 `/pay/catalog`(如 `pro_month`
example: pro_month
method:
type: string
description: 支付方式,取值以 pay 侧支持渠道为准(如 `crypto` / `alipay`
example: crypto
metadata:
type: object
additionalProperties:
type: string
description: |
透传给 pay 的端型元数据,白名单仅 `is_mobile` / `render`(其余字段
服务端静默丢弃,不透传)。
example:
is_mobile: "true"
responses:
"200":
description: 下单成功,返回渲染会话
content:
application/json:
schema:
$ref: "#/components/schemas/PayOrderSessionResult"
"400":
description: 参数非法(未知 sku / method 为空 / 请求体解析失败)
content:
application/json:
schema:
$ref: "#/components/schemas/Error"
"401":
$ref: "#/components/responses/Unauthorized"
"404":
description: 当前用户不存在或账户非 activeNOT_FOUND
content:
application/json:
schema:
$ref: "#/components/schemas/Error"
"409":
$ref: "#/components/responses/PayConflict"
"429":
$ref: "#/components/responses/TooManyRequests"
"500":
$ref: "#/components/responses/Internal"
"502":
$ref: "#/components/responses/PayUpstream"
/pay/orders/{orderNo}:
get:
operationId: payGetOrder
summary: 查单(代理 pay v2 查单 + 本地台账开通状态)
description: |
每次调用都会回源 pay 查一次单(无本地缓存;量大后可加 server 短缓存,见交付
说明「取舍」条目)。`activated` 是客户端轮询判定成功开通的唯一依据——本地台账
`pay_purchases.status = 'paid'`,与 `pay_status`pay 侧状态词汇原样透传,
仅展示、不做分支判断)是两条独立信息。
tags: [Pay]
parameters:
- name: orderNo
in: path
required: true
schema:
type: string
description: pay 侧订单号(下单响应 `order_no`)
example: "PAY202607100001"
responses:
"200":
description: 订单状态
content:
application/json:
schema:
$ref: "#/components/schemas/PayOrderStatus"
"401":
$ref: "#/components/responses/Unauthorized"
"404":
description: 订单不属于当前用户,或订单不存在(NOT_FOUND)
content:
application/json:
schema:
$ref: "#/components/schemas/Error"
"500":
$ref: "#/components/responses/Internal"
"502":
$ref: "#/components/responses/PayUpstream"
/pay/orders/{orderNo}/retry:
post:
operationId: payRetryOrder
summary: 换渠道重试(同一订单换 method
description: |
同一订单更换支付方式(如 crypto → alipay)。若目标渠道结算币种与订单原币种
不符,pay 侧拒绝并返回 409 `CURRENCY_MISMATCH`——客户端应据此自动取消旧单、
用新 method 重新走 `/pay/orders` 下单(见联调 checklist「换渠道」条)。
tags: [Pay]
parameters:
- name: orderNo
in: path
required: true
schema:
type: string
description: pay 侧订单号(下单响应 `order_no`)
example: "PAY202607100001"
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [method]
properties:
method:
type: string
example: alipay
metadata:
type: object
additionalProperties:
type: string
description: 同 `/pay/orders`,仅 `is_mobile` / `render` 白名单透传
responses:
"200":
description: 重试成功,返回新的渲染会话
content:
application/json:
schema:
$ref: "#/components/schemas/PayOrderSessionResult"
"400":
description: method 为空或请求体解析失败(BAD_REQUEST
content:
application/json:
schema:
$ref: "#/components/schemas/Error"
"401":
$ref: "#/components/responses/Unauthorized"
"404":
description: 订单不属于当前用户,或订单不存在(NOT_FOUND)
content:
application/json:
schema:
$ref: "#/components/schemas/Error"
"409":
$ref: "#/components/responses/PayConflict"
"429":
$ref: "#/components/responses/TooManyRequests"
"500":
$ref: "#/components/responses/Internal"
"502":
$ref: "#/components/responses/PayUpstream"
/pay/orders/{orderNo}/cancel:
post:
operationId: payCancelOrder
summary: 取消订单
description: |
取消一个仍处于待支付状态的订单。仅当用户显式点击「取消订单」时调用——
等待支付页面被系统返回键/手势关闭**不**触发取消(订单仍在 pay 侧 pending
可从头再进),这是桌面壳的既定行为,见风险清单第 6 条。
tags: [Pay]
parameters:
- name: orderNo
in: path
required: true
schema:
type: string
description: pay 侧订单号(下单响应 `order_no`)
example: "PAY202607100001"
responses:
"200":
description: 取消结果(`canceled=false` 表示订单已不在待支付状态,未被取消)
content:
application/json:
schema:
type: object
required: [canceled]
properties:
canceled:
type: boolean
"401":
$ref: "#/components/responses/Unauthorized"
"404":
description: 订单不属于当前用户,或订单不存在(NOT_FOUND)
content:
application/json:
schema:
$ref: "#/components/schemas/Error"
"500":
$ref: "#/components/responses/Internal"
"502":
$ref: "#/components/responses/PayUpstream"
/webhook/pay:
post:
operationId: payWebhook
summary: pay v2 出站 webhook 接收(payment.succeeded
description: |
pay 侧支付成功后的服务端到服务端回调,**不经过 JWT**(本接口不在
`bearerAuth` 保护范围内),改用 HMAC 请求头验签:与 pay
`util/sign.go` 逐字节同构,`parts = [X-Pay-System, X-Pay-Timestamp,
X-Pay-Nonce, rawBody]``\n` join 后 HMAC-SHA256 + 标准 base64
时间窗容忍 ±5 分钟。
幂等三层:`X-Pay-Nonce` SETNX(传输层重放,Redis 不可用时跳过此层)→
`out_trade_no` 行锁 CAS(业务幂等,重投唯一可靠键,已 `paid` 直接
返回 SUCCESS)→ `biz_ref` 兜底(台账缺行按用户 uuid 自修复)。
**注意**:本接口的成功/失败响应体是**纯文本**,不是 `Error` JSON
schema——ACK 判据是 HTTP 200 且 body 字面包含大写 `SUCCESS`;验签/
载荷失败时用 `http.Error`(纯文本)返回 400/401;开通失败返回 500
纯文本(pay 侧据此退避重投,事务已回滚、不产生半态)。
tags: [Pay]
security: []
parameters:
- name: X-Pay-System
in: header
required: true
schema:
type: string
description: 业务系统标识,须等于服务端配置的 `PAY_BIZ_SYSTEM`(默认 `pangolin`
- name: X-Pay-Timestamp
in: header
required: true
schema:
type: string
description: Unix 秒级时间戳,±300s 容忍窗口
- name: X-Pay-Nonce
in: header
required: true
schema:
type: string
description: 一次性随机串,用于传输层防重放(SETNX + TTL)
- name: X-Pay-Sign
in: header
required: true
schema:
type: string
description: HMAC-SHA256(secret, join("\n", system, ts, nonce, rawBody)) 的标准 base64
requestBody:
required: true
content:
application/json:
schema:
$ref: "#/components/schemas/PayWebhookEvent"
responses:
"200":
description: 已确认处理(幂等;重复投递也回 200 SUCCESS
content:
text/plain:
schema:
type: string
example: SUCCESS
"400":
description: 请求体读取失败或载荷非法 JSON(纯文本响应,非 Error schema
content:
text/plain:
schema:
type: string
"401":
description: 验签失败(system 不符 / 缺头 / 时间窗超限 / 签名不匹配,纯文本响应)
content:
text/plain:
schema:
type: string
"500":
description: 开通事务失败,pay 将按退避策略重投(纯文本响应,事务已回滚不产生半态)
content:
text/plain:
schema:
type: string
/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"
PayConflict:
description: |
订单状态冲突。`code` 可能为 `CURRENCY_MISMATCH`(换渠道结算币种与订单不符,
客户端应取消旧单重新下单)或 `ORDER_NOT_PENDING`(订单状态已变化,需刷新)。
content:
application/json:
schema:
$ref: "#/components/schemas/Error"
PayUpstream:
description: |
pay 网关暂不可用(`code = PAY_UPSTREAM`)——上游 pay-server 不可达、超时,
或返回了未分类的业务错误(`no_account` / `no_settle_currency` /
`create_failed` / `upstream_error` 等),客户端应提示稍后重试。
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, free]
description: 订阅来源(试用 / 激活码 / 管理员 / 无订阅时的免费回落)
TodayUsageSummary:
type: object
required: [minutes_used, minutes_remaining]
properties:
minutes_used:
type: integer
description: 今日已使用分钟数
example: 3
minutes_cap:
type: integer
nullable: true
description: 今日额度 = 套餐每日分钟 + 看广告累加分钟;付费无限制时为 null
example: 20
minutes_remaining:
type: integer
nullable: true
description: 今日剩余分钟数(全账户共享),付费无限制时为 null
example: 7
ad_bonus_minutes:
type: integer
description: 今日已通过看广告累加的分钟数
example: 10
ad_unlocked:
type: boolean
description: 免费版今日是否已通过看广告加过时(ad_bonus_minutes > 0,历史字段)
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
# ── Pay(pay v2 统一支付网关代理)──────────────────────
PayCatalogItem:
type: object
description: 可购档位(三档单源,见 `internal/pay/catalog.go`
required: [sku, plan, days, price_minor, currency]
properties:
sku:
type: string
description: 档位代码,与 pay 侧 `products.biz_code` 一一对应
example: pro_month
plan:
type: string
enum: [free, pro, team]
description: 兑换后生效的套餐代码
example: pro
days:
type: integer
description: 时长天数(宽松口径:31/92/366,覆盖大月与最长季)
example: 31
price_minor:
type: integer
format: int64
description: |
展示价,CNY 分。**仅展示**,实际扣款以 pay 侧
`product_prices`USDT 微单位)/ `products.price`CNY 元)为准,
两处人工对齐(见交付说明「取舍」条目 4)。
example: 2999
currency:
type: string
example: CNY
PaySession:
type: object
description: |
pay 下单/retry 返回的渲染会话。`render_type` 多态,`payload` 结构随之变化,
原样透传给客户端:
- `crypto_address``{address, amount, amount_minor, currency, network}`(如 USDT/TRC20
- `redirect``{url}`(如支付宝拉起链接)
- `qr``{data}`(当前仅复制兜底,未渲染二维码——见交付说明「取舍」条目 3)
required: [render_type, payload]
properties:
render_type:
type: string
enum: [crypto_address, redirect, qr]
payload:
type: object
description: 结构随 render_type 变化,原样透传,服务端不解析
additionalProperties: true
expires_at:
type: string
format: date-time
nullable: true
description: 会话/收款地址有效期(UTC ISO-8601),无限期时为 null
PayOrderSessionResult:
type: object
description: "`/pay/orders`(下单)与 `/pay/orders/{orderNo}/retry`(换渠道)的共同响应体"
required: [order_no, session]
properties:
order_no:
type: string
description: pay 侧订单号
example: "PAY202607100001"
session:
$ref: "#/components/schemas/PaySession"
PayOrderStatus:
type: object
required: [order_no, pay_status, activated]
properties:
order_no:
type: string
pay_status:
type: string
description: |
pay 侧状态词汇原样透传(`pending` / `succeeded` / `canceled` 等,具体取值
以 pay 实现为准)。**仅展示,不做分支判断**——本地成功判据是 `activated`。
example: succeeded
activated:
type: boolean
description: |
本地台账(`pay_purchases.status = 'paid'`)是否已消费权益开通。
客户端轮询以此字段为唯一成功判据。
example: true
expires_at:
type: string
format: date-time
nullable: true
description: 开通/延长后的订阅到期时间(UTC ISO-8601);未开通或查询失败时缺省
PayWebhookEvent:
type: object
description: |
pay `settle.go::enqueuePaymentSucceeded` 出站 payloadpayment.succeeded 事件,
无 `refund_id` 字段)。
required: [event_type, out_trade_no, biz_system, biz_ref, product_biz_code,
amount_minor, currency, channel, paid_at]
properties:
event_type:
type: string
enum: [payment.succeeded]
description: 事件类型;白名单外的事件本接口直接确认(200 SUCCESS)不处理
out_trade_no:
type: string
description: pay 订单号,业务幂等唯一键
biz_system:
type: string
example: pangolin
biz_ref:
type: string
format: uuid
description: 下单时传入的业务方用户标识(`users.uuid`
product_biz_code:
type: string
description: 对应 `/pay/catalog` 的 `sku`
example: pro_month
amount_minor:
type: integer
format: int64
description: 实付金额(最小单位)
currency:
type: string
example: USDT
channel:
type: string
description: 实际支付渠道
example: crypto
paid_at:
type: string
format: date-time
description: 支付完成时间(RFC3339);解析失败时服务端回落为处理时刻
tags:
- name: Auth
description: 认证相关(发验证码、注册、登录、刷新 Token)——无需 JWT
- name: Account
description: 账户与设备管理
- name: Commerce
description: 商业闭环(激活码兑换、广告解锁、套餐目录)
- name: Pay
description: |
pay v2 统一支付网关代理(下单/查单/换渠道/取消 + webhook 接收)。
`PAY_BASE_URL` 未配置时整组端点不挂载(服务端启动日志告警,无 404 以外的降级)。
- name: Nodes
description: 节点目录与连接凭证下发(数据面入口)
- name: Usage
description: 用量统计
- name: Notices
description: 公告