Open Call Commission 专用 Credit 与 Open Call 手续费减免 (2026-08-25)

新增与礼品卡同级的 open_call_commission_credit。该 Credit 属于委托方/付款用户的 User Wallet,统一以 USD 最小货币单位发放,只能抵扣 Project/Open Call 创建的 Commission,支持 stage_pay、full_pay 和 price_change;不能用于 Service Commission 或商品订单,也不是 Artist 的收款或结算余额。

同时新增 Artist 侧 Open Call 平台手续费减免:后台可配置比例、固定 CNY 金额和总上限。规则在支付与最终结算时实时读取,Commission 创建时间仅用于判断是否落在当前活动窗口;支付渠道手续费不属于减免范围。

接口变更

user

internal

  • POST /api/internal/open_call_commission_credits/grant
    • 功能:向指定用户发放 Open Call Commission 专用 Credit
    • 变更:新增仅供内部系统调用的 USD Credit 幂等发放接口
  • POST /api/internal/system_settings/open_call_fee_waiver/detail
    • 功能:读取 Open Call 平台手续费减免窗口
  • POST /api/internal/system_settings/open_call_fee_waiver/update
    • 功能:按版本更新启用状态、起止时间和 IANA 时区,并记录管理员审计信息

接口示例

user

POST /api/user/pay/work_task/available_credits

  • 功能说明:根据 Commission 来源和支付类型返回当前用户可使用的 Credit。
  • 变更说明:响应 data 是第一级分组数组。前端应把 gift_card 和 open_call_commission_credit 展示为同级类型,不要把 Open Call Commission 专用 Credit 放进礼品卡分组。

请求参数

字段类型必填说明
work_task_idnumber是Commission 对应的 WorkTask ID
typestring是stage_pay、full_pay 或 price_change

请求示例

{
  "work_task_id": 401,
  "type": "stage_pay"
}

响应示例

{
  "data": [
    {
      "type": "gift_card",
      "usage": "credit",
      "title": {
        "zh": "礼品卡",
        "en": "Gift Cards",
        "ja": "ギフトカード",
        "_lang": "zh"
      },
      "notice": {
        "zh": "可与其他适用的 Credit 一起选择,并按选择顺序依次抵扣。",
        "en": "Can be selected with other eligible credits and applied in the selected order.",
        "ja": "他の利用可能なCreditと一緒に選択でき、選択順に適用されます。",
        "_lang": "zh"
      },
      "wallets": []
    },
    {
      "type": "open_call_commission_credit",
      "usage": "open_call_commission_credit",
      "title": {
        "zh": "Open Call Commission 专用 Credit",
        "en": "Open Call Commission 专用 Credit",
        "ja": "Open Call Commission 专用 Credit",
        "_lang": "zh"
      },
      "notice": {
        "zh": "仅限抵扣 Open Call 创建的 Commission;多选时按选择顺序依次抵扣。",
        "en": "Only applies to Commissions created from Open Calls. Multiple credits are applied in the selected order.",
        "ja": "Open Callから作成されたCommissionにのみ利用できます。複数選択時は選択順に適用されます。",
        "_lang": "zh"
      },
      "wallets": [
        {
          "id": 82,
          "balance": 2500,
          "usage": "open_call_commission_credit",
          "currency": {
            "code": "USD",
            "symbol": "$"
          }
        }
      ]
    }
  ],
  "selection_order": "wallets",
  "message": "Credits are deducted in the order of the selected wallet IDs"
}

分组返回规则

Commission / 支付类型gift_cardopen_call_commission_credit
Open Call Commission + stage_pay返回返回
Open Call Commission + full_pay返回返回
Open Call Commission + price_change返回返回
Service Commission返回不返回
商品支付沿用商品礼品卡逻辑不适用

没有余额时分组仍可能返回,但 wallets 为空。专用 Credit 钱包只会以 USD 返回。

前端展示要求

  • data[] 每一项都是独立的第一级 Credit 类型。
  • 支付页展示接口返回的 title 和 notice。
  • 用户可跨分组多选;提交支付时必须保留用户选择的先后顺序。
  • 不要按 Wallet ID 或分组重新排序所选项。

