openapi: 3.0.3
info:
  title: 电子合同生成公开 API
  version: 1.1.0
  description: |
    对接方服务端使用授权码同步可用名称，并请求合同服务器生成、盖章和交付成品。
    公开 API 不返回模板内容、模板源文件、原始印章、印章坐标或企业内部资料。
    API Key 只能保存在对接方后端，禁止放入浏览器、移动端或小程序。
servers:
  - url: https://contract.64-90-13-100.sslip.io
security:
  - apiKey: []
paths:
  /api/v1/catalog:
    get:
      summary: 获取当前授权码可用的模板、分组和企业名称
      parameters:
        - in: header
          name: If-None-Match
          schema: { type: string }
          description: 上一次目录响应的 ETag
      responses:
        '200':
          description: 当前授权目录
          headers:
            ETag:
              schema: { type: string }
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Catalog' }
        '304':
          description: 目录没有变化，无响应体
        '401':
          $ref: '#/components/responses/Unauthorized'
  /api/v1/generate:
    post:
      summary: 按模板名称生成单个，或按分组名称生成整组
      description: |
        templateName 与 groupName 必须二选一。所有名称必须与最新目录完全一致。
        fields 可直接使用目录字段的中文 label，例如“剧目名称”，不需要传“{剧目名称}”。
        整组生成会在受理前完成全部校验，任意模板失败时整组不会创建任务。
      parameters:
        - $ref: '#/components/parameters/IdempotencyKey'
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/GenerateRequest' }
            examples:
              single:
                summary: 生成单个模板
                value:
                  templateName: 腾讯-承诺函
                  companyName: 示例文化科技有限公司
                  fields:
                    剧目名称: 示例剧目
                    集数: '24'
                  outputFormat: pdf
              group:
                summary: 生成整组模板
                value:
                  groupName: 腾讯
                  companyName: 示例文化科技有限公司
                  fields:
                    剧目名称: 示例剧目
                    集数: '24'
                  outputFormat: pdf
      responses:
        '202':
          description: 新任务已受理
          content:
            application/json:
              schema: { $ref: '#/components/schemas/GenerateResponse' }
        '200':
          description: 幂等键对应的任务已经存在
          content:
            application/json:
              schema: { $ref: '#/components/schemas/GenerateResponse' }
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '409':
          $ref: '#/components/responses/Conflict'
        '429':
          description: 请求过快或授权码剩余额度不足
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }
  /api/v1/generation-jobs/batch:
    post:
      deprecated: true
      summary: 旧版兼容生成入口
      description: |
        新接入请使用 POST /api/v1/generate 和名称参数。
        本入口继续接受新名称参数，也兼容旧 combinationId 与 companyId 参数。
      parameters:
        - $ref: '#/components/parameters/IdempotencyKey'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              oneOf:
                - $ref: '#/components/schemas/GenerateRequest'
                - $ref: '#/components/schemas/LegacyBatchRequest'
      responses:
        '202':
          description: 任务已受理
          content:
            application/json:
              schema: { $ref: '#/components/schemas/GenerateResponse' }
        '200':
          description: 幂等键对应的任务已经存在
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
  /api/v1/generation-jobs:
    get:
      summary: 查询当前授权码的生成记录
      parameters:
        - in: query
          name: limit
          schema: { type: integer, minimum: 1, maximum: 100, default: 20 }
        - in: query
          name: offset
          schema: { type: integer, minimum: 0, default: 0 }
      responses:
        '200':
          description: 任务列表
          content:
            application/json:
              schema:
                type: object
                required: [jobs, pagination]
                properties:
                  jobs:
                    type: array
                    items: { $ref: '#/components/schemas/Job' }
                  pagination:
                    type: object
                    required: [limit, offset, total, hasMore]
                    properties:
                      limit: { type: integer, minimum: 1 }
                      offset: { type: integer, minimum: 0 }
                      total: { type: integer, minimum: 0 }
                      hasMore: { type: boolean }
        '401':
          $ref: '#/components/responses/Unauthorized'
  /api/v1/generation-jobs/{jobId}:
    get:
      summary: 查询单个任务状态
      parameters:
        - $ref: '#/components/parameters/JobId'
      responses:
        '200':
          description: 任务状态
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Job' }
        '401':
          $ref: '#/components/responses/Unauthorized'
        '404':
          description: 任务不存在或不属于当前授权码
  /api/v1/generation-jobs/{jobId}/download:
    get:
      summary: 下载任务的第一个输出文件
      description: 服务器完整发送响应后立即删除文件，不等待接收确认；传输中断时保留文件供重试。
      parameters:
        - $ref: '#/components/parameters/JobId'
      responses:
        '200':
          description: 合同成品文件
          content:
            application/octet-stream:
              schema: { type: string, format: binary }
        '401':
          $ref: '#/components/responses/Unauthorized'
        '404':
          description: 任务、文件不存在或文件已经被领取
        '409':
          description: 同一文件正在发送，请等待当前请求结束
  /api/v1/generation-files/{fileId}/download:
    get:
      summary: 下载指定输出文件
      description: 多页图片应遍历任务 files 并逐个调用；完整发送后文件立即删除，传输中断时可重试。
      parameters:
        - in: path
          name: fileId
          required: true
          schema: { type: string }
      responses:
        '200':
          description: 合同成品文件
          content:
            application/octet-stream:
              schema: { type: string, format: binary }
        '401':
          $ref: '#/components/responses/Unauthorized'
        '404':
          description: 文件不存在、不属于当前授权码或已经被领取
        '409':
          description: 同一文件正在发送，请等待当前请求结束
