#!/usr/bin/env node /** * F3 验收与结算的真实链路脚本(Planning 用例 F3-01 – F3-06)。 * 需要 voko-worker 在跑:F3-04 的 AI 仲裁意见会真实调用大模型,F3-05 的擂台赛到截止由 worker 关闭。 */ import { randomUUID } from 'node:crypto' import { runScenario } from './_lib.mjs' await runScenario('f3', async ({ db, check, call, admin, createOwner, createGuild, createAgent, grantBudget, publish, waitForTask, wallet, submit, claim, review }) => { const alice = await createOwner('alice') const bob = await createOwner('bob') const carol = await createOwner('carol') const guild = await createGuild('main', alice.user_id) const publisher = await createAgent('publisher', alice, guild) const taker = await createAgent('taker', bob, guild) const helper = await createAgent('helper', carol, guild) await grantBudget(publisher.id, 1000) const items = [{ id: 'c1', text: 'Natural Japanese' }, { id: 'c2', text: 'Keeps all links' }] const ids = ['c1', 'c2'] const xpOf = async (agent) => (await call('GET', '/v1/agents/me', { token: agent.key })).json.data.xp // F3-01 验收格式。 const first = (await publish(publisher, { reward: 60, acceptance: items })).json.data await claim(taker, first.id) const early = await review(publisher, first.id, 'accept', ids) check('未提交就验收 → 409', early.status === 409, early) await submit(taker, first.id) const partial = await review(publisher, first.id, 'accept', ['c1']) const contradiction = await review(publisher, first.id, 'accept', ids, ['c2']) const allPassReject = await review(publisher, first.id, 'reject', ids) check('F3-01 checks 未覆盖全部清单项,或 pass 与 action 矛盾 → 422', partial.status === 422 && contradiction.status === 422 && allPassReject.status === 422 && partial.json.error.code === 'rule_violation', { partial, contradiction, allPassReject }) const stranger = await review(taker, first.id, 'accept', ids) check('非发布方验收 → 403', stranger.status === 403, stranger) // F3-02 通过 → 积分入接单方、托管 released、XP 增加、流水可查。 const accepted = await review(publisher, first.id, 'accept', ids) const escrow = await db('vk_escrows').where({ task_id: first.id }).first() const payoutLedger = await db('billing_ledger').where({ ref_id: first.id, reason: 'vk_task_payout' }) check('F3-02 通过 → settled,接单方 +60、托管 released、XP +60、一笔流水', accepted.status === 200 && accepted.json.data.status === 'settled' && (await wallet(taker)).available === 160 && escrow.status === 'released' && (await xpOf(taker)) === 60 && payoutLedger.length === 1, { accepted, escrow, wallet: await wallet(taker) }) const again = await review(publisher, first.id, 'accept', ids) check('已结算再验收 → 409,不重复发放', again.status === 409 && (await wallet(taker)).available === 160, again) const takerEvents = await call('GET', '/v1/events?since=0', { token: taker.key }) check('接单方收到 task.accepted', takerEvents.json.data.events.some((e) => e.type === 'task.accepted' && e.task_id === first.id), takerEvents) // F3-03 接力:helper 留检查点后放弃,taker 提交通过 → 平分,余数归 taker。 const relayTask = (await publish(publisher, { reward: 101, acceptance: items })).json.data await claim(helper, relayTask.id) await call('POST', `/v1/tasks/${relayTask.id}/checkpoints`, { token: helper.key, body: { progress: 50, note: 'Half done' } }) await call('POST', `/v1/tasks/${relayTask.id}/release`, { token: helper.key }) await claim(taker, relayTask.id) await submit(taker, relayTask.id) const relayAccepted = await review(publisher, relayTask.id, 'accept', ids) const payouts = await db('vk_escrow_payouts').where({ task_id: relayTask.id }).orderBy('amount', 'desc') check('F3-03 接力结算:最终提交者 51、前任 50', relayAccepted.status === 200 && (await wallet(taker)).available === 211 && (await wallet(helper)).available === 150 && payouts.map((p) => `${p.kind}:${p.amount}`).join() === 'winner:51,relay_share:50', { relayAccepted, payouts, taker: await wallet(taker), helper: await wallet(helper) }) // F3-04 驳回两次返工,第 3 次进仲裁;运营确认前不动账,确认后按结果结算。 const hard = (await publish(publisher, { reward: 40, acceptance: items })).json.data await claim(taker, hard.id) for (const round of [1, 2]) { await submit(taker, hard.id) const rejected = await review(publisher, hard.id, 'reject', ids, ['c2'], { reason: 'Links are missing.' }) check(`第 ${round} 次驳回 → rework,返工计数 ${round}`, rejected.status === 200 && rejected.json.data.status === 'rework' && rejected.json.data.rework_count === round, rejected) } await submit(taker, hard.id) const third = await review(publisher, hard.id, 'reject', ids, ['c2'], { reason: 'Still missing.' }) const dispute = await db('vk_disputes').where({ task_id: hard.id }).first() check('F3-04 第 3 次驳回 → disputed,生成争议,未动账', third.status === 200 && third.json.data.status === 'disputed' && dispute.status === 'open' && (await wallet(taker)).available === 211 && (await db('vk_escrows').where({ task_id: hard.id }).first()).status === 'held', { third, dispute }) const rounds = await db('vk_submissions').where({ task_id: hard.id }).orderBy('round', 'asc').select('round') check('三轮提交各记一份', rounds.map((r) => r.round).join() === '1,2,3', rounds) const adminToken = await admin() let proposed = null for (let i = 0; i < 90 && proposed === null; i += 1) { const row = await db('vk_disputes').where({ task_id: hard.id, status: 'ai_proposed' }).first() if (row === undefined) await new Promise((resolve) => setTimeout(resolve, 1000)) else proposed = row } check('F3-04 AI 仲裁意见写入,且此时仍未动账', proposed !== null && ['pay_taker', 'refund_publisher'].includes(proposed.ai_proposal.recommendation) && proposed.ai_proposal.reason.length > 0 && (await wallet(taker)).available === 211, proposed) const queue = await call('GET', '/api/voko/admin/disputes', { token: adminToken }) check('运营仲裁队列里有这条争议及 AI 建议', queue.status === 200 && queue.json.disputes.some((d) => d.task.id === hard.id && d.ai_proposal !== null), queue) const notAdmin = await call('POST', `/api/voko/admin/disputes/${hard.id}/resolve`, { token: alice.token, body: { outcome: 'settled' } }) check('非运营裁决 → 403', notAdmin.status === 403, notAdmin) const resolved = await call('POST', `/api/voko/admin/disputes/${hard.id}/resolve`, { token: adminToken, body: { outcome: 'settled' } }) check('F3-04 运营裁决支付接单方 → settled,接单方 +40', resolved.status === 200 && resolved.json.task.status === 'settled' && (await wallet(taker)).available === 251 && (await db('vk_disputes').where({ task_id: hard.id }).first()).status === 'resolved', { resolved, wallet: await wallet(taker) }) // 任一方发起争议 → 运营裁决退款。 const disputed = (await publish(publisher, { reward: 30, acceptance: items })).json.data await claim(taker, disputed.id) await submit(taker, disputed.id) const publisherBefore = (await wallet(publisher)).available const opened = await call('POST', `/v1/tasks/${disputed.id}/dispute`, { token: taker.key }) const refunded = await call('POST', `/api/voko/admin/disputes/${disputed.id}/resolve`, { token: adminToken, body: { outcome: 'refunded' } }) check('接单方发起争议 → 运营裁决退款 → refunded,发布方拿回 30', opened.status === 200 && opened.json.data.status === 'disputed' && refunded.json.task.status === 'refunded' && (await wallet(publisher)).available === publisherBefore + 30, { opened, refunded }) // F3-05 擂台赛选赢家 → 赢家全额,其他人无流水。 const contest = (await publish(publisher, { reward: 40, template: 'contest', acceptance: items, deadline: new Date(Date.now() + 5000).toISOString() })).json.data await call('POST', `/v1/tasks/${contest.id}/enter`, { token: taker.key }) await call('POST', `/v1/tasks/${contest.id}/enter`, { token: helper.key }) const takerSubmission = await submit(taker, contest.id) await submit(helper, contest.id) const closed = await waitForTask(publisher, contest.id, (task) => task.status === 'reviewing') check('擂台赛到截止由 worker 关闭 → reviewing,发布方看得到两份提交', closed.status === 'reviewing' && closed.submissions.length === 2, closed) const noSubmission = await review(publisher, contest.id, 'select_winner', ids) check('select_winner 未指明提交 → 422', noSubmission.status === 422, noSubmission) const helperBefore = (await wallet(helper)).available const won = await review(publisher, contest.id, 'select_winner', ids, [], { submission_id: takerSubmission.json.data.submission_id }) const contestPayouts = await db('vk_escrow_payouts').where({ task_id: contest.id }) check('F3-05 选赢家 → 赢家全额 40,其他人无流水', won.status === 200 && won.json.data.status === 'settled' && (await wallet(taker)).available === 291 && (await wallet(helper)).available === helperBefore && contestPayouts.length === 1, { won, contestPayouts }) // F3-06 打赏:打赏人减、收款人加;重放不重复;余额不足 → 402;未结算任务与打赏自己被拒。 const tipKey = randomUUID() const tipperBefore = (await wallet(helper)).available const takerBeforeTip = (await wallet(taker)).available const tipped = await call('POST', `/v1/tasks/${first.id}/tip`, { token: helper.key, idempotency_key: tipKey, body: { amount: 15 } }) const tipReplay = await call('POST', `/v1/tasks/${first.id}/tip`, { token: helper.key, idempotency_key: tipKey, body: { amount: 15 } }) check('F3-06 打赏 → 打赏人 -15、收款人 +15,重放不重复', tipped.status === 200 && tipped.json.data.wallet.available === tipperBefore - 15 && tipReplay.json.data.tip_id === tipped.json.data.tip_id && (await wallet(helper)).available === tipperBefore - 15 && (await wallet(taker)).available === takerBeforeTip + 15, { tipped, tipReplay }) const tooMuch = await call('POST', `/v1/tasks/${first.id}/tip`, { token: helper.key, idempotency_key: randomUUID(), body: { amount: 100000 } }) check('F3-06 余额不足 → 402,余额不变', tooMuch.status === 402 && (await wallet(helper)).available === tipperBefore - 15, tooMuch) const selfTip = await call('POST', `/v1/tasks/${first.id}/tip`, { token: taker.key, idempotency_key: randomUUID(), body: { amount: 1 } }) const unsettled = await call('POST', `/v1/tasks/${disputed.id}/tip`, { token: helper.key, idempotency_key: randomUUID(), body: { amount: 1 } }) check('打赏自己 → 422;未结算(已退款)任务 → 409', selfTip.status === 422 && unsettled.status === 409, { selfTip, unsettled }) const ledger = await call('GET', '/v1/wallet/ledger', { token: taker.key }) check('流水接口:收款人能查到打赏与任务收入,每笔带类型、金额、任务与时间', ledger.status === 200 && ledger.json.data.ledger.some((row) => row.reason === 'vk_tip' && row.amount === 15) && ledger.json.data.ledger.some((row) => row.reason === 'vk_task_payout' && row.task_id === first.id && typeof row.at === 'string'), ledger) check('收款人收到 tip.received', (await call('GET', '/v1/events?since=0', { token: taker.key })).json.data.events.some((e) => e.type === 'tip.received' && e.task_id === first.id)) })