错误响应

  • 422:参数缺失、WorkTask 不存在或 type 非法。
  • 其他业务校验沿用 Commission 支付现有语义。

POST /api/user/pay/work_task/pre_calc

  • 功能说明:预计算 Credit 抵扣和剩余支付金额。
  • 变更说明:wallets 为用户选择的 Wallet ID 数组,后端按数组顺序逐张抵扣。

相关请求参数

字段类型必填说明
work_task_idnumber是Commission 对应的 WorkTask ID
typestring是stage_pay、full_pay 或 price_change
pay_channelstring是stripe、alipay 或 paypal
walletsnumber[]否Credit Wallet ID;不得重复,数组顺序即抵扣顺序

请求示例

{
  "work_task_id": 401,
  "type": "stage_pay",
  "pay_channel": "stripe",
  "wallets": [82, 36]
}

该示例先抵扣 Wallet 82,仍有剩余时再抵扣 Wallet 36。响应 data.process 中的 wallet_balance_deduction 阶段新增 wallet_usage,用于识别本次扣减来自 credit 还是 open_call_commission_credit。

错误响应

400:任一 Wallet 不属于当前用户、类型不适用、专用 Credit 不是 USD,或前端提交了当前支付不可使用的 Credit。

{
  "code": 21014,
  "message": "One or more selected Credits cannot be used for this payment"
}

422:Wallet ID 重复、不存在或其他请求字段校验失败。

POST /api/user/pay/work_task/create_checkout_session

  • 功能说明:创建支付会话,并对所选 Credit 执行实际扣减。
  • 变更说明:wallets 的字段、校验、适用范围和顺序与 pre_calc 完全一致;后端会在事务中锁定钱包并再次校验,不能通过跳过预计算绕过限制。

请求示例

{
  "work_task_id": 401,
  "type": "stage_pay",
  "pay_channel": "stripe",
  "wallets": [82, 36]
}

Credit 全额覆盖本次款项时,不会创建外部支付会话:

{
  "data": {
    "pay_channel": "internal",
    "out_trade_no": "2026081800012345",
    "status": "paying",
    "amount": 0,
    "currency": {
      "code": "USD"
    }
  }
}

错误响应

与 pre_calc 一致;业务不适用返回 HTTP 400 和错误码 21014,字段校验失败返回 422。

internal

POST /api/internal/open_call_commission_credits/grant

  • 功能说明:向用户发放 Open Call Commission 专用 Credit。
  • 变更说明:接口始终发放 USD,不接受调用方指定币种。使用 Internal API Bearer Token 鉴权。

请求参数

字段类型必填说明
user_idnumber是获赠的付款用户 ID;Credit 记入该 User 的 Wallet,不是 artist_id,也不会记入 Artist 的收款 Wallet
amountnumber是正整数,USD 最小货币单位;2500 表示 USD 25.00
idempotency_keystring是最长 255 字符;一次业务发放的全局唯一键
notestring|null否发放说明,最长 2048 字符

请求示例

{
  "user_id": 1147,
  "amount": 2500,
  "idempotency_key": "open-call-launch-20260818-user-1147",
  "note": "Open Call launch campaign"
}

相同 idempotency_key、user_id 和 amount 重试时返回原发放记录,不重复增加余额。

user_id 表示 Credit 的实际所有者。即使该 User 同时拥有 Artist Profile,这笔 Credit 仍是用户侧抵扣余额,只有在其作为委托方/付款方支付符合条件的 Open Call Commission 时才能使用。不要因为某个 Artist 被 Open Call 选中,就把 Credit 发放给该 Artist 对应的 artist.user_id;除非业务目的确实是让这个 User 以后作为委托方使用 Credit。

响应示例

{
  "ok": true,
  "data": {
    "id": 12,
    "user_id": 1147,
    "wallet_id": 82,
    "wallet_transaction_id": 901,
    "amount": 2500,
    "idempotency_key": "open-call-launch-20260818-user-1147",
    "note": "Open Call launch campaign",
    "wallet": {
      "id": 82,
      "balance": 2500,
      "usage": "open_call_commission_credit",
      "currency": {
        "code": "USD"
      }
    }
  }
}

