openapi: 3.0.3
info:
  title: 穿山甲 (Pangolin) Control-Plane API
  description: |
    穿山甲网络加速 控制面 API v1。

    **设计原则**
    - 控制面仅管账户、套餐、节点目录、兑换；用户流量走加速节点(WireGuard)，不经过本 API。
    - App 内无支付表单；资金流全走外部（激活码渠道）。
    - 最小数据：不记录目的地址；仅保留计费所需元数据。

    **认证**：除 `/auth/*` 外，全部接口需 `Authorization: Bearer <access_token>`。

    **错误体**：`{ code: string, message_zh: string, message_en: string }`
  version: 1.0.0
  contact:
    email: support@pangolin.app

servers:
  - url: https://api.pangolin.app/v1
    description: 生产环境

security:
  - bearerAuth: []

components:
  securitySchemes:
    bearerAuth:
      type: http
      scheme: bearer
      bearerFormat: JWT

  schemas:
    Error:
      type: object
      required: [code, message_zh, message_en]
      properties:
        code:
          type: string
          example: INVALID_CODE
        message_zh:
          type: string
          example: 兑换码无效
        message_en:
          type: string
          example: Invalid activation code

    JWT:
      type: object
      required: [access_token, refresh_token]
      properties:
        access_token:
          type: string
          description: 有效期 15 分钟
        refresh_token:
          type: string
          description: 有效期 30 天

    UserProfile:
      type: object
      properties:
        id:
          type: string
          format: uuid
        email:
          type: string
          format: email
        subscription:
          $ref: '#/components/schemas/Subscription'
        usage:
          $ref: '#/components/schemas/UsageSummary'

    Subscription:
      type: object
      properties:
        plan:
          type: string
          enum: [free, pro, team]
        expires_at:
          type: string
          format: date-time
          nullable: true
          description: null 表示无限期（免费版）
        source:
          type: string
          enum: [trial, code, admin, free]

    Device:
      type: object
      properties:
        id:
          type: string
          format: uuid
        name:
          type: string
        platform:
          type: string
          example: ios
        pubkey:
          type: string
          description: WireGuard 公钥 (base64)
        last_seen:
          type: string
          format: date-time

    Plan:
      type: object
      properties:
        id:
          type: string
          format: uuid
        code:
          type: string
          enum: [free, pro, team]
          description: free=免费版; pro=25¥/月; team=99¥/月(10席位)
        max_devices:
          type: integer
        max_routes:
          type: integer
        daily_minutes:
          type: integer
          nullable: true
          description: null 表示无限制；免费版为 10 分钟/天
        ad_gate:
          type: boolean
          description: 是否需要每日激励视频解锁

    Node:
      type: object
      properties:
        id:
          type: string
          format: uuid
        region:
          type: string
          description: 2 字母地区码，如 HK / JP / SG
          example: HK
        name_zh:
          type: string
          example: 香港 01
        name_en:
          type: string
          example: Hong Kong 01
        tier:
          type: string
          enum: [free, pro]
        status:
          type: string
          enum: [up, down, draining]
        weight:
          type: integer
        latency_ms:
          type: integer
          nullable: true
          description: 由客户端测速后本地排序；此字段为服务端建议值

    NodeList:
      type: object
      required: [version, nodes]
      properties:
        version:
          type: integer
          description: 节点目录版本号，递增；客户端带 if_version 实现 304
        nodes:
          type: array
          items:
            $ref: '#/components/schemas/Node'

    WireGuardConfig:
      type: object
      required: [endpoint, server_pubkey, client_ip, dns]
      properties:
        endpoint:
          type: string
          description: 节点 WireGuard 端点，格式 host:port
          example: hk1.pangolin.app:51820
        server_pubkey:
          type: string
          description: 服务端 WireGuard 公钥 (base64)
        client_ip:
          type: string
          description: 分配给客户端的隧道内网 IP
          example: 10.66.0.2/32
        dns:
          type: string
          example: 1.1.1.1
        ttl_seconds:
          type: integer
          nullable: true
          description: peer TTL；免费版 = 今日剩余秒数，付费版 = 86400

    UsageSummary:
      type: object
      properties:
        bytes_up:
          type: integer
        bytes_down:
          type: integer
        minutes_used:
          type: integer
        ad_unlocked_at:
          type: string
          format: date-time
          nullable: true
          description: 今日激励视频解锁时刻；null = 未解锁（免费版）

    DailyUsage:
      type: object
      properties:
        date:
          type: string
          format: date
        bytes_up:
          type: integer
        bytes_down:
          type: integer
        minutes_used:
          type: integer

