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"
        "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 <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: 公告