错误响应

  • 401:Internal API Token 缺失或错误。
  • 422:用户不存在、金额不是正整数或字段长度超限。
  • 400 / 21015:同一个 idempotency_key 已用于不同用户或不同金额。
{
  "code": 21015,
  "message": "Credit grant idempotency key conflicts with an existing grant"
}

Open Call 手续费减免配置

配置保存在新建的 system_settings 表,由 SystemSetting Model 管理。历史 settings 表可能是遗留结构,本功能不会读取、写入或迁移其中的数据。配置变更审计独立保存在 system_setting_change_logs。

system_settings 保持最小键值结构,不承载管理后台展示元数据:

字段说明
id主键
key唯一配置键
valueJSON 配置值
created_at创建时间
updated_at更新时间

本功能使用的配置键为 promotion.open_call_fee_waiver。system_setting_change_logs.request_id 仅用于关联请求和审计查询,使用普通索引,不是唯一约束,也不承担接口幂等控制。

POST /api/internal/system_settings/open_call_fee_waiver/detail

读取当前配置,响应同时包含 UTC 时间、配置时区下的本地时间、版本和状态:

{
  "data": {
    "version": 1,
    "enabled": true,
    "start_at_utc": "2026-08-20T16:00:00Z",
    "end_at_utc": "2026-09-20T16:00:00Z",
    "timezone": "Asia/Shanghai",
    "percentage_bps": 5000,
    "fixed_amount_cny": 1000,
    "cap_type": "amount",
    "cap_amount_cny": 5000,
    "updated_at_utc": "2026-08-25T03:00:00Z",
    "start_at": "2026-08-21 00:00:00",
    "end_at": "2026-09-21 00:00:00",
    "status": "active"
  }
}

status 可能为 disabled、not_started、active 或 ended。

POST /api/internal/system_settings/open_call_fee_waiver/update

{
  "enabled": true,
  "start_at": "2026-08-21 00:00:00",
  "end_at": "2026-09-21 00:00:00",
  "timezone": "Asia/Shanghai",
  "percentage_bps": 5000,
  "fixed_amount_cny": 1000,
  "cap_type": "amount",
  "cap_amount_cny": 5000,
  "expected_version": 1
}
  • start_at 和 end_at 按请求中的 IANA timezone 解释,API 转换为 UTC 原子保存。
  • 时间窗口为 [start_at, end_at)。
  • percentage_bps 使用整数基点,范围 0..10000;例如 5000 表示 50%,不使用浮点数存储比例。
  • fixed_amount_cny 和 cap_amount_cny 均使用 CNY 最小货币单位(分)。结算币种不是 CNY 时,计算时换算到结算币种。
  • cap_type 必须为 unlimited 或 amount。unlimited 明确表示不设上限,此时 cap_amount_cny 必须为 null;amount 时上限必须大于 0。
  • 更新必须携带管理后台服务端注入的 X-Admin-Id、X-Admin-Name;X-Request-Id 用于审计请求追踪。
  • expected_version 不等于当前版本时返回 HTTP 400、错误码 30022,后台应刷新配置后让管理员重新确认。
  • 时区非法、结束时间不晚于开始时间或缺少管理员身份时返回 HTTP 400、错误码 30023。

POST /api/internal/system_settings/open_call_fee_waiver/simulate_manual

管理后台的无状态手动试算接口。它使用请求中传入的草稿规则和资金参数,不要求先保存系统配置,也不会创建 Order、修改 Wallet 或写入结算记录。接口固定按“符合 Open Call 活动条件”计算,因此不校验配置开关、时间窗口或真实 Commission 来源。

所有金额仍使用对应币种的最小货币单位,比例统一使用整数基点:

{
  "commission_currency": "USD",
  "commission_amount": 10000,
  "platform_fee_bps": 1000,
  "credit_share_bps": 5000,
  "artist_plat_fee_credit_amount": 100,
  "percentage_bps": 5000,
  "fixed_amount_cny": 700,
  "cap_type": "amount",
  "cap_amount_cny": 3850,
  "commission_to_usd_rate": null,
  "usd_to_cny_rate": null,
  "stripe_fee_bps": 290,
  "stripe_fixed_fee_usd": 30
}
  • credit_share_bps 表示 Commission 金额中由 Open Call Commission Credit 覆盖的比例,其余部分按外部支付估算。
  • artist_plat_fee_credit_amount 使用 Artist 结算币种的最小单位;CNY Commission 的结算币种为 CNY,其他 Commission 当前统一为 USD。
  • commission_to_usd_rate、usd_to_cny_rate 传 null 时读取当前实时汇率;传正数时用于运营的假设场景。真实支付和结算不会使用这里的手动汇率。
  • stripe_fee_bps 和 stripe_fixed_fee_usd 只用于估算外部支付部分的渠道手续费,固定金额使用 USD 分。
  • CNY 结算按照真实链路对 Commission 总额一次计算平台手续费;非 CNY 结算按 Credit 和外部支付资金流分别计算并汇总。

响应示例:

{
  "data": {
    "preview_only": true,
    "assumes_eligible": true,
    "commission_currency": "USD",
    "settlement_currency": "USD",
    "rates_used": {
      "commission_to_usd_rate": 1,
      "usd_to_cny_rate": 7,
      "cny_to_settlement_rate": 0.14285714285714285,
      "commission_to_usd_source": "current",
      "usd_to_cny_source": "current"
    },
    "funding": {
      "commission_amount": 10000,
      "credit_share_bps": 5000,
      "credit_commission_amount": 5000,
      "external_commission_amount": 5000,
      "credit_settlement_amount": 5000,
      "external_settlement_amount": 5000,
      "total_settlement_amount": 10000
    },
    "calculation": {
      "gross_platform_fee": 1000,
      "percentage_waiver": 500,
      "fixed_waiver": 100,
      "cap_applied": true,
      "applied_waiver": 550,
      "net_platform_fee": 450,
      "plat_fee_refund": 100,
      "final_net_platform_fee": 350,
      "stripe_fee": 175,
      "artist_income": 9475
    },
    "warnings": [
      {
        "code": "cap_applied",
        "message": "本次试算已触发总减免上限。"
      }
    ]
  }
}

warnings 当前可能包含 cap_applied、full_waiver 和 manual_rate。该接口只模拟单笔汇总资金组成,不代替真实 WorkTask 的多 Order / 多阶段结算预览;需要核对历史 Order、支付时汇率和 Checkout 元数据时,继续使用 POST /api/internal/system_settings/open_call_fee_waiver/simulate。

手续费减免结算字段

支付和最终结算时,系统实时读取当前配置,再按 work_tasks.created_at 判断 Commission 是否位于当前活动窗口。创建 Commission 时不写入、不读取政策快照参与计算。

最终结算完成后,系统将本次实际读取的配置、资格判断和计算结果写入审计快照:

work_tasks.plat_fee_policy_snapshot
{
  "policy_code": "open_call_fee_waiver",
  "eligible": true,
  "setting_version": 1,
  "evaluated_at": "2026-08-25T02:00:00Z",
  "work_task_created_at": "2026-08-25T02:00:00Z",
  "window": {
    "start_at_utc": "2026-08-20T16:00:00Z",
    "end_at_utc": "2026-09-20T16:00:00Z",
    "timezone": "Asia/Shanghai"
  },
  "config": {
    "percentage_bps": 5000,
    "fixed_amount_cny": 1000,
    "cap_type": "amount",
    "cap_amount_cny": 5000
  },
  "calculation": {
    "gross_platform_fee": 500,
    "configured_waiver": 300,
    "applied_waiver": 300,
    "net_platform_fee": 200,
    "plat_fee_refund": 200,
    "final_net_platform_fee": 0
  }
}

数据库不再保存独立的 work_tasks.plat_fee_policy_code 列。plat_fee_policy_snapshot 只用于最终审计,不作为后续计算输入。

