d88c1ae647
手动合并 tsk_x7wrlA87orsY(设备管理 + 订阅校验中间件)到 main:
冲突解决:
- server/internal/apierr/apierr.go:保留 tsk_GXDoc3Cs07Rn 版本(New/StatusFor/
Middleware/ErrConflict/改善文档),并入 tsk_x7wrlA87orsY 新增的 ErrAccountBanned
及对应 StatusFor case(→ 403)。
新增文件(来自 tsk_x7wrlA87orsY):
- server/internal/devices/doc.go package 文档(替换占位 stub)
- server/internal/devices/context.go CtxKeyUserID / Plan / WithPlan / PlanFromCtx
- server/internal/devices/handler.go GET /v1/me/devices · DELETE /v1/me/devices/{id}
- server/internal/devices/middleware.go SubscriptionMiddleware · CheckDeviceQuota · RequirePaidTier
- server/internal/devices/service.go RegisterIfAbsent / DeleteDevice / ResolvePlan + 纯函数 resolveEffectivePlan
- server/internal/devices/store.go MySQL 数据访问层
- server/internal/devices/service_test.go 15 个单测(全通过)
- server/internal/devices/devices_integration_test.go testcontainers 集成测试
OpenAPI 更新(来自 tsk_x7wrlA87orsY):
- server/api/openapi.yaml:SubscriptionInfo.source 枚举补 free
- design/server/openapi.yaml:SubscriptionInfo.source 枚举补 admin, free
测试:go build ./... ✓;go test ./internal/apierr/... ✓(8 tests);
go test ./internal/devices/... ✓(15 tests)。
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1000 lines
33 KiB
YAML
1000 lines
33 KiB
YAML
openapi: 3.0.3
|
||
info:
|
||
title: Pangolin VPN — Control-Plane API
|
||
description: |
|
||
穿山甲 (Pangolin) 控制面 HTTP API,版本 v1。
|
||
数据面使用 sing-box(REALITY 主线 + 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')`,
|
||
一个邮箱仅一次)→ 返回 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_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, free]
|
||
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: 节点 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: 公告
|