paths:
  # ── Auth ──────────────────────────────────────────────────────────────────

  /auth/code:
    post:
      summary: 发送邮箱验证码
      description: 触发邮件发送 6 位数字验证码；有效期 10 分钟，限 1 次/分钟（Redis 滑窗）。
      security: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [email]
              properties:
                email:
                  type: string
                  format: email
      responses:
        '204':
          description: 验证码已发送
        '422':
          description: 参数格式错误
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
        '429':
          description: 请求频率超限
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'

  /auth/register:
    post:
      summary: 注册账户（自动开 7 天体验期）
      security: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [email, code, password]
              properties:
                email:
                  type: string
                  format: email
                code:
                  type: string
                  minLength: 6
                  maxLength: 6
                  pattern: '^\d{6}$'
                password:
                  type: string
                  minLength: 8
      responses:
        '200':
          description: 注册成功，返回 JWT
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/JWT'
        '400':
          description: 验证码错误 / 已过期 / 邮箱已注册
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
        '422':
          description: 参数格式错误
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'

  /auth/login:
    post:
      summary: 登录
      security: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [email, password]
              properties:
                email:
                  type: string
                  format: email
                password:
                  type: string
      responses:
        '200':
          description: 登录成功，返回 JWT
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/JWT'
        '401':
          description: 邮箱或密码错误
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'

  /auth/refresh:
    post:
      summary: 刷新 access_token
      security: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [refresh_token]
              properties:
                refresh_token:
                  type: string
      responses:
        '200':
          description: 新 JWT
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/JWT'
        '401':
          description: refresh_token 无效或已过期
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'

  # ── Me ────────────────────────────────────────────────────────────────────

  /me:
    get:
      summary: 获取当前用户信息（账户 + 订阅 + 用量摘要）
      responses:
        '200':
          description: 用户信息
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/UserProfile'
        '401':
          description: 未认证
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'

  /me/devices:
    get:
      summary: 获取设备列表
      responses:
        '200':
          description: 设备列表
          content:
            application/json:
              schema:
                type: array
                items:
                  $ref: '#/components/schemas/Device'
        '401':
          description: 未认证
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'

  /me/devices/{id}:
    delete:
      summary: 移除设备（回收节点 peer）
      parameters:
        - name: id
          in: path
          required: true
          schema:
            type: string
            format: uuid
      responses:
        '204':
          description: 已移除
        '401':
          description: 未认证
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
        '404':
          description: 设备不存在
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'

  # ── Plans & Subscriptions ─────────────────────────────────────────────────

  /plans:
    get:
      summary: 获取套餐目录（供客户端展示价格页）
      security: []
      responses:
        '200':
          description: 套餐列表
          content:
            application/json:
              schema:
                type: array
                items:
                  $ref: '#/components/schemas/Plan'

  /redeem:
    post:
      summary: 兑换激活码（幂等；同码重复提交返回首次结果）
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [code]
              properties:
                code:
                  type: string
                  description: Crockford Base32，16 位，含校验位
                  example: 0A1B2C3D4E5F6G7H
      responses:
        '200':
          description: 兑换成功，返回最新订阅信息
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Subscription'
        '400':
          description: 激活码无效、已使用或已作废
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
        '401':
          description: 未认证
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
        '429':
          description: 兑换失败频率超限（5次/小时后锁 1 小时）
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'

  # ── Ads ───────────────────────────────────────────────────────────────────

  /ads/unlock:
    post:
      summary: 免费版：激励视频解锁当日时长
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [device_id, ad_token]
              properties:
                device_id:
                  type: string
                  format: uuid
                ad_token:
                  type: string
                  description: 广告 SDK 服务端回执 token（用于服务端验证）
      responses:
        '200':
          description: 当日时长已解锁
          content:
            application/json:
              schema:
                type: object
                required: [ad_unlocked_at]
                properties:
                  ad_unlocked_at:
                    type: string
                    format: date-time
        '400':
          description: ad_token 无效 / 今日已解锁
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
        '401':
          description: 未认证
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'

  # ── Nodes ─────────────────────────────────────────────────────────────────

  /nodes:
    get:
      summary: 获取节点目录（按当前订阅过滤；支持 304）
      parameters:
        - name: if_version
          in: query
          required: false
          schema:
            type: integer
          description: 客户端持有的版本号；服务端未变更时返回 304
      responses:
        '200':
          description: 节点列表（含版本号）
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/NodeList'
        '304':
          description: 节点目录未更新
        '401':
          description: 未认证
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'

  /nodes/{id}/connect:
    post:
      summary: 连接节点，下发 WireGuard 配置
      description: |
        校验：订阅状态、设备数限制；免费版额外校验当日 ad_unlocked_at 与剩余分钟。
        通过后经 gRPC 让节点 agent 注册 peer，返回 WireGuard 配置。
      parameters:
        - name: id
          in: path
          required: true
          schema:
            type: string
            format: uuid
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [device_pubkey]
              properties:
                device_pubkey:
                  type: string
                  description: 客户端 WireGuard 公钥 (base64)
      responses:
        '200':
          description: WireGuard 配置
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/WireGuardConfig'
        '401':
          description: 未认证
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
        '402':
          description: 订阅已到期 / 设备数超限 / 免费版未解锁当日时长
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
        '404':
          description: 节点不存在或已下线
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'

  /nodes/{id}/disconnect:
    post:
      summary: 断开节点（回收 WireGuard peer）
      parameters:
        - name: id
          in: path
          required: true
          schema:
            type: string
            format: uuid
      responses:
        '204':
          description: 已断开
        '401':
          description: 未认证
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
        '404':
          description: 节点不存在
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'

  # ── Usage ─────────────────────────────────────────────────────────────────

  /usage:
    get:
      summary: 获取用量曲线（统计页）
      parameters:
        - name: days
          in: query
          required: false
          schema:
            type: integer
            minimum: 1
            maximum: 30
            default: 7
          description: 返回最近 N 天数据
      responses:
        '200':
          description: 每日用量数组（含字节数与时长，不含目的地址）
          content:
            application/json:
              schema:
                type: array
                items:
                  $ref: '#/components/schemas/DailyUsage'
        '401':
          description: 未认证
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