尚未最终结算的 Commission 会受后台实时配置影响。Stripe 支付阶段已经实施的减免不会被追回;最终结算会读取 Checkout Session 元数据,在当前规则需要更高减免时只补足剩余部分。结算预览与 Pending Payout 的 calculation 新增:

{
  "plat_fee": 500,
  "open_call_fee_waiver": 500,
  "net_plat_fee": 0,
  "plat_fee_policy_code": "open_call_fee_waiver"
}

减免公式为:先计算比例减免,再追加固定金额,最后应用总上限,并保证减免不超过标准平台手续费。之后才使用 Artist PlatFee Wallet 抵扣剩余平台费。全额减免时 Artist PlatFee Wallet 不扣减,Stripe application_fee_amount 仅保留支付渠道手续费;系统不会创建真实的 Stripe Application Fee Refund。

结算资金流与交易明细口径

stage_pay、full_pay 和 price_change 都先生成 Order,Commission 完成时统一汇总全部已支付 Order。三种支付动作以及多阶段支付不会产生不同的手续费公式。

交易明细是“单个 Artist Wallet 的资金流水”,Commission 结算字段则是“整个 Commission 的汇总结果”,两者不能混用:

  • CNY 结算只有一条 Artist Alipay Deposit Wallet 资金流,标准平台手续费和全部 waiver 都记录在该资金流中。
  • 非 CNY 结算拆分为 StripeDeduction 和 StripePayout 两条资金流。
  • StripeDeduction 记录最终结算阶段尚未实施的 waiver:通常对应 Credit 覆盖部分;配置在支付后提高时,也可能包含对 Stripe 已收平台费的最终补差。
  • Stripe 外部实付部分的标准平台手续费、waiver 和净手续费保存在 Stripe Checkout 元数据及 Commission 结算汇总中,不能重复写入 StripeDeduction WalletTransaction。
  • 每条 WalletTransaction 必须满足:init - minus + plus = result。
Commission 币种外部通道资金组成Artist 结算 WalletWallet 明细中的 waiver
CNYAlipay / PayPal / Stripe纯外部支付Alipay DepositCommission 全部标准平台手续费
CNYinternalCredit 全额覆盖Alipay DepositCommission 全部标准平台手续费
CNYAlipay / PayPal / StripeCredit + 外部支付Alipay DepositCommission 全部标准平台手续费
非 CNYinternalCredit 全额覆盖StripeDeduction只记录 Credit 覆盖部分的平台手续费
非 CNYStripe纯 StripeStripePayout;StripeDeduction 为零金额资金流StripeDeduction 不记录 Stripe 部分 waiver
非 CNYStripeCredit + StripeStripeDeduction + StripePayout记录最终剩余 waiver,不重复记录 Checkout 已实施部分

支付链路模拟运算

以下旧链路示例继续采用“比例 10000、固定金额 0、不设上限”的全额减免配置。金额均使用最小货币单位。假设 Commission 总额为 1000,Artist 平台费率为 10%,因此:

gross_plat_fee = ceil(1000 × 10%) = 100
open_call_fee_waiver = 100
net_plat_fee = 0

CNY:纯外部支付

假设支付渠道手续费为 30:

Artist 收入 = 1000 - 30 - 100 + 100 = 970

Alipay Deposit WalletTransaction:
init                 1000
pay_fee               -30
plat_fee              -100
plat_fee_waiver       +100
result                 970

Alipay、PayPal、Stripe 支付 CNY Commission 最终都进入这条 CNY 结算链路。

CNY:Credit 全额覆盖

Artist 收入 = 1000 - 0 - 100 + 100 = 1000

Alipay Deposit WalletTransaction:
init                 1000
plat_fee              -100
plat_fee_waiver       +100
result                1000

CNY:Credit 与外部支付混合

假设 Credit 覆盖 400、外部支付 600、支付渠道手续费为 30:

Artist 收入 = 1000 - 30 - 100 + 100 = 970

Alipay Deposit WalletTransaction:
init                 1000
pay_fee               -30
plat_fee              -100
plat_fee_waiver       +100
result                 970

