openapi: 3.0.3
info:
  title: WorkMembership API
  version: 0.1.0
  description: |
    Unified identity (OAuth2/OIDC) and payment platform. Partners bring their own
    WeChat Pay / Alipay merchant credentials; the platform only signs requests and
    records orders. Funds never transit the platform account.
  contact:
    name: WorkMembership
servers:
  - url: http://localhost:8080
    description: Local development (docker compose)
  - url: /
    description: Default
tags:
  - name: System
  - name: Auth
  - name: OAuth
  - name: Partner
  - name: Admin
  - name: Payment
  - name: Catalog
  - name: Checkout
  - name: Subscriptions
  - name: Wallet
  - name: Invite
  - name: Privacy
  - name: OfflineCred
  - name: Notifications

components:
  securitySchemes:
    bearerAuth:
      type: http
      scheme: bearer
      bearerFormat: JWT
    apiKeyAuth:
      type: apiKey
      in: header
      name: X-API-Key

  schemas:
    Error:
      type: object
      required: [code, message]
      properties:
        code: { type: string, example: request.invalid }
        message: { type: string, example: malformed JSON body }
    ErrorEnvelope:
      type: object
      required: [error]
      properties:
        error: { $ref: '#/components/schemas/Error' }
    StatusOK:
      type: object
      properties:
        status: { type: string, example: ok }
    SentStatus:
      type: object
      properties:
        status: { type: string, example: sent }
    NullString:
      type: object
      properties:
        String: { type: string }
        Valid: { type: boolean }
    NullTime:
      type: object
      properties:
        Time: { type: string, format: date-time }
        Valid: { type: boolean }

    User:
      type: object
      properties:
        id: { type: string, format: uuid }
        email: { type: string, format: email }
        display_name: { type: string }
        avatar: { type: string }
        email_verified: { type: boolean }
        mfa_enabled: { type: boolean }
        is_admin: { type: boolean }
        created_at: { type: string, format: date-time }
    AuthResponse:
      type: object
      properties:
        access_token: { type: string }
        refresh_token: { type: string }
        token_type: { type: string, example: Bearer }
        expires_in: { type: integer, format: int64 }
        user: { $ref: '#/components/schemas/User' }
    MFARequired:
      type: object
      properties:
        mfa_required: { type: boolean, example: true }
        mfa_token: { type: string }
    DeviceInfoFields:
      type: object
      properties:
        install_id: { type: string, description: 'Stable device/installation identifier' }
        device_name: { type: string }
        device_type: { type: string, enum: [ios, android, web, desktop, other] }
        os_version: { type: string }
        app_version: { type: string }
    ForgotResponse:
      type: object
      properties:
        status: { type: string, example: accepted }
        _dev_reset_token: { type: string, description: Only present outside production. }
    MFASetup:
      type: object
      properties:
        secret: { type: string }
        otpauth_uri: { type: string }
    AuthorizeResponse:
      type: object
      properties:
        code: { type: string }
        state: { type: string }
    UserInfoResponse:
      type: object
      properties:
        sub: { type: string, description: Subject (user id) of the access token. }
        email: { type: string, format: email }
        email_verified: { type: boolean }
        name: { type: string }
        picture: { type: string }
        mfa_enabled: { type: boolean, description: Whether the account has TOTP MFA enabled (profile scope). }
    UpdateProfileReq:
      type: object
      properties:
        display_name: { type: string }
        avatar: { type: string }
    GrantDTO:
      type: object
      properties:
        client_id: { type: string }
        app_name: { type: string, description: Application display name (empty if the client record is gone). }
        scopes: { type: string }
        granted_at: { type: string, format: date-time }
        updated_at: { type: string, format: date-time, description: Last time the grant was (re-)consented. }
    GrantsResponse:
      type: object
      properties:
        grants: { type: array, items: { $ref: '#/components/schemas/GrantDTO' } }
    SessionDTO:
      type: object
      properties:
        id: { type: string }
        device_name: { type: string }
        device_type: { type: string }
        os_version: { type: string }
        app_version: { type: string }
        created_at: { type: string, format: date-time }
        expires_at: { type: string, format: date-time }
    DeviceDTO:
      type: object
      properties:
        id: { type: string, format: uuid }
        install_id: { type: string }
        name: { type: string }
        device_type: { type: string }
        os_version: { type: string }
        app_version: { type: string }
        last_ip: { type: string }
        last_seen_at: { type: string, format: date-time }
        created_at: { type: string, format: date-time }
        revoked_at: { type: string, format: date-time, nullable: true }
    DevicesResponse:
      type: object
      properties:
        devices: { type: array, items: { $ref: '#/components/schemas/DeviceDTO' } }
    RevokedStatus:
      type: object
      properties:
        status: { type: string, example: revoked }
    RevokeReq:
      type: object
      properties:
        token: { type: string }
        client_id: { type: string }
        client_secret: { type: string }
    SessionsResponse:
      type: object
      properties:
        sessions: { type: array, items: { $ref: '#/components/schemas/SessionDTO' } }

    OAuthClient:
      type: object
      properties:
        client_id: { type: string }
        name: { type: string }
        redirect_uris: { type: array, items: { type: string, format: uri } }
        scopes: { type: string }
        status: { type: string }
        notify_url: { type: string, format: uri, description: "Payment-result webhook for service-provider mode (absent when unset). Direct-mode partners keep the webhook on their payment config; deliveries are signed HMAC-SHA256 (X-Chandler-Signature) keyed by sha256_hex(client_secret) in SP mode." }
        miniprogram_appid: { type: string, description: "WeChat mini-program AppID this client is bound to for openid-login (0082); absent when unbound — openid-login is denied until set." }
        mp_appid: { type: string, description: "WeChat official-account (公众号) AppID binding for applyment SALES_SCENES_MP; absent when unbound." }
        payment_config_require_approval: { type: boolean, description: "Whether payment-config edits require the partner-side dual-approval flow before taking effect (0088); false by default." }
        created_at: { type: string, format: date-time }
    CreatedClient:
      allOf:
        - $ref: '#/components/schemas/OAuthClient'
        - type: object
          properties:
            id: { type: string, format: uuid }
            client_secret: { type: string, description: Plaintext secret, returned once. }
    PartnerClientCreated:
      type: object
      properties:
        client_id: { type: string }
        client_secret: { type: string }
        name: { type: string }
        redirect_uris: { type: array, items: { type: string, format: uri } }
        scopes: { type: string }
        status: { type: string }
        notify_url: { type: string, description: "Echo of the registered webhook (empty when unset)." }
        miniprogram_appid: { type: string, description: "Echo of the registered mini-program AppID binding (empty when unset)." }
        mp_appid: { type: string, description: "Echo of the registered 公众号 AppID binding (empty when unset)." }
    TokenResponse:
      type: object
      properties:
        access_token: { type: string }
        refresh_token: { type: string }
        token_type: { type: string, example: Bearer }
        expires_in: { type: integer, format: int64 }
        scope: { type: string }

    Member:
      type: object
      properties:
        user_id: { type: string, format: uuid }
        role: { type: string, enum: [admin, finance, operator, developer, viewer], description: "Permission tier. admin: everything except member management; finance: refund write; operator: re-notify + complaint close; developer: payment-config write; viewer: read-only. The registering owner is implicit and not a member row." }
        created_at: { type: string, format: date-time }
    PartnerClientUser:
      type: object
      description: One row per user grant — public user fields plus the authorization record (scopes + timestamps).
      properties:
        user_id: { type: string, format: uuid }
        email: { type: string, format: email }
        display_name: { type: string }
        status: { type: string }
        scopes: { type: string, description: Space-separated scopes the user granted to this client. }
        granted_at: { type: string, format: date-time }
        updated_at: { type: string, format: date-time }
        attributes: { type: [object, 'null'], description: "Per-application custom user attributes (0066), managed via the attributes endpoints; stored verbatim and never interpreted. Null when never set." }
    PartnerClientUserListEnvelope:
      type: object
      properties:
        items: { type: array, items: { $ref: '#/components/schemas/PartnerClientUser' } }
        meta:
          type: object
          properties:
            total: { type: integer, format: int64 }
            page: { type: integer }
            limit: { type: integer }
    AddMemberReq:
      type: object
      required: [user_id, role]
      properties:
        user_id: { type: string, format: uuid }
        role: { type: string, enum: [admin, finance, operator, developer, viewer], description: "Grantable member role (owner cannot be granted — ownership comes from client registration)." }
    UpdateClientReq:
      type: object
      description: Omitted fields keep their current values.
      properties:
        name: { type: string }
        redirect_uris: { type: array, items: { type: string, format: uri }, description: Non-empty replaces the full list; https only (http for localhost loopback). }
        scopes: { type: string }
        notify_url: { type: [string, 'null'], format: uri, description: "Tri-state payment-result webhook (SP mode): omitted keeps the current value, empty string clears it, otherwise must be an absolute http/https URL. Production additionally requires https, a publicly resolvable host, and a live endpoint (probed at save time)." }
        miniprogram_appid: { type: [string, 'null'], description: "Tri-state mini-program AppID binding (0082): omitted keeps the current value, empty string clears it (openid-login denied), otherwise 1-64 alphanumeric characters." }
        mp_appid: { type: [string, 'null'], description: "Tri-state 公众号 AppID binding (0085): omitted keeps the current value, empty string clears it, otherwise 1-64 alphanumeric characters." }
    SetClientStatusReq:
      type: object
      required: [status]
      properties:
        status: { type: string, enum: [active, disabled] }

    SavePaymentConfigReq:
      type: object
      required: [client_id, channel]
      properties:
        client_id: { type: string }
        channel: { type: string, enum: [wechat, alipay] }
        mch_id: { type: string }
        merchant_serial_no: { type: string }
        app_id: { type: string }
        api_key: { type: string, description: Stored encrypted at rest; never returned. }
        api_cert: { type: string, description: Stored encrypted at rest; never returned. }
        alipay_public_key: { type: string }
        wechat_public_key_serial: { type: string, description: "微信支付公钥 ID (PUB_KEY_ID_...). 公钥模式账户必填；证书模式账户留空。" }
        wechat_public_key_pem: { type: string, description: "微信支付公钥 PEM 内容。公钥非敏感，明文存储；回显时只暴露存在性。" }
        notify_url: { type: string, format: uri, description: "Payment-result webhook. Production requires https, a publicly resolvable host, and a live endpoint (probed at save time)." }
        return_url: { type: string, format: uri }
        mode: { type: string }
    PaymentConfig:
      type: object
      description: Redacted view; secrets are never returned.
      properties:
        application_id: { type: string }
        channel: { type: string }
        mch_id: { type: string }
        merchant_serial_no: { type: string }
        app_id: { type: string }
        has_api_key: { type: boolean }
        has_api_cert: { type: boolean }
        alipay_public_key: { type: string }
        wechat_public_key_serial: { type: string }
        has_wechat_public_key: { type: boolean }
        notify_url: { type: string }
        return_url: { type: string }
        mode: { type: string }
    ConfigVersionDTO:
      type: object
      description: One immutable config version. Secrets appear only as SHA-256 fingerprints; ciphertext is never exposed.
      properties:
        application_id: { type: string }
        channel: { type: string }
        version: { type: integer, format: int32 }
        mch_id: { type: string }
        merchant_serial_no: { type: string }
        app_id: { type: string }
        api_key_fingerprint: { type: string, description: SHA-256 hex of the plaintext API key. }
        has_api_cert: { type: boolean }
        alipay_public_key: { type: string }
        wechat_public_key_serial: { type: string }
        has_wechat_public_key: { type: boolean }
        notify_url: { type: string }
        return_url: { type: string }
        mode: { type: string }
        change_type: { type: string, enum: [create, update, rollback] }
        changed_by: { type: string }
        created_at: { type: string, format: date-time }
    ConfigVersionsResponse:
      type: object
      properties:
        versions: { type: array, items: { $ref: '#/components/schemas/ConfigVersionDTO' } }
    RollbackConfigReq:
      type: object
      required: [version]
      properties:
        version: { type: integer, format: int32, minimum: 1 }

    PartnerOrder:
      type: object
      properties:
        platform_order_no: { type: string }
        merchant_order_no: { type: string }
        channel: { type: string }
        amount: { type: integer, format: int64, description: Smallest currency unit (fen). }
        currency: { type: string }
        subject: { type: string }
        source: { type: string, description: Partner-defined origin tag (4.9); omitted when untagged. }
        status: { type: string }
        paid_at: { type: string, format: date-time }
        created_at: { type: string, format: date-time }
        partner_data: { type: [object, 'null'], description: "Partner-writable business payload (0066), stored verbatim and never interpreted by the platform; null when unset. Writable at order creation (POST /v1/pay/orders) and via PUT /v1/me/orders/{platform_order_no}/partner-data — the only partner-mutable order field." }
    PartnerRefund:
      type: object
      properties:
        refund_no: { type: string }
        amount: { type: integer, format: int64 }
        reason: { type: string }
        status: { type: string }
        refunded_at: { type: string, format: date-time }
        created_at: { type: string, format: date-time }
    OrderListEnvelope:
      type: object
      properties:
        items:
          type: array
          items: { $ref: '#/components/schemas/PartnerOrder' }
        meta:
          type: object
          properties:
            total: { type: integer, format: int64 }
            page: { type: integer }
            limit: { type: integer }
    SecurityLogDTO:
      type: object
      properties:
        id: { type: string, format: uuid }
        user_id: { type: string }
        event: { type: string, example: kms_decrypt }
        ip: { type: string }
        user_agent: { type: string }
        meta: { type: object, description: Redacted event payload; key-access events carry application_id/channel/secret-name only. }
        created_at: { type: string, format: date-time }
    SecurityLogEnvelope:
      type: object
      properties:
        items:
          type: array
          items: { $ref: '#/components/schemas/SecurityLogDTO' }
        meta:
          type: object
          properties:
            total: { type: integer, format: int64 }
            page: { type: integer }
            limit: { type: integer }
    ReconciliationItem:
      type: object
      properties:
        platform_order_no: { type: string }
        discrepancy_type: { type: string, enum: [dropped, amount_mismatch, channel_only, query_failed] }
        platform_amount: { type: integer, format: int64, description: Fen. 0 when the order exists only on the channel side. }
        channel_amount: { type: integer, format: int64, description: Fen. 0 when the order is missing on the channel side. }
    ReconciliationSummary:
      type: object
      properties:
        recon_date: { type: string, format: date }
        channel: { type: string }
        platform_amount: { type: integer, format: int64, description: Fen }
        channel_amount: { type: integer, format: int64, description: Fen }
        diff_amount: { type: integer, format: int64, description: Fen }
        platform_count: { type: integer }
        channel_count: { type: integer }
        diff_count: { type: integer }
        status: { type: string, enum: [balanced, mismatched] }
        items:
          type: array
          description: Discrepancy items — present only in the day view; empty for a balanced day.
          items: { $ref: '#/components/schemas/ReconciliationItem' }
    ReconciliationListEnvelope:
      type: object
      properties:
        reconciliations:
          type: array
          items: { $ref: '#/components/schemas/ReconciliationSummary' }
    AnalyticsBucket:
      type: object
      description: One UTC day of payment activity.
      properties:
        date: { type: string, format: date }
        created_count: { type: integer }
        paid_count: { type: integer, description: Orders currently paid or refunded (gross). }
        paid_amount: { type: integer, format: int64, description: Fen, gross (refunds not netted). }
    PaymentAnalyticsEnvelope:
      type: object
      properties:
        client_id: { type: string }
        from: { type: string, format: date-time }
        to: { type: string, format: date-time }
        summary:
          type: object
          properties:
            created_count: { type: integer }
            paid_count: { type: integer }
            paid_amount: { type: integer, format: int64, description: Fen }
            conversion_rate: { type: number, description: paid_count / created_count over the creation cohort; 0 when no orders. }
        buckets:
          type: array
          items: { $ref: '#/components/schemas/AnalyticsBucket' }
        truncated: { type: boolean, description: true when the 50000-row source cap was hit — narrow the window. }
    UserRetentionEnvelope:
      type: object
      properties:
        client_id: { type: string }
        from: { type: string, format: date-time }
        to: { type: string, format: date-time }
        paying_users: { type: integer, format: int64, description: Distinct non-empty user_ids with a paid/refunded order paid in the window. }
        new_users: { type: integer, format: int64, description: Paying users whose first paid order falls in the window. }
        returning_users: { type: integer, format: int64, description: Paying users who had already paid before the window. }
        repeat_rate: { type: number, description: returning_users / paying_users; 0 when no paying users. }
    SourceStat:
      type: object
      description: One origin-tag row of the source analytics breakdown (4.9 P2).
      properties:
        source: { type: string, description: Partner-defined origin tag; empty string groups untagged orders. }
        created_count: { type: integer }
        paid_count: { type: integer, description: Orders currently paid or refunded (gross; refunds not netted). }
        paid_amount: { type: integer, format: int64, description: Fen }
    SourceAnalyticsEnvelope:
      type: object
      properties:
        client_id: { type: string }
        from: { type: string, format: date-time }
        to: { type: string, format: date-time }
        items:
          type: array
          description: Ordered by paid_amount descending.
          items: { $ref: '#/components/schemas/SourceStat' }
    Purchase:
      type: object
      description: One paid order as seen by the end user (6.3 user-center history).
      properties:
        platform_order_no: { type: string }
        application_id: { type: string }
        app_name: { type: string, description: Application display name (empty if the client record is gone). }
        channel: { type: string, enum: [wechat, alipay] }
        amount: { type: integer, format: int64, description: Fen }
        currency: { type: string }
        subject: { type: string }
        status: { type: string, enum: [paid, refunded] }
        paid_at: { type: string, format: date-time }
        created_at: { type: string, format: date-time }
    PurchaseListEnvelope:
      type: object
      properties:
        items:
          type: array
          items: { $ref: '#/components/schemas/Purchase' }
        meta:
          type: object
          properties:
            total: { type: integer, format: int64 }
            page: { type: integer }
            limit: { type: integer }
    ExportGrant:
      type: object
      properties:
        client_id: { type: string }
        app_name: { type: string }
        scopes: { type: string }
        granted_at: { type: string, format: date-time }
    UserDataExport:
      type: object
      description: Everything the platform holds about the user; credential material (password_hash, totp_secret) is never included.
      properties:
        exported_at: { type: string, format: date-time }
        profile:
          type: object
          properties:
            id: { type: string }
            email: { type: string }
            phone: { type: string }
            display_name: { type: string }
            avatar: { type: string }
            status: { type: string }
            email_verified: { type: boolean }
            mfa_enabled: { type: boolean }
            created_at: { type: string, format: date-time }
            updated_at: { type: string, format: date-time }
        grants:
          type: array
          items: { $ref: '#/components/schemas/ExportGrant' }
        purchases:
          type: array
          items: { $ref: '#/components/schemas/Purchase' }
        truncated: { type: boolean, description: true when the 50000-row purchase cap was hit — earlier history is not in the export. }
    DirectoryApp:
      type: object
      description: Public directory view of an active application (no owner, secret material or redirect URIs).
      properties:
        client_id: { type: string }
        name: { type: string }
        description: { type: string }
        category: { type: string }
        scopes: { type: string }
    DirectoryAppDetail:
      allOf:
        - { $ref: '#/components/schemas/DirectoryApp' }
        - type: object
          properties:
            published_at: { type: string, format: date-time }
    DirectoryListEnvelope:
      type: object
      properties:
        items:
          type: array
          items: { $ref: '#/components/schemas/DirectoryApp' }
        meta:
          type: object
          properties:
            total: { type: integer, format: int64 }
            page: { type: integer }
            limit: { type: integer }
    UpdateDirectoryReq:
      type: object
      description: Public directory profile; full replace — empty string clears the field.
      properties:
        description: { type: string, maxLength: 512 }
        category: { type: string, maxLength: 64 }
    Review:
      type: object
      description: Public view of one app review. Reviewer identity is their display name only ("anonymous" when unset); user_id is never exposed.
      properties:
        user_name: { type: string }
        rating: { type: integer, minimum: 1, maximum: 5 }
        comment: { type: string, maxLength: 1024 }
        created_at: { type: string, format: date-time }
    ReviewListEnvelope:
      type: object
      properties:
        items:
          type: array
          items: { $ref: '#/components/schemas/Review' }
        meta:
          type: object
          properties:
            total: { type: integer, format: int64 }
            page: { type: integer }
            limit: { type: integer }
        summary:
          type: object
          properties:
            avg_rating: { type: number, format: double }
            review_count: { type: integer, format: int64 }
    UpsertReviewReq:
      type: object
      required: [rating]
      properties:
        rating: { type: integer, minimum: 1, maximum: 5 }
        comment: { type: string, maxLength: 1024 }
    CreateComplaintReq:
      type: object
      required: [reason]
      properties:
        order_no: { type: string, maxLength: 64, description: Optional; must be one of the caller's own orders on this app. }
        reason: { type: string, minLength: 1, maxLength: 1024 }
    UpdateComplaintStatusReq:
      type: object
      required: [status]
      properties:
        status: { type: string, enum: [open, closed] }
    MyComplaint:
      type: object
      description: User-side view of one complaint, with the app display name joined in.
      properties:
        id: { type: string }
        client_id: { type: string }
        app_name: { type: string }
        order_no: { type: string }
        reason: { type: string }
        status: { type: string, enum: [open, closed] }
        created_at: { type: string, format: date-time }
    ClientComplaint:
      type: object
      description: Partner-side view of one complaint against their application, including the complainant's user_id.
      properties:
        id: { type: string }
        user_id: { type: string }
        order_no: { type: string }
        reason: { type: string }
        status: { type: string, enum: [open, closed] }
        created_at: { type: string, format: date-time }
    MyComplaintListEnvelope:
      type: object
      properties:
        items:
          type: array
          items: { $ref: '#/components/schemas/MyComplaint' }
        meta:
          type: object
          properties:
            total: { type: integer, format: int64 }
            page: { type: integer }
            limit: { type: integer }
    ClientComplaintListEnvelope:
      type: object
      properties:
        items:
          type: array
          items: { $ref: '#/components/schemas/ClientComplaint' }
        meta:
          type: object
          properties:
            total: { type: integer, format: int64 }
            page: { type: integer }
            limit: { type: integer }
    PartnerProfile:
      type: object
      description: "Partner onboarding profile (one per account). status draft → submitted; under_review/approved/rejected are driven by the platform review flow. Sensitive fields are always masked in responses: legal_rep_id_no keeps the first 3 / last 2 characters, settle_account_no only the last 4."
      properties:
        user_id: { type: string, format: uuid }
        company_name: { type: string, maxLength: 128 }
        credit_code: { type: string, maxLength: 32, description: Unified social credit code. }
        contact_name: { type: string, maxLength: 64 }
        contact_email: { type: string, maxLength: 128 }
        contact_phone: { type: string, maxLength: 32 }
        license_note: { type: string, maxLength: 1024 }
        merchant_short_name: { type: string, maxLength: 64, description: 商户简称（微信进件必填）. }
        service_phone: { type: string, maxLength: 32, description: 客服电话. }
        business_category: { type: string, maxLength: 64, description: 经营类目. }
        legal_rep_name: { type: string, maxLength: 64, description: 法定代表人姓名. }
        legal_rep_id_no: { type: string, maxLength: 32, description: 法人身份证号 — masked (first 3 / last 2) in every response. }
        settle_bank_name: { type: string, maxLength: 64, description: 结算账户开户行. }
        settle_account_no: { type: string, maxLength: 64, description: 结算账号 — masked (last 4 only) in every response. }
        settle_account_type: { type: string, enum: [corporate, personal, ''], description: corporate=对公账户 / personal=法人个人卡. }
        status: { type: string, enum: [draft, submitted, under_review, approved, rejected] }
        review_note: { type: string, maxLength: 512, description: Admin approval/rejection note shown to the partner. }
        updated_at: { type: string, format: date-time }
    PartnerProfileReviewNoteReq:
      type: object
      properties:
        note: { type: string, maxLength: 512 }
    PartnerProfileListEnvelope:
      type: object
      properties:
        items:
          type: array
          items: { $ref: '#/components/schemas/PartnerProfile' }
        meta:
          type: object
          properties:
            total: { type: integer, format: int64 }
            page: { type: integer }
            limit: { type: integer }
    PartnerProfileReq:
      type: object
      required: [company_name]
      properties:
        company_name: { type: string, minLength: 1, maxLength: 128 }
        credit_code: { type: string, maxLength: 32 }
        contact_name: { type: string, maxLength: 64 }
        contact_email: { type: string, maxLength: 128 }
        contact_phone: { type: string, maxLength: 32 }
        license_note: { type: string, maxLength: 1024 }
        merchant_short_name: { type: string, maxLength: 64 }
        service_phone: { type: string, maxLength: 32 }
        business_category: { type: string, maxLength: 64 }
        legal_rep_name: { type: string, maxLength: 64 }
        legal_rep_id_no: { type: string, maxLength: 32, description: Full value on write; a value containing '*' (masked echo) keeps the stored value. }
        settle_bank_name: { type: string, maxLength: 64 }
        settle_account_no: { type: string, maxLength: 64, description: Full value on write; a value containing '*' (masked echo) keeps the stored value. }
        settle_account_type: { type: string, enum: [corporate, personal, ''] }
    PartnerDocument:
      type: object
      description: Partner qualification document metadata (4.1); the payload lives in object storage and is fetched via the download endpoints.
      properties:
        id: { type: string, format: uuid }
        kind: { type: string, enum: [business_license, legal_rep_id_front, legal_rep_id_back, other] }
        filename: { type: string, description: Original base filename (max 256 chars). }
        content_type: { type: string, enum: [application/pdf, image/jpeg, image/png] }
        size_bytes: { type: integer, format: int64 }
        sha256: { type: string, description: Lower-case hex SHA-256 of the payload, computed at upload. }
        created_at: { type: string, format: date-time }
    PartnerDocumentListEnvelope:
      type: object
      properties:
        items:
          type: array
          items: { $ref: '#/components/schemas/PartnerDocument' }
    ServiceFeeMonth:
      type: object
      description: One calendar month of service-fee billing (amounts in fen).
      properties:
        month: { type: string, description: YYYY-MM }
        gmv_amount: { type: integer, format: int64, description: Orders paid in the month (cash basis, gross). }
        refund_amount: { type: integer, format: int64, description: Successful refunds completed in the month. }
        net_amount: { type: integer, format: int64, description: gmv_amount - refund_amount (may be negative). }
        fee_amount: { type: integer, format: int64, description: max(net,0) × fee_bps / 10000, floored. }
    ServiceFeeEnvelope:
      type: object
      properties:
        client_id: { type: string }
        fee_bps: { type: integer, description: Platform service fee in basis points (SERVICE_FEE_BPS, default 0). }
        months:
          type: array
          items: { $ref: '#/components/schemas/ServiceFeeMonth' }
        totals:
          type: object
          properties:
            gmv_amount: { type: integer, format: int64 }
            refund_amount: { type: integer, format: int64 }
            net_amount: { type: integer, format: int64 }
            fee_amount: { type: integer, format: int64 }
    RequestInvoiceReq:
      type: object
      required: [client_id, period, type, title, email]
      properties:
        client_id: { type: string, description: Application the invoice is billed to; caller needs owner/admin/finance. }
        period: { type: string, description: YYYY-MM; must not be later than the current UTC month. }
        type: { type: string, enum: [company, personal] }
        title: { type: string, minLength: 1, maxLength: 256, description: Invoice title (抬头). }
        tax_no: { type: string, maxLength: 64, description: Taxpayer number; required when type is company. }
        email: { type: string, format: email, description: Delivery address for the issued invoice. }
    InvoiceRequest:
      type: object
      description: One invoice request. amount_fen is the monthly service fee recomputed server-side from the order/refund ledger (never client-supplied).
      properties:
        id: { type: string, format: uuid }
        user_id: { type: string, format: uuid, description: Applicant. }
        client_id: { type: string }
        period: { type: string, description: YYYY-MM }
        amount_fen: { type: integer, format: int64 }
        type: { type: string, enum: [company, personal] }
        title: { type: string }
        tax_no: { type: string }
        email: { type: string, format: email }
        status: { type: string, enum: [pending, issued, rejected] }
        invoice_no: { type: string, description: Present once issued. }
        note: { type: string, description: Review note (rejection reason when rejected). }
        reviewed_by: { type: string, format: uuid, description: Admin who decided; present once reviewed. }
        created_at: { type: string, format: date-time }
        reviewed_at: { type: string, format: date-time, description: Present once reviewed. }
    InvoiceRequestListEnvelope:
      type: object
      properties:
        items:
          type: array
          items: { $ref: '#/components/schemas/InvoiceRequest' }
        meta:
          type: object
          properties:
            limit: { type: integer }
            next_cursor: { type: string, description: Pass as `cursor` for the next page; empty when no more pages }
    AdminInvoiceRequest:
      type: object
      description: InvoiceRequest plus the applicant's account email and the application display name.
      properties:
        id: { type: string, format: uuid }
        user_id: { type: string, format: uuid }
        client_id: { type: string }
        period: { type: string, description: YYYY-MM }
        amount_fen: { type: integer, format: int64 }
        type: { type: string, enum: [company, personal] }
        title: { type: string }
        tax_no: { type: string }
        email: { type: string, format: email }
        status: { type: string, enum: [pending, issued, rejected] }
        invoice_no: { type: string }
        note: { type: string }
        reviewed_by: { type: string, format: uuid }
        created_at: { type: string, format: date-time }
        reviewed_at: { type: string, format: date-time }
        applicant_email: { type: string, format: email }
        app_name: { type: string }
    AdminInvoiceRequestListEnvelope:
      type: object
      properties:
        items:
          type: array
          items: { $ref: '#/components/schemas/AdminInvoiceRequest' }
        meta:
          type: object
          properties:
            limit: { type: integer }
            next_cursor: { type: string, description: Pass as `cursor` for the next page; empty when no more pages }
    IssueInvoiceReq:
      type: object
      required: [invoice_no]
      properties:
        invoice_no: { type: string, minLength: 1, maxLength: 64 }
        note: { type: string, maxLength: 1024 }
    RejectInvoiceReq:
      type: object
      required: [note]
      properties:
        note: { type: string, minLength: 1, maxLength: 1024, description: Rejection reason shown to the partner. }
    PaymentRecord:
      type: object
      description: One per-order service-fee ledger row — an order that reached a paid state (refunded orders included); fee_fen uses the same integer-floor feeBps formula as the monthly bill.
      properties:
        platform_order_no: { type: string }
        subject: { type: string }
        channel: { type: string }
        status: { type: string }
        amount_fen: { type: integer, format: int64 }
        fee_fen: { type: integer, format: int64 }
        paid_at: { type: string, format: date-time }
    PaymentRecordListEnvelope:
      type: object
      properties:
        items:
          type: array
          items: { $ref: '#/components/schemas/PaymentRecord' }
        meta:
          type: object
          properties:
            limit: { type: integer }
            next_cursor: { type: string, description: Pass as `cursor` for the next page; empty when no more pages }
    OrderDetail:
      type: object
      properties:
        order: { $ref: '#/components/schemas/PartnerOrder' }
        refunds:
          type: array
          items: { $ref: '#/components/schemas/PartnerRefund' }
    RefundListEnvelope:
      type: object
      properties:
        items:
          type: array
          items: { $ref: '#/components/schemas/PartnerRefund' }
        meta:
          type: object
          properties:
            total: { type: integer, format: int64 }
            page: { type: integer }
            limit: { type: integer }

    OrderRaw:
      type: object
      description: Raw order record (sqlc). Null* fields marshal as objects.
      properties:
        id: { type: string, format: uuid }
        platform_order_no: { type: string }
        merchant_order_no: { type: string }
        channel_order_no: { $ref: '#/components/schemas/NullString' }
        application_id: { type: string }
        user_id: { $ref: '#/components/schemas/NullString' }
        channel: { type: string }
        amount: { type: integer, format: int64 }
        currency: { type: string }
        subject: { type: string }
        status: { type: string }
        paid_at: { $ref: '#/components/schemas/NullTime' }
        notified_at: { $ref: '#/components/schemas/NullTime' }
        created_at: { type: string, format: date-time }
        updated_at: { type: string, format: date-time }
        source: { type: string, description: Partner-defined origin tag (4.9); empty = untagged. }
        payee_name: { type: string, description: "Cashier-facing 收款方 display name: approved partner profile company name, else the OAuth client name. Present only on order-detail reads; omitted when no platform-side name exists." }
        partner_data: { type: object, description: "Partner's business payload (0066), stored verbatim; omitted when unset." }
    RefundRaw:
      type: object
      properties:
        id: { type: string, format: uuid }
        order_id: { type: string, format: uuid }
        refund_no: { type: string }
        channel_refund_no: { $ref: '#/components/schemas/NullString' }
        amount: { type: integer, format: int64 }
        reason: { $ref: '#/components/schemas/NullString' }
        status: { type: string }
        created_at: { type: string, format: date-time }
        refunded_at: { $ref: '#/components/schemas/NullTime' }

    CreateOrderReq:
      type: object
      required: [application_id, merchant_order_no]
      properties:
        application_id: { type: string }
        merchant_order_no: { type: string }
        channel: { type: string, enum: [wechat, alipay] }
        amount: { type: integer, format: int64 }
        currency: { type: string }
        subject: { type: string }
        source: { type: string, maxLength: 64, description: "Optional partner-defined origin tag (campaign, channel page, affiliate) for source analytics; empty = untagged." }
        sku_id: { type: string, format: uuid, description: "可选：伙伴 SKU（/v1/me/oauth/clients/{client_id}/skus）ID。传了则由服务端按该 SKU 当前有效价格权威定价，客户端提交的 amount/currency 被忽略；不传保持客户端传价。" }
        partner_data: { type: object, description: "Optional partner business payload (0066): must be a JSON object (arrays/scalars rejected) of at most 16KB serialized, else 400 partner_data.invalid. Stored verbatim on the order and never interpreted; returned by order list/detail/export and the payment-result webhook." }
    PrepayResult:
      type: object
      description: Channel-specific payment parameters for the cashier page.
      properties:
        channel: { type: string, enum: [wechat, alipay] }
        platform_order_no: { type: string }
        amount: { type: integer, format: int64, description: Amount in fen (smallest currency unit). }
        currency: { type: string }
        subject: { type: string }
        return_url: { type: string, description: "Partner-configured return URL for post-payment redirect." }
        code_url: { type: string, description: "WeChat Native QR code URL (wechat only)." }
        h5_url: { type: string, description: "WeChat H5 redirect URL (wechat only)." }
        pay_url: { type: string, description: "Alipay page/wap redirect URL (alipay only)." }
    CodeURLResp:
      type: object
      description: Fresh WeChat Native code_url for cashier QR rotation.
      properties:
        code_url: { type: string, description: "WeChat Native QR code URL (weixin://...)." }
        expires_at: { type: string, format: date-time, description: "RFC3339; now + 2h." }
    PaymentConfigStatusResp:
      type: object
      description: Result of a payment-config kill-switch transition. previous_status == status means the transition was an idempotent no-op.
      properties:
        application_id: { type: string }
        channel: { type: string, enum: [wechat, alipay] }
        status: { type: string, enum: [active, disabled] }
        previous_status: { type: string, enum: [active, disabled] }
    SubMerchant:
      type: object
      description: WeChat service-provider 特约商户 registration (10.1). previous_status appears only on activate/disable responses.
      properties:
        id: { type: string }
        application_id: { type: string }
        sub_mchid: { type: string, description: "WeChat 特约商户号; masked on partner-facing reads." }
        company_name: { type: string }
        status: { type: string, enum: [pending, applying, active, disabled] }
        remark: { type: string }
        created_at: { type: string, format: date-time }
        previous_status: { type: string, enum: [pending, active, disabled] }
        papay_plan_id: { type: string, description: "委托代扣(自动续费)模板 ID registered for this 特约商户 (0081); empty = partner auto-renew signings fail plan_not_configured." }
    PartnerSubMerchantOp:
      type: object
      description: Partner-facing result of an operation on the caller's own 特约商户; sub_mchid is masked like every partner read (omitted while 进件未完成, instead of degrading to a short-value mask). previous_status carries the status before the operation and appears only when the call produced a real transition.
      properties:
        id: { type: string }
        application_id: { type: string }
        sub_mchid: { type: string, description: "WeChat 特约商户号, masked; omitted when no number is recorded yet." }
        company_name: { type: string }
        status: { type: string, enum: [pending, applying, active, disabled] }
        business_code: { type: string }
        applyment_state: { type: string }
        sign_url: { type: string }
        rejected_reason: { type: string }
        previous_status: { type: string, enum: [pending, applying, active, disabled] }
        profit_sharing_receiver_state: { type: string }
        profit_sharing_receiver_error: { type: string }
        created_at: { type: string, format: date-time }
    PartnerSubMerchantProgress:
      type: object
      description: Partner-facing 进件进度 of one of the caller's 特约商户 registrations (GET /v1/me/sub-merchants). id addresses the partner-side action endpoints (withdraw/activate/disable). sub_mchid is masked and omitted until 进件回填; sign_url appears only while the applyment is in flight (status applying); applyment_state/rejected_reason appear only when set; created_at is formatted YYYY-MM-DD HH:mm:ss (no zone).
      properties:
        id: { type: string }
        application_id: { type: string }
        sub_mchid: { type: string, description: "WeChat 特约商户号, masked; omitted when no number is recorded yet." }
        company_name: { type: string }
        status: { type: string, enum: [pending, applying, active, disabled] }
        applyment_state: { type: string }
        sign_url: { type: string }
        rejected_reason: { type: string }
        created_at: { type: string, description: "YYYY-MM-DD HH:mm:ss, server-local time" }
    SetSubMerchantPapayPlanReq:
      type: object
      properties:
        plan_id: { type: string, pattern: '^(\d{1,32})?$', description: 'Numeric 委托代扣(自动续费)模板 ID issued by WeChat for this 特约商户 (templates are approved per merchant number). Empty string clears the registration.' }
    CreateSubMerchantReq:
      type: object
      required: [application_id]
      properties:
        application_id: { type: string, description: OAuth client_id of the partner application. Must reference an existing application (400 otherwise). }
        sub_mchid: { type: string, pattern: '^\d{8,32}$', description: 'Optional. Set to reuse a 特约商户号 already issued to another application of the same partner (see GET /v1/admin/sub-merchants/reusable); empty keeps the API 进件 path where WeChat issues the number after approval. Sharing across different partners is rejected (409 sub_merchant.owner_mismatch).' }
        remark: { type: string }
    UpdateSubMerchantReq:
      type: object
      required: [sub_mchid, company_name]
      properties:
        sub_mchid: { type: string, pattern: '^\d{8,32}$', description: WeChat 特约商户号, digits only, 8-32 chars. }
        company_name: { type: string }
    ApplySubMerchantReq:
      type: object
      description: Optional overrides for the applyment fields the partner profile does not collect (10.3). card_period_begin is effectively required (身份证有效期开始); web_domain is required for the default SALES_SCENES_WEB scene.
      properties:
        subject_type: { type: string, description: "Default SUBJECT_TYPE_ENTERPRISE" }
        contact_type: { type: string, enum: [LEGAL, SUPER], description: "Default LEGAL (经营者/法定代表人)" }
        settlement_id: { type: string, description: "结算规则 ID; default 716" }
        qualification_type: { type: string, description: "所属行业（《费率结算规则对照表》行业名称枚举，如 电商平台/餐饮/零售）; default 电商平台" }
        card_period_begin: { type: string, description: 身份证有效期开始, format: date }
        card_period_end: { type: string, description: "身份证有效期结束 YYYY-MM-DD 或 长期; default 长期" }
        bank_address_code: { type: string, description: 开户行省市编码 (optional) }
        sales_scenes_type: { type: array, items: { type: string }, description: "经营场景; default [SALES_SCENES_WEB]" }
        web_domain: { type: string, description: "互联网网站域名，须以 http:// 或 https:// 开头（SALES_SCENES_WEB 时必填；裸域名会自动补 https://）" }
    SubMerchantApplyment:
      type: object
      description: Result of POST /v1/admin/sub-merchants/{id}/apply. already_applying=true means the call was an idempotent repeat — the in-flight applyment is returned unchanged.
      properties:
        id: { type: string }
        application_id: { type: string }
        status: { type: string, enum: [pending, applying, active, disabled] }
        business_code: { type: string, description: "进件幂等键 ({sp_mchid}_{sub_merchant_id})" }
        applyment_id: { type: integer, format: int64, description: 微信支付申请单号 }
        applyment_state: { type: string, description: WeChat applyment_state snapshot }
        sign_url: { type: string, description: 超管签约链接 }
        rejected_reason: { type: string, description: 驳回原因摘要 (back to pending 后保留) }
        already_applying: { type: boolean }
    PaginationMeta:
      type: object
      properties:
        total: { type: integer, format: int64 }
        page: { type: integer }
        limit: { type: integer }
    AdminSubMerchantItem:
      type: object
      properties:
        id: { type: string }
        application_id: { type: string }
        application_name: { type: string }
        sub_mchid: { type: string }
        company_name: { type: string }
        status: { type: string, enum: [pending, active, disabled] }
        profit_sharing_bps: { type: integer, description: 分账比例(万分比), 0 = 不分账 }
        profit_sharing_receiver_state: { type: string, enum: ['', registered, failed], description: 默认接收方(灏曦)登记状态 }
        extra_receivers:
          type: array
          description: 额外对公接收方(多方分账 0077)的按子商户登记状态
          items: { $ref: '#/components/schemas/SubMerchantReceiverState' }
        created_at: { type: string, format: date-time }
    PageWatch:
      type: object
      description: Embedded-page patrol / content-scan target (9.2/9.3). last_result is empty until the first patrol run.
      properties:
        id: { type: string }
        application_id: { type: string }
        application_name: { type: string }
        url: { type: string }
        check_type: { type: string, enum: [embed, content] }
        status: { type: string, enum: [active, paused] }
        last_result: { type: string, enum: ['', ok, hit, error] }
        last_detail: { type: string }
        last_checked_at: { type: string, format: date-time }
        remark: { type: string }
        created_at: { type: string, format: date-time }
    CreatePageWatchReq:
      type: object
      required: [application_id, url, check_type]
      properties:
        application_id: { type: string, description: OAuth client_id of the partner application. Must reference an existing application (400 otherwise). }
        url: { type: string, description: Absolute http(s) URL of the page to patrol. }
        check_type: { type: string, enum: [embed, content], description: "embed = cashier iframe 防调包巡检; content = 敏感关键词内容扫描" }
        remark: { type: string }
    PageWatchFinding:
      type: object
      description: One patrol hit (9.2/9.3) pending manual review (复核).
      properties:
        id: { type: string }
        watch_id: { type: string }
        watch_url: { type: string }
        application_id: { type: string }
        application_name: { type: string }
        check_type: { type: string, enum: [embed, content] }
        kind: { type: string, enum: [iframe_host_mismatch, partner_mismatch, suspicious_script, sensitive_keyword, fetch_error] }
        detail: { type: string }
        status: { type: string, enum: [open, resolved] }
        resolved_by: { type: string }
        resolved_at: { type: string, format: date-time }
        resolve_note: { type: string }
        created_at: { type: string, format: date-time }
    ResolvePageWatchFindingReq:
      type: object
      properties:
        note: { type: string, description: 复核备注（可选） }
    AdminApplicationItem:
      type: object
      description: Application row of the M10-b 客户管理面. service_fee_bps is null when the application falls back to the global default (effective_fee_bps then equals it).
      properties:
        client_id: { type: string }
        name: { type: string }
        owner_user_id: { type: string }
        status: { type: string }
        sub_merchant_status: { type: string, enum: [pending, active, disabled] }
        service_fee_bps: { type: [integer, 'null'] }
        effective_fee_bps: { type: integer }
        created_at: { type: string, format: date-time }
    SetServiceFeeReq:
      type: object
      properties:
        service_fee_bps: { type: [integer, 'null'], description: "Custom fee in basis points (0–cap); null restores the global default." }
    AdminPaymentConfigItem:
      type: object
      description: Payment-config status view for the admin console. Never carries secret material.
      properties:
        application_id: { type: string }
        channel: { type: string, enum: [wechat, alipay] }
        mch_id: { type: string }
        merchant_serial_no: { type: string }
        app_id: { type: string }
        mode: { type: string, enum: [test, live] }
        status: { type: string, enum: [pending, active, disabled] }
        consecutive_failures: { type: integer, description: Current credwatch probe-failure streak (0 when the watchdog is not running). }
        updated_at: { type: string, format: date-time }
    AdminWechatSPStatus:
      type: object
      description: Platform WeChat service-provider (服务商) merged configuration view for the admin console — env baseline overlaid per-field by the platform_wechat_sp row (a non-empty DB field wins). Never carries secret material — the private key is existence-checked only and the APIv3 key is omitted. When configured=false every other field is omitted.
      required: [configured]
      properties:
        configured: { type: boolean, description: Whether WECHAT_SP_* service-provider mode is configured at all. }
        sp_mchid: { type: string, description: Service-provider merchant number (WECHAT_SP_MCHID). }
        sp_appid: { type: string, description: Service-provider APPID bound to sp_mchid (WECHAT_SP_APPID). }
        serial_no: { type: string, description: Service-provider API certificate serial number (non-sensitive). }
        private_key_path: { type: string, description: Mounted PEM path (WECHAT_SP_PRIVATE_KEY_PATH); content is never read or returned. }
        private_key_present: { type: boolean, description: Whether the private key file exists at private_key_path. }
        profit_sharing: { type: string, enum: [auto, 'true', 'false'], description: Tri-state WECHAT_SP_PROFIT_SHARING switch; the env-empty form is surfaced as auto. }
        profit_sharing_effective: { type: boolean, description: Resolved profit-sharing state (WechatSPProfitSharingEnabled semantics). }
        fee_receiver_mchid: { type: string, description: Platform merchant number receiving the service-fee share. }
        fee_receiver_name: { type: string, description: Fee receiver company full name (商户全称). }
        max_sharing_bps: { type: integer, description: Hard cap of a single order's share ratio in basis points. }
        sources:
          type: object
          description: Origin of each effective field — db (platform_wechat_sp override), env (WECHAT_SP_*), or default (built-in fallback).
          properties:
            sp_mchid: { type: string, enum: [db, env, default] }
            sp_appid: { type: string, enum: [db, env, default] }
            serial_no: { type: string, enum: [db, env, default] }
            fee_receiver_mchid: { type: string, enum: [db, env, default] }
            fee_receiver_name: { type: string, enum: [db, env, default] }
            profit_sharing: { type: string, enum: [db, env, default] }
            max_sharing_bps: { type: string, enum: [db, env, default] }
        credwatch:
          type: object
          description: Service-provider credential probe health; omitted when the watchdog is not running.
          properties:
            consecutive_failures: { type: integer }
            last_error: { type: string, enum: [credentials_rejected, network], description: Failure category of the most recent failed probe; empty after a success. }
            last_check: { type: string, format: date-time, description: When the probe last ran; omitted before the first run. }
    AdminWechatSPPutReq:
      type: object
      description: Full replacement of the platform_wechat_sp singleton. Empty strings clear the override (the env value takes over); max_sharing_bps null does the same. Merchant numbers must be 8-32 digits when set.
      properties:
        sp_mchid: { type: string, description: "8-32 digits or empty (env fallback)." }
        sp_appid: { type: string }
        serial_no: { type: string }
        fee_receiver_mchid: { type: string, description: "8-32 digits or empty (env fallback)." }
        fee_receiver_name: { type: string }
        profit_sharing: { type: string, enum: [auto, 'true', 'false'], description: "Empty clears the override." }
        max_sharing_bps: { type: [integer, 'null'], minimum: 0, maximum: 10000, description: "Null falls back to WECHAT_SP_PROFIT_SHARING_MAX_BPS." }
    CreateRefundReq:
      type: object
      required: [platform_order_no]
      properties:
        platform_order_no: { type: string }
        amount: { type: integer, format: int64 }
        reason: { type: string }
    CreatePartnerRefundReq:
      type: object
      required: [client_id, platform_order_no, amount]
      properties:
        client_id: { type: string }
        platform_order_no: { type: string }
        amount: { type: integer, format: int64, description: Smallest currency unit (fen). Must be ≤ refundable balance. }
        reason: { type: string }
    TriggerBillReq:
      type: object
      required: [application_id, channel, date]
      properties:
        application_id: { type: string }
        channel: { type: string, enum: [wechat, alipay] }
        date: { type: string, format: date }

    RegisterReq:
      allOf:
        - $ref: '#/components/schemas/DeviceInfoFields'
        - type: object
          required: [email, password]
          properties:
            email: { type: string, format: email }
            password: { type: string, format: password }
            display_name: { type: string }
            invite_code:
              type: string
              description: Optional inviter's stable code; bound inside the registration transaction. Invalid code fails the whole registration (400 invite.invalid_code).
            channel:
              type: string
              description: Optional acquisition channel recorded on the invite binding.
            agree_policies:
              type: boolean
              description: When true, records versioned consent rows for the current privacy policy and user agreement (17).
            client_version:
              type: string
              description: Optional client version snapshot stored on consent records.
            username:
              type: string
              description: Optional login username (3-32 chars [a-z0-9_.-], unique, immutable once set; reserved words rejected).
    LoginReq:
      allOf:
        - $ref: '#/components/schemas/DeviceInfoFields'
        - type: object
          required: [email, password]
          properties:
            email:
              type: string
              description: Email address OR login username (7.3.1).
            password: { type: string, format: password }
    RefreshReq:
      type: object
      required: [refresh_token]
      properties:
        refresh_token: { type: string }
    ForgotReq:
      type: object
      required: [email]
      properties:
        email: { type: string, format: email }
    ResetReq:
      type: object
      required: [token, new_password]
      properties:
        token: { type: string }
        new_password: { type: string, format: password }
    PhoneForgotReq:
      type: object
      required: [phone]
      properties:
        phone: { type: string, description: E.164 phone number, e.g. +8613800138000 }
    PhoneResetReq:
      type: object
      required: [phone, code, new_password]
      properties:
        phone: { type: string, description: E.164 phone number, e.g. +8613800138000 }
        code: { type: string, description: SMS code from /v1/auth/phone/forgot-password }
        new_password: { type: string, format: password }
    ChangeReq:
      type: object
      required: [old_password, new_password]
      properties:
        old_password: { type: string, format: password }
        new_password: { type: string, format: password }
    MFAVerifyReq:
      allOf:
        - $ref: '#/components/schemas/DeviceInfoFields'
        - type: object
          required: [mfa_token, code]
          properties:
            mfa_token: { type: string }
            code: { type: string }
    MFAVerifySetupReq:
      type: object
      required: [code]
      properties:
        code: { type: string }
    MFADisableReq:
      type: object
      required: [password, code]
      properties:
        password: { type: string, format: password }
        code: { type: string }
    PhoneSendOTPReq:
      type: object
      required: [phone, purpose]
      properties:
        phone: { type: string, description: 'E.164 phone number' }
        purpose: { type: string, enum: [register, login] }
    PhoneRegisterReq:
      allOf:
        - $ref: '#/components/schemas/DeviceInfoFields'
        - type: object
          required: [phone, code]
          properties:
            phone: { type: string, description: 'E.164 phone number' }
            code: { type: string, description: '6-digit OTP code' }
            display_name: { type: string }
    PhoneLoginReq:
      allOf:
        - $ref: '#/components/schemas/DeviceInfoFields'
        - type: object
          required: [phone, code]
          properties:
            phone: { type: string, description: 'E.164 phone number' }
            code: { type: string, description: '6-digit OTP code' }
    OTPSendReq:
      type: object
      required: [target, target_type]
      properties:
        target: { type: string, description: 'Email address or E.164 phone number' }
        target_type: { type: string, enum: [email, phone] }
    OTPLoginReq:
      allOf:
        - $ref: '#/components/schemas/DeviceInfoFields'
        - type: object
          required: [target, target_type, code]
          properties:
            target: { type: string, description: 'Email address or E.164 phone number' }
            target_type: { type: string, enum: [email, phone] }
            code: { type: string, description: '6-digit OTP code' }
    IdentityDTO:
      type: object
      properties:
        id: { type: string, format: uuid }
        provider: { type: string, enum: [email, phone, wechat] }
        value: { type: string, description: 'Masked email, E.164 phone, or WeChat unionid' }
        verified: { type: boolean }
        is_primary: { type: boolean }
        created_at: { type: string, format: date-time }
    BindIdentityReq:
      type: object
      required: [provider, value]
      properties:
        provider: { type: string, enum: [email, phone] }
        value: { type: string }
        current_password: { type: string, description: 'Current account password; required when the account has a password set' }
        reauth_code: { type: string, description: 'OTP sent to the existing verified identity; required for password-less accounts' }
    VerifyIdentityReq:
      type: object
      required: [code]
      properties:
        code: { type: string, description: '6-digit OTP code' }
    IdentitiesResponse:
      type: object
      properties:
        identities: { type: array, items: { $ref: '#/components/schemas/IdentityDTO' } }
    SendReauthResp:
      type: object
      properties:
        status: { type: string, example: sent }
        provider: { type: string, enum: [email, phone] }
        value: { type: string, description: 'Masked destination address' }

    RegisterClientReq:
      type: object
      required: [name]
      properties:
        name: { type: string }
        redirect_uris: { type: array, items: { type: string, format: uri } }
        scopes: { type: string }
    PartnerRegisterReq:
      type: object
      required: [name, redirect_uris]
      properties:
        name: { type: string }
        redirect_uris: { type: array, items: { type: string, format: uri } }
        scopes: { type: string }
        notify_url: { type: string, format: uri, description: "Optional payment-result webhook (SP mode); absolute http/https URL." }
        miniprogram_appid: { type: string, description: "Optional wechat mini-program AppID binding for openid-login (0082); 1-64 alphanumeric characters." }
    AuthorizeReq:
      type: object
      required: [client_id, redirect_uri]
      properties:
        response_type: { type: string, enum: [code] }
        client_id: { type: string }
        redirect_uri: { type: string, format: uri }
        scope: { type: string }
        state: { type: string }
        code_challenge: { type: string }
        code_challenge_method: { type: string, enum: [S256, plain] }
    TokenReq:
      type: object
      required: [grant_type]
      properties:
        grant_type: { type: string, enum: [authorization_code, refresh_token] }
        code: { type: string, description: 'authorization_code grant only' }
        redirect_uri: { type: string, format: uri, description: 'authorization_code grant only' }
        client_id: { type: string, description: 'required for authorization_code; optional for refresh_token (verified when presented)' }
        client_secret: { type: string, description: 'required for authorization_code; optional for refresh_token' }
        code_verifier: { type: string, description: 'authorization_code grant only, required when a PKCE challenge was issued' }
        refresh_token: { type: string, description: 'refresh_token grant only' }

    SendVerificationEmailReq:
      type: object
      properties:
        email:
          type: string
          format: email
          description: Ignored; email is taken from the authenticated user. Kept for forward-compatible clients.
    VerifyEmailReq:
      type: object
      required: [token]
      properties:
        token: { type: string }

    APIKey:
      type: object
      properties:
        id: { type: string, format: uuid }
        name: { type: string }
        last_used_at: { type: string, format: date-time }
        created_at: { type: string, format: date-time }
    APIKeyListEnvelope:
      type: object
      properties:
        data:
          type: array
          items: { $ref: '#/components/schemas/APIKey' }
    CreateAPIKeyReq:
      type: object
      required: [name]
      properties:
        name: { type: string }
    CreatedAPIKey:
      type: object
      properties:
        id: { type: string, format: uuid }
        name: { type: string }
        key:
          type: string
          description: Raw API key, returned only once at creation time.
        created_at: { type: string, format: date-time }

    InviteInfo:
      type: object
      properties:
        invite_code: { type: string, description: Stable per-user invite code (10-char Crockford base32) }
        links:
          type: array
          items: { $ref: '#/components/schemas/InviteLink' }
        invited_count: { type: integer, format: int64 }
    InviteLink:
      type: object
      properties:
        id: { type: string, format: uuid }
        token: { type: string, description: Public link token; older links stay valid after rotation }
        created_at: { type: string, format: date-time }
    InviteLinkResolve:
      type: object
      properties:
        valid: { type: boolean }
        invite_code: { type: string }
        inviter_display_name: { type: string }
    InviteBinding:
      type: object
      properties:
        id: { type: string, format: uuid }
        invitee_email: { type: string, description: Masked email (e.g. a***n@example.com) }
        invitee_display_name: { type: string }
        channel: { type: string, nullable: true }
        reward_status: { type: string, enum: [none, pending, granted, clawed_back] }
        bound_at: { type: string, format: date-time }
    InviteBindingList:
      type: object
      properties:
        items:
          type: array
          items: { $ref: '#/components/schemas/InviteBinding' }
        meta:
          type: object
          properties:
            total: { type: integer, format: int64 }
            page: { type: integer }
            limit: { type: integer }
    WalletHold:
      type: object
      properties:
        id: { type: string, format: uuid }
        currency: { type: string }
        amount: { type: integer, format: int64 }
        status: { type: string, enum: [held, captured, released, expired] }
        description: { type: string }
        expires_at: { type: string, format: date-time }
        captured_at: { type: string, format: date-time, nullable: true }
        released_at: { type: string, format: date-time, nullable: true }
        created_at: { type: string, format: date-time }
    OfflineCredential:
      type: object
      properties:
        credential: { type: string, description: RS256 JWT; verify offline against /.well-known/jwks.json }
        expires_at: { type: string, format: date-time }
    OfflineCredentialRedeemReq:
      type: object
      required: [credential]
      properties:
        credential: { type: string }
        install_id: { type: string, description: Required when the credential carries a device binding }
    OfflineCredentialRedeemResult:
      type: object
      properties:
        user_id: { type: string, format: uuid }
        plan: { type: string }
        entitlements:
          type: array
          items:
            type: object
            properties:
              key: { type: string }
              qty: { type: integer, format: int64 }
              vu: { type: integer, format: int64, description: unix seconds; 0 = no expiry }
        expires_at: { type: string, format: date-time }
    ConsentStatus:
      type: object
      properties:
        doc_type: { type: string, enum: [privacy_policy, user_agreement] }
        current_version: { type: string }
        version: { type: string, nullable: true, description: Latest consented version; null when never consented }
        consented_at: { type: string, format: date-time, nullable: true }
        needs_reconsent: { type: boolean }
    ConsentStatusList:
      type: object
      properties:
        items:
          type: array
          items: { $ref: '#/components/schemas/ConsentStatus' }
    RecordConsentReq:
      type: object
      required: [doc_type]
      properties:
        doc_type: { type: string, enum: [privacy_policy, user_agreement] }
        client_version: { type: string }
    DeletionRequest:
      type: object
      properties:
        id: { type: string, format: uuid }
        status: { type: string, enum: [pending, canceled, completed] }
        reason: { type: string, nullable: true }
        requested_at: { type: string, format: date-time }
        scheduled_at: { type: string, format: date-time }
        canceled_at: { type: string, format: date-time, nullable: true }
        completed_at: { type: string, format: date-time, nullable: true }
    DeletionBlockers:
      type: object
      properties:
        pending_orders: { type: integer, format: int64 }
        pending_refunds: { type: integer, format: int64 }
        active_subscriptions: { type: integer, format: int64 }
        negative_wallets: { type: integer, format: int64 }
    SendInviteReq:
      type: object
      required: [email, role]
      properties:
        email: { type: string, format: email }
        role:
          type: string
          enum: [admin, finance, operator, developer, viewer]
          description: "Permission tier. admin: everything except member management; finance: refund write; operator: re-notify + complaint close; developer: payment-config write; viewer: read-only."
    Invite:
      type: object
      properties:
        invite_id: { type: string, format: uuid }
        client_id: { type: string }
        email: { type: string, format: email }
        role: { type: string }
        status: { type: string, example: pending }
        expires_at: { type: string, format: date-time }
        created_at: { type: string, format: date-time }
        _dev_invite_token:
          type: string
          description: Raw invite token returned only in non-production environments.
    InviteListEnvelope:
      type: object
      properties:
        items:
          type: array
          items: { $ref: '#/components/schemas/Invite' }
    AcceptInviteReq:
      type: object
      required: [token]
      properties:
        token: { type: string }
    InviteAccepted:
      type: object
      properties:
        client_id: { type: string }
        user_id: { type: string, format: uuid }
        role: { type: string }

    WeChatQRSession:
      type: object
      properties:
        session_id: { type: string }
        status: { type: string, enum: [pending, confirmed, needs_bind] }
        qr_url: { type: string, format: uri }
        union_id: { type: string, description: 'Masked unionid; present for needs_bind' }
        expires_at: { type: string, format: date-time }
    WeChatExchangeReq:
      allOf:
        - $ref: '#/components/schemas/DeviceInfoFields'
        - type: object
          required: [session_id, exchange_code]
          properties:
            session_id: { type: string }
            exchange_code: { type: string }
    WeChatRegisterReq:
      allOf:
        - $ref: '#/components/schemas/DeviceInfoFields'
        - type: object
          required: [session_id, exchange_code]
          properties:
            session_id: { type: string }
            exchange_code: { type: string }
            display_name: { type: string }
    WeChatBindReq:
      type: object
      required: [session_id, exchange_code]
      properties:
        session_id: { type: string }
        exchange_code: { type: string }
        current_password: { type: string }
        reauth_code: { type: string }

    Product:
      type: object
      properties:
        id: { type: string, format: uuid }
        code: { type: string }
        name: { type: string }
        description: { type: string }
        product_type: { type: string, enum: [subscription, topup, one_time] }
        status: { type: string, enum: [draft, active, archived, superseded] }
        is_default: { type: boolean }
        created_at: { type: string, format: date-time }
        updated_at: { type: string, format: date-time }
    SKU:
      type: object
      properties:
        id: { type: string, format: uuid }
        product_id: { type: string, format: uuid }
        code: { type: string }
        name: { type: string }
        sku_type: { type: string, enum: [monthly, yearly, topup, one_time] }
        status: { type: string, enum: [draft, active, archived, superseded] }
        device_limit: { type: integer, format: int32, nullable: true }
        trial_days: { type: integer, format: int32 }
        credit_type: { type: string }
        credit_amount: { type: integer, format: int64 }
        created_at: { type: string, format: date-time }
        updated_at: { type: string, format: date-time }
    Price:
      type: object
      properties:
        id: { type: string, format: uuid }
        sku_id: { type: string, format: uuid }
        currency: { type: string }
        amount: { type: integer, format: int64, description: Smallest currency unit (fen/points). }
        billing_interval: { type: string, enum: [month, year, once] }
        interval_count: { type: integer, format: int32 }
        status: { type: string, enum: [draft, active, archived, superseded] }
        effective_at: { type: string, format: date-time }
        expires_at: { type: string, format: date-time, nullable: true }
        created_at: { type: string, format: date-time }
    PartnerSKU:
      type: object
      properties:
        id: { type: string, format: uuid }
        client_id: { type: string, description: oauth client_id, 与订单 application_id 一致 }
        code: { type: string }
        name: { type: string }
        status: { type: string, enum: [active, inactive] }
        created_at: { type: string, format: date-time }
        updated_at: { type: string, format: date-time }
        active_price: { $ref: '#/components/schemas/Price' }
    CreatePartnerSKURequest:
      type: object
      required: [code]
      properties:
        code: { type: string, description: 应用内唯一 }
        name: { type: string }
    CreatePartnerPriceRequest:
      type: object
      required: [amount, billing_interval, effective_at]
      properties:
        currency: { type: string, description: default CNY }
        amount: { type: integer, format: int64, description: 最小货币单位（分）; >= 0 }
        billing_interval: { type: string, enum: [month, year, once] }
        interval_count: { type: integer, minimum: 1, default: 1 }
        effective_at: { type: string, format: date-time }
        expires_at: { type: string, format: date-time, nullable: true }
    Entitlement:
      type: object
      properties:
        id: { type: string, format: uuid }
        key: { type: string }
        display_name: { type: string }
        quantity: { type: integer, format: int32 }
        is_free: { type: boolean }
    SKUDetail:
      type: object
      properties:
        product: { $ref: '#/components/schemas/Product' }
        sku: { $ref: '#/components/schemas/SKU' }
        active_price: { $ref: '#/components/schemas/Price', nullable: true }
        entitlements:
          type: array
          items: { $ref: '#/components/schemas/Entitlement' }
    ProductDetail:
      type: object
      properties:
        product: { $ref: '#/components/schemas/Product' }
        skus:
          type: array
          items: { $ref: '#/components/schemas/SKUDetail' }
        entitlements:
          type: array
          items: { $ref: '#/components/schemas/Entitlement' }
    Subscription:
      type: object
      properties:
        id: { type: string, format: uuid }
        user_id: { type: string, format: uuid }
        sku_id: { type: string, format: uuid }
        price_id: { type: string, format: uuid }
        status: { type: string, enum: [active, canceled] }
        current_period_start: { type: string, format: date-time, nullable: true }
        current_period_end: { type: string, format: date-time, nullable: true }
        canceled_at: { type: string, format: date-time, nullable: true }
        cancel_at_period_end: { type: boolean }
        auto_renew: { type: boolean }
        auto_renew_channel: { type: string, enum: [wechat, alipay], nullable: true }
        created_at: { type: string, format: date-time }
        updated_at: { type: string, format: date-time }
    EntitlementTotal:
      type: object
      properties:
        quantity: { type: integer, format: int32 }
        valid_until: { type: string, format: date-time, nullable: true }
    EntitlementSummary:
      type: object
      properties:
        entitlements:
          type: object
          additionalProperties: { $ref: '#/components/schemas/EntitlementTotal' }
    Wallet:
      type: object
      properties:
        id: { type: string, format: uuid }
        user_id: { type: string, format: uuid }
        currency: { type: string }
        balance: { type: integer, format: int64 }
        status: { type: string }
        updated_at: { type: string, format: date-time }
    LedgerEntry:
      type: object
      properties:
        id: { type: string, format: uuid }
        wallet_id: { type: string, format: uuid }
        user_id: { type: string, format: uuid }
        currency: { type: string }
        entry_type: { type: string, enum: [topup, grant, consumption, refund, admin_adjust, expire, reversal] }
        amount: { type: integer, format: int64 }
        running_balance: { type: integer, format: int64 }
        reference_type: { type: string }
        reference_id: { type: string }
        description: { type: string }
        expires_at: { type: string, format: date-time, nullable: true }
        created_at: { type: string, format: date-time }
    CheckoutRequest:
      type: object
      required: [sku_id, quantity, channel]
      properties:
        sku_id: { type: string, format: uuid }
        quantity: { type: integer, minimum: 1 }
        channel: { type: string, enum: [wechat, alipay] }
    CheckoutResponse:
      type: object
      properties:
        order_no: { type: string }
        amount: { type: integer, format: int64 }
        currency: { type: string }
        subject: { type: string }
        subscription_id: { type: string, format: uuid }
    SetStatusRequest:
      type: object
      required: [status]
      properties:
        status: { type: string, enum: [draft, active, archived, superseded] }
    CreatePriceVersionRequest:
      type: object
      required: [sku_id, currency, amount, billing_interval, interval_count, effective_at]
      properties:
        sku_id: { type: string, format: uuid }
        currency: { type: string }
        amount: { type: integer, format: int64, description: Smallest currency unit. }
        billing_interval: { type: string, enum: [month, year, once] }
        interval_count: { type: integer, minimum: 1 }
        effective_at: { type: string, format: date-time }
        expires_at: { type: string, format: date-time, nullable: true }
    CreateEntitlementRequest:
      type: object
      required: [key, display_name, is_free]
      properties:
        key: { type: string }
        display_name: { type: string }
        is_free: { type: boolean }
    CreateProductRequest:
      type: object
      required: [code, name, product_type, status]
      properties:
        code: { type: string }
        name: { type: string }
        description: { type: string }
        product_type: { type: string, enum: [subscription, topup, one_time] }
        status: { type: string, enum: [draft, active, archived, superseded] }
        is_default: { type: boolean }
    CreateSKURequest:
      type: object
      required: [product_id, code, sku_type, status]
      properties:
        product_id: { type: string, format: uuid }
        code: { type: string }
        name: { type: string }
        sku_type: { type: string, enum: [monthly, yearly, topup, one_time] }
        status: { type: string, enum: [draft, active, archived, superseded] }
        device_limit: { type: integer, format: int32, nullable: true, description: Omit to inherit the platform default. }
        trial_days: { type: integer, format: int32 }
        credit_type: { type: string, description: Topup SKU credit currency, e.g. POINTS. }
        credit_amount: { type: integer, format: int64, description: Topup SKU credit amount. }
    AddSKUEntitlementRequest:
      type: object
      required: [entitlement_id, quantity]
      properties:
        entitlement_id: { type: string, format: uuid }
        quantity: { type: integer, minimum: 1 }
    UpdateProductRequest:
      type: object
      required: [name, is_default]
      properties:
        name: { type: string }
        description: { type: string }
        is_default: { type: boolean, description: Setting true clears the default flag on all other products. }
    UpdateSKURequest:
      type: object
      required: [name, trial_days]
      properties:
        name: { type: string }
        device_limit: { type: integer, format: int32, nullable: true, description: Omit or null to inherit the platform default. }
        trial_days: { type: integer, format: int32 }
        credit_type: { type: string }
        credit_amount: { type: integer, format: int64 }
    UpdateEntitlementRequest:
      type: object
      required: [display_name, is_free]
      properties:
        display_name: { type: string }
        is_free: { type: boolean, description: Toggling takes effect for all users immediately. }
    AdminSKUDetail:
      type: object
      properties:
        sku: { $ref: '#/components/schemas/SKU' }
        prices:
          type: array
          items: { $ref: '#/components/schemas/Price' }
        entitlements:
          type: array
          items: { $ref: '#/components/schemas/Entitlement' }
    AdminProductDetail:
      type: object
      properties:
        product: { $ref: '#/components/schemas/Product' }
        skus:
          type: array
          items: { $ref: '#/components/schemas/AdminSKUDetail' }
    AdminAdjustRequest:
      type: object
      required: [user_id, currency, amount, reason]
      properties:
        user_id: { type: string, format: uuid }
        currency: { type: string }
        amount: { type: integer, format: int64, description: Positive for credit, negative for debit. }
        reason: { type: string }
    CancelSubscriptionReq:
      type: object
      properties:
        at_period_end: { type: boolean }
    EnableAutoRenewReq:
      type: object
      required: [channel]
      properties:
        channel: { type: string, enum: [wechat, alipay] }
    ProductListEnvelope:
      type: object
      properties:
        data:
          type: object
          properties:
            products:
              type: array
              items: { $ref: '#/components/schemas/Product' }
    ProductDetailEnvelope:
      type: object
      properties:
        data: { $ref: '#/components/schemas/ProductDetail' }
    CheckoutResponseEnvelope:
      type: object
      properties:
        data: { $ref: '#/components/schemas/CheckoutResponse' }
    SubscriptionListEnvelope:
      type: object
      properties:
        data:
          type: object
          properties:
            subscriptions:
              type: array
              items: { $ref: '#/components/schemas/Subscription' }
    SubscriptionEnvelope:
      type: object
      properties:
        data: { $ref: '#/components/schemas/Subscription' }
    EntitlementSummaryEnvelope:
      type: object
      properties:
        data: { $ref: '#/components/schemas/EntitlementSummary' }
    WalletEnvelope:
      type: object
      properties:
        data:
          type: object
          properties:
            wallets:
              type: array
              items: { $ref: '#/components/schemas/Wallet' }
    LedgerEnvelope:
      type: object
      properties:
        data:
          type: object
          properties:
            currency: { type: string }
            entries:
              type: array
              items: { $ref: '#/components/schemas/LedgerEntry' }
            next: { type: string, description: Encoded cursor for the next page. }
    PriceEnvelope:
      type: object
      properties:
        data: { $ref: '#/components/schemas/Price' }
    EntitlementEnvelope:
      type: object
      properties:
        data: { $ref: '#/components/schemas/Entitlement' }
    ProductEnvelope:
      type: object
      properties:
        data: { $ref: '#/components/schemas/Product' }
    SKUEnvelope:
      type: object
      properties:
        data: { $ref: '#/components/schemas/SKU' }
    SKUEntitlementEnvelope:
      type: object
      properties:
        data:
          type: object
          properties:
            sku_id: { type: string, format: uuid }
            entitlement_id: { type: string, format: uuid }
            quantity: { type: integer, format: int32 }
    SKUEntitlementUnlinkEnvelope:
      type: object
      properties:
        data:
          type: object
          properties:
            sku_id: { type: string, format: uuid }
            entitlement_id: { type: string, format: uuid }
            status: { type: string, enum: [unlinked] }
    EntitlementListEnvelope:
      type: object
      properties:
        data:
          type: object
          properties:
            entitlements:
              type: array
              items: { $ref: '#/components/schemas/Entitlement' }
    PriceListEnvelope:
      type: object
      properties:
        data:
          type: object
          properties:
            prices:
              type: array
              items: { $ref: '#/components/schemas/Price' }
    AdminProductDetailEnvelope:
      type: object
      properties:
        data: { $ref: '#/components/schemas/AdminProductDetail' }
    LedgerEntryEnvelope:
      type: object
      properties:
        data: { $ref: '#/components/schemas/LedgerEntry' }
    ProductStatusEnvelope:
      type: object
      properties:
        data:
          type: object
          properties:
            product_id: { type: string, format: uuid }
            status: { type: string, enum: [draft, active, archived, superseded] }
    SKUStatusEnvelope:
      type: object
      properties:
        data:
          type: object
          properties:
            sku_id: { type: string, format: uuid }
            status: { type: string, enum: [draft, active, archived, superseded] }
    SubscriptionCancelEnvelope:
      type: object
      properties:
        data:
          type: object
          properties:
            subscription_id: { type: string, format: uuid }
            canceled: { type: boolean }
    SubscriptionResumeEnvelope:
      type: object
      properties:
        data:
          type: object
          properties:
            subscription_id: { type: string, format: uuid }
            resumed: { type: boolean }
    AutoRenewSignEnvelope:
      type: object
      properties:
        data:
          type: object
          properties:
            sign_url: { type: string, description: Channel agreement sign URL the user's browser must open. }
    SubscriptionAutoRenewDisableEnvelope:
      type: object
      properties:
        data:
          type: object
          properties:
            subscription_id: { type: string, format: uuid }
            auto_renew: { type: boolean, enum: [false] }
    PartnerAgreement:
      type: object
      description: Multi-tenant auto-renewal agreement (0081) — a WeChat 委托代扣 contract between the user and a partner application's 特约商户, anchored to one recurring partner SKU. charge_paused=true means billing stopped after repeated failures; re-signing restores it.
      properties:
        application_id: { type: string }
        partner_sku_id: { type: string, format: uuid }
        channel: { type: string, enum: [wechat] }
        status: { type: string, enum: [signing, signed, terminated] }
        charge_paused: { type: boolean }
        signed_at: { type: string, format: date-time }
        created_at: { type: string, format: date-time }
    PartnerAgreementListEnvelope:
      type: object
      properties:
        data:
          type: object
          properties:
            agreements:
              type: array
              items: { $ref: '#/components/schemas/PartnerAgreement' }
    PartnerAutoRenewDisableEnvelope:
      type: object
      properties:
        data:
          type: object
          properties:
            client_id: { type: string }
            sku_id: { type: string, format: uuid }
            auto_renew: { type: boolean, enum: [false] }

    AdminUser:
      type: object
      properties:
        id: { type: string, format: uuid }
        email: { type: string, format: email }
        phone: { type: string }
        display_name: { type: string }
        avatar: { type: string }
        status: { type: string, enum: [active, disabled, deleted] }
        risk_level: { type: string, enum: [normal, watch, restricted, frozen] }
        email_verified: { type: boolean }
        mfa_enabled: { type: boolean }
        is_admin: { type: boolean }
        created_at: { type: string, format: date-time }
        updated_at: { type: string, format: date-time }
    AdminUserListEnvelope:
      type: object
      properties:
        users:
          type: array
          items: { $ref: '#/components/schemas/AdminUser' }
        meta:
          type: object
          properties:
            total: { type: integer, format: int64 }
            page: { type: integer }
            limit: { type: integer }
    AdminSetUserRiskRequest:
      type: object
      required: [level]
      properties:
        level: { type: string, enum: [normal, watch, restricted, frozen] }
        reason: { type: string, description: Required when level is not normal; written to the audit log }
    AdminSetUserStatusRequest:
      type: object
      required: [status]
      properties:
        status: { type: string, enum: [active, disabled, deleted] }
    AdminOrder:
      type: object
      properties:
        platform_order_no: { type: string }
        merchant_order_no: { type: string }
        channel_order_no: { type: string }
        application_id: { type: string, format: uuid }
        channel: { type: string, enum: [wechat, alipay] }
        amount: { type: integer, format: int64 }
        currency: { type: string }
        subject: { type: string }
        source: { type: string }
        status: { type: string, enum: [pending, paid, closed, refunding, partially_refunded, refunded, disputed] }
        order_type: { type: string }
        product_id: { type: string, format: uuid }
        sku_id: { type: string, format: uuid }
        price_id: { type: string, format: uuid }
        quantity: { type: integer, format: int32 }
        subscription_id: { type: string, format: uuid }
        fulfilled_at: { type: string, format: date-time, nullable: true }
        snapshot:
          type: object
          description: Frozen catalog view at order creation (product/sku/price/entitlements, 8.4)
        created_at: { type: string, format: date-time }
    AdminOrderListEnvelope:
      type: object
      properties:
        orders:
          type: array
          items: { $ref: '#/components/schemas/AdminOrder' }
        meta:
          type: object
          properties:
            total: { type: integer, format: int64 }
            page: { type: integer }
            limit: { type: integer }
    AdminSubscriptionListEnvelope:
      type: object
      properties:
        subscriptions:
          type: array
          items: { $ref: '#/components/schemas/Subscription' }
    AuditEvent:
      type: object
      properties:
        id: { type: string, format: uuid }
        user_id: { type: string, format: uuid, nullable: true }
        event: { type: string }
        ip: { type: string, nullable: true }
        user_agent: { type: string, nullable: true }
        meta: { type: object, nullable: true }
        created_at: { type: string, format: date-time }
    AuditEventListEnvelope:
      type: object
      properties:
        events:
          type: array
          items: { $ref: '#/components/schemas/AuditEvent' }
        meta:
          type: object
          properties:
            total: { type: integer, format: int64 }
            page: { type: integer }
            limit: { type: integer }
    AdminStats:
      type: object
      properties:
        users:
          type: object
          properties:
            total: { type: integer, format: int64 }
            active: { type: integer, format: int64 }
            disabled: { type: integer, format: int64 }
        partner_profiles_pending: { type: integer, format: int64 }
        subscriptions_active: { type: integer, format: int64 }
        orders_total: { type: integer, format: int64 }
        orders_today: { type: integer, format: int64 }
        paid_amount_today: { type: integer, format: int64 }
    ApprovalRequest:
      type: object
      properties:
        id: { type: string, format: uuid }
        type: { type: string, enum: [refund, wallet_adjust, entitlement_grant, deletion] }
        payload:
          type: object
          description: |-
            Type-specific payload, validated at creation and re-parsed on approval:
            - refund: { platform_order_no: string, amount: int64 > 0 (minor units), reason?: string }
            - wallet_adjust: { user_id: uuid, currency: string, amount: int64 != 0 (negative = debit), reason: string }
            - entitlement_grant: { user_id: uuid, entitlement_code: string, valid_from?: date-time, valid_until: date-time (must be in the future, and after valid_from when set), reason: string }
            - deletion: { deletion_request_id: uuid }
        status: { type: string, enum: [pending, approved, rejected, canceled, executed, failed] }
        requester_id: { type: string, format: uuid }
        approver_id: { type: string, format: uuid, nullable: true }
        reason: { type: string }
        decision_note: { type: string, nullable: true }
        result_ref: { type: string, nullable: true, description: Reference to the executed side effect (e.g. refund record) }
        created_at: { type: string, format: date-time }
        decided_at: { type: string, format: date-time, nullable: true }
        executed_at: { type: string, format: date-time, nullable: true }
    ApprovalRequestListEnvelope:
      type: object
      properties:
        items:
          type: array
          items: { $ref: '#/components/schemas/ApprovalRequest' }
        meta:
          type: object
          properties:
            limit: { type: integer }
            next_cursor: { type: string, description: Pass as `cursor` for the next page; empty when no more pages }
    CreateApprovalReq:
      type: object
      required: [type, payload, reason]
      properties:
        type: { type: string, enum: [refund, wallet_adjust, entitlement_grant, deletion] }
        payload:
          type: object
          description: |-
            Payload shape depends on type:
            - refund: { platform_order_no: string, amount: int64 > 0 (minor units), reason?: string }
            - wallet_adjust: { user_id: uuid, currency: string, amount: int64 != 0 (negative = debit), reason: string }
            - entitlement_grant: { user_id: uuid, entitlement_code: string, valid_from?: date-time, valid_until: date-time (must be in the future, and after valid_from when set), reason: string }
            - deletion: { deletion_request_id: uuid }
        reason: { type: string, description: Why the mutation is needed; required, max 1024 characters }
    DecideApprovalReq:
      type: object
      properties:
        note: { type: string, description: Decision note; required on reject, optional on approve }
    SalesPartner:
      type: object
      description: Sales partner profile (10.3 销售档案); the invite code is the sales-dedicated code bound at registration. Automatic first-layer profit sharing requires the company receiver; an empty receiver_mchid means the platform collects the sales share (held) for a manual month-end payout. 销售份额固定为服务费的 25%（0078 定案），由第一层自动分账逐单分配，不再是每个 partner 的可配比例
      properties:
        id: { type: string, format: uuid }
        name: { type: string }
        type: { type: string, enum: [company, personal], description: Profile shape only — a personal sales must still register a company receiver (receiver_mchid/receiver_name) to be shared automatically }
        invite_code: { type: string }
        status: { type: string, enum: [active, disabled], description: disabled 停止新绑定与新的自动分账，历史归因与已确认月结行不变 }
        remark: { type: string }
        receiver_mchid: { type: string, pattern: '^\d{8,32}$', description: 销售公司接收方商户号 (对公); 空 = 平台代收, 月结手工支付 }
        receiver_name: { type: string, maxLength: 128, description: 销售公司全称 (提交分账时 OAEP 加密); receiver_mchid 非空时必填 }
        created_at: { type: string, format: date-time }
        updated_at: { type: string, format: date-time }
    CreateSalesPartnerReq:
      type: object
      required: [name]
      properties:
        name: { type: string, maxLength: 64 }
        type: { type: string, enum: [company, personal], default: personal }
        invite_code: { type: string, description: Optional explicit code; empty auto-generates one. Must be unique across users and sales partners }
        remark: { type: string }
        receiver_mchid: { type: string, pattern: '^\d{8,32}$', description: 销售公司接收方商户号; 空 = 平台代收 (月结手工支付), 此时 receiver_name 必须为空 }
        receiver_name: { type: string, maxLength: 128, description: 销售公司全称; receiver_mchid 非空时必填 }
    UpdateSalesPartnerReq:
      type: object
      required: [name, type, status]
      properties:
        name: { type: string, maxLength: 64 }
        type: { type: string, enum: [company, personal] }
        status: { type: string, enum: [active, disabled] }
        remark: { type: string }
        receiver_mchid: { type: string, pattern: '^\d{8,32}$', description: 销售公司接收方商户号; 空 = 平台代收 (月结手工支付), 此时 receiver_name 必须为空 }
        receiver_name: { type: string, maxLength: 128, description: 销售公司全称; receiver_mchid 非空时必填 }
    ProfitSharingReceiver:
      type: object
      description: Extra corporate profit-sharing receiver (多方分账 0077, MERCHANT_ID only — e.g. 中科智能). 0078 起份额由分账规则决定（无销售 1/3，有销售 25%，产品负责人份额并入），不再有可配的 share_bps；灏曦（configured via /v1/admin/wechat-sp）为平台余量接收方
      properties:
        id: { type: string, format: uuid }
        mchid: { type: string, pattern: '^\d{8,32}$', description: 接收方商户号 (对公) }
        name: { type: string, description: 接收方商户全称 }
        relation_type: { type: string, description: 微信分账关系类型 (SERVICE_PROVIDER/SUPPLIER/PARTNER/...), default SUPPLIER }
        status: { type: string, enum: [active, disabled], description: disabled 只影响新订单的拆分, 在途分账单按快照执行 }
        created_at: { type: string, format: date-time }
        updated_at: { type: string, format: date-time }
    UpsertProfitSharingReceiverReq:
      type: object
      required: [mchid, name]
      properties:
        mchid: { type: string, pattern: '^\d{8,32}$' }
        name: { type: string, maxLength: 128 }
        relation_type: { type: string, default: SUPPLIER }
    ProfitSharingReceiverStatusReq:
      type: object
      required: [status]
      properties:
        status: { type: string, enum: [active, disabled] }
    SubMerchantReceiverState:
      type: object
      description: One extra receiver's registration state for one sub-merchant (sub_merchant_receivers); a receiver missing from the list was never attempted
      properties:
        receiver_mchid: { type: string }
        state: { type: string, enum: ['', registered, failed] }
        error: { type: string }
    SalesShareRow:
      type: object
      description: One sales company's confirmed share for the month by disposition (profit_sharing_order_shares, role='sales'); amounts in fen. 'shared' already moved through the automatic channel split; 'held' was collected by the platform and must be paid out manually this month; 'merged_platform'/'merged_fixed' folded into the platform/fixed bucket; 'deducted' was carved out of the fee (the sales company is the order's own sub-merchant)
      properties:
        beneficiary_mchid: { type: string, description: 销售公司接收方商户号快照 (held 时可能为空) }
        beneficiary_name: { type: string, description: 受益方名称快照 }
        disposition: { type: string, enum: [shared, deducted, held, merged_platform, merged_fixed] }
        order_count: { type: integer, format: int64 }
        amount_fen: { type: integer, format: int64 }
    SalesSettlementReport:
      type: object
      description: "Monthly split-snapshot report (10.3 月结, 0078 定案) over channel-confirmed profit-sharing orders (status success/finished, windowed on created_at): full rule fee vs actually charged fee, the product-owner obligation split, the platform's own share and the per-sales-company breakdown; amounts in fen"
      properties:
        month: { type: string, description: YYYY-MM (UTC) }
        total_order_count: { type: integer, format: int64 }
        total_full_fee_fen: { type: integer, format: int64, description: 规则全额服务费 (抵扣前) }
        total_charged_fee_fen: { type: integer, format: int64, description: 实收服务费 = 提交分账合计 }
        total_deducted_fen: { type: integer, format: int64, description: 抵扣 = full − charged (受益方为本单特约商户的份额) }
        product_owner_shared_fen: { type: integer, format: int64, description: 韩磊份额·随中科智能桶自动流出 (中科智能应付韩磊) }
        product_owner_deducted_fen: { type: integer, format: int64, description: 韩磊份额·中科智能自有订单抵扣 (中科智能应付韩磊) }
        platform_received_fen: { type: integer, format: int64, description: 灏曦渠道实收 (role=platform, disposition=shared; 不含代收/并入的销售份额) }
        sales_held_fen: { type: integer, format: int64, description: 平台代收待月结手工支付的销售份额合计 (disposition=held) }
        sales:
          type: array
          items: { $ref: '#/components/schemas/SalesShareRow' }
    AdminDeletionRequest:
      type: object
      properties:
        id: { type: string, format: uuid }
        user_id: { type: string, format: uuid }
        user_email: { type: string }
        user_display_name: { type: string }
        status: { type: string, enum: [pending, canceled, completed] }
        review_status: { type: string, enum: [none, approved, rejected] }
        reviewed_by: { type: string, format: uuid }
        review_note: { type: string }
        reason: { type: string }
        requested_at: { type: string, format: date-time }
        scheduled_at: { type: string, format: date-time }
        reviewed_at: { type: string, format: date-time, nullable: true }
        blockers: { type: integer, format: int64, description: Live count of deletion blockers (7.8) }
    AdminDeletionRequestListEnvelope:
      type: object
      properties:
        items:
          type: array
          items: { $ref: '#/components/schemas/AdminDeletionRequest' }
        meta:
          type: object
          properties:
            limit: { type: integer }
            next_cursor: { type: string, description: Pass as `cursor` for the next page; empty when no more pages }
    RejectDeletionReq:
      type: object
      required: [note]
      properties:
        note: { type: string, description: Review note explaining the rejection }
  responses:
    BadRequest:
      description: Bad request
      content:
        application/json:
          schema: { $ref: '#/components/schemas/ErrorEnvelope' }
    Unauthorized:
      description: Missing or invalid access token
      content:
        application/json:
          schema: { $ref: '#/components/schemas/ErrorEnvelope' }
    Forbidden:
      description: >-
        Authenticated user lacks permission for this operation. For partner
        console writes a member whose role is too low gets 403 with code
        client.forbidden.
      content:
        application/json:
          schema: { $ref: '#/components/schemas/ErrorEnvelope' }
    NotFound:
      description: >-
        Not found (also used to prevent cross-tenant enumeration). A member
        whose role is too low instead gets 403 client.forbidden.
      content:
        application/json:
          schema: { $ref: '#/components/schemas/ErrorEnvelope' }
    TooManyRequests:
      description: Rate limit exceeded
      headers:
        Retry-After:
          schema: { type: integer }
          description: Seconds until the current window resets.
      content:
        application/json:
          schema: { $ref: '#/components/schemas/ErrorEnvelope' }
    Conflict:
      description: Conflict (e.g. order not in a refundable state)
      content:
        application/json:
          schema: { $ref: '#/components/schemas/ErrorEnvelope' }
    BadGateway:
      description: Upstream channel call failed; the operation stays pending and is retryable
      content:
        application/json:
          schema: { $ref: '#/components/schemas/ErrorEnvelope' }
    ServerError:
      description: Internal server error
      content:
        application/json:
          schema: { $ref: '#/components/schemas/ErrorEnvelope' }
    ServiceUnavailable:
      description: Upstream delivery failed; the operation stays pending and is retryable
      content:
        application/json:
          schema: { $ref: '#/components/schemas/ErrorEnvelope' }

paths:
  /health:
    get:
      tags: [System]
      summary: Liveness / DB ping
      operationId: health
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema:
                type: object
                properties:
                  status: { type: string, example: ok }
                  db: { type: string, enum: [ok, skipped, error] }
        '503': { $ref: '#/components/responses/ServerError' }
  /metrics:
    get:
      tags: [System]
      summary: Prometheus text exposition
      operationId: metrics
      responses:
        '200':
          description: Prometheus metrics
          content:
            text/plain:
              schema: { type: string }
  /.well-known/jwks.json:
    get:
      tags: [System]
      summary: JSON Web Key Set for access-token verification
      operationId: jwks
      responses:
        '200':
          description: JWKS document
          content:
            application/json:
              schema: { type: object, additionalProperties: true }
        '500': { $ref: '#/components/responses/ServerError' }
  /openapi.yaml:
    get:
      tags: [System]
      summary: "This OpenAPI contract, served verbatim from the repository file (path configurable via OPENAPI_PATH, default openapi/openapi.yaml)"
      operationId: openapiSpec
      responses:
        '200':
          description: OpenAPI 3.0.3 document
          content:
            application/yaml:
              schema: { type: string }
        '404': { $ref: '#/components/responses/NotFound' }
  /docs:
    get:
      tags: [System]
      summary: "Human-readable API docs (Redoc page rendering /openapi.yaml; the Redoc runtime loads from the jsDelivr CDN at view time)"
      operationId: apiDocs
      responses:
        '200':
          description: Redoc HTML page
          content:
            text/html:
              schema: { type: string }

  /v1/auth/register:
    post:
      tags: [Auth]
      summary: Register a user (rate-limited)
      operationId: register
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/RegisterReq' }
      responses:
        '200':
          description: Registered
          content:
            application/json:
              schema: { $ref: '#/components/schemas/AuthResponse' }
        '400': { $ref: '#/components/responses/BadRequest' }
        '409': { $ref: '#/components/responses/Conflict' }
        '422': { $ref: '#/components/responses/BadRequest' }
        '429': { $ref: '#/components/responses/TooManyRequests' }
        '500': { $ref: '#/components/responses/ServerError' }
  /v1/auth/login:
    post:
      tags: [Auth]
      summary: Log in (rate-limited). 428 when MFA is enabled.
      operationId: login
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/LoginReq' }
      responses:
        '200':
          description: Authenticated
          content:
            application/json:
              schema: { $ref: '#/components/schemas/AuthResponse' }
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/BadRequest' }
        '428':
          description: MFA challenge required
          content:
            application/json:
              schema: { $ref: '#/components/schemas/MFARequired' }
        '429': { $ref: '#/components/responses/TooManyRequests' }
        '500': { $ref: '#/components/responses/ServerError' }
  /v1/auth/refresh:
    post:
      tags: [Auth]
      summary: Rotate refresh token
      operationId: refresh
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/RefreshReq' }
      responses:
        '200':
          description: New token pair
          content:
            application/json:
              schema: { $ref: '#/components/schemas/AuthResponse' }
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '500': { $ref: '#/components/responses/ServerError' }
  /v1/auth/logout:
    post:
      tags: [Auth]
      summary: Revoke a refresh token
      operationId: logout
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/RefreshReq' }
      responses:
        '200':
          description: Logged out
          content:
            application/json:
              schema: { $ref: '#/components/schemas/StatusOK' }
        '400': { $ref: '#/components/responses/BadRequest' }
        '500': { $ref: '#/components/responses/ServerError' }
  /v1/auth/forgot-password:
    post:
      tags: [Auth]
      summary: Request a password-reset token (rate-limited)
      operationId: forgotPassword
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/ForgotReq' }
      responses:
        '202':
          description: Accepted
          content:
            application/json:
              schema: { $ref: '#/components/schemas/ForgotResponse' }
        '400': { $ref: '#/components/responses/BadRequest' }
        '429': { $ref: '#/components/responses/TooManyRequests' }
        '500': { $ref: '#/components/responses/ServerError' }
  /v1/auth/reset-password:
    post:
      tags: [Auth]
      summary: Reset password with a reset token (rate-limited)
      operationId: resetPassword
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/ResetReq' }
      responses:
        '200':
          description: Password reset
          content:
            application/json:
              schema: { $ref: '#/components/schemas/StatusOK' }
        '400': { $ref: '#/components/responses/BadRequest' }
        '422': { $ref: '#/components/responses/BadRequest' }
        '429': { $ref: '#/components/responses/TooManyRequests' }
        '500': { $ref: '#/components/responses/ServerError' }
  /v1/auth/phone/forgot-password:
    post:
      tags: [Auth]
      summary: Request an SMS password-reset code (rate-limited). Always 202 for well-formed input (no account enumeration).
      operationId: phoneForgotPassword
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/PhoneForgotReq' }
      responses:
        '202':
          description: Accepted
          content:
            application/json:
              schema: { $ref: '#/components/schemas/StatusOK' }
        '400': { $ref: '#/components/responses/BadRequest' }
        '429': { $ref: '#/components/responses/TooManyRequests' }
        '500': { $ref: '#/components/responses/ServerError' }
  /v1/auth/phone/reset-password:
    post:
      tags: [Auth]
      summary: Reset password with an SMS code (rate-limited). Unknown phones, wrong codes, and disabled accounts all answer 401 auth.invalid_credentials (no account enumeration).
      operationId: phoneResetPassword
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/PhoneResetReq' }
      responses:
        '200':
          description: Password reset
          content:
            application/json:
              schema: { $ref: '#/components/schemas/StatusOK' }
        '400': { $ref: '#/components/responses/BadRequest' }
        '401':
          description: Unknown phone, wrong code, or disabled account (auth.invalid_credentials)
          content:
            application/json:
              schema: { $ref: '#/components/schemas/ErrorEnvelope' }
        '422': { $ref: '#/components/responses/BadRequest' }
        '429': { $ref: '#/components/responses/TooManyRequests' }
        '500': { $ref: '#/components/responses/ServerError' }
  /v1/auth/mfa/verify:
    post:
      tags: [Auth]
      summary: Complete login with an MFA challenge (rate-limited)
      operationId: mfaVerify
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/MFAVerifyReq' }
      responses:
        '200':
          description: Authenticated
          content:
            application/json:
              schema: { $ref: '#/components/schemas/AuthResponse' }
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/BadRequest' }
        '429': { $ref: '#/components/responses/TooManyRequests' }
        '500': { $ref: '#/components/responses/ServerError' }

  /v1/auth/wechat/qr-sessions:
    post:
      tags: [Auth]
      summary: Create a WeChat QR login session (rate-limited)
      operationId: createWeChatQRSession
      responses:
        '200':
          description: QR session created
          content:
            application/json:
              schema: { $ref: '#/components/schemas/WeChatQRSession' }
        '400': { $ref: '#/components/responses/BadRequest' }
        '429': { $ref: '#/components/responses/TooManyRequests' }
        '500': { $ref: '#/components/responses/ServerError' }
  /v1/auth/wechat/qr-sessions/{session_id}:
    get:
      tags: [Auth]
      summary: Poll WeChat QR session status (rate-limited)
      operationId: getWeChatQRSession
      parameters:
        - name: session_id
          in: path
          required: true
          schema: { type: string }
        - name: wm_wechat_bt
          in: cookie
          required: true
          schema: { type: string }
          description: Browser-bound token issued by POST /v1/auth/wechat/qr-sessions.
      responses:
        '200':
          description: Session status
          content:
            application/json:
              schema: { $ref: '#/components/schemas/WeChatQRSession' }
        '400': { $ref: '#/components/responses/BadRequest' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/NotFound' }
        '429': { $ref: '#/components/responses/TooManyRequests' }
        '500': { $ref: '#/components/responses/ServerError' }
  /v1/auth/wechat/callback:
    get:
      tags: [Auth]
      summary: WeChat Open Platform callback (query params, rate-limited)
      operationId: weChatCallbackGet
      parameters:
        - name: code
          in: query
          required: true
          schema: { type: string }
        - name: state
          in: query
          required: true
          schema: { type: string }
      responses:
        '200':
          description: Callback accepted
          content:
            application/json:
              schema: { $ref: '#/components/schemas/StatusOK' }
        '400': { $ref: '#/components/responses/BadRequest' }
        '429': { $ref: '#/components/responses/TooManyRequests' }
        '502': { $ref: '#/components/responses/BadGateway' }
        '500': { $ref: '#/components/responses/ServerError' }
    post:
      tags: [Auth]
      summary: WeChat Open Platform callback (form/JSON body, rate-limited)
      operationId: weChatCallbackPost
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [code, state]
              properties:
                code: { type: string }
                state: { type: string }
          application/x-www-form-urlencoded:
            schema:
              type: object
              required: [code, state]
              properties:
                code: { type: string }
                state: { type: string }
      responses:
        '200':
          description: Callback accepted
          content:
            application/json:
              schema: { $ref: '#/components/schemas/StatusOK' }
        '400': { $ref: '#/components/responses/BadRequest' }
        '429': { $ref: '#/components/responses/TooManyRequests' }
        '502': { $ref: '#/components/responses/BadGateway' }
        '500': { $ref: '#/components/responses/ServerError' }
  /v1/auth/wechat/exchange:
    post:
      tags: [Auth]
      summary: Exchange a confirmed WeChat QR session for tokens (rate-limited)
      operationId: exchangeWeChatQRSession
      parameters:
        - name: wm_wechat_bt
          in: cookie
          required: true
          schema: { type: string }
          description: Browser-bound token issued by POST /v1/auth/wechat/qr-sessions.
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/WeChatExchangeReq' }
      responses:
        '200':
          description: Authenticated
          content:
            application/json:
              schema: { $ref: '#/components/schemas/AuthResponse' }
        '400': { $ref: '#/components/responses/BadRequest' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '409': { $ref: '#/components/responses/Conflict' }
        '428':
          description: MFA challenge required
          content:
            application/json:
              schema: { $ref: '#/components/schemas/MFARequired' }
        '429': { $ref: '#/components/responses/TooManyRequests' }
        '500': { $ref: '#/components/responses/ServerError' }
  /v1/auth/wechat/register:
    post:
      tags: [Auth]
      summary: Register a new account from a WeChat QR needs_bind session (rate-limited)
      operationId: registerByWeChatQRSession
      parameters:
        - name: wm_wechat_bt
          in: cookie
          required: true
          schema: { type: string }
          description: Browser-bound token issued by POST /v1/auth/wechat/qr-sessions.
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/WeChatRegisterReq' }
      responses:
        '200':
          description: Registered and authenticated
          content:
            application/json:
              schema: { $ref: '#/components/schemas/AuthResponse' }
        '400': { $ref: '#/components/responses/BadRequest' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '409': { $ref: '#/components/responses/Conflict' }
        '429': { $ref: '#/components/responses/TooManyRequests' }
        '500': { $ref: '#/components/responses/ServerError' }

  /v1/auth/wechat/mini-program/openid-login:
    post:
      tags: [Auth]
      summary: Mint a platform session from a verified mini-program openid (partner-only, rate-limited). 428 when MFA is enabled.
      description: |
        For partner backends that own a mini-program AppSecret: they perform
        code2session themselves, then call this endpoint with their client
        credentials. The requested app_id must match the client's
        miniprogram_appid binding (0082, managed via PUT /v1/partner/clients/{client_id});
        unbound or mismatched app_id is rejected with 403 auth.wechat_mp_app_not_bound.
      operationId: miniProgramOpenIDLogin
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [client_id, client_secret, app_id, openid]
              properties:
                client_id: { type: string }
                client_secret: { type: string }
                app_id: { type: string, description: "Mini-program AppID; must equal the client's miniprogram_appid binding." }
                openid: { type: string }
                unionid: { type: string }
      responses:
        '200':
          description: Authenticated
          content:
            application/json:
              schema: { $ref: '#/components/schemas/AuthResponse' }
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '428':
          description: MFA challenge required
          content:
            application/json:
              schema: { $ref: '#/components/schemas/MFARequired' }
        '429': { $ref: '#/components/responses/TooManyRequests' }
        '500': { $ref: '#/components/responses/ServerError' }
        '503': { $ref: '#/components/responses/ServiceUnavailable' }

  /v1/auth/phone/send-otp:
    post:
      tags: [Auth]
      summary: Send an SMS OTP for phone registration or login (rate-limited)
      operationId: phoneSendOTP
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/PhoneSendOTPReq' }
      responses:
        '200':
          description: Code sent (or accepted without sending for unknown/disabled targets)
          content:
            application/json:
              schema: { $ref: '#/components/schemas/SentStatus' }
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '429': { $ref: '#/components/responses/TooManyRequests' }
        '503': { $ref: '#/components/responses/ServiceUnavailable' }
        '500': { $ref: '#/components/responses/ServerError' }
  /v1/auth/phone/register:
    post:
      tags: [Auth]
      summary: Register a phone-only account with an SMS OTP (rate-limited)
      operationId: phoneRegister
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/PhoneRegisterReq' }
      responses:
        '200':
          description: Registered
          content:
            application/json:
              schema: { $ref: '#/components/schemas/AuthResponse' }
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '409': { $ref: '#/components/responses/Conflict' }
        '429': { $ref: '#/components/responses/TooManyRequests' }
        '500': { $ref: '#/components/responses/ServerError' }
  /v1/auth/phone/login:
    post:
      tags: [Auth]
      summary: Log in with an SMS OTP (rate-limited). 428 when MFA is enabled.
      operationId: phoneLogin
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/PhoneLoginReq' }
      responses:
        '200':
          description: Authenticated
          content:
            application/json:
              schema: { $ref: '#/components/schemas/AuthResponse' }
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/BadRequest' }
        '428':
          description: MFA challenge required
          content:
            application/json:
              schema: { $ref: '#/components/schemas/MFARequired' }
        '429': { $ref: '#/components/responses/TooManyRequests' }
        '500': { $ref: '#/components/responses/ServerError' }
  /v1/auth/otp/send:
    post:
      tags: [Auth]
      summary: Send an OTP to an email or phone for login (rate-limited)
      operationId: otpSend
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/OTPSendReq' }
      responses:
        '200':
          description: Code sent (or, for unknown targets, accepted without delivery)
          content:
            application/json:
              schema: { $ref: '#/components/schemas/SentStatus' }
        '400': { $ref: '#/components/responses/BadRequest' }
        '429': { $ref: '#/components/responses/TooManyRequests' }
        '500': { $ref: '#/components/responses/ServerError' }
  /v1/auth/otp/login:
    post:
      tags: [Auth]
      summary: Log in with an email or phone OTP (rate-limited). 428 when MFA is enabled.
      operationId: otpLogin
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/OTPLoginReq' }
      responses:
        '200':
          description: Authenticated
          content:
            application/json:
              schema: { $ref: '#/components/schemas/AuthResponse' }
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/BadRequest' }
        '428':
          description: MFA challenge required
          content:
            application/json:
              schema: { $ref: '#/components/schemas/MFARequired' }
        '429': { $ref: '#/components/responses/TooManyRequests' }
        '500': { $ref: '#/components/responses/ServerError' }

  /v1/auth/capabilities:
    get:
      tags: [Auth]
      summary: Public auth feature flags for the login UI (unauthenticated)
      operationId: authCapabilities
      responses:
        '200':
          description: Feature flags
          content:
            application/json:
              schema:
                type: object
                properties:
                  sms_otp_enabled: { type: boolean, description: "False while SMS verification is switched off (SMS_OTP_ENABLED); phone OTP flows then return 403 auth.sms_disabled." }

  /v1/auth/reauth/send:
    post:
      tags: [Auth]
      summary: Request a re-authentication code for sensitive actions (rate-limited)
      operationId: sendReauthCode
      security: [{ bearerAuth: [] }]
      responses:
        '200':
          description: Re-authentication code sent
          content:
            application/json:
              schema: { $ref: '#/components/schemas/SendReauthResp' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '409': { $ref: '#/components/responses/Conflict' }
        '429': { $ref: '#/components/responses/TooManyRequests' }
        '503': { $ref: '#/components/responses/ServiceUnavailable' }
        '500': { $ref: '#/components/responses/ServerError' }

  /v1/me/identities:
    get:
      tags: [Auth]
      summary: List my bound identities
      operationId: listIdentities
      security: [{ bearerAuth: [] }]
      responses:
        '200':
          description: Identity list
          content:
            application/json:
              schema: { $ref: '#/components/schemas/IdentitiesResponse' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '429': { $ref: '#/components/responses/TooManyRequests' }
        '500': { $ref: '#/components/responses/ServerError' }
    post:
      tags: [Auth]
      summary: Bind a new email or phone identity (rate-limited)
      operationId: bindIdentity
      security: [{ bearerAuth: [] }]
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/BindIdentityReq' }
      responses:
        '201':
          description: Identity created; verification OTP sent
          content:
            application/json:
              schema: { $ref: '#/components/schemas/IdentityDTO' }
        '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' }
        '503': { $ref: '#/components/responses/ServiceUnavailable' }
        '500': { $ref: '#/components/responses/ServerError' }
  /v1/me/identities/wechat:
    post:
      tags: [Auth]
      summary: Bind a WeChat identity from a needs_bind QR session (rate-limited)
      operationId: bindWeChatIdentity
      security: [{ bearerAuth: [] }]
      parameters:
        - name: wm_wechat_bt
          in: cookie
          required: true
          schema: { type: string }
          description: Browser-bound token issued by POST /v1/auth/wechat/qr-sessions.
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/WeChatBindReq' }
      responses:
        '201':
          description: WeChat identity bound
          content:
            application/json:
              schema: { $ref: '#/components/schemas/IdentityDTO' }
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '409': { $ref: '#/components/responses/Conflict' }
        '422': { $ref: '#/components/responses/BadRequest' }
        '429': { $ref: '#/components/responses/TooManyRequests' }
        '500': { $ref: '#/components/responses/ServerError' }
  /v1/me/identities/{identity_id}:
    delete:
      tags: [Auth]
      summary: Unbind an identity
      operationId: deleteIdentity
      security: [{ bearerAuth: [] }]
      parameters:
        - name: identity_id
          in: path
          required: true
          schema: { type: string }
      responses:
        '200':
          description: Identity removed
          content:
            application/json:
              schema: { $ref: '#/components/schemas/StatusOK' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/NotFound' }
        '409': { $ref: '#/components/responses/Conflict' }
        '500': { $ref: '#/components/responses/ServerError' }
  /v1/me/identities/{identity_id}/verify:
    post:
      tags: [Auth]
      summary: Verify an identity with an OTP
      operationId: verifyIdentity
      security: [{ bearerAuth: [] }]
      parameters:
        - name: identity_id
          in: path
          required: true
          schema: { type: string }
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/VerifyIdentityReq' }
      responses:
        '200':
          description: Identity verified
          content:
            application/json:
              schema: { $ref: '#/components/schemas/StatusOK' }
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/NotFound' }
        '500': { $ref: '#/components/responses/ServerError' }
  /v1/me/identities/{identity_id}/primary:
    post:
      tags: [Auth]
      summary: Set the primary identity
      operationId: setPrimaryIdentity
      security: [{ bearerAuth: [] }]
      parameters:
        - name: identity_id
          in: path
          required: true
          schema: { type: string }
      responses:
        '200':
          description: Primary identity updated
          content:
            application/json:
              schema: { $ref: '#/components/schemas/StatusOK' }
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/NotFound' }
        '500': { $ref: '#/components/responses/ServerError' }

  /v1/oauth/clients:
    post:
      tags: [OAuth]
      summary: Register an unowned OAuth client (admin)
      operationId: registerClient
      security: [{ bearerAuth: [] }]
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/RegisterClientReq' }
      responses:
        '201':
          description: Client created (secret returned once)
          content:
            application/json:
              schema: { $ref: '#/components/schemas/CreatedClient' }
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '500': { $ref: '#/components/responses/ServerError' }
  /v1/oauth/token:
    post:
      tags: [OAuth]
      summary: Exchange an authorization code for tokens (PKCE supported), or rotate a refresh token
      description: >
        grant_type=authorization_code exchanges a code for a token pair (client_secret required,
        PKCE enforced when a challenge was issued). grant_type=refresh_token rotates a refresh
        token and returns a fresh pair; client_id/client_secret are optional for this grant —
        when presented they are verified, when absent the refresh token itself is the credential
        (same semantics as /v1/auth/refresh). Accepts JSON or application/x-www-form-urlencoded.
      operationId: token
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/TokenReq' }
      responses:
        '200':
          description: Tokens issued
          content:
            application/json:
              schema: { $ref: '#/components/schemas/TokenResponse' }
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '500': { $ref: '#/components/responses/ServerError' }
  /v1/oauth/revoke:
    post:
      tags: [OAuth]
      summary: Revoke a refresh token (RFC 7009); unknown tokens also return 200
      operationId: revokeToken
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/RevokeReq' }
      responses:
        '200':
          description: Revocation accepted (also for unknown tokens)
          content:
            application/json:
              schema: { $ref: '#/components/schemas/StatusOK' }
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '500': { $ref: '#/components/responses/ServerError' }
  /v1/oauth/authorize:
    post:
      tags: [OAuth]
      summary: Issue an authorization code (authenticated)
      operationId: authorize
      security: [{ bearerAuth: [] }]
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/AuthorizeReq' }
      responses:
        '200':
          description: Code issued
          content:
            application/json:
              schema: { $ref: '#/components/schemas/AuthorizeResponse' }
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '500': { $ref: '#/components/responses/ServerError' }
  /v1/oauth/userinfo:
    get:
      tags: [OAuth]
      summary: OIDC-style claims of the access-token subject (authenticated)
      description: Claims are filtered by the token's consented scopes — `email` gates email/email_verified, `profile` gates name/picture/mfa_enabled, `sub` is always returned. Scope-less tokens (login/refresh) receive the full identity.
      operationId: userinfo
      security: [{ bearerAuth: [] }]
      responses:
        '200':
          description: Identity claims
          content:
            application/json:
              schema: { $ref: '#/components/schemas/UserInfoResponse' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { $ref: '#/components/responses/NotFound' }
        '500': { $ref: '#/components/responses/ServerError' }
  /v1/oauth/consent:
    get:
      tags: [OAuth]
      summary: Render the HTML consent page (authenticated)
      operationId: consentPage
      security: [{ bearerAuth: [] }]
      parameters:
        - { name: client_id, in: query, required: true, schema: { type: string } }
        - { name: redirect_uri, in: query, required: true, schema: { type: string, format: uri } }
        - { name: scope, in: query, required: false, schema: { type: string } }
        - { name: state, in: query, required: false, schema: { type: string } }
        - { name: code_challenge, in: query, required: false, schema: { type: string } }
        - { name: code_challenge_method, in: query, required: false, schema: { type: string } }
      responses:
        '200':
          description: Consent HTML page
          content:
            text/html:
              schema: { type: string }
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
    post:
      tags: [OAuth]
      summary: Submit the consent decision (authenticated); redirects on completion
      operationId: consentSubmit
      security: [{ bearerAuth: [] }]
      requestBody:
        required: true
        content:
          application/x-www-form-urlencoded:
            schema:
              type: object
              properties:
                client_id: { type: string }
                redirect_uri: { type: string, format: uri }
                scope: { type: string }
                state: { type: string }
                code_challenge: { type: string }
                code_challenge_method: { type: string }
                consent: { type: string, enum: [yes, no] }
      responses:
        '302':
          description: Redirect to the validated redirect_uri (code= or error=)
          headers:
            Location:
              schema: { type: string, format: uri }
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }

  /v1/me:
    get:
      tags: [Partner]
      summary: Current user profile
      operationId: me
      security: [{ bearerAuth: [] }]
      responses:
        '200':
          description: Profile
          content:
            application/json:
              schema: { $ref: '#/components/schemas/User' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { $ref: '#/components/responses/NotFound' }
        '500': { $ref: '#/components/responses/ServerError' }
    put:
      tags: [Partner]
      summary: Replace the current user's display name and avatar
      operationId: updateProfile
      security: [{ bearerAuth: [] }]
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/UpdateProfileReq' }
      responses:
        '200':
          description: Updated profile
          content:
            application/json:
              schema: { $ref: '#/components/schemas/User' }
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { $ref: '#/components/responses/NotFound' }
        '500': { $ref: '#/components/responses/ServerError' }
    delete:
      tags: [Partner]
      summary: Delete the current user's account (PII erased, grants dropped, sessions revoked; order ledger kept with opaque user_id)
      operationId: deleteMyAccount
      security: [{ bearerAuth: [] }]
      responses:
        '200':
          description: Account deleted
          content:
            application/json:
              schema: { $ref: '#/components/schemas/StatusOK' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { $ref: '#/components/responses/NotFound' }
        '500': { $ref: '#/components/responses/ServerError' }
  /v1/me/entitlements/offline-credential:
    post:
      tags: [OfflineCred]
      summary: Issue a signed offline entitlement credential (短期有效, JWKS-verifiable offline)
      operationId: issueOfflineCredential
      security: [{ bearerAuth: [] }]
      requestBody:
        required: false
        content:
          application/json:
            schema:
              type: object
              properties:
                install_id: { type: string, description: Optional device binding }
      responses:
        '201':
          description: Credential issued
          content:
            application/json:
              schema: { $ref: '#/components/schemas/OfflineCredential' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '500': { $ref: '#/components/responses/ServerError' }
    delete:
      tags: [OfflineCred]
      summary: Revoke all outstanding offline credentials (bumps revocation version)
      operationId: revokeOfflineCredentials
      security: [{ bearerAuth: [] }]
      responses:
        '200':
          description: New revocation version
        '401': { $ref: '#/components/responses/Unauthorized' }
        '500': { $ref: '#/components/responses/ServerError' }
  /v1/me/entitlements/offline-credential/redeem:
    post:
      tags: [OfflineCred]
      summary: Online check for a presented credential (signature, expiry, revocation version, device, anti-replay)
      operationId: redeemOfflineCredential
      security: [{ bearerAuth: [] }]
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/OfflineCredentialRedeemReq' }
      responses:
        '200':
          description: Credential valid and redeemed
          content:
            application/json:
              schema: { $ref: '#/components/schemas/OfflineCredentialRedeemResult' }
        '401':
          description: credential.invalid / credential.expired / credential.revoked
        '403':
          description: credential.device_mismatch
        '409':
          description: credential.replayed (anti-replay)
        '500': { $ref: '#/components/responses/ServerError' }
  /v1/me/wallet/holds:
    post:
      tags: [Wallet]
      summary: Create a consumption hold (预占, 8.3)
      operationId: createWalletHold
      security: [{ bearerAuth: [] }]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [amount]
              properties:
                currency: { type: string, default: POINTS }
                amount: { type: integer, format: int64, minimum: 1 }
                description: { type: string }
                ttl_seconds: { type: integer, format: int64, description: Hold lifetime; default 86400 }
      responses:
        '201':
          description: Hold created; amount debited until capture/release
          content:
            application/json:
              schema: { $ref: '#/components/schemas/WalletHold' }
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '409':
          description: wallet.insufficient_balance
        '500': { $ref: '#/components/responses/ServerError' }
    get:
      tags: [Wallet]
      summary: List my wallet holds
      operationId: listWalletHolds
      security: [{ bearerAuth: [] }]
      parameters:
        - { name: page, in: query, schema: { type: integer, default: 1 } }
        - { name: limit, in: query, schema: { type: integer, default: 20 } }
      responses:
        '200':
          description: Hold list (newest first)
        '401': { $ref: '#/components/responses/Unauthorized' }
        '500': { $ref: '#/components/responses/ServerError' }
  /v1/me/wallet/holds/{id}/capture:
    post:
      tags: [Wallet]
      summary: Capture (settle) a hold
      operationId: captureWalletHold
      security: [{ bearerAuth: [] }]
      parameters:
        - { name: id, in: path, required: true, schema: { type: string, format: uuid } }
      responses:
        '200':
          description: Captured
          content:
            application/json:
              schema: { $ref: '#/components/schemas/WalletHold' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { $ref: '#/components/responses/NotFound' }
        '409':
          description: wallet.hold_not_pending
        '500': { $ref: '#/components/responses/ServerError' }
  /v1/me/wallet/holds/{id}/release:
    post:
      tags: [Wallet]
      summary: Release a hold (credit the amount back)
      operationId: releaseWalletHold
      security: [{ bearerAuth: [] }]
      parameters:
        - { name: id, in: path, required: true, schema: { type: string, format: uuid } }
      responses:
        '200':
          description: Released
          content:
            application/json:
              schema: { $ref: '#/components/schemas/WalletHold' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { $ref: '#/components/responses/NotFound' }
        '409':
          description: wallet.hold_not_pending
        '500': { $ref: '#/components/responses/ServerError' }
  /v1/me/username:
    post:
      tags: [Partner]
      summary: Claim a login username once (immutable; 7.2)
      operationId: setUsername
      security: [{ bearerAuth: [] }]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [username]
              properties:
                username: { type: string, minLength: 3, maxLength: 32 }
      responses:
        '200':
          description: Username set
        '400':
          description: user.invalid_username (format or reserved word)
        '401': { $ref: '#/components/responses/Unauthorized' }
        '409':
          description: user.username_taken or user.username_locked
        '500': { $ref: '#/components/responses/ServerError' }
  /v1/me/consents:
    get:
      tags: [Privacy]
      summary: Latest privacy consent per document with current versions (17)
      operationId: listConsents
      security: [{ bearerAuth: [] }]
      responses:
        '200':
          description: Consent statuses; needs_reconsent=true when the document was never consented or the version moved
          content:
            application/json:
              schema: { $ref: '#/components/schemas/ConsentStatusList' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '500': { $ref: '#/components/responses/ServerError' }
    post:
      tags: [Privacy]
      summary: Re-consent to the current version of a document
      operationId: recordConsent
      security: [{ bearerAuth: [] }]
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/RecordConsentReq' }
      responses:
        '201':
          description: Consent recorded
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '500': { $ref: '#/components/responses/ServerError' }
  /v1/me/deletion-requests:
    post:
      tags: [Privacy]
      summary: Request account deletion (cooling-off flow, 7.8)
      operationId: requestDeletion
      security: [{ bearerAuth: [] }]
      requestBody:
        required: false
        content:
          application/json:
            schema:
              type: object
              properties:
                reason: { type: string, maxLength: 512 }
      responses:
        '201':
          description: Deletion request created; account anonymizes after scheduled_at
          content:
            application/json:
              schema: { $ref: '#/components/schemas/DeletionRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '409':
          description: deletion.already_pending, or deletion.blocked with in-flight business detail
          content:
            application/json:
              schema:
                type: object
                properties:
                  error: { $ref: '#/components/schemas/Error' }
                  blockers: { $ref: '#/components/schemas/DeletionBlockers' }
        '500': { $ref: '#/components/responses/ServerError' }
    get:
      tags: [Privacy]
      summary: Latest deletion request status (or status=none)
      operationId: getDeletionRequest
      security: [{ bearerAuth: [] }]
      responses:
        '200':
          description: Latest deletion request
        '401': { $ref: '#/components/responses/Unauthorized' }
        '500': { $ref: '#/components/responses/ServerError' }
    delete:
      tags: [Privacy]
      summary: Cancel the pending deletion request during cooling-off
      operationId: cancelDeletionRequest
      security: [{ bearerAuth: [] }]
      responses:
        '200':
          description: Canceled
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { $ref: '#/components/responses/NotFound' }
        '500': { $ref: '#/components/responses/ServerError' }
  /v1/me/invite:
    get:
      tags: [Invite]
      summary: My invite code, links and invite count (7.7)
      operationId: myInvite
      security: [{ bearerAuth: [] }]
      responses:
        '200':
          description: Invite info; code (and first link) are lazily allocated on first access
          content:
            application/json:
              schema: { $ref: '#/components/schemas/InviteInfo' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '500': { $ref: '#/components/responses/ServerError' }
  /v1/me/invite/links:
    post:
      tags: [Invite]
      summary: Rotate - mint a new invite link (older links stay valid)
      operationId: createInviteLink
      security: [{ bearerAuth: [] }]
      responses:
        '201':
          description: New invite link
          content:
            application/json:
              schema: { $ref: '#/components/schemas/InviteLink' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '429': { $ref: '#/components/responses/TooManyRequests' }
        '500': { $ref: '#/components/responses/ServerError' }
  /v1/me/invite/rewards:
    get:
      tags: [Invite]
      summary: My referral rewards (7.7 奖励账本; granted on invitee's first paid order, clawed back on refund)
      operationId: listInviteRewards
      security: [{ bearerAuth: [] }]
      parameters:
        - { name: page, in: query, schema: { type: integer, default: 1 } }
        - { name: limit, in: query, schema: { type: integer, default: 20 } }
      responses:
        '200':
          description: Reward list
        '401': { $ref: '#/components/responses/Unauthorized' }
        '500': { $ref: '#/components/responses/ServerError' }
  /v1/me/invite/bindings:
    get:
      tags: [Invite]
      summary: Users I invited (invitee email masked, paginated)
      operationId: listInviteBindings
      security: [{ bearerAuth: [] }]
      parameters:
        - { name: page, in: query, schema: { type: integer, default: 1 } }
        - { name: limit, in: query, schema: { type: integer, default: 20, maximum: 100 } }
      responses:
        '200':
          description: Binding list
          content:
            application/json:
              schema: { $ref: '#/components/schemas/InviteBindingList' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '500': { $ref: '#/components/responses/ServerError' }
  /v1/invite-links/{token}:
    get:
      tags: [Invite]
      summary: Publicly resolve an invite link token (registration landing)
      operationId: resolveInviteLink
      parameters:
        - { name: token, in: path, required: true, schema: { type: string } }
      responses:
        '200':
          description: Link resolved
          content:
            application/json:
              schema: { $ref: '#/components/schemas/InviteLinkResolve' }
        '404': { $ref: '#/components/responses/NotFound' }
        '429': { $ref: '#/components/responses/TooManyRequests' }
        '500': { $ref: '#/components/responses/ServerError' }
  /v1/me/sessions:
    get:
      tags: [Partner]
      summary: List the current user's active login sessions
      operationId: listSessions
      security: [{ bearerAuth: [] }]
      responses:
        '200':
          description: Active sessions
          content:
            application/json:
              schema: { $ref: '#/components/schemas/SessionsResponse' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '500': { $ref: '#/components/responses/ServerError' }
  /v1/me/sessions/{session_id}:
    delete:
      tags: [Partner]
      summary: Revoke one of the current user's sessions
      operationId: revokeSession
      security: [{ bearerAuth: [] }]
      parameters:
        - { name: session_id, in: path, required: true, schema: { type: string } }
      responses:
        '200':
          description: Session revoked
          content:
            application/json:
              schema: { $ref: '#/components/schemas/StatusOK' }
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { $ref: '#/components/responses/NotFound' }
        '500': { $ref: '#/components/responses/ServerError' }
  /v1/me/devices:
    get:
      tags: [Auth]
      summary: List the current user's registered devices
      operationId: listDevices
      security: [{ bearerAuth: [] }]
      responses:
        '200':
          description: Devices
          content:
            application/json:
              schema: { $ref: '#/components/schemas/DevicesResponse' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '500': { $ref: '#/components/responses/ServerError' }
  /v1/me/devices/{device_id}/revoke:
    post:
      tags: [Auth]
      summary: Revoke a device and its sessions
      operationId: revokeDevice
      security: [{ bearerAuth: [] }]
      parameters:
        - { name: device_id, in: path, required: true, schema: { type: string, format: uuid } }
      responses:
        '200':
          description: Device revoked
          content:
            application/json:
              schema: { $ref: '#/components/schemas/RevokedStatus' }
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { $ref: '#/components/responses/NotFound' }
        '409': { $ref: '#/components/responses/Conflict' }
        '500': { $ref: '#/components/responses/ServerError' }
  /v1/me/api-keys:
    get:
      tags: [Partner]
      summary: List the current user's API keys
      operationId: listAPIKeys
      security: [{ bearerAuth: [] }]
      responses:
        '200':
          description: Active API keys
          content:
            application/json:
              schema: { $ref: '#/components/schemas/APIKeyListEnvelope' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '500': { $ref: '#/components/responses/ServerError' }
    post:
      tags: [Partner]
      summary: Create a new API key (raw key returned once)
      operationId: createAPIKey
      security: [{ bearerAuth: [] }]
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/CreateAPIKeyReq' }
      responses:
        '200':
          description: API key created
          content:
            application/json:
              schema: { $ref: '#/components/schemas/CreatedAPIKey' }
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '500': { $ref: '#/components/responses/ServerError' }
  /v1/me/api-keys/{key_id}:
    delete:
      tags: [Partner]
      summary: Revoke an API key
      operationId: revokeAPIKey
      security: [{ bearerAuth: [] }]
      parameters:
        - { name: key_id, in: path, required: true, schema: { type: string, format: uuid } }
      responses:
        '200':
          description: API key revoked
          content:
            application/json:
              schema: { $ref: '#/components/schemas/StatusOK' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { $ref: '#/components/responses/NotFound' }
        '500': { $ref: '#/components/responses/ServerError' }
  /v1/me/purchases:
    get:
      tags: [Partner]
      summary: List the current user's purchases (paid/refunded orders) across applications
      operationId: listPurchases
      security: [{ bearerAuth: [] }]
      parameters:
        - { name: page, in: query, schema: { type: integer, default: 1 } }
        - { name: limit, in: query, schema: { type: integer, default: 20, maximum: 100 } }
        - { name: from, in: query, description: "Window start (RFC3339 or YYYY-MM-DD, UTC); filters on order created_at", schema: { type: string } }
        - { name: to, in: query, description: "Window end, half-open (RFC3339 or YYYY-MM-DD end-of-day, UTC)", schema: { type: string } }
      responses:
        '200':
          description: The user's own consumption records, newest first
          content:
            application/json:
              schema: { $ref: '#/components/schemas/PurchaseListEnvelope' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '500': { $ref: '#/components/responses/ServerError' }
  /v1/me/export:
    get:
      tags: [Partner]
      summary: Export all data the platform holds about the current user (JSON download)
      operationId: exportMyData
      security: [{ bearerAuth: [] }]
      responses:
        '200':
          description: Portable data export (profile, OAuth grants, purchase history)
          headers:
            Content-Disposition: { schema: { type: string }, description: 'attachment; filename="workmembership-data-export.json"' }
          content:
            application/json:
              schema: { $ref: '#/components/schemas/UserDataExport' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { $ref: '#/components/responses/NotFound' }
        '500': { $ref: '#/components/responses/ServerError' }
  /v1/auth/logout-all:
    post:
      tags: [Auth]
      summary: Revoke all refresh tokens (authenticated)
      operationId: logoutAll
      security: [{ bearerAuth: [] }]
      responses:
        '200':
          description: All sessions revoked
          content:
            application/json:
              schema: { $ref: '#/components/schemas/StatusOK' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '500': { $ref: '#/components/responses/ServerError' }
  /v1/auth/change-password:
    post:
      tags: [Auth]
      summary: Change password (authenticated)
      operationId: changePassword
      security: [{ bearerAuth: [] }]
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/ChangeReq' }
      responses:
        '200':
          description: Password changed
          content:
            application/json:
              schema: { $ref: '#/components/schemas/StatusOK' }
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '422': { $ref: '#/components/responses/BadRequest' }
        '500': { $ref: '#/components/responses/ServerError' }
  /v1/auth/mfa/setup:
    post:
      tags: [Auth]
      summary: Begin MFA enrollment (authenticated)
      operationId: mfaSetup
      security: [{ bearerAuth: [] }]
      responses:
        '200':
          description: Secret + otpauth URI
          content:
            application/json:
              schema: { $ref: '#/components/schemas/MFASetup' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '409': { $ref: '#/components/responses/Conflict' }
        '500': { $ref: '#/components/responses/ServerError' }
  /v1/auth/mfa/verify-setup:
    post:
      tags: [Auth]
      summary: Confirm MFA enrollment (authenticated)
      operationId: mfaVerifySetup
      security: [{ bearerAuth: [] }]
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/MFAVerifySetupReq' }
      responses:
        '200':
          description: MFA enabled
          content:
            application/json:
              schema: { $ref: '#/components/schemas/StatusOK' }
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '500': { $ref: '#/components/responses/ServerError' }
  /v1/auth/mfa/disable:
    post:
      tags: [Auth]
      summary: Disable MFA (authenticated)
      operationId: mfaDisable
      security: [{ bearerAuth: [] }]
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/MFADisableReq' }
      responses:
        '200':
          description: MFA disabled
          content:
            application/json:
              schema: { $ref: '#/components/schemas/StatusOK' }
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '500': { $ref: '#/components/responses/ServerError' }
  /v1/auth/send-verification-email:
    post:
      tags: [Auth]
      summary: Request a verification email for the authenticated user (rate-limited)
      operationId: sendVerificationEmail
      security: [{ bearerAuth: [] }]
      requestBody:
        required: false
        content:
          application/json:
            schema: { $ref: '#/components/schemas/SendVerificationEmailReq' }
      responses:
        '202':
          description: Verification email queued
          content:
            application/json:
              schema:
                type: object
                properties:
                  status: { type: string, example: accepted }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '409': { $ref: '#/components/responses/Conflict' }
        '429': { $ref: '#/components/responses/TooManyRequests' }
        '500': { $ref: '#/components/responses/ServerError' }
  /v1/auth/verify-email:
    post:
      tags: [Auth]
      summary: Verify an email address with a one-time token (rate-limited)
      operationId: verifyEmail
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/VerifyEmailReq' }
      responses:
        '200':
          description: Email verified
          content:
            application/json:
              schema: { $ref: '#/components/schemas/StatusOK' }
        '400': { $ref: '#/components/responses/BadRequest' }
        '429': { $ref: '#/components/responses/TooManyRequests' }
        '500': { $ref: '#/components/responses/ServerError' }

  /v1/me/oauth/grants:
    get:
      tags: [Partner]
      summary: List applications the current user has authorized
      operationId: listGrants
      security: [{ bearerAuth: [] }]
      responses:
        '200':
          description: Authorized applications
          content:
            application/json:
              schema: { $ref: '#/components/schemas/GrantsResponse' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '500': { $ref: '#/components/responses/ServerError' }
  /v1/me/oauth/grants/{client_id}:
    delete:
      tags: [Partner]
      summary: Revoke the current user's authorization for an application (idempotent)
      operationId: revokeGrant
      security: [{ bearerAuth: [] }]
      parameters:
        - { name: client_id, in: path, required: true, schema: { type: string } }
      responses:
        '200':
          description: Revocation result
          content:
            application/json:
              schema: { $ref: '#/components/schemas/StatusOK' }
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '500': { $ref: '#/components/responses/ServerError' }
  /v1/me/oauth/clients:
    post:
      tags: [Partner]
      summary: Register an owned OAuth client (partner self-service)
      operationId: partnerRegisterClient
      security: [{ bearerAuth: [] }]
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/PartnerRegisterReq' }
      responses:
        '201':
          description: Client created (secret returned once)
          content:
            application/json:
              schema: { $ref: '#/components/schemas/PartnerClientCreated' }
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '500': { $ref: '#/components/responses/ServerError' }
    get:
      tags: [Partner]
      summary: List OAuth clients accessible to the caller (owned + shared)
      operationId: partnerListClients
      security: [{ bearerAuth: [] }]
      responses:
        '200':
          description: Bare array of clients
          content:
            application/json:
              schema:
                type: array
                items: { $ref: '#/components/schemas/OAuthClient' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '500': { $ref: '#/components/responses/ServerError' }
  /v1/me/oauth/clients/{client_id}:
    put:
      tags: [Partner]
      summary: Edit client info — name, redirect_uris, scopes (admin+, omitted fields preserved)
      operationId: partnerUpdateClient
      security: [{ bearerAuth: [] }]
      parameters:
        - { name: client_id, in: path, required: true, schema: { type: string } }
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/UpdateClientReq' }
      responses:
        '200':
          description: Updated client
          content:
            application/json:
              schema: { $ref: '#/components/schemas/OAuthClient' }
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { $ref: '#/components/responses/NotFound' }
        '500': { $ref: '#/components/responses/ServerError' }
  /v1/me/oauth/clients/{client_id}/directory:
    put:
      tags: [Partner]
      summary: Edit the public directory profile — description and category (admin+, full replace; empty clears)
      operationId: partnerUpdateDirectoryInfo
      security: [{ bearerAuth: [] }]
      parameters:
        - { name: client_id, in: path, required: true, schema: { type: string } }
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/UpdateDirectoryReq' }
      responses:
        '200':
          description: Updated directory profile
          content:
            application/json:
              schema: { $ref: '#/components/schemas/UpdateDirectoryReq' }
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { $ref: '#/components/responses/NotFound' }
        '500': { $ref: '#/components/responses/ServerError' }
  /v1/me/oauth/clients/{client_id}/status:
    post:
      tags: [Partner]
      summary: Shelve (disabled) or restore (active) a client (owner only)
      operationId: partnerSetClientStatus
      security: [{ bearerAuth: [] }]
      parameters:
        - { name: client_id, in: path, required: true, schema: { type: string } }
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/SetClientStatusReq' }
      responses:
        '200':
          description: New status
          content:
            application/json:
              schema:
                type: object
                properties:
                  client_id: { type: string }
                  status: { type: string, enum: [active, disabled] }
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { $ref: '#/components/responses/NotFound' }
        '500': { $ref: '#/components/responses/ServerError' }
  /v1/me/oauth/clients/{client_id}/secret:
    post:
      tags: [Partner]
      summary: Rotate the client secret (owner only, new secret returned once)
      operationId: partnerRegenerateClientSecret
      security: [{ bearerAuth: [] }]
      parameters:
        - { name: client_id, in: path, required: true, schema: { type: string } }
      responses:
        '200':
          description: Rotated secret (shown once)
          content:
            application/json:
              schema:
                type: object
                properties:
                  client_id: { type: string }
                  client_secret: { type: string }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { $ref: '#/components/responses/NotFound' }
        '500': { $ref: '#/components/responses/ServerError' }
  /v1/me/oauth/clients/{client_id}/members:
    post:
      tags: [Partner]
      summary: Add or update a member (owner only)
      operationId: partnerAddMember
      security: [{ bearerAuth: [] }]
      parameters:
        - { name: client_id, in: path, required: true, schema: { type: string } }
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/AddMemberReq' }
      responses:
        '201':
          description: Member upserted
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Member' }
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { $ref: '#/components/responses/NotFound' }
        '500': { $ref: '#/components/responses/ServerError' }
    get:
      tags: [Partner]
      summary: List members (viewer+)
      operationId: partnerListMembers
      security: [{ bearerAuth: [] }]
      parameters:
        - { name: client_id, in: path, required: true, schema: { type: string } }
      responses:
        '200':
          description: Bare array of members
          content:
            application/json:
              schema:
                type: array
                items: { $ref: '#/components/schemas/Member' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { $ref: '#/components/responses/NotFound' }
        '500': { $ref: '#/components/responses/ServerError' }
  /v1/me/oauth/clients/{client_id}/members/{user_id}:
    delete:
      tags: [Partner]
      summary: Remove a member (owner only)
      operationId: partnerRemoveMember
      security: [{ bearerAuth: [] }]
      parameters:
        - { name: client_id, in: path, required: true, schema: { type: string } }
        - { name: user_id, in: path, required: true, schema: { type: string, format: uuid } }
      responses:
        '204': { description: Removed }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { $ref: '#/components/responses/NotFound' }
        '500': { $ref: '#/components/responses/ServerError' }
  /v1/me/oauth/clients/{client_id}/payment-config-approval:
    put:
      tags: [Partner]
      summary: Toggle the app-level dual-approval gate for payment config changes (owner/admin)
      operationId: partnerSetPaymentConfigApprovalRequired
      security: [{ bearerAuth: [] }]
      parameters:
        - { name: client_id, in: path, required: true, schema: { type: string } }
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                require_approval: { type: boolean }
              required: [require_approval]
      responses:
        '200':
          description: New gate state
          content:
            application/json:
              schema:
                type: object
                properties:
                  client_id: { type: string }
                  require_approval: { type: boolean }
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/NotFound' }
        '409': { $ref: '#/components/responses/Conflict' }
        '500': { $ref: '#/components/responses/ServerError' }
  /v1/me/oauth/clients/{client_id}/invites:
    parameters:
      - { name: client_id, in: path, required: true, schema: { type: string } }
    get:
      tags: [Partner]
      summary: List member invitations for a client (owner only)
      operationId: listInvites
      security: [{ bearerAuth: [] }]
      responses:
        '200':
          description: Invitation list
          content:
            application/json:
              schema: { $ref: '#/components/schemas/InviteListEnvelope' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { $ref: '#/components/responses/NotFound' }
        '500': { $ref: '#/components/responses/ServerError' }
    post:
      tags: [Partner]
      summary: Send a member invitation (owner only, rate-limited)
      operationId: sendInvite
      security: [{ bearerAuth: [] }]
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/SendInviteReq' }
      responses:
        '201':
          description: Invitation created
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Invite' }
        '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/ServerError' }
  /v1/me/oauth/clients/{client_id}/invites/{invite_id}/cancel:
    parameters:
      - { name: client_id, in: path, required: true, schema: { type: string } }
      - { name: invite_id, in: path, required: true, schema: { type: string, format: uuid } }
    post:
      tags: [Partner]
      summary: Cancel a pending invitation (owner only)
      operationId: cancelInvite
      security: [{ bearerAuth: [] }]
      responses:
        '204': { description: Cancelled }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { $ref: '#/components/responses/NotFound' }
        '409': { $ref: '#/components/responses/Conflict' }
        '500': { $ref: '#/components/responses/ServerError' }
  /v1/me/oauth/clients/{client_id}/invites/{invite_id}/resend:
    parameters:
      - { name: client_id, in: path, required: true, schema: { type: string } }
      - { name: invite_id, in: path, required: true, schema: { type: string, format: uuid } }
    post:
      tags: [Partner]
      summary: Resend a pending invitation with a rotated token (owner only, rate-limited)
      operationId: resendInvite
      security: [{ bearerAuth: [] }]
      responses:
        '200':
          description: Invitation updated
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Invite' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { $ref: '#/components/responses/NotFound' }
        '409': { $ref: '#/components/responses/Conflict' }
        '429': { $ref: '#/components/responses/TooManyRequests' }
        '500': { $ref: '#/components/responses/ServerError' }
  /v1/me/oauth/invites/accept:
    post:
      tags: [Partner]
      summary: Accept a member invitation (rate-limited)
      operationId: acceptInvite
      security: [{ bearerAuth: [] }]
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/AcceptInviteReq' }
      responses:
        '200':
          description: Membership granted
          content:
            application/json:
              schema: { $ref: '#/components/schemas/InviteAccepted' }
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/NotFound' }
        '409': { $ref: '#/components/responses/Conflict' }
        '410':
          description: Invitation expired
          content:
            application/json:
              schema: { $ref: '#/components/schemas/ErrorEnvelope' }
        '429': { $ref: '#/components/responses/TooManyRequests' }
        '500': { $ref: '#/components/responses/ServerError' }
  /v1/me/oauth/clients/{client_id}/users:
    get:
      tags: [Partner]
      summary: List users who authorized this client, with their grant records (viewer+)
      operationId: partnerListClientUsers
      security: [{ bearerAuth: [] }]
      parameters:
        - { name: client_id, in: path, required: true, schema: { type: string } }
        - { name: page, in: query, schema: { type: integer, default: 1 } }
        - { name: limit, in: query, schema: { type: integer, default: 20 } }
      responses:
        '200':
          description: Paged users + grant records; public user fields only (no phone/avatar/credential material)
          content:
            application/json:
              schema: { $ref: '#/components/schemas/PartnerClientUserListEnvelope' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { $ref: '#/components/responses/NotFound' }
        '500': { $ref: '#/components/responses/ServerError' }
  /v1/me/oauth/clients/{client_id}/users/{user_id}/attributes:
    get:
      tags: [Partner]
      summary: Read the per-application custom attributes of a granted user (viewer+)
      description: "Returns the attributes JSON object stored for this (client, user) pair, or null when never set. 404 when the user has no grant for this client (same population as the users list). Attributes are per application — the same user carries independent attributes on every client."
      operationId: partnerGetAppUserAttributes
      security: [{ bearerAuth: [] }]
      parameters:
        - { name: client_id, in: path, required: true, schema: { type: string } }
        - { name: user_id, in: path, required: true, schema: { type: string, format: uuid } }
      responses:
        '200':
          description: The stored attributes object, or null when never set
          content:
            application/json:
              schema:
                type: object
                properties:
                  attributes: { type: [object, 'null'] }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { $ref: '#/components/responses/NotFound' }
        '500': { $ref: '#/components/responses/ServerError' }
    put:
      tags: [Partner]
      summary: Upsert the per-application custom attributes of a granted user (admin+)
      description: "Body {\"attributes\": {...}} — must be a JSON object of at most 16KB serialized (400 attributes.invalid otherwise). The platform stores the payload verbatim and never interprets it (e.g. product subscription validity). 404 when the user has no grant for this client. Audit-logged as app_user_attributes_update."
      operationId: partnerPutAppUserAttributes
      security: [{ bearerAuth: [] }]
      parameters:
        - { name: client_id, in: path, required: true, schema: { type: string } }
        - { name: user_id, in: path, required: true, schema: { type: string, format: uuid } }
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [attributes]
              properties:
                attributes: { type: object }
      responses:
        '200':
          description: Attributes stored
          content:
            application/json:
              schema:
                type: object
                properties:
                  application_id: { type: string }
                  user_id: { type: string, format: uuid }
                  attributes: { type: object }
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { $ref: '#/components/responses/NotFound' }
        '500': { $ref: '#/components/responses/ServerError' }
  /v1/me/oauth/clients/{client_id}/grants/{user_id}:
    delete:
      tags: [Partner]
      summary: Revoke a user's authorization on this client (admin+)
      description: Compliance scope — revokes only this client's grant for the user; the user account and grants on other clients are untouched. Idempotent hard delete, same semantics as the user-side revoke.
      operationId: partnerRevokeClientUserGrant
      security: [{ bearerAuth: [] }]
      parameters:
        - { name: client_id, in: path, required: true, schema: { type: string } }
        - { name: user_id, in: path, required: true, schema: { type: string, format: uuid } }
      responses:
        '204': { description: Revoked (also returned when the grant was already absent) }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { $ref: '#/components/responses/NotFound' }
        '500': { $ref: '#/components/responses/ServerError' }

  /v1/me/oauth/clients/{client_id}/skus:
    get:
      tags: [Partner]
      summary: List the partner's SKUs with current active price (viewer+)
      operationId: partnerListSKUs
      security: [{ bearerAuth: [] }]
      parameters:
        - { name: client_id, in: path, required: true, schema: { type: string } }
      responses:
        '200':
          description: SKU list
          content:
            application/json:
              schema:
                type: object
                properties:
                  skus: { type: array, items: { $ref: '#/components/schemas/PartnerSKU' } }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { $ref: '#/components/responses/NotFound' }
        '500': { $ref: '#/components/responses/ServerError' }
    post:
      tags: [Partner]
      summary: Create a SKU under the application (owner/admin)
      operationId: partnerCreateSKU
      security: [{ bearerAuth: [] }]
      parameters:
        - { name: client_id, in: path, required: true, schema: { type: string } }
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/CreatePartnerSKURequest' }
      responses:
        '201':
          description: SKU created
          content:
            application/json:
              schema: { $ref: '#/components/schemas/PartnerSKU' }
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { $ref: '#/components/responses/NotFound' }
        '409': { description: sku code already exists }
        '500': { $ref: '#/components/responses/ServerError' }
  /v1/me/oauth/clients/{client_id}/skus/{sku_id}/status:
    post:
      tags: [Partner]
      summary: Enable/disable a partner SKU (owner/admin)
      operationId: partnerSetSKUStatus
      security: [{ bearerAuth: [] }]
      parameters:
        - { name: client_id, in: path, required: true, schema: { type: string } }
        - { name: sku_id, in: path, required: true, schema: { type: string, format: uuid } }
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [status]
              properties:
                status: { type: string, enum: [active, inactive] }
      responses:
        '200': { description: Status updated }
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { $ref: '#/components/responses/NotFound' }
        '500': { $ref: '#/components/responses/ServerError' }
  /v1/me/oauth/clients/{client_id}/skus/{sku_id}/prices:
    get:
      tags: [Partner]
      summary: Price version history of a partner SKU (viewer+)
      operationId: partnerListSKUPrices
      security: [{ bearerAuth: [] }]
      parameters:
        - { name: client_id, in: path, required: true, schema: { type: string } }
        - { name: sku_id, in: path, required: true, schema: { type: string, format: uuid } }
      responses:
        '200':
          description: Price versions, newest first
          content:
            application/json:
              schema:
                type: object
                properties:
                  prices: { type: array, items: { $ref: '#/components/schemas/Price' } }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { $ref: '#/components/responses/NotFound' }
        '500': { $ref: '#/components/responses/ServerError' }
    post:
      tags: [Partner]
      summary: Create a price version (supersedes overlapping active price; owner/admin)
      operationId: partnerCreatePriceVersion
      security: [{ bearerAuth: [] }]
      parameters:
        - { name: client_id, in: path, required: true, schema: { type: string } }
        - { name: sku_id, in: path, required: true, schema: { type: string, format: uuid } }
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/CreatePartnerPriceRequest' }
      responses:
        '201':
          description: Price version created
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Price' }
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { $ref: '#/components/responses/NotFound' }
        '500': { $ref: '#/components/responses/ServerError' }

  /v1/me/payment-configs:
    get:
      tags: [Partner]
      summary: List active payment configs the caller may copy from (redacted; every client they own or collaborate on)
      operationId: listAccessiblePaymentConfigs
      security: [{ bearerAuth: [] }]
      parameters:
        - { name: channel, in: query, required: true, schema: { type: string, enum: [wechat, alipay] } }
      responses:
        '200':
          description: Copy-source list; never carries secret material
          content:
            application/json:
              schema:
                type: object
                properties:
                  configs:
                    type: array
                    items:
                      type: object
                      properties:
                        application_id: { type: string }
                        app_name: { type: string }
                        channel: { type: string }
                        mch_id: { type: string }
                        app_id: { type: string }
                        mode: { type: string, enum: [test, live] }
                        updated_at: { type: string, format: date-time }
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '500': { $ref: '#/components/responses/ServerError' }
    post:
      tags: [Partner]
      summary: Save encrypted payment-channel credentials (admin+)
      operationId: savePaymentConfig
      security: [{ bearerAuth: [] }]
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/SavePaymentConfigReq' }
      responses:
        '201':
          description: Config saved
          content:
            application/json:
              schema:
                type: object
                properties:
                  application_id: { type: string }
                  channel: { type: string }
                  status: { type: string, example: active }
        '202':
          description: "Config staged for in-app approval (PAYMENT_CONFIG_REQUIRE_APPROVAL=on, 9.1 P0): credentials are KMS-encrypted as usual but take effect only after one of the application's own owner/admin members approves via /v1/me/payment-configs/{client_id}/approvals/{id}/approve. staged=true means a previously active config keeps serving until approval."
          content:
            application/json:
              schema:
                type: object
                properties:
                  application_id: { type: string }
                  channel: { type: string }
                  status: { type: string, example: pending_approval }
                  approval_id: { type: string, format: uuid }
                  staged: { type: boolean }
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { $ref: '#/components/responses/NotFound' }
        '422': { $ref: '#/components/responses/BadRequest', description: Channel rejected the submitted credentials (config.verification_failed), only when PAYMENT_VERIFY_CONFIG_ON_SAVE is enabled }
        '500': { $ref: '#/components/responses/ServerError' }
  /v1/me/payment-configs/{client_id}/{channel}:
    get:
      tags: [Partner]
      summary: Read redacted payment-channel config (viewer+)
      operationId: getPaymentConfig
      security: [{ bearerAuth: [] }]
      parameters:
        - { name: client_id, in: path, required: true, schema: { type: string } }
        - { name: channel, in: path, required: true, schema: { type: string, enum: [wechat, alipay] } }
      responses:
        '200':
          description: Redacted config
          content:
            application/json:
              schema: { $ref: '#/components/schemas/PaymentConfig' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { $ref: '#/components/responses/NotFound' }
        '500': { $ref: '#/components/responses/ServerError' }
  /v1/me/sub-merchant:
    get:
      tags: [Partner]
      summary: Read the service-provider 特约商户 registered for the caller's own application (10.1); sub_mchid is masked. Any membership role may read
      operationId: getMySubMerchant
      security: [{ bearerAuth: [] }]
      parameters:
        - { name: client_id, in: query, required: true, schema: { type: string } }
      responses:
        '200':
          description: Sub-merchant registration for the application
          content:
            application/json:
              schema: { $ref: '#/components/schemas/SubMerchant' }
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { $ref: '#/components/responses/NotFound' }
        '500': { $ref: '#/components/responses/ServerError' }
  /v1/me/sub-merchants:
    get:
      tags: [Partner]
      summary: "List the 进件进度 of every 特约商户 registered for the applications the caller owns or manages as an app admin member, so the partner can act on them itself (scan the sign URL as 经办人, fix and resubmit after a rejection) instead of waiting for an admin screenshot. sub_mchid masked; sign_url present only while status is applying. Read-only: no audit event"
      operationId: listMySubMerchants
      security: [{ bearerAuth: [] }]
      responses:
        '200':
          description: Sub-merchant progress records, newest first (empty when the caller has no qualifying applications)
          content:
            application/json:
              schema:
                type: object
                properties:
                  items:
                    type: array
                    items: { $ref: '#/components/schemas/PartnerSubMerchantProgress' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '500': { $ref: '#/components/responses/ServerError' }
  /v1/me/sub-merchants/{id}/applyment/withdraw:
    post:
      tags: [Partner]
      summary: 放弃进件 — withdraw an in-flight applyment of the caller's own 特约商户 without touching the channel, folding the record back to pending (rejected_reason set to the fixed 放弃进件 text) so it can be corrected and resubmitted; the sweep no longer picks it up. Owner/admin of the owning application only; foreign sub-merchants answer 404. Repeating on an already-pending record is an idempotent 200 no-op; audited as sub_merchant_applyment_withdrawn on the real applying → pending transition
      operationId: partnerWithdrawSubMerchantApplyment
      security: [{ bearerAuth: [] }]
      parameters:
        - { name: id, in: path, required: true, schema: { type: string } }
      responses:
        '200':
          description: Applyment withdrawn (or already pending)
          content:
            application/json:
              schema: { $ref: '#/components/schemas/PartnerSubMerchantOp' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/NotFound' }
        '409': { $ref: '#/components/responses/Conflict' }
        '500': { $ref: '#/components/responses/ServerError' }
  /v1/me/sub-merchants/{id}/activate:
    post:
      tags: [Partner]
      summary: Activate the caller's own 特约商户 (pending|disabled → active), enabling service-provider collection for the application; the 分账接收方 registration fires best-effort on a real activation. Rejected with 409 sub_merchant.direct_only when the application is pinned to WeChat direct payment, or 409 sub_merchant.no_sub_mchid while no 特约商户号 is recorded yet (fill in a reusable number or complete 进件 first). Owner/admin only; foreign sub-merchants answer 404. Idempotent; audited as sub_merchant_activated on the real transition
      operationId: partnerActivateSubMerchant
      security: [{ bearerAuth: [] }]
      parameters:
        - { name: id, in: path, required: true, schema: { type: string } }
      responses:
        '200':
          description: Sub-merchant activated (or already active)
          content:
            application/json:
              schema: { $ref: '#/components/schemas/PartnerSubMerchantOp' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/NotFound' }
        '409': { $ref: '#/components/responses/Conflict' }
        '500': { $ref: '#/components/responses/ServerError' }
  /v1/me/sub-merchants/{id}/disable:
    post:
      tags: [Partner]
      summary: Disable the caller's own 特约商户 (kill switch); new service-provider orders for the application are rejected while disabled. Owner/admin only; foreign sub-merchants answer 404. Idempotent; audited as sub_merchant_disabled on the real transition
      operationId: partnerDisableSubMerchant
      security: [{ bearerAuth: [] }]
      parameters:
        - { name: id, in: path, required: true, schema: { type: string } }
      responses:
        '200':
          description: Sub-merchant disabled (or already disabled)
          content:
            application/json:
              schema: { $ref: '#/components/schemas/PartnerSubMerchantOp' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/NotFound' }
        '409': { $ref: '#/components/responses/Conflict' }
        '500': { $ref: '#/components/responses/ServerError' }
  /v1/me/sub-merchants/reusable:
    get:
      tags: [Partner]
      summary: "List the 特约商户号 already registered to other applications owned by the same partner — candidates for sharing one number across the partner's applications (multiple apps under the same company entity share one 特约商户号). sub_mchids are masked like every partner read. Owner/admin of the queried application only; foreign applications answer 404. Read-only: no audit event"
      operationId: partnerListReusableSubMchIDs
      security: [{ bearerAuth: [] }]
      parameters:
        - { name: application_id, in: query, required: true, schema: { type: string } }
      responses:
        '200':
          description: Reusable sub_mchids of the partner's other applications (empty when the application has no owner or the partner has no other registrations)
          content:
            application/json:
              schema:
                type: object
                properties:
                  reusable:
                    type: array
                    items:
                      type: object
                      properties:
                        sub_mchid: { type: string }
                        company_name: { type: string }
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/NotFound' }
        '500': { $ref: '#/components/responses/ServerError' }
  /v1/me/payment-configs/{client_id}/{channel}/versions:
    get:
      tags: [Partner]
      summary: List immutable config version history (viewer+, newest first)
      operationId: listPaymentConfigVersions
      security: [{ bearerAuth: [] }]
      parameters:
        - { name: client_id, in: path, required: true, schema: { type: string } }
        - { name: channel, in: path, required: true, schema: { type: string, enum: [wechat, alipay] } }
      responses:
        '200':
          description: Version history
          content:
            application/json:
              schema: { $ref: '#/components/schemas/ConfigVersionsResponse' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { $ref: '#/components/responses/NotFound' }
        '500': { $ref: '#/components/responses/ServerError' }
  /v1/me/payment-configs/{client_id}/{channel}/rollback:
    post:
      tags: [Partner]
      summary: Restore a historical config version as the live config (admin+)
      operationId: rollbackPaymentConfig
      security: [{ bearerAuth: [] }]
      parameters:
        - { name: client_id, in: path, required: true, schema: { type: string } }
        - { name: channel, in: path, required: true, schema: { type: string, enum: [wechat, alipay] } }
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/RollbackConfigReq' }
      responses:
        '200':
          description: Rollback applied and recorded as a new version
          content:
            application/json:
              schema:
                type: object
                properties:
                  application_id: { type: string }
                  channel: { type: string }
                  rolled_back_to: { type: integer, format: int32 }
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { $ref: '#/components/responses/NotFound' }
        '500': { $ref: '#/components/responses/ServerError' }

  /v1/me/payment-configs/{client_id}/{channel}/copy:
    post:
      tags: [Partner]
      summary: Copy the active payment config of another accessible client into this one (developer+ on BOTH source and target). Reuses the standard save path — verification, version snapshot and the 9.1 approval gate apply exactly as for a manual save
      operationId: copyPaymentConfig
      security: [{ bearerAuth: [] }]
      parameters:
        - { name: client_id, in: path, required: true, schema: { type: string }, description: Target application }
        - { name: channel, in: path, required: true, schema: { type: string, enum: [wechat, alipay] } }
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [source_client_id]
              properties:
                source_client_id: { type: string, description: Source application whose ACTIVE config is copied; must differ from the target }
      responses:
        '201':
          description: Config copied and active
          content:
            application/json:
              schema:
                type: object
                properties:
                  application_id: { type: string }
                  channel: { type: string }
                  status: { type: string, example: active }
                  copied_from: { type: string }
        '202':
          description: Copied config staged for admin approval (PAYMENT_CONFIG_REQUIRE_APPROVAL=on); same semantics as savePaymentConfig
          content:
            application/json:
              schema:
                type: object
                properties:
                  application_id: { type: string }
                  channel: { type: string }
                  status: { type: string, example: pending_approval }
                  approval_id: { type: string, format: uuid }
                  staged: { type: boolean }
                  copied_from: { type: string }
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { $ref: '#/components/responses/NotFound', description: Target/source not accessible, or the source has no active config for the channel }
        '409': { $ref: '#/components/responses/BadRequest', description: Another save for this application+channel already awaits approval (approval.pending_exists) }
        '500': { $ref: '#/components/responses/ServerError' }
  /v1/me/payment-configs/{client_id}/{channel}/test-payment:
    post:
      tags: [Partner]
      summary: Place a small test order (default 0.01 CNY) against the active config and return a scannable payment target (developer+). A test-mode config short-circuits to a deterministic mock target without any channel call, same as prepay
      operationId: testPayment
      security: [{ bearerAuth: [] }]
      parameters:
        - { name: client_id, in: path, required: true, schema: { type: string } }
        - { name: channel, in: path, required: true, schema: { type: string, enum: [wechat, alipay] } }
      requestBody:
        required: false
        content:
          application/json:
            schema:
              type: object
              properties:
                amount: { type: integer, format: int64, minimum: 1, maximum: 100000, description: Amount in fen; 0/absent defaults to 1 }
      responses:
        '201':
          description: Test order created; code_url (wechat) or pay_url (alipay) is the payment target. The order carries source=payment_config_test and a cfgtest- order-no prefix, and flows through the normal callback path
          content:
            application/json:
              schema:
                type: object
                properties:
                  order_no: { type: string }
                  amount: { type: integer, format: int64 }
                  mode: { type: string, enum: [test, live] }
                  code_url: { type: string }
                  pay_url: { type: string }
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { $ref: '#/components/responses/NotFound', description: No active config for this application+channel }
        '500': { $ref: '#/components/responses/ServerError' }
        '502': { $ref: '#/components/responses/ServerError', description: Channel prepay failed; the message carries the channel error code/reason for the config owner }
  /v1/me/payment-configs/{client_id}/{channel}/test-payment/status:
    get:
      tags: [Partner]
      summary: Poll a config test-payment order (viewer+). Only cfgtest- orders belonging to this application are reported; anything else answers 404
      operationId: testPaymentStatus
      security: [{ bearerAuth: [] }]
      parameters:
        - { name: client_id, in: path, required: true, schema: { type: string } }
        - { name: channel, in: path, required: true, schema: { type: string, enum: [wechat, alipay] } }
        - { name: order_no, in: query, required: true, schema: { type: string } }
      responses:
        '200':
          description: Order state
          content:
            application/json:
              schema:
                type: object
                properties:
                  order_no: { type: string }
                  status: { type: string }
                  paid: { type: boolean }
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { $ref: '#/components/responses/NotFound' }
        '500': { $ref: '#/components/responses/ServerError' }

  /v1/me/payment-configs/{client_id}/approvals:
    get:
      tags: [Partner]
      summary: List payment-config approval requests of one application, newest first (viewer+; decisions by the app's own owner/admin members, 9.1 P0)
      operationId: listPaymentConfigApprovals
      security: [{ bearerAuth: [] }]
      parameters:
        - { name: client_id, in: path, required: true, schema: { type: string } }
        - { name: status, in: query, schema: { type: string, enum: [pending, approved, rejected, canceled, executed, failed] } }
        - { name: cursor, in: query, schema: { type: string }, description: Encoded cursor from a previous page's meta.next_cursor }
        - { name: limit, in: query, schema: { type: integer, default: 20, maximum: 100 } }
      responses:
        '200':
          description: Approval requests
          content:
            application/json:
              schema: { $ref: '#/components/schemas/ApprovalRequestListEnvelope' }
        '400':
          description: pagination.invalid_cursor
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/NotFound' }
        '500': { $ref: '#/components/responses/ServerError' }
  /v1/me/payment-configs/{client_id}/approvals/{id}/approve:
    post:
      tags: [Partner]
      summary: Approve a payment-config approval request and activate the staged save (owner/admin only; requester cannot approve their own)
      operationId: approvePaymentConfigApproval
      security: [{ bearerAuth: [] }]
      parameters:
        - { name: client_id, in: path, required: true, schema: { type: string } }
        - { name: id, in: path, required: true, schema: { type: string, format: uuid } }
      requestBody:
        required: false
        content:
          application/json:
            schema: { $ref: '#/components/schemas/DecideApprovalReq' }
      responses:
        '200':
          description: Request approved; mutation executed or retryable (see status)
          content:
            application/json:
              schema: { $ref: '#/components/schemas/ApprovalRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403':
          description: client.forbidden (role below owner/admin) or approval.self_approve
        '404':
          description: approval.not_found - no such payment-config approval for this application
        '409':
          description: approval.not_pending - request is not awaiting a decision
        '500': { $ref: '#/components/responses/ServerError' }
        '502':
          description: approval.execute_failed - execution failed; the request stays retryable
  /v1/me/payment-configs/{client_id}/approvals/{id}/reject:
    post:
      tags: [Partner]
      summary: Reject a payment-config approval request (note required; owner/admin only; requester cannot reject their own)
      operationId: rejectPaymentConfigApproval
      security: [{ bearerAuth: [] }]
      parameters:
        - { name: client_id, in: path, required: true, schema: { type: string } }
        - { name: id, in: path, required: true, schema: { type: string, format: uuid } }
      requestBody:
        required: false
        content:
          application/json:
            schema: { $ref: '#/components/schemas/DecideApprovalReq' }
      responses:
        '200':
          description: Request rejected
          content:
            application/json:
              schema: { $ref: '#/components/schemas/ApprovalRequest' }
        '400':
          description: approval.note_required - decision note is required
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403':
          description: client.forbidden (role below owner/admin) or approval.self_approve
        '404':
          description: approval.not_found - no such payment-config approval for this application
        '409':
          description: approval.not_pending - request is not awaiting a decision
        '500': { $ref: '#/components/responses/ServerError' }
  /v1/me/payment-configs/{client_id}/approvals/{id}/cancel:
    post:
      tags: [Partner]
      summary: Withdraw a pending payment-config approval request (only the requester may cancel)
      operationId: cancelPaymentConfigApproval
      security: [{ bearerAuth: [] }]
      parameters:
        - { name: client_id, in: path, required: true, schema: { type: string } }
        - { name: id, in: path, required: true, schema: { type: string, format: uuid } }
      responses:
        '200':
          description: Request canceled
          content:
            application/json:
              schema: { $ref: '#/components/schemas/ApprovalRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404':
          description: approval.not_found - no such payment-config approval for this application
        '409':
          description: approval.not_pending - request is not pending or not yours
        '500': { $ref: '#/components/responses/ServerError' }

  /v1/me/orders:
    get:
      tags: [Partner]
      summary: List orders for a client (viewer+, paginated, filterable)
      operationId: listOrders
      security: [{ bearerAuth: [] }]
      parameters:
        - { name: client_id, in: query, required: true, schema: { type: string } }
        - { name: status, in: query, required: false, schema: { type: string } }
        - { name: partner_product_id, in: query, required: false, schema: { type: string }, description: "Matches the product_id key the partner stored inside partner_data (0066); exact match on that JSON key, empty disables the filter." }
        - { name: from, in: query, required: false, schema: { type: string }, description: RFC3339 or YYYY-MM-DD }
        - { name: to, in: query, required: false, schema: { type: string }, description: RFC3339 or YYYY-MM-DD (half-open end) }
        - { name: page, in: query, required: false, schema: { type: integer, default: 1 } }
        - { name: limit, in: query, required: false, schema: { type: integer, default: 20, maximum: 100 } }
      responses:
        '200':
          description: Orders page
          content:
            application/json:
              schema: { $ref: '#/components/schemas/OrderListEnvelope' }
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { $ref: '#/components/responses/NotFound' }
        '500': { $ref: '#/components/responses/ServerError' }
  /v1/me/orders/{platform_order_no}:
    get:
      tags: [Partner]
      summary: Order detail with refunds (viewer+)
      operationId: getOrderDetail
      security: [{ bearerAuth: [] }]
      parameters:
        - { name: platform_order_no, in: path, required: true, schema: { type: string } }
        - { name: client_id, in: query, required: true, schema: { type: string } }
      responses:
        '200':
          description: Order + refunds
          content:
            application/json:
              schema: { $ref: '#/components/schemas/OrderDetail' }
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { $ref: '#/components/responses/NotFound' }
        '500': { $ref: '#/components/responses/ServerError' }
  /v1/me/orders/{platform_order_no}/confirm-offline:
    post:
      tags: [Partner]
      summary: "Confirm receipt of an offline payment for the caller's own order (offline_pending → paid), recording how the money was collected and firing the same fulfillment chain as the admin endpoint. Owner/admin/finance of the owning application; foreign orders answer 404. Idempotent: an already-paid order answers 200 unchanged; a non-offline-pending order answers 409 order.invalid_status. Audited as partner_order_confirmed_offline"
      operationId: partnerConfirmOfflinePayment
      security: [{ bearerAuth: [] }]
      parameters:
        - { name: platform_order_no, in: path, required: true, schema: { type: string } }
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [payment_method]
              properties:
                payment_method: { type: string, description: "How the offline payment was collected (bank_transfer, cash, …); recorded on the order and in the audit log." }
      responses:
        '200':
          description: Offline payment confirmed (or the order was already paid)
          content:
            application/json:
              schema:
                type: object
                required: [platform_order_no, status, amount]
                properties:
                  platform_order_no: { type: string }
                  status: { type: string, enum: [offline_pending, paid] }
                  amount: { type: integer, description: Amount in fen }
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/NotFound' }
        '409': { $ref: '#/components/responses/Conflict' }
        '500': { $ref: '#/components/responses/ServerError' }
  /v1/me/orders/{platform_order_no}/partner-data:
    put:
      tags: [Partner]
      summary: Rewrite the partner_data extension field of one order (viewer+; the ONLY partner-mutable order field)
      description: "Body {\"partner_data\": {...}} — must be a JSON object of at most 16KB serialized (400 partner_data.invalid otherwise; null clears the field). The platform stores the payload verbatim and never interprets it; every other order field stays platform-owned. client_id scoping identical to the order detail endpoint; cross-tenant 404. Audit-logged as order_partner_data_update."
      operationId: partnerUpdateOrderPartnerData
      security: [{ bearerAuth: [] }]
      parameters:
        - { name: platform_order_no, in: path, required: true, schema: { type: string } }
        - { name: client_id, in: query, required: true, schema: { type: string } }
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [partner_data]
              properties:
                partner_data: { type: [object, 'null'] }
      responses:
        '200':
          description: partner_data updated
          content:
            application/json:
              schema:
                type: object
                properties:
                  platform_order_no: { type: string }
                  partner_data: { type: [object, 'null'] }
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { $ref: '#/components/responses/NotFound' }
        '500': { $ref: '#/components/responses/ServerError' }
  /v1/me/orders/{platform_order_no}/notify:
    post:
      tags: [Partner]
      summary: Re-enqueue the outbound payment-result notification for a paid/refunded order (admin+). 409 order.not_paid if the order never reached paid; 409 config.notify_url_missing when no webhook is configured (payment-config notify_url for direct orders, the application's notify_url for service-provider orders). Delivery is signed HMAC-SHA256 (X-Chandler-Signature) — keyed by the partner's channel API key in direct mode, by sha256_hex(client_secret) in SP mode — and retried by the notify worker. The payload carries the order fields plus partner_data (0066; null when unset).
      operationId: partnerReNotifyOrder
      security: [{ bearerAuth: [] }]
      parameters:
        - { name: platform_order_no, in: path, required: true, schema: { type: string } }
        - { name: client_id, in: query, required: true, schema: { type: string } }
      responses:
        '202':
          description: Notification queued
          content:
            application/json:
              schema:
                type: object
                required: [enqueued]
                properties:
                  enqueued: { type: boolean }
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { $ref: '#/components/responses/NotFound' }
        '409': { $ref: '#/components/responses/Conflict' }
        '500': { $ref: '#/components/responses/ServerError' }
  /v1/me/orders/export:
    get:
      tags: [Partner]
      summary: "Export orders as CSV (viewer+; same status/from/to filters as the list endpoint; capped at 50000 rows — X-Export-Truncated: true when hit). The partner_data column carries the JSON-serialized partner payload (empty when unset)."
      operationId: partnerExportOrders
      security: [{ bearerAuth: [] }]
      parameters:
        - { name: client_id, in: query, required: true, schema: { type: string } }
        - { name: status, in: query, required: false, schema: { type: string } }
        - { name: from, in: query, required: false, schema: { type: string }, description: RFC3339 or YYYY-MM-DD }
        - { name: to, in: query, required: false, schema: { type: string }, description: RFC3339 or YYYY-MM-DD (half-open end) }
      responses:
        '200':
          description: CSV download (attachment orders-YYYY-MM-DD.csv; amounts in fen; times RFC3339 UTC)
          content:
            text/csv:
              schema: { type: string }
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { $ref: '#/components/responses/NotFound' }
        '500': { $ref: '#/components/responses/ServerError' }
  /v1/me/reconciliations:
    get:
      tags: [Partner]
      summary: Reconciliation report — exactly one of date (YYYY-MM-DD, per-channel summaries with discrepancy items attached, i.e. the diff highlight) or month (YYYY-MM, daily summaries newest-first without items); optional channel prefix filter; viewer+; cross-tenant 404
      operationId: partnerGetReconciliations
      security: [{ bearerAuth: [] }]
      parameters:
        - { name: client_id, in: query, required: true, schema: { type: string } }
        - { name: date, in: query, required: false, schema: { type: string, format: date }, description: Day view, YYYY-MM-DD }
        - { name: month, in: query, required: false, schema: { type: string }, description: Month view, YYYY-MM }
        - { name: channel, in: query, required: false, schema: { type: string }, description: Channel prefix filter (wechat/alipay) }
      responses:
        '200':
          description: Reconciliation summaries
          content:
            application/json:
              schema: { $ref: '#/components/schemas/ReconciliationListEnvelope' }
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { $ref: '#/components/responses/NotFound' }
        '500': { $ref: '#/components/responses/ServerError' }
  /v1/me/reconciliations/export:
    get:
      tags: [Partner]
      summary: "Export the reconciliation statement as CSV — one row per discrepancy item across the from/to window (viewer+; optional channel filter; capped at 50000 rows — X-Export-Truncated: true when hit)"
      operationId: partnerExportReconciliations
      security: [{ bearerAuth: [] }]
      parameters:
        - { name: client_id, in: query, required: true, schema: { type: string } }
        - { name: channel, in: query, required: false, schema: { type: string }, description: Channel prefix filter (wechat/alipay) }
        - { name: from, in: query, required: false, schema: { type: string }, description: RFC3339 or YYYY-MM-DD }
        - { name: to, in: query, required: false, schema: { type: string }, description: RFC3339 or YYYY-MM-DD (half-open end) }
      responses:
        '200':
          description: CSV download (attachment reconciliations-YYYY-MM-DD.csv; columns recon_date,channel,platform_order_no,discrepancy_type,platform_amount,channel_amount; amounts in fen)
          content:
            text/csv:
              schema: { type: string }
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { $ref: '#/components/responses/NotFound' }
        '500': { $ref: '#/components/responses/ServerError' }
  /v1/me/analytics/payments:
    get:
      tags: [Partner]
      summary: "Payment analytics dashboard — per-UTC-day payment trend buckets (created/paid counts, gross paid amount) plus the window conversion rate. Conversion uses the creation cohort: created in-window and currently paid or refunded (refunded counts as converted; refund amounts are not netted). viewer+; cross-tenant 404; truncated=true when the 50000-row source cap is hit (narrow the window)"
      operationId: partnerGetPaymentAnalytics
      security: [{ bearerAuth: [] }]
      parameters:
        - { name: client_id, in: query, required: true, schema: { type: string } }
        - { name: from, in: query, required: false, schema: { type: string }, description: "RFC3339 or YYYY-MM-DD (default: 1970-01-01)" }
        - { name: to, in: query, required: false, schema: { type: string }, description: "RFC3339 or YYYY-MM-DD (half-open end; default: now+24h)" }
      responses:
        '200':
          description: Payment analytics
          content:
            application/json:
              schema: { $ref: '#/components/schemas/PaymentAnalyticsEnvelope' }
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { $ref: '#/components/responses/NotFound' }
        '500': { $ref: '#/components/responses/ServerError' }
  /v1/me/analytics/retention:
    get:
      tags: [Partner]
      summary: "User retention — distinct paying users in the paid_at window (non-empty user_id orders only) split into returning (had a paid order before the window) and new, with repeat_rate = returning/paying (0 when no paying users). viewer+; cross-tenant 404"
      operationId: partnerGetUserRetention
      security: [{ bearerAuth: [] }]
      parameters:
        - { name: client_id, in: query, required: true, schema: { type: string } }
        - { name: from, in: query, required: false, schema: { type: string }, description: "RFC3339 or YYYY-MM-DD (default: 1970-01-01)" }
        - { name: to, in: query, required: false, schema: { type: string }, description: "RFC3339 or YYYY-MM-DD (half-open end; default: now+24h)" }
      responses:
        '200':
          description: User retention
          content:
            application/json:
              schema: { $ref: '#/components/schemas/UserRetentionEnvelope' }
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { $ref: '#/components/responses/NotFound' }
        '500': { $ref: '#/components/responses/ServerError' }
  /v1/me/analytics/sources:
    get:
      tags: [Partner]
      summary: "Source analytics (4.9 P2) — orders created in the window grouped by the partner-defined source tag (empty string groups untagged orders), ordered by paid amount descending. Gross creation-cohort counting, same as the payment-trends endpoint: paid/refunded count as converted and refunds are not netted. viewer+; cross-tenant 404"
      operationId: partnerGetSourceAnalytics
      security: [{ bearerAuth: [] }]
      parameters:
        - { name: client_id, in: query, required: true, schema: { type: string } }
        - { name: from, in: query, required: false, schema: { type: string }, description: "RFC3339 or YYYY-MM-DD (default: 1970-01-01)" }
        - { name: to, in: query, required: false, schema: { type: string }, description: "RFC3339 or YYYY-MM-DD (half-open end; default: now+24h)" }
      responses:
        '200':
          description: Source analytics
          content:
            application/json:
              schema: { $ref: '#/components/schemas/SourceAnalyticsEnvelope' }
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { $ref: '#/components/responses/NotFound' }
        '500': { $ref: '#/components/responses/ServerError' }
  /v1/me/billing/fees:
    get:
      tags: [Partner]
      summary: "Technical service-fee bill (4.8) — per-calendar-month cash-basis GMV (orders paid in the month), successful refunds completed in the month, net, and the platform service fee in fen (fee = max(net,0) × SERVICE_FEE_BPS / 10000, default 0). Computed on read from the ledger; funds never transit the platform. viewer+; cross-tenant 404"
      operationId: partnerGetServiceFees
      security: [{ bearerAuth: [] }]
      parameters:
        - { name: client_id, in: query, required: true, schema: { type: string } }
        - { name: from, in: query, required: false, schema: { type: string }, description: "YYYY-MM (default: current UTC month)" }
        - { name: to, in: query, required: false, schema: { type: string }, description: "YYYY-MM inclusive (default: current UTC month)" }
      responses:
        '200':
          description: Service-fee bill
          content:
            application/json:
              schema: { $ref: '#/components/schemas/ServiceFeeEnvelope' }
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { $ref: '#/components/responses/NotFound' }
        '500': { $ref: '#/components/responses/ServerError' }
  /v1/me/billing/invoices:
    post:
      tags: [Partner]
      summary: "Apply for an invoice covering one month's technical service fee (4.8). The amount is recomputed server-side from the ledger with the same formula as the bill, so it can never drift from it. owner/admin/finance on the client; cross-tenant 404"
      operationId: partnerRequestInvoice
      security: [{ bearerAuth: [] }]
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/RequestInvoiceReq' }
      responses:
        '201':
          description: Invoice request created (pending review)
          content:
            application/json:
              schema: { $ref: '#/components/schemas/InvoiceRequest' }
        '400':
          description: request.invalid (bad JSON, period not YYYY-MM or later than the current month, invalid type/title/tax_no/email) / invoice.no_fee - no service fee for this period
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404':
          description: client.not_found
        '409':
          description: invoice.duplicate - a request for this period is already pending or issued
        '500': { $ref: '#/components/responses/ServerError' }
    get:
      tags: [Partner]
      summary: List the caller's own invoice requests for one client, newest first, cursor-paginated (viewer+; cross-tenant 404)
      operationId: partnerListInvoiceRequests
      security: [{ bearerAuth: [] }]
      parameters:
        - { name: client_id, in: query, required: true, schema: { type: string } }
        - { name: cursor, in: query, schema: { type: string }, description: Encoded cursor from a previous page's meta.next_cursor }
        - { name: limit, in: query, schema: { type: integer, default: 20, maximum: 100 } }
      responses:
        '200':
          description: Invoice requests page
          content:
            application/json:
              schema: { $ref: '#/components/schemas/InvoiceRequestListEnvelope' }
        '400':
          description: request.invalid / pagination.invalid_cursor
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404':
          description: client.not_found
        '500': { $ref: '#/components/responses/ServerError' }
  /v1/me/billing/payments:
    get:
      tags: [Partner]
      summary: Payment-record ledger (4.8) — every paid order of one client with its platform fee, newest paid first, cursor-paginated (viewer+; cross-tenant 404)
      operationId: partnerListPaymentRecords
      security: [{ bearerAuth: [] }]
      parameters:
        - { name: client_id, in: query, required: true, schema: { type: string } }
        - { name: cursor, in: query, schema: { type: string }, description: Encoded cursor from a previous page's meta.next_cursor }
        - { name: limit, in: query, schema: { type: integer, default: 20, maximum: 100 } }
      responses:
        '200':
          description: Payment records page
          content:
            application/json:
              schema: { $ref: '#/components/schemas/PaymentRecordListEnvelope' }
        '400':
          description: request.invalid / pagination.invalid_cursor
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404':
          description: client.not_found
        '500': { $ref: '#/components/responses/ServerError' }
  /v1/me/security-logs:
    get:
      tags: [Partner]
      summary: Security audit trail — own user events plus events on accessible clients (logins, KMS key access); config changes via the versions API
      operationId: listSecurityLogs
      security: [{ bearerAuth: [] }]
      parameters:
        - { name: client_id, in: query, required: false, schema: { type: string }, description: Restrict to one client (requires viewer+; cross-tenant 404) }
        - { name: event, in: query, required: false, schema: { type: string }, description: Exact event match, e.g. login_success, kms_decrypt }
        - { name: page, in: query, required: false, schema: { type: integer, default: 1 } }
        - { name: limit, in: query, required: false, schema: { type: integer, default: 20, maximum: 100 } }
      responses:
        '200':
          description: Audit events page
          content:
            application/json:
              schema: { $ref: '#/components/schemas/SecurityLogEnvelope' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { $ref: '#/components/responses/NotFound' }
        '500': { $ref: '#/components/responses/ServerError' }
  /v1/me/refunds:
    post:
      tags: [Partner]
      summary: Create and submit a refund to the channel (admin+). On channel failure the refund stays pending (502) and is retryable via the execute endpoint.
      operationId: partnerCreateRefund
      security: [{ bearerAuth: [] }]
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/CreatePartnerRefundReq' }
      responses:
        '201':
          description: Refund succeeded
          content:
            application/json:
              schema: { $ref: '#/components/schemas/PartnerRefund' }
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { $ref: '#/components/responses/NotFound' }
        '409': { $ref: '#/components/responses/Conflict' }
        '500': { $ref: '#/components/responses/ServerError' }
        '502': { $ref: '#/components/responses/BadGateway' }
    get:
      tags: [Partner]
      summary: List refunds for a client, newest first (viewer+)
      operationId: partnerListRefunds
      security: [{ bearerAuth: [] }]
      parameters:
        - { name: client_id, in: query, required: true, schema: { type: string } }
        - { name: page, in: query, required: false, schema: { type: integer, default: 1 } }
        - { name: limit, in: query, required: false, schema: { type: integer, default: 20, maximum: 100 } }
      responses:
        '200':
          description: Refunds page
          content:
            application/json:
              schema: { $ref: '#/components/schemas/RefundListEnvelope' }
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { $ref: '#/components/responses/NotFound' }
        '500': { $ref: '#/components/responses/ServerError' }
  /v1/me/refunds/export:
    get:
      tags: [Partner]
      summary: "Export refunds as CSV (viewer+; newest first; joined with the parent order number; capped at 50000 rows — X-Export-Truncated: true when hit)"
      operationId: partnerExportRefunds
      security: [{ bearerAuth: [] }]
      parameters:
        - { name: client_id, in: query, required: true, schema: { type: string } }
      responses:
        '200':
          description: CSV download (attachment refunds-YYYY-MM-DD.csv; amounts in fen; times RFC3339 UTC)
          content:
            text/csv:
              schema: { type: string }
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { $ref: '#/components/responses/NotFound' }
        '500': { $ref: '#/components/responses/ServerError' }
  /v1/me/refunds/{refund_no}:
    get:
      tags: [Partner]
      summary: Refund detail scoped to a client (viewer+; cross-tenant 404)
      operationId: partnerGetRefund
      security: [{ bearerAuth: [] }]
      parameters:
        - { name: refund_no, in: path, required: true, schema: { type: string } }
        - { name: client_id, in: query, required: true, schema: { type: string } }
      responses:
        '200':
          description: Refund
          content:
            application/json:
              schema: { $ref: '#/components/schemas/PartnerRefund' }
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { $ref: '#/components/responses/NotFound' }
        '500': { $ref: '#/components/responses/ServerError' }
  /v1/me/refunds/{refund_no}/execute:
    post:
      tags: [Partner]
      summary: (Re)submit a pending refund to the channel (admin+). Idempotent; terminal refunds return unchanged.
      operationId: partnerExecuteRefund
      security: [{ bearerAuth: [] }]
      parameters:
        - { name: refund_no, in: path, required: true, schema: { type: string } }
        - { name: client_id, in: query, required: true, schema: { type: string } }
      responses:
        '200':
          description: Refund after execution
          content:
            application/json:
              schema: { $ref: '#/components/schemas/PartnerRefund' }
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { $ref: '#/components/responses/NotFound' }
        '500': { $ref: '#/components/responses/ServerError' }
        '502': { $ref: '#/components/responses/BadGateway' }

  /v1/directory/apps:
    get:
      tags: [Directory]
      summary: Public app market — list active applications (optional category filter and case-insensitive name search)
      operationId: listDirectoryApps
      parameters:
        - { name: category, in: query, required: false, schema: { type: string }, description: Exact category filter }
        - { name: q, in: query, required: false, schema: { type: string }, description: Case-insensitive name search }
        - { name: page, in: query, schema: { type: integer, default: 1 } }
        - { name: limit, in: query, schema: { type: integer, default: 20, maximum: 100 } }
      responses:
        '200':
          description: Active applications, name ascending
          content:
            application/json:
              schema: { $ref: '#/components/schemas/DirectoryListEnvelope' }
        '500': { $ref: '#/components/responses/ServerError' }
  /v1/directory/apps/{client_id}:
    get:
      tags: [Directory]
      summary: Public app detail (unknown or disabled apps are a uniform 404)
      operationId: getDirectoryApp
      parameters:
        - { name: client_id, in: path, required: true, schema: { type: string } }
      responses:
        '200':
          description: Application detail
          content:
            application/json:
              schema: { $ref: '#/components/schemas/DirectoryAppDetail' }
        '404': { $ref: '#/components/responses/NotFound' }
        '500': { $ref: '#/components/responses/ServerError' }
  /v1/directory/apps/{client_id}/reviews:
    get:
      tags: [Directory]
      summary: Public review list for an application (newest first, with average-rating summary)
      operationId: listAppReviews
      parameters:
        - { name: client_id, in: path, required: true, schema: { type: string } }
        - { name: page, in: query, schema: { type: integer, default: 1 } }
        - { name: limit, in: query, schema: { type: integer, default: 20, maximum: 100 } }
      responses:
        '200':
          description: Reviews and rating summary
          content:
            application/json:
              schema: { $ref: '#/components/schemas/ReviewListEnvelope' }
        '404': { $ref: '#/components/responses/NotFound' }
        '500': { $ref: '#/components/responses/ServerError' }
  /v1/me/apps/{client_id}/reviews:
    put:
      tags: [Directory]
      summary: Create or edit the caller's review of an application (one review per user per app)
      operationId: upsertMyReview
      security: [{ bearerAuth: [] }]
      parameters:
        - { name: client_id, in: path, required: true, schema: { type: string } }
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/UpsertReviewReq' }
      responses:
        '200':
          description: Review saved
          content:
            application/json:
              schema: { $ref: '#/components/schemas/StatusOK' }
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { $ref: '#/components/responses/NotFound' }
        '500': { $ref: '#/components/responses/ServerError' }
    delete:
      tags: [Directory]
      summary: Remove the caller's review of an application (idempotent)
      operationId: deleteMyReview
      security: [{ bearerAuth: [] }]
      parameters:
        - { name: client_id, in: path, required: true, schema: { type: string } }
      responses:
        '200':
          description: Review removed (or absent)
          content:
            application/json:
              schema: { $ref: '#/components/schemas/StatusOK' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '500': { $ref: '#/components/responses/ServerError' }
  /v1/me/apps/{client_id}/complaints:
    post:
      tags: [Directory]
      summary: File a complaint against an application (optionally tied to one of the caller's orders)
      operationId: createComplaint
      security: [{ bearerAuth: [] }]
      parameters:
        - { name: client_id, in: path, required: true, schema: { type: string } }
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/CreateComplaintReq' }
      responses:
        '201':
          description: Complaint filed
          content:
            application/json:
              schema: { $ref: '#/components/schemas/StatusOK' }
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { $ref: '#/components/responses/NotFound' }
        '500': { $ref: '#/components/responses/ServerError' }
  /v1/me/complaints:
    get:
      tags: [Directory]
      summary: The caller's own complaints across applications (newest first)
      operationId: listMyComplaints
      security: [{ bearerAuth: [] }]
      parameters:
        - { name: page, in: query, schema: { type: integer, default: 1 } }
        - { name: limit, in: query, schema: { type: integer, default: 20, maximum: 100 } }
      responses:
        '200':
          description: The caller's complaints
          content:
            application/json:
              schema: { $ref: '#/components/schemas/MyComplaintListEnvelope' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '500': { $ref: '#/components/responses/ServerError' }
  /v1/me/notifications:
    get:
      tags: [Notifications]
      summary: The caller's in-app notifications (newest first, cursor pagination)
      operationId: listNotifications
      security: [{ bearerAuth: [] }]
      parameters:
        - { name: cursor, in: query, schema: { type: string }, description: opaque cursor from next_cursor }
        - { name: limit, in: query, schema: { type: integer, default: 20, maximum: 100 } }
      responses:
        '200':
          description: One page of notifications
          content:
            application/json:
              schema:
                type: object
                properties:
                  items:
                    type: array
                    items:
                      type: object
                      properties:
                        id: { type: string }
                        kind: { type: string, example: order.paid }
                        title: { type: string }
                        body: { type: string }
                        link: { type: string, example: /partner/orders }
                        read_at: { type: string, format: date-time, nullable: true }
                        created_at: { type: string, format: date-time }
                  next_cursor: { type: string, description: empty when no further page }
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '500': { $ref: '#/components/responses/ServerError' }
  /v1/me/notifications/unread-count:
    get:
      tags: [Notifications]
      summary: Unread notification count (bell badge)
      operationId: unreadNotificationCount
      security: [{ bearerAuth: [] }]
      responses:
        '200':
          description: Unread count
          content:
            application/json:
              schema:
                type: object
                properties:
                  count: { type: integer, example: 3 }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '500': { $ref: '#/components/responses/ServerError' }
  /v1/me/notifications/{id}/read:
    post:
      tags: [Notifications]
      summary: Mark one notification read (another user's answers 404)
      operationId: markNotificationRead
      security: [{ bearerAuth: [] }]
      parameters:
        - { name: id, in: path, required: true, schema: { type: string } }
      responses:
        '200':
          description: Marked read (idempotent)
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { $ref: '#/components/responses/NotFound' }
        '500': { $ref: '#/components/responses/ServerError' }
  /v1/me/notifications/read-all:
    post:
      tags: [Notifications]
      summary: Mark all of the caller's notifications read
      operationId: markAllNotificationsRead
      security: [{ bearerAuth: [] }]
      responses:
        '200':
          description: All marked read
        '401': { $ref: '#/components/responses/Unauthorized' }
        '500': { $ref: '#/components/responses/ServerError' }
  /v1/me/partner-profile:
    get:
      tags: [Partner]
      summary: The caller's partner onboarding profile (404 until the first save)
      operationId: getPartnerProfile
      security: [{ bearerAuth: [] }]
      responses:
        '200':
          description: The caller's profile
          content:
            application/json:
              schema: { $ref: '#/components/schemas/PartnerProfile' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { $ref: '#/components/responses/NotFound' }
        '500': { $ref: '#/components/responses/ServerError' }
    put:
      tags: [Partner]
      summary: Save company/qualification info (full replace; locked while under review or approved)
      operationId: savePartnerProfile
      security: [{ bearerAuth: [] }]
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/PartnerProfileReq' }
      responses:
        '200':
          description: Profile saved
          content:
            application/json:
              schema: { $ref: '#/components/schemas/PartnerProfile' }
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '409': { $ref: '#/components/responses/Conflict' }
        '500': { $ref: '#/components/responses/ServerError' }
  /v1/me/partner-profile/submit:
    post:
      tags: [Partner]
      summary: Submit the profile for platform review (idempotent while submitted; requires the complete sub-merchant onboarding material set — 400 partner_profile.incomplete lists the missing items in Chinese)
      operationId: submitPartnerProfile
      security: [{ bearerAuth: [] }]
      responses:
        '200':
          description: Profile submitted
          content:
            application/json:
              schema: { $ref: '#/components/schemas/PartnerProfile' }
        '400':
          description: partner_profile.incomplete — the message lists the missing onboarding items (merchant short name, service phone, business category, legal rep name, settlement account info, and the three required document kinds)
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { $ref: '#/components/responses/NotFound' }
        '409': { $ref: '#/components/responses/Conflict' }
        '500': { $ref: '#/components/responses/ServerError' }
  /v1/me/partner/documents:
    post:
      tags: [Partner]
      summary: "Upload a qualification document (4.1) — multipart form: file (required, pdf/jpg/jpeg/png, max 10MB; the part's Content-Type must match the extension) and kind (business_license|legal_rep_id_front|legal_rep_id_back|other, default business_license; the legacy 'license' maps to business_license). The payload streams into object storage and its SHA-256 is recorded."
      operationId: uploadPartnerDocument
      security: [{ bearerAuth: [] }]
      requestBody:
        required: true
        content:
          multipart/form-data:
            schema:
              type: object
              required: [file]
              properties:
                file:
                  type: string
                  format: binary
                  description: pdf/jpg/jpeg/png, max 10MB; declared Content-Type must match the file extension.
                kind:
                  type: string
                  enum: [business_license, legal_rep_id_front, legal_rep_id_back, other, license]
                  default: business_license
      responses:
        '201':
          description: Document stored
          content:
            application/json:
              schema: { $ref: '#/components/schemas/PartnerDocument' }
        '400':
          description: request.invalid (malformed multipart, file over 10MB, missing file, bad kind/filename) / partner_document.unsupported_type
        '401': { $ref: '#/components/responses/Unauthorized' }
        '500': { $ref: '#/components/responses/ServerError' }
    get:
      tags: [Partner]
      summary: List the caller's own qualification documents
      operationId: listMyPartnerDocuments
      security: [{ bearerAuth: [] }]
      responses:
        '200':
          description: The caller's documents
          content:
            application/json:
              schema: { $ref: '#/components/schemas/PartnerDocumentListEnvelope' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '500': { $ref: '#/components/responses/ServerError' }
  /v1/me/partner/documents/{id}:
    delete:
      tags: [Partner]
      summary: Delete one of the caller's own documents (404 for others' IDs, so documents cannot be enumerated)
      operationId: deleteMyPartnerDocument
      security: [{ bearerAuth: [] }]
      parameters:
        - { name: id, in: path, required: true, schema: { type: string, format: uuid } }
      responses:
        '204':
          description: Document deleted
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404':
          description: partner_document.not_found
        '500': { $ref: '#/components/responses/ServerError' }
  /v1/me/partner/documents/{id}/download:
    get:
      tags: [Partner]
      summary: Download one of the caller's own documents — binary stream with Content-Disposition attachment and the original filename/Content-Type
      operationId: downloadMyPartnerDocument
      security: [{ bearerAuth: [] }]
      parameters:
        - { name: id, in: path, required: true, schema: { type: string, format: uuid } }
      responses:
        '200':
          description: Document payload (Content-Type is the stored content_type; Content-Disposition attachment with the original filename)
          content:
            application/octet-stream:
              schema: { type: string, format: binary }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404':
          description: partner_document.not_found
        '500': { $ref: '#/components/responses/ServerError' }
  /v1/admin/partner-profiles:
    get:
      tags: [Admin]
      summary: List partner profiles submitted for platform review
      operationId: listSubmittedPartnerProfiles
      security: [{ bearerAuth: [] }]
      parameters:
        - { name: page, in: query, schema: { type: integer, default: 1 } }
        - { name: limit, in: query, schema: { type: integer, default: 20, maximum: 100 } }
      responses:
        '200':
          description: Paginated list of submitted partner profiles
          content:
            application/json:
              schema: { $ref: '#/components/schemas/PartnerProfileListEnvelope' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '500': { $ref: '#/components/responses/ServerError' }
  /v1/admin/partner-profiles/{user_id}/begin-review:
    post:
      tags: [Admin]
      summary: Mark a submitted profile as under review
      operationId: beginPartnerProfileReview
      security: [{ bearerAuth: [] }]
      parameters:
        - { name: user_id, in: path, required: true, schema: { type: string, format: uuid } }
      responses:
        '200':
          description: Profile now under review
          content:
            application/json:
              schema: { $ref: '#/components/schemas/PartnerProfile' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/NotFound' }
        '409': { $ref: '#/components/responses/Conflict' }
        '429': { $ref: '#/components/responses/TooManyRequests' }
        '500': { $ref: '#/components/responses/ServerError' }
  /v1/admin/partner-profiles/{user_id}/approve:
    post:
      tags: [Admin]
      summary: Approve a profile under review
      operationId: approvePartnerProfile
      security: [{ bearerAuth: [] }]
      parameters:
        - { name: user_id, in: path, required: true, schema: { type: string, format: uuid } }
      requestBody:
        required: false
        content:
          application/json:
            schema: { $ref: '#/components/schemas/PartnerProfileReviewNoteReq' }
      responses:
        '200':
          description: Profile approved
          content:
            application/json:
              schema: { $ref: '#/components/schemas/PartnerProfile' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/NotFound' }
        '409': { $ref: '#/components/responses/Conflict' }
        '429': { $ref: '#/components/responses/TooManyRequests' }
        '500': { $ref: '#/components/responses/ServerError' }
  /v1/admin/partner-profiles/{user_id}/reject:
    post:
      tags: [Admin]
      summary: Reject a profile under review
      operationId: rejectPartnerProfile
      security: [{ bearerAuth: [] }]
      parameters:
        - { name: user_id, in: path, required: true, schema: { type: string, format: uuid } }
      requestBody:
        required: false
        content:
          application/json:
            schema: { $ref: '#/components/schemas/PartnerProfileReviewNoteReq' }
      responses:
        '200':
          description: Profile rejected
          content:
            application/json:
              schema: { $ref: '#/components/schemas/PartnerProfile' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/NotFound' }
        '409': { $ref: '#/components/responses/Conflict' }
        '429': { $ref: '#/components/responses/TooManyRequests' }
        '500': { $ref: '#/components/responses/ServerError' }
  /v1/admin/partner-profiles/{user_id}/direct-contract:
    post:
      tags: [Admin]
      summary: Approve or revoke the direct-pay monthly service fee contract (直连支付月度服务费合同门控, 公司级)
      description: >-
        Partners must sign a direct-pay monthly service fee contract with the
        platform offline (与灏曦线下签署) before their applications can place
        direct (non-sub-merchant) payment orders. Admin approval here records
        the consent timestamp; revoking immediately re-blocks direct orders
        with 409 payment.direct_contract_required.
      operationId: setPartnerDirectPayContract
      security: [{ bearerAuth: [] }]
      parameters:
        - { name: user_id, in: path, required: true, schema: { type: string, format: uuid } }
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [approved]
              properties:
                approved:
                  type: boolean
                  description: true = 已线下签署合同, 同意开通直连支付; false = 撤销同意
      responses:
        '200':
          description: Contract consent updated
          content:
            application/json:
              schema: { $ref: '#/components/schemas/PartnerProfile' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/NotFound' }
        '429': { $ref: '#/components/responses/TooManyRequests' }
        '500': { $ref: '#/components/responses/ServerError' }
  /v1/admin/partner-profiles/{user_id}/documents:
    get:
      tags: [Admin]
      summary: List one partner's qualification documents (reviewed alongside the partner profile)
      operationId: adminListPartnerDocuments
      security: [{ bearerAuth: [] }]
      parameters:
        - { name: user_id, in: path, required: true, schema: { type: string, format: uuid } }
      responses:
        '200':
          description: The partner's documents
          content:
            application/json:
              schema: { $ref: '#/components/schemas/PartnerDocumentListEnvelope' }
        '400':
          description: request.invalid - missing user_id
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '500': { $ref: '#/components/responses/ServerError' }
  /v1/admin/partner-documents/{id}/download:
    get:
      tags: [Admin]
      summary: Download a partner qualification document (audited as a sensitive read) — binary stream with Content-Disposition attachment
      operationId: adminDownloadPartnerDocument
      security: [{ bearerAuth: [] }]
      parameters:
        - { name: id, in: path, required: true, schema: { type: string, format: uuid } }
      responses:
        '200':
          description: Document payload (Content-Type is the stored content_type; Content-Disposition attachment with the original filename)
          content:
            application/octet-stream:
              schema: { type: string, format: binary }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404':
          description: partner_document.not_found
        '500': { $ref: '#/components/responses/ServerError' }
  /v1/admin/invoices:
    get:
      tags: [Admin]
      summary: List partner invoice requests, newest first, with an optional status filter and cursor pagination (platform finance queue)
      operationId: adminListInvoiceRequests
      security: [{ bearerAuth: [] }]
      parameters:
        - { name: status, in: query, schema: { type: string, enum: [pending, issued, rejected] } }
        - { name: cursor, in: query, schema: { type: string }, description: Encoded cursor from a previous page's meta.next_cursor }
        - { name: limit, in: query, schema: { type: integer, default: 20, maximum: 100 } }
      responses:
        '200':
          description: Invoice requests with applicant email and app name
          content:
            application/json:
              schema: { $ref: '#/components/schemas/AdminInvoiceRequestListEnvelope' }
        '400':
          description: pagination.invalid_cursor
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '500': { $ref: '#/components/responses/ServerError' }
  /v1/admin/invoices/{id}/issue:
    post:
      tags: [Admin]
      summary: Mark a pending invoice request issued, recording the invoice number (CAS pending → issued, so two reviewers cannot double-decide)
      operationId: adminIssueInvoice
      security: [{ bearerAuth: [] }]
      parameters:
        - { name: id, in: path, required: true, schema: { type: string, format: uuid } }
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/IssueInvoiceReq' }
      responses:
        '200':
          description: Invoice request issued
          content:
            application/json:
              schema: { $ref: '#/components/schemas/InvoiceRequest' }
        '400':
          description: request.invalid / invoice.invoice_no_required - invoice_no is required (1-64 characters); note max 1024 characters
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404':
          description: invoice.not_found
        '409':
          description: invoice.already_reviewed
        '429': { $ref: '#/components/responses/TooManyRequests' }
        '500': { $ref: '#/components/responses/ServerError' }
  /v1/admin/invoices/{id}/reject:
    post:
      tags: [Admin]
      summary: Mark a pending invoice request rejected; a note (reason) is required so the partner can see why (CAS pending → rejected)
      operationId: adminRejectInvoice
      security: [{ bearerAuth: [] }]
      parameters:
        - { name: id, in: path, required: true, schema: { type: string, format: uuid } }
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/RejectInvoiceReq' }
      responses:
        '200':
          description: Invoice request rejected
          content:
            application/json:
              schema: { $ref: '#/components/schemas/InvoiceRequest' }
        '400':
          description: request.invalid / invoice.note_required - reject reason is required; note max 1024 characters
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404':
          description: invoice.not_found
        '409':
          description: invoice.already_reviewed
        '429': { $ref: '#/components/responses/TooManyRequests' }
        '500': { $ref: '#/components/responses/ServerError' }
  /v1/admin/users:
    get:
      tags: [Admin]
      summary: List and search platform users
      operationId: adminListUsers
      security: [{ bearerAuth: [] }]
      parameters:
        - { name: q, in: query, schema: { type: string }, description: Search email/display_name/phone }
        - { name: status, in: query, schema: { type: string, enum: [active, disabled, deleted] } }
        - { name: page, in: query, schema: { type: integer, default: 1 } }
        - { name: limit, in: query, schema: { type: integer, default: 20, maximum: 100 } }
      responses:
        '200':
          description: Paginated user list
          content:
            application/json:
              schema: { $ref: '#/components/schemas/AdminUserListEnvelope' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '500': { $ref: '#/components/responses/ServerError' }
  /v1/admin/users/{id}:
    get:
      tags: [Admin]
      summary: Get a single user (admin view)
      operationId: adminGetUser
      security: [{ bearerAuth: [] }]
      parameters:
        - { name: id, in: path, required: true, schema: { type: string, format: uuid } }
      responses:
        '200':
          description: User details
          content:
            application/json:
              schema: { $ref: '#/components/schemas/AdminUser' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/NotFound' }
        '500': { $ref: '#/components/responses/ServerError' }
  /v1/admin/users/{id}/status:
    put:
      tags: [Admin]
      summary: Change a user's status
      operationId: adminSetUserStatus
      security: [{ bearerAuth: [] }]
      parameters:
        - { name: id, in: path, required: true, schema: { type: string, format: uuid } }
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/AdminSetUserStatusRequest' }
      responses:
        '200':
          description: Updated user
          content:
            application/json:
              schema: { $ref: '#/components/schemas/AdminUser' }
        '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/ServerError' }
  /v1/admin/payment-configs/{application_id}/{channel}/disable:
    post:
      tags: [Admin]
      summary: One-click disable of a partner payment config (kill switch, 支付合规防线); idempotent — repeated disable answers 200
      operationId: adminDisablePaymentConfig
      security: [{ bearerAuth: [] }]
      parameters:
        - { name: application_id, in: path, required: true, schema: { type: string } }
        - { name: channel, in: path, required: true, schema: { type: string, enum: [wechat, alipay] } }
      requestBody:
        required: false
        content:
          application/json:
            schema:
              type: object
              properties:
                reason: { type: string }
      responses:
        '200':
          description: Config disabled (or already disabled)
          content:
            application/json:
              schema: { $ref: '#/components/schemas/PaymentConfigStatusResp' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/NotFound' }
  /v1/admin/payment-configs/{application_id}/{channel}/enable:
    post:
      tags: [Admin]
      summary: Re-enable a disabled partner payment config; idempotent — repeated enable answers 200
      operationId: adminEnablePaymentConfig
      security: [{ bearerAuth: [] }]
      parameters:
        - { name: application_id, in: path, required: true, schema: { type: string } }
        - { name: channel, in: path, required: true, schema: { type: string, enum: [wechat, alipay] } }
      requestBody:
        required: false
        content:
          application/json:
            schema:
              type: object
              properties:
                reason: { type: string }
      responses:
        '200':
          description: Config enabled (or already active)
          content:
            application/json:
              schema: { $ref: '#/components/schemas/PaymentConfigStatusResp' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/NotFound' }
  /v1/admin/sub-merchants:
    get:
      tags: [Admin]
      summary: List sub-merchants (特约商户) with application name, status and creation time; paginated
      operationId: adminListSubMerchants
      security: [{ bearerAuth: [] }]
      parameters:
        - { name: page, in: query, schema: { type: integer, default: 1 } }
        - { name: limit, in: query, schema: { type: integer, default: 20, maximum: 100 } }
      responses:
        '200':
          description: Paginated sub-merchant list
          content:
            application/json:
              schema:
                type: object
                properties:
                  sub_merchants:
                    type: array
                    items: { $ref: '#/components/schemas/AdminSubMerchantItem' }
                  meta: { $ref: '#/components/schemas/PaginationMeta' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
    post:
      tags: [Admin]
      summary: Register a WeChat service-provider 特约商户 for an application (10.1); starts pending until 进件 is confirmed
      operationId: adminCreateSubMerchant
      security: [{ bearerAuth: [] }]
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/CreateSubMerchantReq' }
      responses:
        '201':
          description: Sub-merchant registered (status pending)
          content:
            application/json:
              schema: { $ref: '#/components/schemas/SubMerchant' }
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '409':
          description: A sub-merchant is already registered for this application, or the given sub_mchid belongs to another partner (sub_merchant.owner_mismatch)
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }
  /v1/admin/sub-merchants/reusable:
    get:
      tags: [Admin]
      summary: List sub_mchids already registered to other applications of the same partner — candidates for sharing one 特约商户号 across that partner's applications
      operationId: adminListReusableSubMchIDs
      security: [{ bearerAuth: [] }]
      parameters:
        - { name: application_id, in: query, required: true, schema: { type: string } }
      responses:
        '200':
          description: Reusable sub_mchids (empty when the application has no owner or the partner has no other registrations)
          content:
            application/json:
              schema:
                type: object
                properties:
                  reusable:
                    type: array
                    items:
                      type: object
                      properties:
                        sub_mchid: { type: string }
                        company_name: { type: string }
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
  /v1/admin/applications:
    get:
      tags: [Admin]
      summary: List applications (oauth clients) with sub-merchant status and effective service fee (M10-b 客户管理面); paginated
      operationId: adminListApplications
      security: [{ bearerAuth: [] }]
      parameters:
        - { name: page, in: query, schema: { type: integer, default: 1 } }
        - { name: limit, in: query, schema: { type: integer, default: 20, maximum: 100 } }
      responses:
        '200':
          description: Paginated application list
          content:
            application/json:
              schema:
                type: object
                properties:
                  applications:
                    type: array
                    items: { $ref: '#/components/schemas/AdminApplicationItem' }
                  meta: { $ref: '#/components/schemas/PaginationMeta' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
  /v1/admin/applications/{client_id}/fee:
    put:
      tags: [Admin]
      summary: Set or clear the per-application service fee rate (bps). Null restores the global default; audited as service_fee_changed
      operationId: adminSetApplicationFee
      security: [{ bearerAuth: [] }]
      parameters:
        - { name: client_id, in: path, required: true, schema: { type: string } }
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/SetServiceFeeReq' }
      responses:
        '200':
          description: Fee updated
          content:
            application/json:
              schema:
                type: object
                properties:
                  client_id: { type: string }
                  service_fee_bps: { type: [integer, 'null'] }
                  effective_fee_bps: { type: integer }
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/NotFound' }
  /v1/admin/payment-configs:
    get:
      tags: [Admin]
      summary: List payment configs (status/mode/health only — secrets are never decrypted or returned). Optional application_id prefix filter
      operationId: adminListPaymentConfigs
      security: [{ bearerAuth: [] }]
      parameters:
        - { name: application_id, in: query, schema: { type: string } }
        - { name: page, in: query, schema: { type: integer, default: 1 } }
        - { name: limit, in: query, schema: { type: integer, default: 20, maximum: 100 } }
      responses:
        '200':
          description: Payment config status list
          content:
            application/json:
              schema:
                type: object
                properties:
                  payment_configs:
                    type: array
                    items: { $ref: '#/components/schemas/AdminPaymentConfigItem' }
                  meta: { $ref: '#/components/schemas/PaginationMeta' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
  /v1/admin/wechat-sp:
    get:
      tags: [Admin]
      summary: Platform WeChat service-provider merged configuration (env + platform_wechat_sp per-field override; secrets — private key material, APIv3 key — are never returned). configured=false omits every other field
      operationId: adminGetWechatSP
      security: [{ bearerAuth: [] }]
      responses:
        '200':
          description: Service-provider configuration status, per-field sources and credwatch health
          content:
            application/json:
              schema: { $ref: '#/components/schemas/AdminWechatSPStatus' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
    put:
      tags: [Admin]
      summary: Replace the platform_wechat_sp non-secret overrides (idempotent; hot-applied without restart; audited as wechat_sp_config_changed). Secrets are not editable — the private key path and APIv3 key stay env-only
      operationId: adminPutWechatSP
      security: [{ bearerAuth: [] }]
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/AdminWechatSPPutReq' }
      responses:
        '200':
          description: Saved; returns the merged configuration view
          content:
            application/json:
              schema: { $ref: '#/components/schemas/AdminWechatSPStatus' }
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
  /v1/admin/sub-merchants/{id}:
    put:
      tags: [Admin]
      summary: Correct a still-pending sub-merchant registration (sub_mchid / company_name, 进件信息填错); audited; non-pending records are frozen
      operationId: adminUpdateSubMerchant
      security: [{ bearerAuth: [] }]
      parameters:
        - { name: id, in: path, required: true, schema: { type: string } }
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/UpdateSubMerchantReq' }
      responses:
        '200':
          description: Registration corrected
          content:
            application/json:
              schema: { $ref: '#/components/schemas/SubMerchant' }
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/NotFound' }
        '409':
          description: Only a pending sub-merchant can be corrected, or the given sub_mchid belongs to another partner (sub_merchant.owner_mismatch)
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }
  /v1/admin/sub-merchants/{id}/activate:
    post:
      tags: [Admin]
      summary: Activate a sub-merchant (pending|disabled → active); enables service-provider collection for the application. Idempotent
      operationId: adminActivateSubMerchant
      security: [{ bearerAuth: [] }]
      parameters:
        - { name: id, in: path, required: true, schema: { type: string } }
      responses:
        '200':
          description: Sub-merchant activated (or already active)
          content:
            application/json:
              schema: { $ref: '#/components/schemas/SubMerchant' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/NotFound' }
        '409':
          description: Invalid status transition
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }
  /v1/admin/sub-merchants/{id}/disable:
    post:
      tags: [Admin]
      summary: Disable a sub-merchant (kill switch); new service-provider orders for the application are rejected while disabled. Idempotent
      operationId: adminDisableSubMerchant
      security: [{ bearerAuth: [] }]
      parameters:
        - { name: id, in: path, required: true, schema: { type: string } }
      responses:
        '200':
          description: Sub-merchant disabled (or already disabled)
          content:
            application/json:
              schema: { $ref: '#/components/schemas/SubMerchant' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/NotFound' }
        '409':
          description: Invalid status transition
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }
  /v1/admin/sub-merchants/{id}/papay-plan:
    put:
      tags: [Admin]
      summary: Register the 委托代扣(自动续费) template ID (plan_id) approved for this 特约商户; enables partner auto-renew signings. Audited
      description: |
        Templates are approved per merchant number on the WeChat partner platform; platform ops
        complete the 代办 there and backfill the issued numeric plan_id here. An empty plan_id
        clears the registration — new partner auto-renew signings then fail
        `auto_renew.plan_not_configured` while existing agreements keep billing.
      operationId: adminSetSubMerchantPapayPlan
      security: [{ bearerAuth: [] }]
      parameters:
        - { name: id, in: path, required: true, schema: { type: string } }
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/SetSubMerchantPapayPlanReq' }
      responses:
        '200':
          description: Template ID registered
          content:
            application/json:
              schema: { $ref: '#/components/schemas/SubMerchant' }
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/NotFound' }
  /v1/admin/sub-merchants/{id}/apply:
    post:
      tags: [Admin]
      summary: Submit the sub-merchant's 进件申请单 to WeChat (特约商户进件 API, 10.3); assembles the applyment from the partner profile + uploaded documents, uploads the required pictures and flips the record to applying. Repeating while an applyment is in flight is an idempotent 200 (same business_code/applyment_id). The channel audit result is folded back by the periodic applyment sweep (FINISHED → sub_mchid + active; REJECTED → pending + rejected_reason)
      operationId: adminApplySubMerchant
      security: [{ bearerAuth: [] }]
      parameters:
        - { name: id, in: path, required: true, schema: { type: string } }
      requestBody:
        required: false
        content:
          application/json:
            schema: { $ref: '#/components/schemas/ApplySubMerchantReq' }
      responses:
        '200':
          description: Applyment submitted (or already in flight)
          content:
            application/json:
              schema: { $ref: '#/components/schemas/SubMerchantApplyment' }
        '400':
          description: Onboarding materials incomplete (missing items named in the message) or malformed body
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/NotFound' }
        '409':
          description: Sub-merchant not pending, or SP credentials not configured
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }
        '502':
          description: WeChat applyment submission failed (nothing persisted; retry safe)
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }
        '503':
          description: No usable WeChat platform certificate for sensitive-field encryption
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }
  /v1/admin/sub-merchants/{id}/applyment/withdraw:
    post:
      tags: [Admin]
      summary: 放弃进件 ("申请提交了但不想签了") — withdraw an in-flight applyment without touching the channel, folding the record back to pending (rejected_reason set to 管理员放弃进件) so it can be corrected and resubmitted; the sweep no longer picks it up. Repeating on an already-pending record is an idempotent 200 no-op; audited as sub_merchant_applyment_withdrawn
      operationId: adminWithdrawSubMerchantApplyment
      security: [{ bearerAuth: [] }]
      parameters:
        - { name: id, in: path, required: true, schema: { type: string } }
      responses:
        '200':
          description: Applyment withdrawn (or already pending)
          content:
            application/json:
              schema: { $ref: '#/components/schemas/SubMerchantApplyment' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/NotFound' }
        '409':
          description: Sub-merchant not applying (only an in-flight applyment can be withdrawn)
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }
  /v1/admin/page-watches:
    get:
      tags: [Admin]
      summary: List embedded-page patrol / content-scan targets (9.2/9.3) with the owning application name and latest result; paginated
      operationId: adminListPageWatches
      security: [{ bearerAuth: [] }]
      parameters:
        - { name: page, in: query, schema: { type: integer, default: 1 } }
        - { name: limit, in: query, schema: { type: integer, default: 20, maximum: 100 } }
      responses:
        '200':
          description: Paginated page-watch list
          content:
            application/json:
              schema:
                type: object
                properties:
                  page_watches:
                    type: array
                    items: { $ref: '#/components/schemas/PageWatch' }
                  meta: { $ref: '#/components/schemas/PaginationMeta' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
    post:
      tags: [Admin]
      summary: Register a page-watch target (embed 防调包巡检 or content 敏感内容扫描); starts active and is patrolled on the next tick; audited as page_watch_created
      operationId: adminCreatePageWatch
      security: [{ bearerAuth: [] }]
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/CreatePageWatchReq' }
      responses:
        '201':
          description: Page-watch target registered (status active)
          content:
            application/json:
              schema: { $ref: '#/components/schemas/PageWatch' }
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
  /v1/admin/page-watches/{id}:
    delete:
      tags: [Admin]
      summary: Delete a page-watch target; the finding ledger is kept as the historical record; audited as page_watch_deleted
      operationId: adminDeletePageWatch
      security: [{ bearerAuth: [] }]
      parameters:
        - { name: id, in: path, required: true, schema: { type: string } }
      responses:
        '200':
          description: Page-watch target deleted
          content:
            application/json:
              schema:
                type: object
                properties:
                  deleted: { type: boolean }
                  id: { type: string }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/NotFound' }
  /v1/admin/page-watch-findings:
    get:
      tags: [Admin]
      summary: List patrol hits (9.2/9.3 命中记录) for manual review; filterable by watch and status; paginated
      operationId: adminListPageWatchFindings
      security: [{ bearerAuth: [] }]
      parameters:
        - { name: watch_id, in: query, schema: { type: string }, description: Restrict to one watch target }
        - { name: status, in: query, schema: { type: string, enum: [open, resolved] }, description: open = 待复核, resolved = 已复核; omit for all }
        - { name: page, in: query, schema: { type: integer, default: 1 } }
        - { name: limit, in: query, schema: { type: integer, default: 20, maximum: 100 } }
      responses:
        '200':
          description: Paginated finding ledger
          content:
            application/json:
              schema:
                type: object
                properties:
                  findings:
                    type: array
                    items: { $ref: '#/components/schemas/PageWatchFinding' }
                  meta: { $ref: '#/components/schemas/PaginationMeta' }
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
  /v1/admin/page-watch-findings/{id}/resolve:
    post:
      tags: [Admin]
      summary: Mark a patrol hit resolved after manual review (9.2 复核流); audited as page_watch_finding_resolved
      operationId: adminResolvePageWatchFinding
      security: [{ bearerAuth: [] }]
      parameters:
        - { name: id, in: path, required: true, schema: { type: string } }
      requestBody:
        content:
          application/json:
            schema: { $ref: '#/components/schemas/ResolvePageWatchFindingReq' }
      responses:
        '200':
          description: Finding resolved
          content:
            application/json:
              schema:
                type: object
                properties:
                  id: { type: string }
                  status: { type: string, enum: [resolved] }
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/NotFound' }
        '409':
          description: Finding is already resolved
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }
  /v1/admin/orders:
    get:
      tags: [Admin]
      summary: List orders platform-wide (10.3 admin order console)
      operationId: adminListOrders
      security: [{ bearerAuth: [] }]
      parameters:
        - { name: application_id, in: query, schema: { type: string }, description: Filter by owning application (client_id) }
        - { name: status, in: query, schema: { type: string, enum: [pending, paid, closed, refunding, partially_refunded, refunded, disputed] } }
        - { name: from, in: query, schema: { type: string }, description: created_at lower bound (RFC3339 or YYYY-MM-DD); empty = all time }
        - { name: to, in: query, schema: { type: string }, description: created_at upper bound (exclusive; a bare date covers that whole day) }
        - { name: page, in: query, schema: { type: integer, default: 1 } }
        - { name: limit, in: query, schema: { type: integer, default: 20, maximum: 100 } }
      responses:
        '200':
          description: Paginated order list joined with the application display name
          content:
            application/json:
              schema:
                type: object
                properties:
                  orders:
                    type: array
                    items:
                      type: object
                      properties:
                        platform_order_no: { type: string }
                        application_id: { type: string }
                        app_name: { type: string }
                        channel: { type: string, enum: [wechat, alipay] }
                        amount: { type: integer, format: int64, description: amount in fen }
                        currency: { type: string }
                        subject: { type: string }
                        status: { type: string, enum: [pending, paid, closed, refunding, partially_refunded, refunded, disputed] }
                        created_at: { type: string, format: date-time }
                  meta:
                    type: object
                    properties:
                      total: { type: integer, format: int64 }
                      page: { type: integer }
                      limit: { type: integer }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
  /v1/admin/orders/{platform_order_no}:
    get:
      tags: [Admin]
      summary: Order detail with refund and profit-sharing records (10.3)
      operationId: adminGetOrder
      security: [{ bearerAuth: [] }]
      parameters:
        - { name: platform_order_no, in: path, required: true, schema: { type: string } }
      responses:
        '200':
          description: Order detail; profit_sharing is null when the order was never shared
          content:
            application/json:
              schema:
                type: object
                properties:
                  order:
                    type: object
                    properties:
                      platform_order_no: { type: string }
                      merchant_order_no: { type: string }
                      channel_order_no: { type: string }
                      application_id: { type: string }
                      app_name: { type: string }
                      user_id: { type: string }
                      channel: { type: string, enum: [wechat, alipay] }
                      amount: { type: integer, format: int64 }
                      currency: { type: string }
                      subject: { type: string }
                      source: { type: string }
                      status: { type: string, enum: [pending, paid, closed, refunding, partially_refunded, refunded, disputed] }
                      order_type: { type: string }
                      subscription_id: { type: string }
                      sub_mchid: { type: string, description: 服务商模式特约商户号 (admin-only) }
                      paid_at: { type: string, format: date-time }
                      created_at: { type: string, format: date-time }
                  refunds:
                    type: array
                    items:
                      type: object
                      properties:
                        refund_no: { type: string }
                        channel_refund_no: { type: string }
                        amount: { type: integer, format: int64 }
                        reason: { type: string }
                        status: { type: string, enum: [pending, success, failed] }
                        created_at: { type: string, format: date-time }
                        refunded_at: { type: string, format: date-time }
                  profit_sharing:
                    type: object
                    nullable: true
                    properties:
                      out_order_no: { type: string }
                      sub_mchid: { type: string }
                      order_amount: { type: integer, format: int64 }
                      fee_bps: { type: integer }
                      fee_amount: { type: integer, format: int64 }
                      receiver_mchid: { type: string }
                      status: { type: string, enum: [pending, success, failed, finished] }
                      retry_count: { type: integer }
                      last_error: { type: string }
                      finished_at: { type: string, format: date-time }
                      created_at: { type: string, format: date-time }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/NotFound' }
  /v1/admin/orders/{platform_order_no}/confirm-offline:
    post:
      tags: [Admin]
      summary: "Confirm receipt of an offline payment for a platform order (offline_pending → paid), recording the payment method and firing the fulfillment chain (subscription/wallet). Limited to platform historical orders — a partner-owned order must be confirmed by the partner via POST /v1/me/orders/{platform_order_no}/confirm-offline (403 order.partner_owned). Idempotent: an already-paid order answers 200 unchanged; a non-offline-pending order answers 409 order.invalid_status. Audited as admin_order_confirmed_offline"
      operationId: adminConfirmOfflinePayment
      security: [{ bearerAuth: [] }]
      parameters:
        - { name: platform_order_no, in: path, required: true, schema: { type: string } }
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [payment_method]
              properties:
                payment_method: { type: string, description: "How the offline payment was collected (bank_transfer, cash, …); recorded on the order and in the audit log." }
      responses:
        '200':
          description: Offline payment confirmed (or the order was already paid)
          content:
            application/json:
              schema:
                type: object
                required: [platform_order_no, status, amount]
                properties:
                  platform_order_no: { type: string }
                  status: { type: string, enum: [offline_pending, paid] }
                  amount: { type: integer, description: Amount in fen }
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/NotFound' }
        '409': { $ref: '#/components/responses/Conflict' }
        '500': { $ref: '#/components/responses/ServerError' }
  /v1/admin/orders/{platform_order_no}/dispute:
    post:
      tags: [Admin]
      summary: Mark an order disputed (finance, 8.4 渠道拒付/争议单独记录)
      operationId: adminDisputeOrder
      security: [{ bearerAuth: [] }]
      parameters:
        - { name: platform_order_no, in: path, required: true, schema: { type: string } }
      requestBody:
        required: false
        content:
          application/json:
            schema:
              type: object
              properties:
                reason: { type: string }
      responses:
        '200':
          description: Order marked disputed
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/NotFound' }
        '409':
          description: order.invalid_transition
  /v1/admin/orders/{platform_order_no}/resolve-dispute:
    post:
      tags: [Admin]
      summary: Resolve a dispute back to a standing status
      operationId: adminResolveOrderDispute
      security: [{ bearerAuth: [] }]
      parameters:
        - { name: platform_order_no, in: path, required: true, schema: { type: string } }
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [target_status]
              properties:
                target_status: { type: string, enum: [paid, partially_refunded, refunded] }
      responses:
        '200':
          description: Dispute resolved
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/NotFound' }
        '409':
          description: order.invalid_transition
  /v1/admin/invite-bindings/{user_id}:
    put:
      tags: [Admin]
      summary: Correct an invite binding (7.7; reason mandatory and audited)
      operationId: adminCorrectInviteBinding
      security: [{ bearerAuth: [] }]
      parameters:
        - { name: user_id, in: path, required: true, schema: { type: string, format: uuid } }
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [reason]
              properties:
                invite_code:
                  type: string
                  description: New inviter's code; empty unbinds the invitee. When the invitee has no binding yet, a non-empty code creates one (10.3 fallback)
                reason: { type: string }
      responses:
        '200':
          description: Binding corrected
        '400':
          description: invite.reason_required / invite.invalid_code / invite.self_invite
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404':
          description: invite.binding_not_found
        '409':
          description: invite.same_inviter
        '500': { $ref: '#/components/responses/ServerError' }
  /v1/admin/sales-attributions/{user_id}:
    put:
      tags: [Admin]
      summary: Correct a user's sales attribution (10.3 手工归属兜底; reason mandatory and audited)
      operationId: adminCorrectSalesAttribution
      security: [{ bearerAuth: [] }]
      parameters:
        - { name: user_id, in: path, required: true, schema: { type: string, format: uuid } }
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [reason]
              properties:
                sales_partner_id:
                  type: string
                  description: Target sales_partners.id; empty clears the sales attribution
                reason: { type: string }
      responses:
        '200':
          description: Attribution corrected
        '400':
          description: settlement.reason_required
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404':
          description: user.not_found / settlement.sales_partner_not_found / settlement.attribution_not_found
        '409':
          description: settlement.same_sales_partner
        '500': { $ref: '#/components/responses/ServerError' }
  /v1/admin/sales-partners:
    get:
      tags: [Admin]
      summary: List sales partners (10.3 销售档案); paginated
      operationId: adminListSalesPartners
      security: [{ bearerAuth: [] }]
      parameters:
        - { name: page, in: query, schema: { type: integer, default: 1 } }
        - { name: limit, in: query, schema: { type: integer, default: 20, maximum: 100 } }
      responses:
        '200':
          description: Paginated sales partner list
          content:
            application/json:
              schema:
                type: object
                properties:
                  items:
                    type: array
                    items: { $ref: '#/components/schemas/SalesPartner' }
                  meta: { $ref: '#/components/schemas/PaginationMeta' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
    post:
      tags: [Admin]
      summary: Register a sales partner with a dedicated invite code (auto-generated when omitted); audited as sales_partner_created
      operationId: adminCreateSalesPartner
      security: [{ bearerAuth: [] }]
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/CreateSalesPartnerReq' }
      responses:
        '201':
          description: Sales partner registered (status active)
          content:
            application/json:
              schema: { $ref: '#/components/schemas/SalesPartner' }
        '400':
          description: sales.name_required / sales.name_too_long / sales.invalid_type / sales.invalid_invite_code / sales.invalid_receiver_mchid / sales.receiver_name_required / sales.receiver_name_too_long
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '409':
          description: sales.invite_code_taken
        '500': { $ref: '#/components/responses/ServerError' }
  /v1/admin/sales-partners/{id}:
    put:
      tags: [Admin]
      summary: Full replace of a sales partner profile (name/type/status/remark/receiver; invite code immutable); audited with old/new values
      operationId: adminUpdateSalesPartner
      security: [{ bearerAuth: [] }]
      parameters:
        - { name: id, in: path, required: true, schema: { type: string, format: uuid } }
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/UpdateSalesPartnerReq' }
      responses:
        '200':
          description: Sales partner updated
          content:
            application/json:
              schema: { $ref: '#/components/schemas/SalesPartner' }
        '400':
          description: sales.name_required / sales.invalid_type / sales.invalid_status / sales.invalid_receiver_mchid / sales.receiver_name_required / sales.receiver_name_too_long
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404':
          description: sales.not_found
        '500': { $ref: '#/components/responses/ServerError' }
  /v1/admin/sales-settlements:
    get:
      tags: [Admin]
      summary: Monthly split-snapshot report (10.3 月结报表, 0078 定案) — full vs charged service fee, the 中科智能应付韩磊 product-owner split, 灏曦 channel income and the per-sales-company breakdown (shared / platform-held) over channel-confirmed profit-sharing orders; held sales shares are paid manually at month end (no automatic payout)
      operationId: adminSalesSettlementReport
      security: [{ bearerAuth: [] }]
      parameters:
        - { name: month, in: query, required: true, schema: { type: string, pattern: '^\d{4}-\d{2}$' }, description: Settlement month YYYY-MM (UTC) }
      responses:
        '200':
          description: Distribution report; amounts in fen
          content:
            application/json:
              schema: { $ref: '#/components/schemas/SalesSettlementReport' }
        '400':
          description: request.invalid (missing month) / sales.invalid_month
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '500': { $ref: '#/components/responses/ServerError' }
  /v1/admin/profit-sharing/receivers:
    get:
      tags: [Admin]
      summary: List extra corporate profit-sharing receivers (多方分账 0077); paginated. The platform default receiver (灏曦) keeps the remainder of every fee and is configured via /v1/admin/wechat-sp
      operationId: adminListProfitSharingReceivers
      security: [{ bearerAuth: [] }]
      parameters:
        - { name: page, in: query, schema: { type: integer, default: 1 } }
        - { name: limit, in: query, schema: { type: integer, default: 20, maximum: 100 } }
      responses:
        '200':
          description: Paginated receiver list
          content:
            application/json:
              schema:
                type: object
                properties:
                  items:
                    type: array
                    items: { $ref: '#/components/schemas/ProfitSharingReceiver' }
                  meta: { $ref: '#/components/schemas/PaginationMeta' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
    post:
      tags: [Admin]
      summary: Add an extra corporate receiver (MERCHANT_ID only). Effective for new paid orders immediately; a background backfill registers it with WeChat for every active sub-merchant. Audited as profit_sharing_receiver_set
      operationId: adminCreateProfitSharingReceiver
      security: [{ bearerAuth: [] }]
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/UpsertProfitSharingReceiverReq' }
      responses:
        '201':
          description: Receiver registered (status active)
          content:
            application/json:
              schema: { $ref: '#/components/schemas/ProfitSharingReceiver' }
        '400':
          description: profit_sharing.invalid_mchid / profit_sharing.name_required / profit_sharing.name_too_long
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '409':
          description: profit_sharing.duplicate_mchid
        '500': { $ref: '#/components/responses/ServerError' }
  /v1/admin/profit-sharing/receivers/{id}:
    put:
      tags: [Admin]
      summary: Full replace of a receiver's mutable fields (mchid/name/relation_type); the new values only affect orders recorded after the update — in-flight shares keep their snapshot. Audited with old/new values
      operationId: adminUpdateProfitSharingReceiver
      security: [{ bearerAuth: [] }]
      parameters:
        - { name: id, in: path, required: true, schema: { type: string, format: uuid } }
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/UpsertProfitSharingReceiverReq' }
      responses:
        '200':
          description: Receiver updated
          content:
            application/json:
              schema: { $ref: '#/components/schemas/ProfitSharingReceiver' }
        '400':
          description: profit_sharing.invalid_mchid / profit_sharing.name_required
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404':
          description: profit_sharing.receiver_not_found
        '409':
          description: profit_sharing.duplicate_mchid
        '500': { $ref: '#/components/responses/ServerError' }
  /v1/admin/profit-sharing/receivers/{id}/status:
    post:
      tags: [Admin]
      summary: Enable or disable a receiver; disabling only stops new splits, enabling kicks off a registration backfill for active sub-merchants. Audited as profit_sharing_receiver_set
      operationId: adminSetProfitSharingReceiverStatus
      security: [{ bearerAuth: [] }]
      parameters:
        - { name: id, in: path, required: true, schema: { type: string, format: uuid } }
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/ProfitSharingReceiverStatusReq' }
      responses:
        '200':
          description: Receiver status updated
          content:
            application/json:
              schema: { $ref: '#/components/schemas/ProfitSharingReceiver' }
        '400':
          description: profit_sharing.invalid_status
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404':
          description: profit_sharing.receiver_not_found
        '500': { $ref: '#/components/responses/ServerError' }
  /v1/admin/roles:
    get:
      tags: [Admin]
      summary: RBAC role model catalog (requirement 16)
      operationId: adminListRoles
      security: [{ bearerAuth: [] }]
      responses:
        '200':
          description: Roles with their permission sets
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
  /v1/admin/users/{id}/roles:
    get:
      tags: [Admin]
      summary: List an admin's RBAC role assignments
      operationId: adminGetUserRoles
      security: [{ bearerAuth: [] }]
      parameters:
        - { name: id, in: path, required: true, schema: { type: string, format: uuid } }
      responses:
        '200':
          description: Assigned roles (empty = bootstrap super)
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
    put:
      tags: [Admin]
      summary: Replace an admin's RBAC role set (super_admin only)
      operationId: adminAssignUserRoles
      security: [{ bearerAuth: [] }]
      parameters:
        - { name: id, in: path, required: true, schema: { type: string, format: uuid } }
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [roles]
              properties:
                roles:
                  type: array
                  items: { type: string, enum: [super_admin, support, ops, finance, release, auditor] }
      responses:
        '200':
          description: Roles replaced
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '409':
          description: roles.not_admin - target is not an active admin
        '500': { $ref: '#/components/responses/ServerError' }
  /v1/admin/users/{id}/risk:
    put:
      tags: [Admin]
      summary: Set a user's risk level (freeze revokes sessions; reason required for non-normal levels)
      operationId: adminSetUserRisk
      security: [{ bearerAuth: [] }]
      parameters:
        - { name: id, in: path, required: true, schema: { type: string, format: uuid } }
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/AdminSetUserRiskRequest' }
      responses:
        '200':
          description: Updated user
          content:
            application/json:
              schema: { $ref: '#/components/schemas/AdminUser' }
        '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/ServerError' }
  /v1/admin/users/{id}/wallets:
    get:
      tags: [Admin]
      summary: List a user's wallet balances
      operationId: adminListUserWallets
      security: [{ bearerAuth: [] }]
      parameters:
        - { name: id, in: path, required: true, schema: { type: string, format: uuid } }
      responses:
        '200':
          description: Wallet balances
          content:
            application/json:
              schema: { $ref: '#/components/schemas/WalletEnvelope' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '500': { $ref: '#/components/responses/ServerError' }
  /v1/admin/users/{id}/wallets/{currency}/ledger:
    get:
      tags: [Admin]
      summary: List a user's wallet ledger
      operationId: adminListUserWalletLedger
      security: [{ bearerAuth: [] }]
      parameters:
        - { name: id, in: path, required: true, schema: { type: string, format: uuid } }
        - { name: currency, in: path, required: true, schema: { type: string } }
        - { name: limit, in: query, schema: { type: integer, default: 20, maximum: 100 } }
        - { name: after, in: query, schema: { type: string }, description: Encoded cursor from previous page }
      responses:
        '200':
          description: Ledger entries
          content:
            application/json:
              schema: { $ref: '#/components/schemas/LedgerEnvelope' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '500': { $ref: '#/components/responses/ServerError' }
  /v1/admin/users/{id}/subscriptions:
    get:
      tags: [Admin]
      summary: List a user's subscriptions
      operationId: adminListUserSubscriptions
      security: [{ bearerAuth: [] }]
      parameters:
        - { name: id, in: path, required: true, schema: { type: string, format: uuid } }
      responses:
        '200':
          description: Subscriptions
          content:
            application/json:
              schema: { $ref: '#/components/schemas/AdminSubscriptionListEnvelope' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '500': { $ref: '#/components/responses/ServerError' }
  /v1/admin/users/{id}/orders:
    get:
      tags: [Admin]
      summary: List a user's orders
      operationId: adminListUserOrders
      security: [{ bearerAuth: [] }]
      parameters:
        - { name: id, in: path, required: true, schema: { type: string, format: uuid } }
        - { name: page, in: query, schema: { type: integer, default: 1 } }
        - { name: limit, in: query, schema: { type: integer, default: 20, maximum: 100 } }
      responses:
        '200':
          description: Orders
          content:
            application/json:
              schema: { $ref: '#/components/schemas/AdminOrderListEnvelope' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '500': { $ref: '#/components/responses/ServerError' }
  /v1/admin/audit-events:
    get:
      tags: [Admin]
      summary: Query platform audit log
      operationId: adminListAuditEvents
      security: [{ bearerAuth: [] }]
      parameters:
        - { name: event_type, in: query, schema: { type: string } }
        - { name: user_id, in: query, schema: { type: string, format: uuid } }
        - { name: from, in: query, schema: { type: string }, description: RFC3339 or YYYY-MM-DD }
        - { name: to, in: query, schema: { type: string }, description: RFC3339 or YYYY-MM-DD (half-open end) }
        - { name: page, in: query, schema: { type: integer, default: 1 } }
        - { name: limit, in: query, schema: { type: integer, default: 20, maximum: 100 } }
      responses:
        '200':
          description: Audit events
          content:
            application/json:
              schema: { $ref: '#/components/schemas/AuditEventListEnvelope' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '500': { $ref: '#/components/responses/ServerError' }
  /v1/admin/stats:
    get:
      tags: [Admin]
      summary: Dashboard statistics
      operationId: adminGetStats
      security: [{ bearerAuth: [] }]
      responses:
        '200':
          description: Dashboard stats
          content:
            application/json:
              schema: { $ref: '#/components/schemas/AdminStats' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '500': { $ref: '#/components/responses/ServerError' }
  /v1/me/oauth/clients/{client_id}/complaints:
    get:
      tags: [Partner]
      summary: Complaints filed against one of the partner's applications (viewer+)
      operationId: listClientComplaints
      security: [{ bearerAuth: [] }]
      parameters:
        - { name: client_id, in: path, required: true, schema: { type: string } }
        - { name: status, in: query, schema: { type: string, enum: [open, closed] } }
        - { name: page, in: query, schema: { type: integer, default: 1 } }
        - { name: limit, in: query, schema: { type: integer, default: 20, maximum: 100 } }
      responses:
        '200':
          description: Complaints against this application
          content:
            application/json:
              schema: { $ref: '#/components/schemas/ClientComplaintListEnvelope' }
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { $ref: '#/components/responses/NotFound' }
        '500': { $ref: '#/components/responses/ServerError' }
  /v1/me/oauth/clients/{client_id}/complaints/{complaint_id}:
    put:
      tags: [Partner]
      summary: Mark a complaint open or closed (admin+)
      operationId: updateComplaintStatus
      security: [{ bearerAuth: [] }]
      parameters:
        - { name: client_id, in: path, required: true, schema: { type: string } }
        - { name: complaint_id, in: path, required: true, schema: { type: string } }
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/UpdateComplaintStatusReq' }
      responses:
        '200':
          description: Status updated
          content:
            application/json:
              schema: { $ref: '#/components/schemas/StatusOK' }
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { $ref: '#/components/responses/NotFound' }
        '500': { $ref: '#/components/responses/ServerError' }
  /v1/pay/orders:
    post:
      tags: [Payment]
      summary: Create a platform order (cashier)
      operationId: createOrder
      security: [{ bearerAuth: [] }]
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/CreateOrderReq' }
      responses:
        '201':
          description: Order created
          content:
            application/json:
              schema: { $ref: '#/components/schemas/OrderRaw' }
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { $ref: '#/components/responses/NotFound' }
        '500': { $ref: '#/components/responses/ServerError' }
  /v1/pay/orders/{platform_order_no}/prepay:
    post:
      tags: [Payment]
      summary: Generate channel-specific payment parameters for a pending order
      operationId: prepayOrder
      security: [{ bearerAuth: [] }]
      parameters:
        - { name: platform_order_no, in: path, required: true, schema: { type: string } }
      responses:
        '200':
          description: Payment parameters
          content:
            application/json:
              schema: { $ref: '#/components/schemas/PrepayResult' }
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { $ref: '#/components/responses/NotFound' }
        '502':
          description: Channel API error
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }
        '500': { $ref: '#/components/responses/ServerError' }
  /v1/pay/orders/{platform_order_no}/code-url:
    get:
      tags: [Payment]
      summary: Refresh the WeChat Native code_url of a pending order (cashier QR rotation, 防挂码). Public — the platform order no is the capability.
      operationId: refreshOrderCodeURL
      parameters:
        - { name: platform_order_no, in: path, required: true, schema: { type: string } }
      responses:
        '200':
          description: Fresh code_url (expires_at = now + 2h, RFC3339)
          content:
            application/json:
              schema: { $ref: '#/components/schemas/CodeURLResp' }
        '400':
          description: channel.unsupported — order is not a WeChat order
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }
        '404': { $ref: '#/components/responses/NotFound' }
        '409':
          description: order.not_pending — order is no longer payable
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }
        '502':
          description: Channel API error
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }
        '503':
          description: payment.config_disabled — the payment config is disabled (kill switch)
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }
  /v1/pay/orders/{platform_order_no}:
    get:
      tags: [Payment]
      summary: Get a platform order (active reconciliation fallback)
      operationId: getOrder
      security: [{ bearerAuth: [] }]
      parameters:
        - { name: platform_order_no, in: path, required: true, schema: { type: string } }
      responses:
        '200':
          description: Order
          content:
            application/json:
              schema: { $ref: '#/components/schemas/OrderRaw' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { $ref: '#/components/responses/NotFound' }
        '500': { $ref: '#/components/responses/ServerError' }
  /v1/pay/refunds:
    post:
      tags: [Payment]
      summary: Open a refund against a paid order
      operationId: createRefund
      security: [{ bearerAuth: [] }]
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/CreateRefundReq' }
      responses:
        '201':
          description: Refund opened
          content:
            application/json:
              schema: { $ref: '#/components/schemas/RefundRaw' }
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { $ref: '#/components/responses/NotFound' }
        '500': { $ref: '#/components/responses/ServerError' }
  /v1/pay/refunds/{refund_no}:
    get:
      tags: [Payment]
      summary: Get a refund's status (active refund-status query; converges with the channel via the refund sweep and WeChat refund notifies)
      operationId: getRefund
      security: [{ bearerAuth: [] }]
      parameters:
        - { name: refund_no, in: path, required: true, schema: { type: string } }
      responses:
        '200':
          description: Refund
          content:
            application/json:
              schema: { $ref: '#/components/schemas/RefundRaw' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { $ref: '#/components/responses/NotFound' }
        '500': { $ref: '#/components/responses/ServerError' }
  /v1/pay/reconciliations:
    get:
      tags: [Payment]
      summary: Reconciliation summary + discrepancy items for a day
      operationId: getReconciliation
      security: [{ bearerAuth: [] }]
      parameters:
        - { name: application_id, in: query, required: true, schema: { type: string } }
        - { name: channel, in: query, required: true, schema: { type: string, enum: [wechat, alipay] } }
        - { name: date, in: query, required: true, schema: { type: string, format: date } }
      responses:
        '200':
          description: Summary + items
          content:
            application/json:
              schema:
                type: object
                properties:
                  summary: { type: object, additionalProperties: true }
                  items:
                    type: array
                    items: { type: object, additionalProperties: true }
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { $ref: '#/components/responses/NotFound' }
        '500': { $ref: '#/components/responses/ServerError' }
  /v1/pay/reconciliations/bill:
    post:
      tags: [Payment]
      summary: Trigger file-level (settlement bill) reconciliation for a day
      operationId: triggerBillRecon
      security: [{ bearerAuth: [] }]
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/TriggerBillReq' }
      responses:
        '200':
          description: Reconciliation summary
          content:
            application/json:
              schema: { type: object, additionalProperties: true }
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { $ref: '#/components/responses/NotFound' }
        '503': { $ref: '#/components/responses/ServerError' }
        '500': { $ref: '#/components/responses/ServerError' }

  /v1/pay/callbacks/{channel}:
    post:
      tags: [Payment]
      summary: Channel async payment notification (unauthenticated; verified by signature)
      operationId: payCallback
      description: >-
        Also carries auto-renewal withholding agreement notifies on the same URL:
        WeChat委托代扣 ENTRUST.SIGN / ENTRUST.TERMINATE events and Alipay cycle-pay
        agreement sign/unsign notifications (external_agreement_no / agreement_no),
        verified by the same signature path.
      parameters:
        - { name: channel, in: path, required: true, schema: { type: string, enum: [wechat, alipay] } }
        - { name: Wechatpay-Serial, in: header, required: false, schema: { type: string } }
        - { name: Wechatpay-Timestamp, in: header, required: false, schema: { type: string } }
        - { name: Wechatpay-Nonce, in: header, required: false, schema: { type: string } }
        - { name: Wechatpay-Signature, in: header, required: false, schema: { type: string } }
      requestBody:
        required: true
        content:
          application/x-www-form-urlencoded:
            schema: { type: object, additionalProperties: { type: string } }
            description: Alipay notify body (form-encoded).
          application/json:
            schema: { type: object, additionalProperties: true }
            description: WeChat notify envelope.
      responses:
        '200':
          description: Acknowledged. Alipay returns text/plain "success"; WeChat returns JSON.
          content:
            text/plain:
              schema: { type: string, example: success }
            application/json:
              schema:
                type: object
                properties:
                  code: { type: string, example: SUCCESS }
                  message: { type: string, example: 成功 }
        '400':
          description: Rejected — malformed body, bad signature, amount mismatch, or a stale timestamp (callback.stale; the signature-covered Wechatpay-Timestamp header / Alipay `timestamp` param must be within ±5 minutes — replay protection).
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }
        '500': { $ref: '#/components/responses/ServerError' }

  /v1/catalog/products:
    get:
      tags: [Catalog]
      summary: List active catalog products (public, rate-limited)
      operationId: listCatalogProducts
      parameters:
        - name: product_type
          in: query
          required: false
          schema: { type: string, enum: [subscription, topup, one_time] }
      responses:
        '200':
          description: Active products
          content:
            application/json:
              schema: { $ref: '#/components/schemas/ProductListEnvelope' }
        '400': { $ref: '#/components/responses/BadRequest' }
        '429': { $ref: '#/components/responses/TooManyRequests' }
        '500': { $ref: '#/components/responses/ServerError' }
  /v1/catalog/products/{product_id}:
    get:
      tags: [Catalog]
      summary: Get a product detail with SKUs and entitlements (public, rate-limited)
      operationId: getCatalogProduct
      parameters:
        - { name: product_id, in: path, required: true, schema: { type: string, format: uuid } }
      responses:
        '200':
          description: Product detail
          content:
            application/json:
              schema: { $ref: '#/components/schemas/ProductDetailEnvelope' }
        '400': { $ref: '#/components/responses/BadRequest' }
        '404': { $ref: '#/components/responses/NotFound' }
        '429': { $ref: '#/components/responses/TooManyRequests' }
        '500': { $ref: '#/components/responses/ServerError' }

  /v1/checkout/subscriptions:
    post:
      tags: [Checkout]
      summary: Create a subscription checkout order (idempotent)
      operationId: createSubscriptionCheckout
      security: [{ bearerAuth: [] }, { apiKeyAuth: [] }]
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/CheckoutRequest' }
      responses:
        '201':
          description: Subscription checkout created
          content:
            application/json:
              schema: { $ref: '#/components/schemas/CheckoutResponseEnvelope' }
        '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/ServerError' }
  /v1/checkout/topups:
    post:
      tags: [Checkout]
      summary: Create a wallet top-up checkout order (idempotent)
      operationId: createTopupCheckout
      security: [{ bearerAuth: [] }, { apiKeyAuth: [] }]
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/CheckoutRequest' }
      responses:
        '201':
          description: Top-up checkout created
          content:
            application/json:
              schema: { $ref: '#/components/schemas/CheckoutResponseEnvelope' }
        '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/ServerError' }

  /v1/me/subscriptions:
    get:
      tags: [Subscriptions]
      summary: List the current user's subscriptions
      operationId: listMySubscriptions
      security: [{ bearerAuth: [] }, { apiKeyAuth: [] }]
      responses:
        '200':
          description: Subscriptions
          content:
            application/json:
              schema: { $ref: '#/components/schemas/SubscriptionListEnvelope' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '500': { $ref: '#/components/responses/ServerError' }
  /v1/me/subscriptions/{subscription_id}:
    get:
      tags: [Subscriptions]
      summary: Get one of the current user's subscriptions
      operationId: getMySubscription
      security: [{ bearerAuth: [] }, { apiKeyAuth: [] }]
      parameters:
        - { name: subscription_id, in: path, required: true, schema: { type: string, format: uuid } }
      responses:
        '200':
          description: Subscription
          content:
            application/json:
              schema: { $ref: '#/components/schemas/SubscriptionEnvelope' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { $ref: '#/components/responses/NotFound' }
        '500': { $ref: '#/components/responses/ServerError' }
  /v1/me/subscriptions/{subscription_id}/cancel:
    post:
      tags: [Subscriptions]
      summary: Cancel a subscription (idempotent)
      operationId: cancelMySubscription
      security: [{ bearerAuth: [] }, { apiKeyAuth: [] }]
      parameters:
        - { name: subscription_id, in: path, required: true, schema: { type: string, format: uuid } }
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/CancelSubscriptionReq' }
      responses:
        '200':
          description: Subscription canceled
          content:
            application/json:
              schema: { $ref: '#/components/schemas/SubscriptionCancelEnvelope' }
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/NotFound' }
        '409': { $ref: '#/components/responses/Conflict' }
        '429': { $ref: '#/components/responses/TooManyRequests' }
        '500': { $ref: '#/components/responses/ServerError' }
  /v1/me/subscriptions/{subscription_id}/resume:
    post:
      tags: [Subscriptions]
      summary: Resume a canceled subscription (idempotent)
      operationId: resumeMySubscription
      security: [{ bearerAuth: [] }, { apiKeyAuth: [] }]
      parameters:
        - { name: subscription_id, in: path, required: true, schema: { type: string, format: uuid } }
      responses:
        '200':
          description: Subscription resumed
          content:
            application/json:
              schema: { $ref: '#/components/schemas/SubscriptionResumeEnvelope' }
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/NotFound' }
        '409': { $ref: '#/components/responses/Conflict' }
        '429': { $ref: '#/components/responses/TooManyRequests' }
        '500': { $ref: '#/components/responses/ServerError' }
  /v1/me/subscriptions/{subscription_id}/auto-renew/enable:
    post:
      tags: [Subscriptions]
      summary: Enable auto-renewal (starts the channel agreement sign flow, returns the sign URL)
      description: |
        Returns the URL the user's browser must open to sign the channel withholding agreement.
        Direct mode returns a locally built H5 纯签约 URL; when the platform settles through a
        WeChat service-provider sub-merchant (服务商模式), this is the partner papay pre-sign
        `redirect_url` instead (valid 10 minutes, created channel-side on each call).
      operationId: enableMySubscriptionAutoRenew
      security: [{ bearerAuth: [] }, { apiKeyAuth: [] }]
      parameters:
        - { name: subscription_id, in: path, required: true, schema: { type: string, format: uuid } }
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/EnableAutoRenewReq' }
      responses:
        '200':
          description: Sign URL for the channel withholding agreement; auto_renew flips on once the channel sign callback arrives
          content:
            application/json:
              schema: { $ref: '#/components/schemas/AutoRenewSignEnvelope' }
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/NotFound' }
        '409': { $ref: '#/components/responses/Conflict' }
        '429': { $ref: '#/components/responses/TooManyRequests' }
        '503': { $ref: '#/components/responses/ServiceUnavailable' }
  /v1/me/subscriptions/{subscription_id}/auto-renew/disable:
    post:
      tags: [Subscriptions]
      summary: Disable auto-renewal (terminates the signed channel agreement, best effort)
      operationId: disableMySubscriptionAutoRenew
      security: [{ bearerAuth: [] }, { apiKeyAuth: [] }]
      parameters:
        - { name: subscription_id, in: path, required: true, schema: { type: string, format: uuid } }
      responses:
        '200':
          description: Auto-renewal disabled
          content:
            application/json:
              schema: { $ref: '#/components/schemas/SubscriptionAutoRenewDisableEnvelope' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/NotFound' }
        '429': { $ref: '#/components/responses/TooManyRequests' }
        '500': { $ref: '#/components/responses/ServerError' }
  /v1/me/auto-renew:
    get:
      tags: [Subscriptions]
      summary: List the current user's partner auto-renewal agreements across applications (0081)
      operationId: listMyPartnerAutoRenew
      security: [{ bearerAuth: [] }, { apiKeyAuth: [] }]
      responses:
        '200':
          description: Partner auto-renewal agreements
          content:
            application/json:
              schema: { $ref: '#/components/schemas/PartnerAgreementListEnvelope' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '500': { $ref: '#/components/responses/ServerError' }
  /v1/me/apps/{client_id}/skus/{sku_id}/auto-renew/enable:
    post:
      tags: [Subscriptions]
      summary: Enable partner auto-renewal for a recurring partner SKU (starts the WeChat 委托代扣 sign flow, returns the sign URL)
      description: |
        Multi-tenant auto-renewal (0081): signs a WeChat 委托代扣 contract between the user and the
        partner application's 特约商户, anchored to one recurring (monthly/yearly) partner SKU.
        Returns the partner papay pre-sign `redirect_url` (valid ~10 minutes) the user's browser
        must open. Requires a user→application grant, an active recurring partner price, and an
        active 特约商户 with a registered papay_plan_id. Idempotent: an already-signed agreement is
        a 409, a still-signing one reuses its out_agreement_no with a fresh sign URL.
      operationId: enableMyPartnerAutoRenew
      security: [{ bearerAuth: [] }, { apiKeyAuth: [] }]
      parameters:
        - { name: client_id, in: path, required: true, schema: { type: string } }
        - { name: sku_id, in: path, required: true, schema: { type: string, format: uuid } }
      responses:
        '200':
          description: Pre-sign redirect URL; the agreement flips to signed once the channel sign callback arrives
          content:
            application/json:
              schema: { $ref: '#/components/schemas/AutoRenewSignEnvelope' }
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403':
          description: The user has not granted the application (app_grant.required)
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }
        '404':
          description: Partner SKU not found (sku.not_found)
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }
        '409':
          description: sku.inactive / price.not_found / auto_renew.not_recurring / sub_merchant.not_configured / sub_merchant.not_active / agreement.already_signed
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }
        '429': { $ref: '#/components/responses/TooManyRequests' }
        '503':
          description: auto_renew.plan_not_configured / auto_renew.disabled
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }
  /v1/me/apps/{client_id}/skus/{sku_id}/auto-renew/disable:
    post:
      tags: [Subscriptions]
      summary: Disable partner auto-renewal for a SKU (terminates the signed 委托代扣 contract, best effort). Idempotent
      operationId: disableMyPartnerAutoRenew
      security: [{ bearerAuth: [] }, { apiKeyAuth: [] }]
      parameters:
        - { name: client_id, in: path, required: true, schema: { type: string } }
        - { name: sku_id, in: path, required: true, schema: { type: string, format: uuid } }
      responses:
        '200':
          description: Auto-renewal disabled
          content:
            application/json:
              schema: { $ref: '#/components/schemas/PartnerAutoRenewDisableEnvelope' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '429': { $ref: '#/components/responses/TooManyRequests' }
        '500': { $ref: '#/components/responses/ServerError' }
  /v1/me/entitlements:
    get:
      tags: [Subscriptions]
      summary: Get the current user's effective entitlement summary
      operationId: getMyEntitlements
      security: [{ bearerAuth: [] }, { apiKeyAuth: [] }]
      responses:
        '200':
          description: Entitlement summary
          content:
            application/json:
              schema: { $ref: '#/components/schemas/EntitlementSummaryEnvelope' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '500': { $ref: '#/components/responses/ServerError' }

  /v1/me/wallet:
    get:
      tags: [Wallet]
      summary: List the current user's wallets
      operationId: getMyWallet
      security: [{ bearerAuth: [] }, { apiKeyAuth: [] }]
      responses:
        '200':
          description: Wallets
          content:
            application/json:
              schema: { $ref: '#/components/schemas/WalletEnvelope' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '500': { $ref: '#/components/responses/ServerError' }
  /v1/me/wallet/ledger:
    get:
      tags: [Wallet]
      summary: List ledger entries for a wallet currency
      operationId: listMyWalletLedger
      security: [{ bearerAuth: [] }, { apiKeyAuth: [] }]
      parameters:
        - { name: currency, in: query, required: false, schema: { type: string, default: POINTS } }
        - { name: cursor, in: query, required: false, schema: { type: string } }
        - { name: limit, in: query, required: false, schema: { type: integer, default: 20, maximum: 100 } }
      responses:
        '200':
          description: Ledger entries
          content:
            application/json:
              schema: { $ref: '#/components/schemas/LedgerEnvelope' }
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '500': { $ref: '#/components/responses/ServerError' }

  /v1/admin/products:
    post:
      tags: [Admin]
      summary: Create a product (admin, idempotent)
      operationId: createProduct
      security: [{ bearerAuth: [] }]
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/CreateProductRequest' }
      responses:
        '201':
          description: Product created
          content:
            application/json:
              schema: { $ref: '#/components/schemas/ProductEnvelope' }
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '429': { $ref: '#/components/responses/TooManyRequests' }
        '500': { $ref: '#/components/responses/ServerError' }
    get:
      tags: [Admin]
      summary: List all products regardless of status (admin)
      operationId: listProductsAdmin
      security: [{ bearerAuth: [] }]
      responses:
        '200':
          description: All products
          content:
            application/json:
              schema: { $ref: '#/components/schemas/ProductListEnvelope' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '429': { $ref: '#/components/responses/TooManyRequests' }
        '500': { $ref: '#/components/responses/ServerError' }
  /v1/admin/products/{product_id}:
    get:
      tags: [Admin]
      summary: Get a product with all SKUs, price history and entitlements (admin)
      operationId: getProductAdmin
      security: [{ bearerAuth: [] }]
      parameters:
        - { name: product_id, in: path, required: true, schema: { type: string, format: uuid } }
      responses:
        '200':
          description: Admin product detail
          content:
            application/json:
              schema: { $ref: '#/components/schemas/AdminProductDetailEnvelope' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/NotFound' }
        '429': { $ref: '#/components/responses/TooManyRequests' }
        '500': { $ref: '#/components/responses/ServerError' }
    put:
      tags: [Admin]
      summary: Update a product's name, description and default flag (admin, idempotent)
      operationId: updateProduct
      security: [{ bearerAuth: [] }]
      parameters:
        - { name: product_id, in: path, required: true, schema: { type: string, format: uuid } }
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/UpdateProductRequest' }
      responses:
        '200':
          description: Product updated
          content:
            application/json:
              schema: { $ref: '#/components/schemas/ProductEnvelope' }
        '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/ServerError' }
  /v1/admin/skus/{sku_id}:
    put:
      tags: [Admin]
      summary: Update a SKU's name, device limit, trial days and credit config (admin, idempotent)
      operationId: updateSKU
      security: [{ bearerAuth: [] }]
      parameters:
        - { name: sku_id, in: path, required: true, schema: { type: string, format: uuid } }
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/UpdateSKURequest' }
      responses:
        '200':
          description: SKU updated
          content:
            application/json:
              schema: { $ref: '#/components/schemas/SKUEnvelope' }
        '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/ServerError' }
  /v1/admin/skus/{sku_id}/prices:
    get:
      tags: [Admin]
      summary: List the full price history of a SKU (admin)
      operationId: listSKUPrices
      security: [{ bearerAuth: [] }]
      parameters:
        - { name: sku_id, in: path, required: true, schema: { type: string, format: uuid } }
      responses:
        '200':
          description: Price history
          content:
            application/json:
              schema: { $ref: '#/components/schemas/PriceListEnvelope' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/NotFound' }
        '429': { $ref: '#/components/responses/TooManyRequests' }
        '500': { $ref: '#/components/responses/ServerError' }
  /v1/admin/entitlements/{entitlement_id}:
    put:
      tags: [Admin]
      summary: Update an entitlement definition's display name and free flag (admin, idempotent)
      operationId: updateEntitlementDefinition
      security: [{ bearerAuth: [] }]
      parameters:
        - { name: entitlement_id, in: path, required: true, schema: { type: string, format: uuid } }
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/UpdateEntitlementRequest' }
      responses:
        '200':
          description: Entitlement updated
          content:
            application/json:
              schema: { $ref: '#/components/schemas/EntitlementEnvelope' }
        '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/ServerError' }
  /v1/admin/skus:
    post:
      tags: [Admin]
      summary: Create a SKU under a product (admin, idempotent)
      operationId: createSKU
      security: [{ bearerAuth: [] }]
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/CreateSKURequest' }
      responses:
        '201':
          description: SKU created
          content:
            application/json:
              schema: { $ref: '#/components/schemas/SKUEnvelope' }
        '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/ServerError' }
  /v1/admin/skus/{sku_id}/entitlements:
    post:
      tags: [Admin]
      summary: Link an entitlement definition to a SKU (admin, idempotent)
      operationId: addSKUEntitlement
      security: [{ bearerAuth: [] }]
      parameters:
        - { name: sku_id, in: path, required: true, schema: { type: string, format: uuid } }
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/AddSKUEntitlementRequest' }
      responses:
        '201':
          description: Entitlement linked to SKU
          content:
            application/json:
              schema: { $ref: '#/components/schemas/SKUEntitlementEnvelope' }
        '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/ServerError' }
  /v1/admin/skus/{sku_id}/entitlements/{entitlement_id}:
    delete:
      tags: [Admin]
      summary: Unlink an entitlement definition from a SKU (admin, idempotent)
      operationId: removeSKUEntitlement
      security: [{ bearerAuth: [] }]
      parameters:
        - { name: sku_id, in: path, required: true, schema: { type: string, format: uuid } }
        - { name: entitlement_id, in: path, required: true, schema: { type: string, format: uuid } }
      responses:
        '200':
          description: Entitlement unlinked
          content:
            application/json:
              schema: { $ref: '#/components/schemas/SKUEntitlementUnlinkEnvelope' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/NotFound' }
        '429': { $ref: '#/components/responses/TooManyRequests' }
        '500': { $ref: '#/components/responses/ServerError' }
  /v1/admin/products/{product_id}/status:
    post:
      tags: [Admin]
      summary: Set a product status (admin, idempotent)
      operationId: setProductStatus
      security: [{ bearerAuth: [] }]
      parameters:
        - { name: product_id, in: path, required: true, schema: { type: string, format: uuid } }
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/SetStatusRequest' }
      responses:
        '200':
          description: Status updated
          content:
            application/json:
              schema: { $ref: '#/components/schemas/ProductStatusEnvelope' }
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '429': { $ref: '#/components/responses/TooManyRequests' }
        '500': { $ref: '#/components/responses/ServerError' }
  /v1/admin/skus/{sku_id}/status:
    post:
      tags: [Admin]
      summary: Set a SKU status (admin, idempotent)
      operationId: setSKUStatus
      security: [{ bearerAuth: [] }]
      parameters:
        - { name: sku_id, in: path, required: true, schema: { type: string, format: uuid } }
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/SetStatusRequest' }
      responses:
        '200':
          description: Status updated
          content:
            application/json:
              schema: { $ref: '#/components/schemas/SKUStatusEnvelope' }
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '429': { $ref: '#/components/responses/TooManyRequests' }
        '500': { $ref: '#/components/responses/ServerError' }
  /v1/admin/prices:
    post:
      tags: [Admin]
      summary: Create a new SKU price version (admin, idempotent)
      operationId: createPriceVersion
      security: [{ bearerAuth: [] }]
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/CreatePriceVersionRequest' }
      responses:
        '201':
          description: Price version created
          content:
            application/json:
              schema: { $ref: '#/components/schemas/PriceEnvelope' }
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '429': { $ref: '#/components/responses/TooManyRequests' }
        '500': { $ref: '#/components/responses/ServerError' }
  /v1/admin/entitlements:
    post:
      tags: [Admin]
      summary: Create an entitlement definition (admin, idempotent)
      operationId: createEntitlementDefinition
      security: [{ bearerAuth: [] }]
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/CreateEntitlementRequest' }
      responses:
        '201':
          description: Entitlement created
          content:
            application/json:
              schema: { $ref: '#/components/schemas/EntitlementEnvelope' }
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '429': { $ref: '#/components/responses/TooManyRequests' }
        '500': { $ref: '#/components/responses/ServerError' }
    get:
      tags: [Admin]
      summary: List all entitlement definitions (admin)
      operationId: listEntitlementDefinitions
      security: [{ bearerAuth: [] }]
      responses:
        '200':
          description: All entitlement definitions
          content:
            application/json:
              schema: { $ref: '#/components/schemas/EntitlementListEnvelope' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '429': { $ref: '#/components/responses/TooManyRequests' }
        '500': { $ref: '#/components/responses/ServerError' }
  /v1/admin/wallet-adjustments:
    post:
      tags: [Admin]
      summary: Adjust a user's wallet balance (admin, idempotent)
      operationId: adminAdjustWallet
      security: [{ bearerAuth: [] }]
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/AdminAdjustRequest' }
      responses:
        '201':
          description: Wallet adjusted
          content:
            application/json:
              schema: { $ref: '#/components/schemas/LedgerEntryEnvelope' }
        '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/ServerError' }
  /v1/admin/approvals:
    get:
      tags: [Admin]
      summary: List dual-approval requests, newest first (any active admin may read the queue)
      operationId: adminListApprovals
      security: [{ bearerAuth: [] }]
      parameters:
        - { name: status, in: query, schema: { type: string, enum: [pending, approved, rejected, canceled, executed, failed] } }
        - { name: type, in: query, schema: { type: string, enum: [refund, wallet_adjust, entitlement_grant, deletion] } }
        - { name: cursor, in: query, schema: { type: string }, description: Encoded cursor from a previous page's meta.next_cursor }
        - { name: limit, in: query, schema: { type: integer, default: 20, maximum: 100 } }
      responses:
        '200':
          description: Approval requests
          content:
            application/json:
              schema: { $ref: '#/components/schemas/ApprovalRequestListEnvelope' }
        '400':
          description: pagination.invalid_cursor
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '500': { $ref: '#/components/responses/ServerError' }
    post:
      tags: [Admin]
      summary: Create a pending dual-approval request (idempotent; per-type RBAC permission required)
      operationId: adminCreateApproval
      security: [{ bearerAuth: [] }]
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/CreateApprovalReq' }
      responses:
        '201':
          description: Approval request created
          content:
            application/json:
              schema: { $ref: '#/components/schemas/ApprovalRequest' }
        '400':
          description: request.invalid / approval.invalid_type / approval.invalid_request (bad payload shape, missing or overlong reason)
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403':
          description: authz.permission_denied - missing the RBAC permission for this request type
        '409':
          description: approval.invalid_target - target is missing or already decided
        '429': { $ref: '#/components/responses/TooManyRequests' }
        '500': { $ref: '#/components/responses/ServerError' }
  /v1/admin/approvals/{id}:
    get:
      tags: [Admin]
      summary: Get a single dual-approval request
      operationId: adminGetApproval
      security: [{ bearerAuth: [] }]
      parameters:
        - { name: id, in: path, required: true, schema: { type: string, format: uuid } }
      responses:
        '200':
          description: Approval request
          content:
            application/json:
              schema: { $ref: '#/components/schemas/ApprovalRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404':
          description: approval.not_found
        '500': { $ref: '#/components/responses/ServerError' }
  /v1/admin/approvals/{id}/approve:
    post:
      tags: [Admin]
      summary: Approve a pending request and execute the mutation (requester cannot approve their own)
      operationId: adminApproveApproval
      security: [{ bearerAuth: [] }]
      parameters:
        - { name: id, in: path, required: true, schema: { type: string, format: uuid } }
      requestBody:
        required: false
        content:
          application/json:
            schema: { $ref: '#/components/schemas/DecideApprovalReq' }
      responses:
        '200':
          description: Request approved; mutation executed or retryable (see status)
          content:
            application/json:
              schema: { $ref: '#/components/schemas/ApprovalRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403':
          description: approval.self_approve, or authz.permission_denied for the request type
        '404':
          description: approval.not_found
        '409':
          description: approval.not_pending - request is not awaiting a decision
        '429': { $ref: '#/components/responses/TooManyRequests' }
        '500': { $ref: '#/components/responses/ServerError' }
        '502':
          description: approval.execute_failed - execution failed; the request stays retryable
  /v1/admin/approvals/{id}/reject:
    post:
      tags: [Admin]
      summary: Reject a pending request (note required; requester cannot reject their own)
      operationId: adminRejectApproval
      security: [{ bearerAuth: [] }]
      parameters:
        - { name: id, in: path, required: true, schema: { type: string, format: uuid } }
      requestBody:
        required: false
        content:
          application/json:
            schema: { $ref: '#/components/schemas/DecideApprovalReq' }
      responses:
        '200':
          description: Request rejected
          content:
            application/json:
              schema: { $ref: '#/components/schemas/ApprovalRequest' }
        '400':
          description: approval.note_required - decision note is required
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403':
          description: approval.self_approve, or authz.permission_denied for the request type
        '404':
          description: approval.not_found
        '409':
          description: approval.not_pending - request is not awaiting a decision
        '429': { $ref: '#/components/responses/TooManyRequests' }
        '500': { $ref: '#/components/responses/ServerError' }
  /v1/admin/approvals/{id}/cancel:
    post:
      tags: [Admin]
      summary: Withdraw a pending request (only the requester may cancel)
      operationId: adminCancelApproval
      security: [{ bearerAuth: [] }]
      parameters:
        - { name: id, in: path, required: true, schema: { type: string, format: uuid } }
      responses:
        '200':
          description: Request canceled
          content:
            application/json:
              schema: { $ref: '#/components/schemas/ApprovalRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404':
          description: approval.not_found
        '409':
          description: approval.not_pending - request is not pending or not yours
        '429': { $ref: '#/components/responses/TooManyRequests' }
        '500': { $ref: '#/components/responses/ServerError' }
  /v1/admin/deletion-requests:
    get:
      tags: [Admin]
      summary: List account-deletion requests, newest first (7.8; requires users:write permission)
      operationId: adminListDeletionRequests
      security: [{ bearerAuth: [] }]
      parameters:
        - { name: status, in: query, schema: { type: string, enum: [pending, canceled, completed] } }
        - { name: review_status, in: query, schema: { type: string, enum: [none, approved, rejected] } }
        - { name: cursor, in: query, schema: { type: string }, description: Encoded cursor from a previous page's meta.next_cursor }
        - { name: limit, in: query, schema: { type: integer, default: 20, maximum: 100 } }
      responses:
        '200':
          description: Deletion requests with requester identity and live blocker count
          content:
            application/json:
              schema: { $ref: '#/components/schemas/AdminDeletionRequestListEnvelope' }
        '400':
          description: pagination.invalid_cursor
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '500': { $ref: '#/components/responses/ServerError' }
  /v1/admin/deletion-requests/{id}/reject:
    post:
      tags: [Admin]
      summary: Reject a deletion request (approval goes through the dual-approval workflow)
      operationId: adminRejectDeletionRequest
      security: [{ bearerAuth: [] }]
      parameters:
        - { name: id, in: path, required: true, schema: { type: string, format: uuid } }
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/RejectDeletionReq' }
      responses:
        '200':
          description: Deletion request marked reviewed-and-rejected
          content:
            application/json:
              schema:
                type: object
                properties:
                  id: { type: string, format: uuid }
                  review_status: { type: string, enum: [rejected] }
        '400':
          description: request.invalid / deletion.note_required
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404':
          description: deletion.not_found
        '409':
          description: deletion.already_reviewed
        '429': { $ref: '#/components/responses/TooManyRequests' }
        '500': { $ref: '#/components/responses/ServerError' }
