openapi: "3.0.3"
info:
  title: allium-web API
  version: "0.1.0"
  description: |
    Empty Sekai (Project Allium) 平台 Web 层统一开放 API。

    主要能力：
    - 名片渲染
    - 组卡推荐
    - QQ 号 ↔ 游戏账号绑定管理
    - 账号认证（邮箱 OTP + OAuth + Passkey）

    API 基址固定为 `https://api.emptysekai.com`。网关 /api/v1/* 与 /v2/* 的受保护调用需在 Header 中携带 `Authorization: Bearer <aw_xxx>`，且 API Key 必须具备对应端点所属 scope。

    游戏数据能力目前仅支持《世界计划：缤纷舞台 feat. 初音未来》国服（CN）。
servers:
  - url: https://api.emptysekai.com
    description: 生产环境
  - url: http://localhost:8080
    description: 本地开发

security:
  - bearerAuth: []

paths:
  /health:
    get:
      summary: 平台健康检查
      tags: [System]
      security: []
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema:
                type: object
                properties:
                  status: { type: string, example: ok }
                  service: { type: string, example: allium-web }

  /api/auth/providers:
    get:
      summary: 获取可用认证方式列表
      tags: [Auth]
      security: []
      responses:
        "200":
          description: 可用 provider 列表
          content:
            application/json:
              schema:
                type: object
                properties:
                  providers:
                    type: array
                    items: { type: string }
                    example: ["email", "github", "google", "passkey"]

  /auth/email/send-otp:
    post:
      summary: 发送邮箱验证码
      tags: [Auth]
      security: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [email]
              properties:
                email: { type: string, format: email }
      responses:
        "200": { description: 验证码已发送 }
        "400": { description: 邮箱格式错误 }
        "429": { description: 发送过于频繁 }

  /auth/email/verify:
    post:
      summary: 校验验证码并登录/注册
      tags: [Auth]
      security: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [email, otp]
              properties:
                email: { type: string, format: email }
                otp: { type: string, minLength: 6, maxLength: 6 }
      responses:
        "200": { description: 登录成功（设置 session_token Cookie） }
        "400": { description: 验证码错误 }

  /auth/{provider}/redirect:
    get:
      summary: OAuth 授权跳转
      tags: [Auth]
      security: []
      parameters:
        - name: provider
          in: path
          required: true
          schema: { type: string, enum: [github, google, qq] }
      responses:
        "302": { description: 重定向到 provider 授权页 }
        "404": { description: Provider 未配置 }

  /auth/{provider}/callback:
    get:
      summary: OAuth 回调
      tags: [Auth]
      security: []
      parameters:
        - name: provider
          in: path
          required: true
          schema: { type: string }
        - name: code
          in: query
          required: true
          schema: { type: string }
        - name: state
          in: query
          required: true
          schema: { type: string }
      responses:
        "200": { description: 登录成功 }

  /api/me:
    get:
      summary: 获取当前登录用户信息
      tags: [User]
      responses:
        "200": { description: 用户信息 }
        "401": { description: 未登录 }

  /api/me/identities:
    get:
      summary: 已绑定的登录方式
      tags: [User]
      responses:
        "200": { description: 身份列表 }

  /api/me/identities/{id}:
    delete:
      summary: 解绑某个登录方式
      tags: [User]
      parameters:
        - name: id
          in: path
          required: true
          schema: { type: string, format: uuid }
      responses:
        "200": { description: 已解绑 }

  /api/me/passkeys:
    get:
      summary: Passkey 列表
      tags: [User]
      responses:
        "200": { description: Passkey 列表 }

  /api/me/passkeys/{id}:
    delete:
      summary: 删除 Passkey
      tags: [User]
      parameters:
        - name: id
          in: path
          required: true
          schema: { type: string }
      responses:
        "200": { description: 已删除 }

  /api/logout:
    post:
      summary: 退出登录
      tags: [User]
      responses:
        "200": { description: 已退出 }

  /api/game/bind/start:
    post:
      summary: 开始游戏账号绑定
      description: |
        Web 端（Cookie session）调用。发起绑定后会收到游戏内验证消息，
        完成后由 Web 端完成签名提交，再于 Bot 端提交 C-xxx 确认码完成所有权验证。
      tags: [Game]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [game_uid]
              properties:
                game_uid: { type: string }
      responses:
        "200": { description: 生成验证码 }

  /api/game/bind/verify:
    post:
      summary: 验证游戏账号绑定
      tags: [Game]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [game_uid, signature]
              properties:
                game_uid: { type: string }
                signature: { type: string }
      responses:
        "200": { description: 验证成功 }

  /api/game/bindings:
    get:
      summary: 我的游戏绑定列表
      tags: [Game]
      responses:
        "200": { description: 绑定列表 }

  /api/game/bindings/{id}:
    delete:
      summary: 解绑游戏账号
      tags: [Game]
      parameters:
        - name: id
          in: path
          required: true
          schema: { type: string }
      responses:
        "200": { description: 已解绑 }

  # ============================================================
  /v2/profiles/{gameUserId}/media:
    get:
      summary: 获取静态图片与动态视频混合名片
      description: 唯一的公开名片媒体端点，仅支持国服。玩家 ID 只通过 URL 路径传递，不接受 query 或请求体中的 ID。生产环境当前通过端点专属开关临时免 API Key；关闭 AW_GATEWAY_PROFILE_MEDIA_AUTH_BYPASS 后恢复 bearerAuth 与 profile scope 校验。静态页返回 image CDN URL，动态页返回 H.264 MP4 CDN URL；公开响应只保留发送所需字段与精简性能计时。Scapus 生成槽繁忙时由 Web 保持当前 HTTP 连接并重试，最长排队 60 秒。
      tags: [Profile]
      security: []
      parameters:
        - name: gameUserId
          in: path
          required: true
          schema: { type: string, pattern: '^[0-9]{5,20}$' }
        - name: page
          in: query
          required: false
          schema: { type: integer, minimum: 1 }
          description: 只返回指定的 1-based 名片页。
      responses:
        "200":
          description: 混合媒体与精简性能信息
          content:
            application/json:
              schema: { $ref: '#/components/schemas/ProfileMediaEnvelope' }
        "400": { description: gameUserId 或 page 非法 }
        "404": { description: 玩家或名片不存在 }
        "504": { description: 等待 Scapus 生成槽超过 60 秒 }

  /api/v1/personal-profile:
    get:
      summary: 个人主页数据（含多页名片、自定义内容）
      tags: [Profile]
      parameters:
        - name: game_user_id
          in: query
          required: true
          schema: { type: string }
      responses:
        "200":
          description: 个人主页渲染数据
          content:
            application/json:
              schema:
                type: object
                properties:
                  image_url: { type: string, description: CDN 渲染图 URL }
                  document_hash: { type: string }
                  height: { type: integer }
                  expires_in: { type: integer }
                  source: { type: string, example: default }
                  kind: { type: string, example: personal_profile }
                  timing: { type: object, description: 渲染耗时细分 }

  /api/v1/personal-profile/{userId}:
    get:
      summary: 个人主页数据（路径参数，兼容旧调用）
      tags: [Profile]
      deprecated: true
      parameters:
        - name: userId
          in: path
          required: true
          schema: { type: string }
      responses:
        "200": { description: 同 /api/v1/personal-profile }

  # ============================================================
  # 组卡（scope: deck）
  # ============================================================

  /api/v1/deck/recommend:
    post:
      summary: 按条件推荐最优组卡
      description: 返回渲染后的卡组图片 URL 列表。需要用户已在 scapus 中存在。
      tags: [Deck]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/DeckRecommendRequest'
      responses:
        "200": { description: 卡组图片 URL 列表 }
        "400": { description: "缺少用户标识（INVALID_REQUEST）" }
        "403": { $ref: '#/components/responses/BindingRequired' }

  # ============================================================
  # 账号绑定（scope: binding）
  # ============================================================

  /api/v1/bind/numeric:
    post:
      summary: 声明绑定：将 QQ 号与游戏 ID 关联
      description: scapus 会验证账号是否存在，存在则创建 numeric 绑定。
      tags: [Binding]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [qq_user_id, game_user_id]
              properties:
                qq_user_id:
                  type: string
                  description: 用户 QQ 号
                game_user_id:
                  type: string
                  description: 游戏内 ID（5-20 位纯数字）
                  pattern: '^\d{5,20}$'
      responses:
        "200":
          description: 绑定成功
          content:
            application/json:
              schema:
                type: object
                properties:
                  binding:
                    type: object
                    properties:
                      id: { type: string }
                      qq_user_id: { type: string }
                      game_user_id: { type: string }
                      created_at: { type: string, format: date-time }
        "400": { description: "参数错误（INVALID_REQUEST）" }
        "404": { description: "未找到该游戏账号（NOT_FOUND）" }

  /api/v1/bind/confirm:
    post:
      summary: 提交 C-xxx 确认码完成 QQ 身份所有权验证
      description: 用户在 Web 端完成游戏内签名验证后会得到一个 C-xxx 格式的确认码，Bot 端提交此码完成身份确认。
      tags: [Binding]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [qq_user_id, confirm_code]
              properties:
                qq_user_id:
                  type: string
                confirm_code:
                  type: string
                  description: C-xxx 格式确认码
                  pattern: '^C-'
      responses:
        "200": { description: QQ 身份确认成功 }
        "400": { description: 参数错误 }

  /api/v1/bind/accounts:
    get:
      summary: 查询 QQ 号下已绑定的游戏账号列表
      description: 返回含绑定层级（numeric / verified）、默认账号、别名。
      tags: [Binding]
      parameters:
        - name: qq_user_id
          in: query
          required: true
          schema: { type: string }
      responses:
        "200":
          description: 绑定账号列表
          content:
            application/json:
              schema:
                type: object
                properties:
                  bindings:
                    type: array
                    items:
                      type: object
                      properties:
                        game_user_id: { type: string }
                        game_server: { type: string }
                        tier: { type: string, enum: [numeric, verified] }
                        is_default: { type: boolean }
                        alias: { type: string, nullable: true }
                        verified_at: { type: string, format: date-time, nullable: true }
                        created_at: { type: string, format: date-time }

  /api/v1/bind/list:
    get:
      summary: 列出 QQ 号下的 numeric 和 verified 两类绑定
      description: 旧接口，建议使用 /api/v1/bind/accounts。
      tags: [Binding]
      deprecated: true
      parameters:
        - name: qq_user_id
          in: query
          required: true
          schema: { type: string }
      responses:
        "200":
          description: 两类绑定分开返回
          content:
            application/json:
              schema:
                type: object
                properties:
                  qq_user_id: { type: string }
                  numeric: { type: array, items: { type: object } }
                  verified: { type: array, items: { type: object } }

  /api/v1/bind/set-default:
    post:
      summary: 切换 QQ 用户的默认游戏账号
      tags: [Binding]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [qq_user_id, game_user_id]
              properties:
                qq_user_id: { type: string }
                game_user_id: { type: string }
      responses:
        "200": { description: 默认账号已切换 }
        "404": { description: 绑定不存在 }

  /api/v1/bind/set-alias:
    post:
      summary: 为绑定设置别名
      tags: [Binding]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [qq_user_id, game_user_id, alias]
              properties:
                qq_user_id: { type: string }
                game_user_id: { type: string }
                alias: { type: string }
      responses:
        "200": { description: 别名已设置 }

  /api/v1/bind/remove:
    post:
      summary: 删除绑定
      tags: [Binding]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [qq_user_id, game_user_id]
              properties:
                qq_user_id: { type: string }
                game_user_id: { type: string }
      responses:
        "200": { description: 绑定已删除 }

  # ============================================================
  # 上传（scope: upload_suite）
  # ============================================================

  /api/v1/upload/suite:
    post:
      summary: 上传卡组/suite 数据
      description: |
        注册 API Key 时默认授予 upload_suite scope。实际调用需携带
        X-Signature 与 X-Timestamp 等 HMAC 头（具体由 UploadVerifier 验证）。
      tags: [Upload]
      responses:
        "200": { description: 上传成功 }
        "400": { description: "缺少签名头（SIGNATURE_INVALID）" }

  # ============================================================
  # 公开只读端点（无需鉴权）
  # ============================================================

  /api/v1/health:
    get:
      summary: "网关上游健康（转发 scapus /health）"
      tags: [Public]
      security: []
      responses:
        "200": { description: 上游健康 }
        "502": { description: "scapus 内网不可达" }

  /api/v1/music/resolve:
    get:
      summary: 解析歌曲查询字符串
      description: 由 allium-web 本地歌曲别名索引解析；scapus 不处理别名，只接收 music_id。
      tags: [Public]
      security: []
      parameters:
        - name: query
          in: query
          required: true
          schema: { type: string }
          description: 歌曲 ID、标题或别名。
        - name: region
          in: query
          required: false
          schema: { type: string, default: cn }
          description: 区服，当前别名数据默认维护 cn。
      responses:
        "200":
          description: 解析后的歌曲信息
          content:
            application/json:
              schema:
                type: object
                properties:
                  music_id: { type: integer, format: int64 }
                  title: { type: string }
                  query: { type: string }
                  alias: { type: string }
                  source: { type: string, enum: [music_id, manual, moesekai-hub] }
        "400": { description: 未找到歌曲、别名歧义或 query 为空 }

  /api/v1/character/resolve:
    get:
      summary: 解析角色查询字符串
      description: 由 allium-web 内置 character_nicknames 映射解析；浏览器组卡页会先解析成数字 ID 再交给本地 WASM。
      tags: [Public]
      security: []
      parameters:
        - name: query
          in: query
          required: true
          schema: { type: string }
          description: 角色 ID、中文名、罗马字或常用缩写。
      responses:
        "200":
          description: 解析后的角色信息
          content:
            application/json:
              schema:
                type: object
                properties:
                  character_id: { type: integer, format: int64 }
                  query: { type: string }
        "400": { description: 未找到匹配角色 }

components:
  securitySchemes:
    bearerAuth:
      type: http
      scheme: bearer
      description: API Key，格式为 aw_ 开头、共 67 字符的字符串。
    cookieAuth:
      type: apiKey
      in: cookie
      name: session_token
      description: Web 端 Cookie session 鉴权。

  parameters:
    EventId:
      name: event_id
      in: query
      required: false
      schema: { type: string }
      description: 活动 ID。省略时取当前进行中的活动；若当前无活动返回 NO_ACTIVE_EVENT。
    GameUserId:
      name: game_user_id
      in: query
      required: false
      schema: { type: string }
      description: 游戏内用户 ID。与 qq_user_id 二选一。
    QqUserId:
      name: qq_user_id
      in: query
      required: false
      schema: { type: string }
      description: QQ 号。使用此参数需先完成 numeric 绑定，否则返回 BINDING_REQUIRED。
    GameUserIds:
      name: game_user_ids
      in: query
      required: false
      schema:
        type: string
        example: "12345,67890"
      description: 逗号分隔的游戏用户 ID 列表。与 qq_user_ids 二选一。
    QqUserIds:
      name: qq_user_ids
      in: query
      required: false
      schema: { type: string }
      description: 逗号分隔的 QQ 号列表。使用此参数需先完成 numeric 绑定，解析失败会跳过。

  responses:
    BindingRequired:
      description: 使用 qq_user_id 查询但未绑定
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/ErrorResponse'
          example:
            ok: false
            error:
              code: BINDING_REQUIRED
              message: 需要先绑定游戏账号

  schemas:
    ErrorResponse:
      type: object
      properties:
        ok: { type: boolean, enum: [false] }
        error:
          type: object
          properties:
            code:
              type: string
              enum:
                - INVALID_REQUEST
                - UNAUTHORIZED
                - SCOPE_DENIED
                - BINDING_REQUIRED
                - BINDING_TIER_INSUFFICIENT
                - NO_ACTIVE_EVENT
                - NOT_FOUND
                - UPSTREAM_ERROR
                - RATE_LIMITED
                - INTERNAL_ERROR
            message: { type: string }
    User:
      type: object
      properties:
        id: { type: string, format: uuid }
        email: { type: string, nullable: true }
        nickname: { type: string }
        avatar_url: { type: string }

    ProfileMediaEnvelope:
      type: object
      required: [ok, data, timing]
      properties:
        ok: { type: boolean, enum: [true] }
        data: { $ref: '#/components/schemas/ProfileMediaResponse' }
        timing: { $ref: '#/components/schemas/GatewayTiming' }

    ProfileMediaResponse:
      type: object
      required: [schema, game_user_id, items, failures, total, timing]
      properties:
        schema: { type: string, enum: [allium.profile-media.v2] }
        game_user_id: { type: string }
        items:
          type: array
          items: { $ref: '#/components/schemas/ProfileMediaItem' }
        failures:
          type: array
          items: { $ref: '#/components/schemas/ProfileMediaFailure' }
        total: { type: integer, minimum: 0 }
        timing: { $ref: '#/components/schemas/ProfileMediaBatchTiming' }

    ProfileMediaItem:
      type: object
      required: [seq, kind, url, bytes, cache_hit, telemetry]
      properties:
        seq: { type: integer, minimum: 1 }
        kind: { type: string, enum: [image, video] }
        url: { type: string, format: uri }
        bytes: { type: integer, minimum: 0 }
        cache_hit: { type: boolean }
        telemetry: { $ref: '#/components/schemas/ProfileMediaTelemetry' }

    ProfileMediaTelemetry:
      type: object
      required: [server]
      properties:
        renderer:
          allOf: [{ $ref: '#/components/schemas/ProfileMediaRendererTiming' }]
          nullable: true
          description: 仅动态视频页返回。
        server: { $ref: '#/components/schemas/ProfileMediaServerTiming' }

    ProfileMediaRendererTiming:
      type: object
      required: [render_ms, encode_ms]
      properties:
        render_ms: { type: number, format: double, minimum: 0 }
        encode_ms: { type: number, format: double, minimum: 0 }

    ProfileMediaServerTiming:
      type: object
      required: [cache_lookup_ms, singleflight_wait_ms, lease_wait_ms, generation_ms, upload_ms, sign_ms, total_ms, cache_status]
      properties:
        cache_lookup_ms: { type: number, format: double, minimum: 0 }
        singleflight_wait_ms: { type: number, format: double, minimum: 0 }
        lease_wait_ms: { type: number, format: double, minimum: 0 }
        generation_ms: { type: number, format: double, minimum: 0 }
        upload_ms: { type: number, format: double, minimum: 0 }
        sign_ms: { type: number, format: double, minimum: 0 }
        total_ms: { type: number, format: double, minimum: 0 }
        cache_status: { type: string }

    ProfileMediaBatchTiming:
      type: object
      required: [profile_fetch_ms, parse_resolve_ms, asset_prefetch_ms, total_ms, selected_pages, succeeded_pages, failed_pages]
      properties:
        profile_fetch_ms: { type: number, format: double, minimum: 0 }
        parse_resolve_ms: { type: number, format: double, minimum: 0 }
        asset_prefetch_ms: { type: number, format: double, minimum: 0 }
        total_ms: { type: number, format: double, minimum: 0 }
        selected_pages: { type: integer, minimum: 0 }
        succeeded_pages: { type: integer, minimum: 0 }
        failed_pages: { type: integer, minimum: 0 }

    ProfileMediaFailure:
      type: object
      required: [seq, code, message]
      properties:
        seq: { type: integer, minimum: 1 }
        code: { type: string }
        message: { type: string }

    GatewayTiming:
      type: object
      required: [queue_wait_ms, compute_ms, total_ms]
      properties:
        queue_wait_ms: { type: integer, minimum: 0, description: Web 收到上游 429 后实际退避等待的时间；不含并行页面内部等待。 }
        compute_ms: { type: integer, minimum: 0, description: 所有成功媒体页 generation_ms 的总和，即总渲染+编码时间。 }
        total_ms: { type: integer, minimum: 0, description: Scapus 接受批次请求到所有媒体上传完成的端到端 wall。 }

    DeckRecommendRequest:
      type: object
      required: [qq_user_id]
      description: |
        用户标识 qq_user_id 或 game_user_id 二选一必填。
        参数组合与冲突规则详见网站文档。
      properties:
        qq_user_id:
          type: string
          description: 用户 QQ 号（与 game_user_id 二选一）。需先声明 numeric 绑定。
        game_user_id:
          type: string
          description: 游戏内用户 ID（与 qq_user_id 二选一）。
        event_id:
          type: string
          description: 活动 ID。默认：当前进行中的活动。
        target:
          type: string
          enum: [score, power, mysekai]
          description: 优化目标。score=分数最优（默认），power=综合力最强，mysekai=烤森。
        live_type:
          type: string
          enum: [challenge, mysekai]
          description: Live 类型。默认：无。
        boost:
          type: integer
          minimum: 1
          maximum: 10
          description: 火数加成，仅影响最终点数计算，不改推荐卡组。默认 1。
        max_profile:
          type: boolean
          description: 使用顶配模拟（满图鉴满配账号）。默认 false。
        card_config_override:
          type: object
          description: 养成覆盖开关，可叠加。默认：使用用户实际配置。
          properties:
            level_max: { type: boolean }
            master_max: { type: boolean }
            skill_max: { type: boolean }
            episode_read: { type: boolean }
            canvas: { type: boolean }
        fixed_cards:
          type: array
          items: { type: integer }
          description: 固定卡牌 ID 列表（强制包含）。第一个为队长。默认 []。
        fixed_character_names:
          type: array
          items: { type: string }
          description: 固定角色名列表。第一个为队长，Web 侧解析为 ID。默认 []。
        music_query:
          type: string
          description: 歌曲 ID、标题或别名。Web 侧解析为 music_id；与 music_id 同传时 music_id 优先。
        challenge_character:
          type: string
          description: 挑战角色名或别名。Web 侧解析为 challenge_live_character_id；省略时挑战组卡会跑全角色并返回单张排序图。
        world_bloom_character:
          type: string
          description: WL 章节角色名或别名。Web 侧按角色优先仲裁，解析为 world_bloom_character_id。
        music_diff:
          type: string
          enum: [expert, master, hard, normal, easy, append]
          description: 歌曲难度。默认 expert。
        force_no_event:
          type: boolean
          description: 强制不限定活动（如最强/最弱组卡）。默认 false。此参数为 true 时 event_id 被忽略。
        minimize:
          type: boolean
          description: 反转优化方向（最弱组卡用）。默认 false。

tags:
  - name: System
    description: 平台级探针
  - name: Auth
    description: 用户认证
  - name: User
    description: 当前登录用户
  - name: Game
    description: "游戏账号绑定（Web 端 Cookie session 内）"
  - name: Profile
    description: "名片渲染（scope: profile）"
  - name: Deck
    description: "组卡推荐（scope: deck）"
  - name: Binding
    description: "QQ 号绑定管理（scope: binding）"
  - name: Upload
    description: "上传（scope: upload_suite）"
  - name: Public
    description: 公开只读端点（无需鉴权）