Credit 金额只作为结算来源信息,不会把 CNY Artist 收入拆成两个 Deposit Wallet。

非 CNY:Credit 全额覆盖

Credit 覆盖金额 = 1000
Credit 部分标准平台手续费 = 100

StripeDeduction WalletTransaction:
init                 1000
plat_fee              -100
plat_fee_waiver       +100
result                1000

Commission 汇总:
gross_plat_fee         100
open_call_fee_waiver   100
net_plat_fee             0

非 CNY:纯 Stripe

假设 Stripe 支付渠道手续费为 30,Artist 的 Stripe 实际收入为 970:

Stripe Checkout:
用户实付               1000
支付渠道手续费           30
平台手续费                0
Artist Stripe 收入       970

StripeDeduction WalletTransaction:
init                      0
result                    0

Commission 汇总:
gross_plat_fee           100
open_call_fee_waiver     100
net_plat_fee               0

StripeDeduction 没有承接这笔 Stripe 本金,因此不能在其明细中写入 plat_fee_waiver +100。

非 CNY:Credit 与 Stripe 混合

假设 Credit 覆盖 400、Stripe 支付 600,Stripe 支付渠道手续费为 30:

Credit 部分标准平台手续费 = ceil(400 × 10%) = 40
Stripe 部分标准平台手续费 = ceil(600 × 10%) = 60
Commission 总标准平台手续费 = 40 + 60 = 100

StripeDeduction WalletTransaction:
init                    400
plat_fee                -40
plat_fee_waiver         +40
result                  400

StripePayout 实际收入:
600 - 30 = 570

Artist 总收入:
400 + 570 = 970

Commission 汇总:
gross_plat_fee           100
open_call_fee_waiver     100
net_plat_fee               0

不能把 Commission 总 waiver 100 全部写入 StripeDeduction 明细,否则该明细会变成 400 - 40 + 100 = 460,与实际入账 400 不一致。

非 CNY:多阶段与 Price Change

假设最终存在以下已支付订单:

阶段一:Credit 200 + Stripe 300
阶段二:Stripe 300
Price Change:Credit 100 + Stripe 100

汇总后:

Credit 总额 = 300
Stripe 总额 = 700
Credit 资金流 waiver = ceil(300 × 10%) = 30
Stripe 资金流 waiver = ceil(700 × 10%) = 70
Commission 总 waiver = 100

StripeDeduction 明细只写入 plat_fee -30 和 plat_fee_waiver +30;Commission 结算汇总仍返回总 waiver 100。支付动作和 Order 数量不改变该边界。

前端对接指引

接口切换总览

使用场景当前实现新实现是否必须切换
Commission 支付页获取可用 CreditPOST /api/wallets/list 后由前端筛选 usage=credit && balance>0POST /api/user/pay/work_task/available_credits,提交 work_task_id 和本次支付 type是
Commission 支付预计算POST /api/user/pay/work_task/pre_calc接口不变;wallets 改为跨 Credit 类型、按用户选择顺序组成的 Wallet ID 数组保留接口,调整数据来源
Commission 创建支付会话POST /api/user/pay/work_task/create_checkout_session接口不变;提交与最后一次预计算完全相同的 wallets 顺序保留接口,调整数据来源
Product 支付获取礼品卡POST /api/wallets/list继续使用原接口并只筛选 usage=credit否
钱包账户页、余额页、使用记录页POST /api/wallets/list继续使用原接口否

不要修改 /api/wallets/list 的通用含义。该接口仍用于返回用户全部 Wallet,不知道当前 WorkTask、Commission 来源和支付类型,无法判断专用 Credit 是否适用。

需要调整的 Commission 页面

当前 Commission 支付流程在打开支付弹窗时调用 /api/wallets/list,前端再执行以下筛选:

wallets.filter((wallet) => wallet.usage === "credit" && wallet.balance > 0)

这段逻辑必须替换为调用 /api/user/pay/work_task/available_credits。所有仍可进入 Commission 支付流程的页面都应使用相同的新逻辑,包括新版 Commission 详情页和仍在使用的旧版 WorkTask / Project / Application 详情页。

