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" # ── 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: 当前用户不存在或账户非 active(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}: 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 `" # ── 统一错误响应 ──────────────────────────────────────────── 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: 节点 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) # ── 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` 出站 payload(payment.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: 公告