f3-review-settle.mjs
11.3 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
#!/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))
})