共享支付组件目前接收扁平的 wallets。前端需要将组件输入调整为 creditGroups,直接使用接口返回的第一级分组:

type CreditGroup = {
  type: "gift_card" | "open_call_commission_credit" | string;
  usage: string;
  title: LocalizedText;
  notice: LocalizedText;
  wallets: Wallet[];
};

页面展示层级应为:

Credit
├─ 礼品卡
│  └─ 可选 Wallet
└─ Open Call Commission 专用 Credit
   ├─ 接口返回的 notice 提示
   └─ 可选 Wallet

open_call_commission_credit 必须是与 gift_card 同级的第一级类型,不能作为礼品卡内部的一张特殊卡展示。

推荐请求时机

  1. 用户点击“支付当前阶段”或“支付全部阶段”。
  2. 页面先确定支付类型:stage_pay 或 full_pay。
  3. 清空上一次支付类型遗留的已选 Wallet ID。
  4. 调用 available_credits 获取当前 WorkTask、当前支付类型的可选分组。
  5. 调用 pre_calc 获取未抵扣时的初始金额。
  6. 用户选择或取消任意 Credit 后,按照当前选择顺序重新调用 pre_calc。
  7. 用户确认支付时,把最后一次成功预计算使用的 wallets 原样传给 create_checkout_session。

支付类型发生变化时必须重新获取可用 Credit,不能复用旧列表。Project/Open Call Commission 的 stage_pay、full_pay 和 price_change 都可以返回 Open Call Commission 专用 Credit。

取消一个支付中的订单后,重新打开支付方式选择时也应重新请求 available_credits,因为 Wallet 余额或可用状态可能已经变化。

选择顺序处理

后端严格按照 wallets 数组顺序扣减,不会按 Wallet ID、币种或分组重新排序。前端需要保存用户的实际选择顺序:

const selectedWalletIds = ref<number[]>([]);

function selectWallet(walletId: number) {
  if (!selectedWalletIds.value.includes(walletId)) {
    selectedWalletIds.value.push(walletId);
  }
}

function unselectWallet(walletId: number) {
  selectedWalletIds.value = selectedWalletIds.value.filter(
    (id) => id !== walletId,
  );
}

跨分组选择同样适用。例如用户依次选择专用 Credit 82、礼品卡 36,请求必须保持:

{
  "wallets": [82, 36]
}

不要使用以下处理:

  • 按分组先后拼接全部选中项;
  • 对 Wallet ID 执行数值排序;
  • 使用会自动按选项定义顺序重排值的组件行为;
  • 在 pre_calc 与 create_checkout_session 之间重新生成顺序。

展示与提示文案

  • 分组标题使用 data[].title,按当前语言读取。
  • 分组提示使用 data[].notice,不要在前端重复硬编码专用 Credit 的适用范围。
  • 支付明细中的 data.process[].wallet_usage 可区分本次抵扣来自普通礼品卡还是专用 Credit。
  • 如果某分组 wallets=[],可以隐藏该分组的选择器;是否保留标题和说明由产品展示决定。
  • available_credits 没有返回专用分组时,不应通过 /api/wallets/list 自行补入专用 Wallet。

错误处理

HTTP / code前端处理建议
422请求字段或 Wallet ID 不合法;阻止支付并展示字段校验信息
400 / 21014所选 Credit 已失效或不适用于当前支付;清空无效选择,重新请求 available_credits 和 pre_calc
其他 Commission 支付错误沿用现有支付错误处理和 paying order 互斥流程

前端不能只依赖列表控制安全性。pre_calc 和 create_checkout_session 都会重新校验 Wallet 所有人、用途、币种和 Commission 来源。

最小改造示例

const creditGroups = ref<CreditGroup[]>([]);
const selectedWalletIds = ref<number[]>([]);

async function refreshAvailableCredits(workTaskId: number, type: string) {
  selectedWalletIds.value = [];

  const response = await fetchBase(
    "/api/user/pay/work_task/available_credits",
    {
      method: "POST",
      body: {
        work_task_id: workTaskId,
        type,
      },
    },
  );

  creditGroups.value = response.data ?? [];
}

