openapi: 3.0.3 info: title: Pangolin VPN — Control-Plane API description: | 穿山甲 (Pangolin) 控制面 HTTP API,版本 v1。 数据面使用 sing-box(REALITY 主线 + Hysteria2 备线),connect 端点下发用户凭证。 **认证**:除 `auth` 四个端点外,全部操作需在 `Authorization` 头携带 `Bearer `(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')`, 一个邮箱仅一次)→ 返回 JWT(access 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 token(30 天有效期) 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" /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 `" # ── 统一错误响应 ──────────────────────────────────────────── 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, 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: 节点 UUID(connect 时作为 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 443;Hysteria2 用 UDP 443) example: 443 uuid: type: string format: uuid description: | 用户数据面 UUID(dp_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: 日期(UTC,YYYY-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: 公告