components:
  securitySchemes:
    apiKey:
      type: http
      scheme: bearer
      bearerFormat: API Key
  parameters:
    IdempotencyKey:
      in: header
      name: Idempotency-Key
      required: true
      schema: { type: string, maxLength: 200, minLength: 1 }
      description: 必填，最长 200 字符（超出返回 400，不会截断）。同一业务请求的稳定唯一值；网络重试必须复用原值。组合任务会基于完整键的 SHA-256 生成子键。
    JobId:
      in: path
      name: jobId
      required: true
      schema: { type: string }
  responses:
    Unauthorized:
      description: API Key 无效、停用、已重置或已过期
      content:
        application/json:
          schema: { $ref: '#/components/schemas/Error' }
    Forbidden:
      description: 模板、分组、企业或输出格式不存在、停用或未授权
      content:
        application/json:
          schema: { $ref: '#/components/schemas/Error' }
    BadRequest:
      description: 请求参数或字段无效，或单模板与整组参数冲突
      content:
        application/json:
          schema: { $ref: '#/components/schemas/Error' }
    Conflict:
      description: 名称无法唯一匹配，或幂等键对应不完整历史任务
      content:
        application/json:
          schema: { $ref: '#/components/schemas/Error' }
  schemas:
    OutputFormat:
      type: string
      enum: [pdf, image]
      default: pdf
    Error:
      type: object
      required: [error]
      properties:
        error: { type: string }
        code: { type: string }
    PublicField:
      type: object
      required: [key, label, type, required, serverProvided]
      properties:
        key:
          type: string
          example: '{剧目名称}'
        label:
          type: string
          example: 剧目名称
        type: { type: string, enum: [text] }
        required: { type: boolean }
        serverProvided: { type: boolean, enum: [false] }
    PublicGroup:
      type: object
      required: [name, version, contractCount, fields, formats]
      properties:
        name: { type: string }
        version: { type: string }
        contractCount: { type: integer, minimum: 1 }
        fields:
          type: array
          items: { $ref: '#/components/schemas/PublicField' }
        formats:
          type: array
          items: { $ref: '#/components/schemas/OutputFormat' }
    PublicTemplate:
      type: object
      required: [name, groupName, version, fields, formats]
      properties:
        name: { type: string }
        groupName: { type: string }
        version: { type: string }
        fields:
          type: array
          items: { $ref: '#/components/schemas/PublicField' }
        formats:
          type: array
          items: { $ref: '#/components/schemas/OutputFormat' }
    PublicCompany:
      type: object
      required: [name]
      properties:
        name: { type: string }
    Catalog:
      type: object
      required: [groups, templates, combinations, companies, catalogVersion]
      properties:
        groups:
          type: array
          items: { $ref: '#/components/schemas/PublicGroup' }
        templates:
          type: array
          items: { $ref: '#/components/schemas/PublicTemplate' }
        combinations:
          type: array
          description: 与 groups 内容一致，保留字段名用于旧客户端读取
          items: { $ref: '#/components/schemas/PublicGroup' }
        companies:
          type: array
          items: { $ref: '#/components/schemas/PublicCompany' }
        catalogVersion: { type: string }
    GenerateRequest:
      type: object
      required: [companyName, fields]
      properties:
        templateName:
          type: string
          description: 来自 catalog.templates[].name；与 groupName 二选一
        groupName:
          type: string
          description: 来自 catalog.groups[].name；与 templateName 二选一
        companyName:
          type: string
          description: 来自 catalog.companies[].name
        fields:
          type: object
          additionalProperties:
            oneOf:
              - { type: string }
              - { type: number }
              - { type: boolean }
          description: 使用字段 key 或中文 label；企业名称、税号和日期由服务器自动填充
        outputFormat:
          $ref: '#/components/schemas/OutputFormat'
      oneOf:
        - required: [templateName]
        - required: [groupName]
    LegacyBatchRequest:
      type: object
      required: [combinationId, companyId, fields]
      properties:
        combinationId:
          type: string
          description: 仅兼容已经保存旧编号的调用方，新接入不再获取编号
        companyId:
          type: string
          description: 仅兼容已经保存旧编号的调用方，新接入不再获取编号
        fields:
          type: object
          additionalProperties: true
        outputFormat:
          $ref: '#/components/schemas/OutputFormat'
    GenerationResult:
      type: object
      required: [jobId, status]
      properties:
        jobId: { type: string }
        status: { type: string, enum: [queued, running, succeeded, failed] }
    GenerateResponse:
      type: object
      required: [mode, companyName, results, total, alreadyExists]
      properties:
        mode: { type: string, enum: [single, group] }
        templateName:
          type: string
          description: 单模板生成时返回
        groupName:
          type: string
          description: 整组生成时返回
        companyName: { type: string }
        results:
          type: array
          items: { $ref: '#/components/schemas/GenerationResult' }
        total: { type: integer, minimum: 1 }
        alreadyExists: { type: boolean }
    JobFile:
      type: object
      required: [id, fileName, mimeType, fileSize]
      properties:
        id: { type: string }
        fileName: { type: string }
        mimeType: { type: string }
        fileSize: { type: integer }
        createdAt: { type: string }
        expiresAt: { type: string, nullable: true }
    Job:
      type: object
      required: [id, templateName, companyName, status, outputFormat, files]
      properties:
        id: { type: string }
        templateName: { type: string }
        companyName: { type: string }
        status: { type: string, enum: [queued, running, succeeded, failed] }
        outputFormat: { $ref: '#/components/schemas/OutputFormat' }
        downloadCount: { type: integer }
        createdAt: { type: string }
        finishedAt: { type: string, nullable: true }
        errorMessage: { type: string, nullable: true }
        files:
          type: array
          items: { $ref: '#/components/schemas/JobFile' }