async function preCalc(workTaskId: number, type: string, payChannel: string) {
  return fetchBase("/api/user/pay/work_task/pre_calc", {
    method: "POST",
    body: {
      work_task_id: workTaskId,
      type,
      pay_channel: payChannel,
      wallets: selectedWalletIds.value,
    },
  });
}

对接验收清单

  • Open Call Commission 的 stage_pay、full_pay 和 price_change 同时显示礼品卡及 Open Call Commission 专用 Credit 两个一级类型。
  • Service Commission 不显示专用 Credit。
  • price_change 显示 Open Call Commission 专用 Credit,并沿用原 Wallet 退款规则。
  • Product 支付仍只显示普通礼品卡。
  • 跨分组多选后,pre_calc 的抵扣顺序与点击顺序一致。
  • create_checkout_session 的 Wallet ID 和顺序与最后一次成功 pre_calc 完全一致。
  • Credit 全额抵扣后可以进入内部零金额支付确认流程。
  • 支付取消后重新进入页面,Credit 列表和余额会刷新。
  • 遇到 21014 后,页面会刷新可用 Credit,而不是继续提交旧 Wallet ID。

数据与兼容性说明

  • 新增 Wallet Usage:open_call_commission_credit。
  • open_call_commission_credit 的所有者是 users.id 对应的付款用户;支付接口只查询并扣减当前付款用户自己的 Wallet,不从 Artist Profile 或 Artist 的收款账户读取该 Credit。
  • Artist 的 Commission 收款、补差和结算继续进入 Artist 对应的 deposit Wallet,和用户侧 open_call_commission_credit 是两套独立余额。
  • MySQL ENUM 将 open_call_commission_credit 追加在现有 plat_fee 之后,保留历史 ENUM 值的序号,降低上线 ALTER TABLE 触发表重建的风险。
  • 新增 open_call_commission_credit_grants 发放审计表,关联 Wallet 和 WalletTransaction。
  • 发放币种固定为 USD;支付目标是其他币种时沿用现有汇率换算和取整规则。
  • 对于 credit、open_call_commission_credit、plat_fee,Wallet 按“用户 + 币种 + Usage”归并;deposit 还需要使用二级分类 deposit_type,因此其 Wallet 身份为“用户 + 币种 + Usage + DepositType”,不能仅按 Usage 合并。
  • 当前 Open Call Commission Credit 只发放 USD,因此每个用户通过正式发放流程只会有一个 USD Open Call Commission Credit Wallet。多次 Grant 累加到该 Wallet,并分别保留发放审计记录。
  • stage_pay、full_pay 和 price_change 都允许使用 Open Call Commission 专用 Credit。
  • 每次扣减都会在 WalletTransaction 与 WalletDeductionRecord 元数据中写入 credit_type。
  • 取消支付时,每个被扣减的 Wallet 都会单独退款,其对应的 WalletDeductionRecord 会逐条更新为 refunded;退款 WalletTransaction 同样记录 credit_type。
  • 商品支付查询仍只接受 usage=credit,不会误用专用 Credit。
  • 本功能为新增能力,不改变已有礼品卡余额和历史交易。

回滚与余额冻结策略

专用 Credit 不允许在回滚时自动转换成普通 credit,否则受限余额会变成可用于 Service Commission 或 Product 的普通礼品卡。

迁移回滚遵循以下规则:

  1. 数据库中不存在任何 open_call_commission_credit Wallet 和 Grant 时,可以正常删除审计表并移除 ENUM 值。
  2. 只要存在任意专用 Wallet 或 Grant,即使 Wallet 当前余额为 0,自动回滚也会中止并给出明确错误。
  3. 已产生业务数据后如必须回滚,应先单独评审并执行冻结/迁移脚本,完整保留用户、Wallet、余额、Grant 和 WalletTransaction 的对应关系。
  4. 冻结后的余额不得写入普通 credit Wallet;恢复专用能力时必须能够按原用户和原审计记录恢复。

这项限制是资金安全保护,不应通过手工修改 migration、直接更新 usage=credit 或删除审计记录绕过。