Commit 14da0adad7cbeb6119ed7fe19f66cc9c205564ca

Authored by yibo
1 parent daf313a4

feat(exchange): W2 后端——发布与托管、认领与报名、/home 与事件游标、限流、worker 执行 vk_jobs

- @voko/exchange:任务 service(发布幂等、状态机执行、看板、评论)、事件游标、job 执行器、稀有度与等级上限
- @voko/clearing:托管冻结 / 补扣 / 退回与钱包视图
- /v1:tasks、comments、home、events、wallet;按 Agent 限流;402 insufficient_points
- 场景脚本 f1 / f2 / concurrency / timers 与对账 verify-conservation,运行手册 verify-alpha.md

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
apps/voko-server/package.json
@@ -13,7 +13,12 @@ @@ -13,7 +13,12 @@
13 "db:status": "node src/db/migration-status.js", 13 "db:status": "node src/db/migration-status.js",
14 "admin:grant": "node tools/grant-admin.mjs", 14 "admin:grant": "node tools/grant-admin.mjs",
15 "verify:skeleton": "node tools/scenarios/voko-alpha/skeleton.mjs", 15 "verify:skeleton": "node tools/scenarios/voko-alpha/skeleton.mjs",
16 - "verify:f0": "node tools/scenarios/voko-alpha/f0-onboarding.mjs" 16 + "verify:f0": "node tools/scenarios/voko-alpha/f0-onboarding.mjs",
  17 + "verify:f1": "node tools/scenarios/voko-alpha/f1-publish.mjs",
  18 + "verify:f2": "node tools/scenarios/voko-alpha/f2-execute.mjs",
  19 + "verify:timers": "node tools/scenarios/voko-alpha/timers.mjs",
  20 + "verify:concurrency": "node tools/scenarios/voko-alpha/concurrency.mjs",
  21 + "verify:conservation": "node tools/scenarios/voko-alpha/verify-conservation.mjs"
17 }, 22 },
18 "dependencies": { 23 "dependencies": {
19 "@drama/auth": "workspace:*", 24 "@drama/auth": "workspace:*",
@@ -22,6 +27,7 @@ @@ -22,6 +27,7 @@
22 "@drama/db": "workspace:*", 27 "@drama/db": "workspace:*",
23 "@drama/email": "workspace:*", 28 "@drama/email": "workspace:*",
24 "@drama/observability": "workspace:*", 29 "@drama/observability": "workspace:*",
  30 + "@drama/ratelimit": "workspace:*",
25 "@drama/server-kit": "workspace:*", 31 "@drama/server-kit": "workspace:*",
26 "@voko/access": "workspace:*", 32 "@voko/access": "workspace:*",
27 "@voko/clearing": "workspace:*", 33 "@voko/clearing": "workspace:*",
apps/voko-server/src/assembly/runtime.js
@@ -9,7 +9,9 @@ import { createBillingRepo, createLedger } from &#39;@drama/billing&#39; @@ -9,7 +9,9 @@ import { createBillingRepo, createLedger } from &#39;@drama/billing&#39;
9 import { createDb } from '@drama/db' 9 import { createDb } from '@drama/db'
10 import { createLogger } from '@drama/observability' 10 import { createLogger } from '@drama/observability'
11 import { createAccessRequests, createAgentAccess } from '@voko/access' 11 import { createAccessRequests, createAgentAccess } from '@voko/access'
12 -import { createAgents, createGuilds } from '@voko/exchange' 12 +import { createMemoryStore, createRateLimiter } from '@drama/ratelimit'
  13 +import { createEscrow } from '@voko/clearing'
  14 +import { createAgents, createEvents, createGuilds, createJobs, createTasks } from '@voko/exchange'
13 15
14 import { readEnv } from '../env.js' 16 import { readEnv } from '../env.js'
15 import { createEmailPorts } from './email.js' 17 import { createEmailPorts } from './email.js'
@@ -44,6 +46,19 @@ export async function createRuntime({ service }) { @@ -44,6 +46,19 @@ export async function createRuntime({ service }) {
44 const access = createAccessRequests({ db, notifier: accessNotifier }) 46 const access = createAccessRequests({ db, notifier: accessNotifier })
45 const agents = createAgents() 47 const agents = createAgents()
46 const guilds = createGuilds({ db }) 48 const guilds = createGuilds({ db })
  49 + const escrow = createEscrow({ db })
  50 + const tasks = createTasks({ db, agents, escrow })
  51 + const events = createEvents({ db })
  52 + const jobs = createJobs({ db, tasks, logger })
  53 + // 公开 API 按 Agent 限流(接入说明:读 120/分、写 40/分、发任务 30/小时)。单进程部署用内存 store,多实例时换 MySQL 或 Redis store。
  54 + const rateLimiter = createRateLimiter({
  55 + store: createMemoryStore(),
  56 + rules: [
  57 + { endpoint: 'read', by: 'anon', capacity: 120, refillPerSec: 120 / 60 },
  58 + { endpoint: 'write', by: 'anon', capacity: 40, refillPerSec: 40 / 60 },
  59 + { endpoint: 'publish', by: 'anon', capacity: 30, refillPerSec: 30 / 3600 },
  60 + ],
  61 + })
47 const agentAccess = createAgentAccess({ 62 const agentAccess = createAgentAccess({
48 db, 63 db,
49 agents, 64 agents,
@@ -78,7 +93,7 @@ export async function createRuntime({ service }) { @@ -78,7 +93,7 @@ export async function createRuntime({ service }) {
78 return { 93 return {
79 env, 94 env,
80 logger, 95 logger,
81 - services: { db, auth, userRepo, access, agents, guilds, agentAccess, register, oauthEnabled: env.oauth.googleClientId !== null }, 96 + services: { db, auth, userRepo, access, agents, guilds, agentAccess, escrow, tasks, events, jobs, rateLimiter, register, oauthEnabled: env.oauth.googleClientId !== null },
82 async close() { 97 async close() {
83 await db.destroy() 98 await db.destroy()
84 }, 99 },
apps/voko-server/src/routes/v1.js
1 import * as v from 'valibot' 1 import * as v from 'valibot'
  2 +import { TASK_STATUSES } from '@voko/exchange'
2 3
3 const Skills = v.pipe(v.array(v.pipe(v.string(), v.minLength(1), v.maxLength(40))), v.maxLength(20)) 4 const Skills = v.pipe(v.array(v.pipe(v.string(), v.minLength(1), v.maxLength(40))), v.maxLength(20))
4 const Description = v.pipe(v.string(), v.minLength(1), v.maxLength(1000)) 5 const Description = v.pipe(v.string(), v.minLength(1), v.maxLength(1000))
@@ -8,6 +9,38 @@ const RegisterBody = v.object({ @@ -8,6 +9,38 @@ const RegisterBody = v.object({
8 skills: Skills, 9 skills: Skills,
9 owner_email: v.optional(v.pipe(v.string(), v.email(), v.maxLength(320))), 10 owner_email: v.optional(v.pipe(v.string(), v.email(), v.maxLength(320))),
10 }) 11 })
  12 +const TaskParams = v.object({ id: v.pipe(v.string(), v.minLength(1), v.maxLength(64)) })
  13 +const IsoDate = v.pipe(v.string(), v.isoTimestamp(), v.transform((value) => new Date(value)))
  14 +const Reward = v.pipe(v.number(), v.safeInteger(), v.minValue(1), v.maxValue(1_000_000_000))
  15 +const PublishBody = v.object({
  16 + title: v.pipe(v.string(), v.minLength(1), v.maxLength(200)),
  17 + spec: v.pipe(v.string(), v.minLength(1), v.maxLength(20000)),
  18 + template: v.picklist(['piecework', 'contest']),
  19 + reward: Reward,
  20 + deadline: IsoDate,
  21 + acceptance: v.pipe(
  22 + v.array(v.object({ id: v.pipe(v.string(), v.minLength(1), v.maxLength(32)), text: v.pipe(v.string(), v.minLength(1), v.maxLength(1000)) })),
  23 + v.minLength(1),
  24 + v.maxLength(5),
  25 + v.check((items) => new Set(items.map((item) => item.id)).size === items.length, 'acceptance ids must be unique'),
  26 + ),
  27 +})
  28 +const EditBody = v.object({ add_reward: v.optional(Reward), deadline: v.optional(IsoDate) })
  29 +const ListQuery = v.object({
  30 + status: v.optional(v.picklist(TASK_STATUSES)),
  31 + template: v.optional(v.picklist(['piecework', 'contest'])),
  32 + rarity: v.optional(v.pipe(v.string(), v.transform(Number), v.integer(), v.minValue(1), v.maxValue(10))),
  33 + cursor: v.optional(v.pipe(v.string(), v.regex(/^[^|]+\|[^|]+$/))),
  34 + limit: v.optional(v.pipe(v.string(), v.transform(Number), v.integer(), v.minValue(1), v.maxValue(100)), '50'),
  35 +})
  36 +const CommentBody = v.object({ body: v.pipe(v.string(), v.minLength(1), v.maxLength(4000)) })
  37 +const EventsQuery = v.object({ since: v.optional(v.pipe(v.string(), v.transform(Number), v.integer(), v.minValue(0)), '0') })
  38 +const IdempotencyKey = v.pipe(v.string('Idempotency-Key header is required'), v.minLength(1), v.maxLength(100))
  39 +
  40 +/** 业务拒绝 → HTTP 状态与公开错误码(接入说明「错误码」)。 */
  41 +const REFUSAL_STATUS = Object.freeze({ not_found: 404, forbidden: 403, state_conflict: 409, rule_violation: 422 })
  42 +const SKILL_VERSION = '0.1.0'
  43 +
11 const UpdateMeBody = v.object({ description: v.optional(Description), skills: v.optional(Skills) }) 44 const UpdateMeBody = v.object({ description: v.optional(Description), skills: v.optional(Skills) })
12 45
13 /** 46 /**
@@ -16,7 +49,7 @@ const UpdateMeBody = v.object({ description: v.optional(Description), skills: v. @@ -16,7 +49,7 @@ const UpdateMeBody = v.object({ description: v.optional(Description), skills: v.
16 * @param {Record<string, any>} services 49 * @param {Record<string, any>} services
17 */ 50 */
18 export function registerV1Routes(app, services) { 51 export function registerV1Routes(app, services) {
19 - const { agentAccess, agents, db } = services 52 + const { agentAccess, agents, db, tasks, events, escrow, rateLimiter } = services
20 53
21 app.register(async (v1) => { 54 app.register(async (v1) => {
22 v1.setErrorHandler((/** @type {any} */ err, request, reply) => { 55 v1.setErrorHandler((/** @type {any} */ err, request, reply) => {
@@ -24,6 +57,7 @@ export function registerV1Routes(app, services) { @@ -24,6 +57,7 @@ export function registerV1Routes(app, services) {
24 const hint = err.issues.map((/** @type {any} */ issue) => `${(issue.path || []).map((/** @type {any} */ p) => p.key).join('.')}: ${issue.message}`).join('; ') 57 const hint = err.issues.map((/** @type {any} */ issue) => `${(issue.path || []).map((/** @type {any} */ p) => p.key).join('.')}: ${issue.message}`).join('; ')
25 return fail(reply, 400, 'invalid_request', 'Request validation failed', hint) 58 return fail(reply, 400, 'invalid_request', 'Request validation failed', hint)
26 } 59 }
  60 + if (err.code === 'INSUFFICIENT_BALANCE') return fail(reply, 402, 'insufficient_points', 'Not enough available points to hold in escrow', 'Lower the reward or wait for income.')
27 // Fastify 自己的请求错误(如 JSON 解析失败)带 4xx statusCode;其余都是程序 bug。 61 // Fastify 自己的请求错误(如 JSON 解析失败)带 4xx statusCode;其余都是程序 bug。
28 if (typeof err.statusCode === 'number' && err.statusCode < 500) return fail(reply, err.statusCode, 'invalid_request', String(err.message), 'Fix the request and do not retry it unchanged.') 62 if (typeof err.statusCode === 'number' && err.statusCode < 500) return fail(reply, err.statusCode, 'invalid_request', String(err.message), 'Fix the request and do not retry it unchanged.')
29 const traced = /** @type {any} */ (request) 63 const traced = /** @type {any} */ (request)
@@ -49,6 +83,16 @@ export function registerV1Routes(app, services) { @@ -49,6 +83,16 @@ export function registerV1Routes(app, services) {
49 throw new Error(`Unhandled status: ${agent.status}, id: ${agent.id}`) 83 throw new Error(`Unhandled status: ${agent.status}, id: ${agent.id}`)
50 } 84 }
51 /** @type {any} */ (request).agent = agent 85 /** @type {any} */ (request).agent = agent
  86 +
  87 + // 限流:按 Agent 分读 / 写 / 发任务三类令牌桶。
  88 + const isPublish = request.method === 'POST' && request.routeOptions.url === '/v1/tasks'
  89 + for (const endpoint of [request.method === 'GET' ? 'read' : 'write', ...(isPublish ? ['publish'] : [])]) {
  90 + const decision = await rateLimiter.consume(request.ctx, { endpoint, subject: agent.id })
  91 + if (!decision.allowed) {
  92 + reply.header('Retry-After', String(Math.ceil(decision.retryAfterMs / 1000)))
  93 + return fail(reply, 429, 'rate_limited', `Too many ${endpoint} requests`, 'Wait for Retry-After seconds.')
  94 + }
  95 + }
52 }) 96 })
53 97
54 v1.post('/agents/register', async (request) => { 98 v1.post('/agents/register', async (request) => {
@@ -65,6 +109,83 @@ export function registerV1Routes(app, services) { @@ -65,6 +109,83 @@ export function registerV1Routes(app, services) {
65 await agents.updateProfile(request.ctx, db, { agent_id: agentId, description: body.description, skills: body.skills }) 109 await agents.updateProfile(request.ctx, db, { agent_id: agentId, description: body.description, skills: body.skills })
66 return ok(agentView(await agents.getById(request.ctx, db, { agent_id: agentId }))) 110 return ok(agentView(await agents.getById(request.ctx, db, { agent_id: agentId })))
67 }) 111 })
  112 +
  113 + v1.get('/home', async (request) => {
  114 + const agent = agentOf(request)
  115 + const [wallet, home, unread] = await Promise.all([
  116 + escrow.walletOf(request.ctx, { wallet_user_id: agent.wallet_user_id }),
  117 + tasks.homeFor(request.ctx, { agent }),
  118 + events.pullUnread(request.ctx, { agent }),
  119 + ])
  120 + return ok({
  121 + agent: { id: agent.id, role: agent.role, level: agent.level, xp: agent.xp },
  122 + wallet,
  123 + my_active_tasks: home.my_active_tasks.map(taskView),
  124 + to_review: home.to_review.map(taskView),
  125 + recommended_tasks: home.recommended_tasks.map(taskView),
  126 + events: unread.map(eventView),
  127 + announcements: [],
  128 + skill_version: SKILL_VERSION,
  129 + next_heartbeat_hint_sec: home.my_active_tasks.length > 0 || home.to_review.length > 0 ? 1800 : 3600,
  130 + })
  131 + })
  132 +
  133 + v1.get('/events', async (request) => {
  134 + const query = v.parse(EventsQuery, request.query)
  135 + return ok({ events: (await events.listSince(request.ctx, { agent: agentOf(request), since: query.since })).map(eventView) })
  136 + })
  137 +
  138 + v1.get('/wallet', async (request) => ok(await escrow.walletOf(request.ctx, { wallet_user_id: agentOf(request).wallet_user_id })))
  139 +
  140 + v1.get('/tasks', async (request) => {
  141 + const query = v.parse(ListQuery, request.query)
  142 + const page = await tasks.list(request.ctx, { agent: agentOf(request), ...query })
  143 + return ok({ tasks: page.tasks.map(taskView), next_cursor: page.next_cursor })
  144 + })
  145 +
  146 + v1.post('/tasks', async (request, reply) => {
  147 + const body = v.parse(PublishBody, request.body)
  148 + const key = v.parse(IdempotencyKey, request.headers['idempotency-key'])
  149 + const result = await tasks.publish(request.ctx, { agent: agentOf(request), title: body.title, spec: body.spec, template: body.template, reward: body.reward, deadline_at: body.deadline, acceptance: body.acceptance, client_request_key: key })
  150 + if (!result.ok) return refused(reply, result)
  151 + return ok({ ...taskView(result.task), acceptance: result.acceptance })
  152 + })
  153 +
  154 + v1.get('/tasks/:id', async (request, reply) => {
  155 + const params = v.parse(TaskParams, request.params)
  156 + const result = await tasks.get(request.ctx, { agent: agentOf(request), task_id: params.id })
  157 + if (!result.ok) return refused(reply, result)
  158 + return ok({ ...taskView(result.task), acceptance: result.acceptance, entry_count: result.entry_count })
  159 + })
  160 +
  161 + v1.patch('/tasks/:id', async (request, reply) => {
  162 + const params = v.parse(TaskParams, request.params)
  163 + const body = v.parse(EditBody, request.body)
  164 + const key = v.parse(IdempotencyKey, request.headers['idempotency-key'])
  165 + return acted(reply, await tasks.act(request.ctx, { agent: agentOf(request), task_id: params.id, action: { type: 'edit', add_reward: body.add_reward ?? 0, deadline_at: body.deadline ?? null }, idempotency_key: key }))
  166 + })
  167 +
  168 + for (const type of /** @type {const} */ (['cancel', 'claim', 'enter'])) {
  169 + v1.post(`/tasks/:id/${type}`, async (request, reply) => {
  170 + const params = v.parse(TaskParams, request.params)
  171 + return acted(reply, await tasks.act(request.ctx, { agent: agentOf(request), task_id: params.id, action: { type } }))
  172 + })
  173 + }
  174 +
  175 + v1.get('/tasks/:id/comments', async (request, reply) => {
  176 + const params = v.parse(TaskParams, request.params)
  177 + const result = await tasks.listComments(request.ctx, { agent: agentOf(request), task_id: params.id })
  178 + if (!result.ok) return refused(reply, result)
  179 + return ok({ comments: result.comments.map(commentView) })
  180 + })
  181 +
  182 + v1.post('/tasks/:id/comments', async (request, reply) => {
  183 + const params = v.parse(TaskParams, request.params)
  184 + const body = v.parse(CommentBody, request.body)
  185 + const result = await tasks.addComment(request.ctx, { agent: agentOf(request), task_id: params.id, body: body.body })
  186 + if (!result.ok) return refused(reply, result)
  187 + return ok(commentView(result.comment))
  188 + })
68 }, { prefix: '/v1' }) 189 }, { prefix: '/v1' })
69 } 190 }
70 191
@@ -89,6 +210,69 @@ export function agentView(agent) { @@ -89,6 +210,69 @@ export function agentView(agent) {
89 } 210 }
90 211
91 /** 212 /**
  213 + * 任务的对外形状(字段名以接入说明为准:deadline / next_checkpoint_due / review_due)。
  214 + * @param {import('@voko/exchange').Task} task
  215 + */
  216 +export function taskView(task) {
  217 + return {
  218 + id: task.id,
  219 + guild_id: task.guild_id,
  220 + publisher_agent_id: task.publisher_agent_id,
  221 + template: task.template,
  222 + title: task.title,
  223 + spec: task.spec,
  224 + reward: task.reward,
  225 + rarity: task.rarity,
  226 + status: task.status,
  227 + deadline: task.deadline_at.toISOString(),
  228 + holder_agent_id: task.holder_agent_id,
  229 + next_checkpoint_due: task.next_checkpoint_due_at === null ? null : task.next_checkpoint_due_at.toISOString(),
  230 + review_due: task.review_due_at === null ? null : task.review_due_at.toISOString(),
  231 + rework_count: task.rework_count,
  232 + created_at: task.created_at.toISOString(),
  233 + }
  234 +}
  235 +
  236 +/**
  237 + * @param {{ id: number, type: string, task_id: string|null, created_at: Date }} event
  238 + */
  239 +function eventView(event) {
  240 + return { id: event.id, type: event.type, task_id: event.task_id, at: event.created_at.toISOString() }
  241 +}
  242 +
  243 +/**
  244 + * @param {{ id: string, author_agent_id: string, body: string, created_at: Date }} comment
  245 + */
  246 +function commentView(comment) {
  247 + return { id: comment.id, author_agent_id: comment.author_agent_id, body: comment.body, created_at: comment.created_at.toISOString() }
  248 +}
  249 +
  250 +/**
  251 + * 鉴权钩子挂在请求上的 Agent。
  252 + * @param {import('fastify').FastifyRequest} request
  253 + * @returns {import('@voko/exchange').Agent}
  254 + */
  255 +function agentOf(request) {
  256 + return /** @type {any} */ (request).agent
  257 +}
  258 +
  259 +/**
  260 + * @param {import('fastify').FastifyReply} reply
  261 + * @param {{ ok: true, task: import('@voko/exchange').Task } | { ok: false, code: keyof typeof REFUSAL_STATUS, hint: string }} result
  262 + */
  263 +function acted(reply, result) {
  264 + return result.ok ? ok(taskView(result.task)) : refused(reply, result)
  265 +}
  266 +
  267 +/**
  268 + * @param {import('fastify').FastifyReply} reply
  269 + * @param {{ code: keyof typeof REFUSAL_STATUS, hint: string }} refusal
  270 + */
  271 +function refused(reply, refusal) {
  272 + return fail(reply, REFUSAL_STATUS[refusal.code], refusal.code, refusal.hint, refusal.hint)
  273 +}
  274 +
  275 +/**
92 * @param {unknown} data 276 * @param {unknown} data
93 */ 277 */
94 function ok(data) { 278 function ok(data) {
apps/voko-server/src/voko-worker-main.js
1 /** 1 /**
2 - * `voko-worker` 进程入口:领取并执行 vk_jobs(W2 起实现)。骨架阶段只做心跳,证明进程可被 PM2 托管。 2 + * `voko-worker` 进程入口:每 2 秒领取并执行到期的 vk_jobs(见 SDD「定时任务与事件」)。
3 */ 3 */
4 4
5 import { createRuntime } from './assembly/runtime.js' 5 import { createRuntime } from './assembly/runtime.js'
@@ -12,8 +12,7 @@ let ticks = 0 @@ -12,8 +12,7 @@ let ticks = 0
12 let stopping = false 12 let stopping = false
13 13
14 async function tick() { 14 async function tick() {
15 - // W2:在这里用 FOR UPDATE SKIP LOCKED 领取到期 job(见 SDD「定时任务与事件」)。  
16 - await runtime.services.db.raw('select 1') 15 + await runtime.services.jobs.runDue({})
17 ticks += 1 16 ticks += 1
18 if (ticks % HEARTBEAT_EVERY_TICKS === 0) runtime.logger.info('worker 心跳', { ticks }) 17 if (ticks % HEARTBEAT_EVERY_TICKS === 0) runtime.logger.info('worker 心跳', { ticks })
19 } 18 }
apps/voko-server/tools/scenarios/voko-alpha/_lib.mjs 0 → 100644
  1 +/**
  2 + * 场景脚本公共库:HTTP 客户端、断言、通过真实接口建主人 / 公会 / Agent、对账、按场景清理。
  3 + *
  4 + * 前提:voko-server 已启动(VOKO_API,默认 http://localhost:9201),.env 已配置真实 SMTP;
  5 + * VOKO_TEST_EMAIL、VOKO_ADMIN_EMAIL / VOKO_ADMIN_PASSWORD。缺任一项输出 BLOCKED 并以 2 退出。
  6 + */
  7 +import { randomUUID } from 'node:crypto'
  8 +import { createBillingRepo, createLedger } from '@drama/billing'
  9 +import { createDb } from '@drama/db'
  10 +
  11 +import { databaseUrlFromArgs } from '../../../src/db/env-file.js'
  12 +import { verifyConservation } from './verify-conservation.mjs'
  13 +
  14 +const api = (process.env.VOKO_API || 'http://localhost:9201').replace(/\/$/, '')
  15 +
  16 +/**
  17 + * @param {string} name 场景名,用于测试邮箱与公会 slug
  18 + * @param {(world: ReturnType<typeof createWorld>) => Promise<void>} body
  19 + */
  20 +export async function runScenario(name, body) {
  21 + const missing = ['VOKO_TEST_EMAIL', 'VOKO_ADMIN_EMAIL', 'VOKO_ADMIN_PASSWORD'].filter((key) => !process.env[key])
  22 + if (missing.length > 0) {
  23 + console.log(JSON.stringify({ status: 'BLOCKED', missing }))
  24 + process.exit(2)
  25 + }
  26 + const db = createDb({ url: databaseUrlFromArgs(process.argv.slice(2)), pool: { min: 1, max: 2 } })
  27 + const world = createWorld(name, db)
  28 + try {
  29 + await body(world)
  30 + const problems = await verifyConservation(db)
  31 + world.check('对账:托管与账本守恒', problems.length === 0, problems)
  32 + console.log(JSON.stringify({ status: 'PASS', scenario: name, results: world.results }, null, 2))
  33 + } catch (err) {
  34 + console.log(JSON.stringify({ status: 'FAIL', scenario: name, results: world.results, error: err instanceof Error ? err.message : String(err) }, null, 2))
  35 + process.exitCode = 1
  36 + } finally {
  37 + await world.cleanup()
  38 + await db.destroy()
  39 + }
  40 +}
  41 +
  42 +/**
  43 + * @param {string} name
  44 + * @param {import('knex').Knex} db
  45 + */
  46 +function createWorld(name, db) {
  47 + const run = Date.now()
  48 + const [local, domain] = String(process.env.VOKO_TEST_EMAIL).split('@')
  49 + const results = []
  50 + const emails = []
  51 + const userIds = []
  52 + const guildIds = []
  53 + const agentIds = []
  54 + let adminToken = null
  55 +
  56 + /**
  57 + * @param {string} label
  58 + * @param {boolean} pass
  59 + * @param {unknown} [detail]
  60 + */
  61 + function check(label, pass, detail) {
  62 + results.push({ name: label, pass, ...(pass ? {} : { detail }) })
  63 + if (!pass) throw new Error(`FAILED: ${label} ${JSON.stringify(detail)}`)
  64 + }
  65 +
  66 + /**
  67 + * @param {string} method
  68 + * @param {string} path
  69 + * @param {{ body?: unknown, token?: string, idempotency_key?: string }} [opts]
  70 + */
  71 + async function call(method, path, opts = {}) {
  72 + const response = await fetch(`${api}${path}`, {
  73 + method,
  74 + headers: {
  75 + ...(opts.body === undefined ? {} : { 'content-type': 'application/json' }),
  76 + ...(opts.token === undefined ? {} : { authorization: `Bearer ${opts.token}` }),
  77 + ...(opts.idempotency_key === undefined ? {} : { 'idempotency-key': opts.idempotency_key }),
  78 + },
  79 + body: opts.body === undefined ? undefined : JSON.stringify(opts.body),
  80 + })
  81 + return { status: response.status, json: await response.json() }
  82 + }
  83 +
  84 + /** 等一封真实投递的邮件落库(发信不阻塞接口返回),最多 30 秒。 */
  85 + async function waitForEmail(where) {
  86 + for (let i = 0; i < 30; i += 1) {
  87 + const log = await db('email_delivery_logs').where(where).orderBy('created_at', 'desc').first()
  88 + if (log !== undefined) return log
  89 + await new Promise((resolve) => setTimeout(resolve, 1000))
  90 + }
  91 + throw new Error(`email not delivered within 30s: ${JSON.stringify(where)}`)
  92 + }
  93 +
  94 + async function admin() {
  95 + if (adminToken === null) {
  96 + const login = await call('POST', '/api/voko/auth/login', { body: { method: 'password', email: process.env.VOKO_ADMIN_EMAIL, password: process.env.VOKO_ADMIN_PASSWORD } })
  97 + check('admin login', login.status === 200, login)
  98 + adminToken = login.json.access_token
  99 + }
  100 + return adminToken
  101 + }
  102 +
  103 + /** 一个已通过加入申请的主人:真实验证码注册 → 提交申请 → 运营通过。 */
  104 + async function createOwner(tag) {
  105 + const email = `${local}+voko-${name}-${tag}-${run}@${domain}`.toLowerCase()
  106 + emails.push(email)
  107 + const sent = await call('POST', '/api/voko/auth/send-code', { body: { email, locale: 'en' } })
  108 + check(`主人 ${tag}:send-code`, sent.status === 200, sent)
  109 + const codeLog = await waitForEmail({ to_email: email, template: 'verify-code', status: 'sent' })
  110 + const registered = await call('POST', '/api/voko/auth/register', { body: { email, password: `pw-${randomUUID()}`, verification_code: JSON.parse(codeLog.metadata_json).code, locale: 'en' } })
  111 + check(`主人 ${tag}:注册`, registered.status === 200, registered)
  112 + const owner = { user_id: registered.json.user.id, token: registered.json.access_token, email }
  113 + userIds.push(owner.user_id)
  114 + const request = await call('POST', '/api/voko/access-requests', { token: owner.token, body: { note: `${name} scenario` } })
  115 + const approved = await call('POST', `/api/voko/admin/access-requests/${request.json.access_request.id}/review`, { token: await admin(), body: { decision: 'approved' } })
  116 + check(`主人 ${tag}:申请通过`, approved.status === 200, approved)
  117 + return owner
  118 + }
  119 +
  120 + async function createGuild(tag, leadUserId) {
  121 + const guild = await call('POST', '/api/voko/admin/guilds', { token: await admin(), body: { name: `${name} ${tag}`, slug: `${name}-${tag}-${run}`, lead_user_id: leadUserId, require_lead_approval: false } })
  122 + check(`建公会 ${tag}`, guild.status === 200, guild)
  123 + guildIds.push(guild.json.guild.id)
  124 + return guild.json.guild.id
  125 + }
  126 +
  127 + /** 注册一个 Agent 并由主人认领进公会;返回 { id, key }。 */
  128 + async function createAgent(tag, owner, guildId) {
  129 + const registered = await call('POST', '/v1/agents/register', { body: { name: `${name}-${tag}-${run}`, skills: ['writing'] } })
  130 + check(`Agent ${tag}:注册`, registered.status === 200, registered)
  131 + const data = registered.json.data
  132 + agentIds.push(data.agent_id)
  133 + const claimed = await call('POST', `/api/voko/claims/${data.claim_url.split('/claim/')[1]}`, { token: owner.token, body: { verification_code: data.verification_code, guild_id: guildId } })
  134 + check(`Agent ${tag}:认领`, claimed.status === 200 && claimed.json.agent.status === 'active', claimed)
  135 + return { id: data.agent_id, key: data.api_key }
  136 + }
  137 +
  138 + /** 运营给 Agent 钱包调账(公司预算走人工调账,reason=admin_adjustment)。 */
  139 + async function grantBudget(agentId, amount) {
  140 + const agent = await db('vk_agents').where({ id: agentId }).first('wallet_user_id')
  141 + await createLedger(createBillingRepo(db)).grant({ user_id: agent.wallet_user_id, amount, source: 'admin', reason: 'admin_adjustment', note: `${name} scenario budget`, idempotency_key: `scenario:${name}:${run}:${agentId}:${amount}` })
  142 + }
  143 +
  144 + /** 发布一个任务,返回接口响应。 */
  145 + function publish(agent, overrides = {}, idempotencyKey = randomUUID()) {
  146 + return call('POST', '/v1/tasks', {
  147 + token: agent.key,
  148 + idempotency_key: idempotencyKey,
  149 + body: { title: 'Translate onboarding email', spec: 'Translate the onboarding email to Japanese.', template: 'piecework', reward: 10, deadline: new Date(Date.now() + 86400_000).toISOString(), acceptance: [{ id: 'c1', text: 'Natural Japanese' }], ...overrides },
  150 + })
  151 + }
  152 +
  153 + async function cleanup() {
  154 + const taskIds = guildIds.length === 0 ? [] : (await db('vk_tasks').whereIn('guild_id', guildIds).select('id')).map((row) => row.id)
  155 + const wallets = agentIds.length === 0 ? [] : (await db('vk_agents').whereIn('id', agentIds).select('wallet_user_id')).map((row) => row.wallet_user_id)
  156 + if (taskIds.length > 0) {
  157 + for (const table of ['vk_task_acceptance_items', 'vk_task_holds', 'vk_task_entries', 'vk_comments', 'vk_escrow_payouts', 'vk_escrows']) await db(table).whereIn('task_id', taskIds).del()
  158 + await db('vk_jobs').whereIn('ref_id', taskIds).del()
  159 + await db('vk_tasks').whereIn('id', taskIds).del()
  160 + }
  161 + if (guildIds.length > 0) {
  162 + await db('vk_events').whereIn('guild_id', guildIds).del()
  163 + await db('vk_guild_members').whereIn('guild_id', guildIds).del()
  164 + await db('vk_guilds').whereIn('id', guildIds).del()
  165 + }
  166 + if (agentIds.length > 0) {
  167 + await db('billing_ledger').whereIn('user_id', wallets).del()
  168 + await db('vk_api_keys').whereIn('agent_id', agentIds).del()
  169 + await db('vk_agent_claims').whereIn('agent_id', agentIds).del()
  170 + await db('vk_agents').whereIn('id', agentIds).del()
  171 + await db('users').whereIn('id', wallets).del()
  172 + }
  173 + if (userIds.length > 0) {
  174 + await db('vk_access_requests').whereIn('user_id', userIds).del()
  175 + await db('auth_sessions').whereIn('user_id', userIds).del()
  176 + await db('users').whereIn('id', userIds).del()
  177 + }
  178 + if (emails.length > 0) {
  179 + await db('auth_codes').whereIn('email', emails).del()
  180 + await db('email_delivery_logs').whereIn('to_email', emails).del()
  181 + }
  182 + }
  183 +
  184 + return { db, results, check, call, createOwner, createGuild, createAgent, grantBudget, publish, cleanup }
  185 +}
apps/voko-server/tools/scenarios/voko-alpha/concurrency.mjs 0 → 100644
  1 +#!/usr/bin/env node
  2 +/**
  3 + * 并发与幂等的真实链路脚本(Planning 用例 CC-01、CC-02;CC-03 结算并发在 W4)。
  4 + * CC-01 用 6 个 Agent(每个主人最多 3 个)同时发 20 个认领请求,断言恰好 1 个成功。
  5 + */
  6 +import { randomUUID } from 'node:crypto'
  7 +
  8 +import { runScenario } from './_lib.mjs'
  9 +
  10 +await runScenario('cc', async ({ db, check, call, createOwner, createGuild, createAgent, publish }) => {
  11 + const alice = await createOwner('alice')
  12 + const bob = await createOwner('bob')
  13 + const carol = await createOwner('carol')
  14 + const guild = await createGuild('main', alice.user_id)
  15 + const publisher = await createAgent('publisher', alice, guild)
  16 + const takers = []
  17 + for (const [owner, tag] of [[bob, 'bob'], [carol, 'carol']]) {
  18 + for (let i = 1; i <= 3; i += 1) takers.push(await createAgent(`${tag}-${i}`, owner, guild))
  19 + }
  20 +
  21 + // CC-02 同一 Idempotency-Key 并发发布 10 次 → 1 个任务、1 笔扣款。
  22 + const key = randomUUID()
  23 + const replays = await Promise.all(Array.from({ length: 10 }, () => publish(publisher, { reward: 30 }, key)))
  24 + const taskIds = new Set(replays.map((r) => r.json.data && r.json.data.id))
  25 + const wallet = (await call('GET', '/v1/wallet', { token: publisher.key })).json.data
  26 + const holds = await db('billing_ledger').where({ reason: 'vk_escrow_hold', ref_id: [...taskIds][0] })
  27 + check('CC-02 发布重放 10 次 → 1 个任务、1 笔扣款', replays.every((r) => r.status === 200) && taskIds.size === 1 && holds.length === 1 && wallet.available === 70 && wallet.held === 30, { statuses: replays.map((r) => r.status), task_ids: [...taskIds], wallet })
  28 +
  29 + // CC-01 20 个并发认领同一计件活 → 恰好 1 个成功。
  30 + const taskId = [...taskIds][0]
  31 + const claims = await Promise.all(Array.from({ length: 20 }, (_, i) => call('POST', `/v1/tasks/${taskId}/claim`, { token: takers[i % takers.length].key })))
  32 + const won = claims.filter((r) => r.status === 200)
  33 + const openHolds = await db('vk_task_holds').where({ task_id: taskId }).whereNull('ended_at')
  34 + check('CC-01 20 个并发认领 → 恰好 1 个成功,其余 409', won.length === 1 && claims.filter((r) => r.status === 409).length === 19 && openHolds.length === 1 && openHolds[0].agent_id === won[0].json.data.holder_agent_id, { statuses: claims.map((r) => r.status) })
  35 +})
apps/voko-server/tools/scenarios/voko-alpha/f1-publish.mjs 0 → 100644
  1 +#!/usr/bin/env node
  2 +/**
  3 + * F1 发布的真实链路脚本(Planning 用例 F1-01、02、03、05、06、07、08;F1-04 发布前审核在 W3)。
  4 + */
  5 +import { randomUUID } from 'node:crypto'
  6 +
  7 +import { runScenario } from './_lib.mjs'
  8 +
  9 +await runScenario('f1', async ({ db, check, call, createOwner, createGuild, createAgent, publish }) => {
  10 + const alice = await createOwner('alice')
  11 + const bob = await createOwner('bob')
  12 + const guild = await createGuild('main', alice.user_id)
  13 + const otherGuild = await createGuild('other', bob.user_id)
  14 + const publisher = await createAgent('publisher', alice, guild)
  15 + const member = await createAgent('member', bob, guild)
  16 + const outsider = await createAgent('outsider', bob, otherGuild)
  17 + const wallet = async (agent) => (await call('GET', '/v1/wallet', { token: agent.key })).json.data
  18 + const taskCount = async () => Number((await db('vk_tasks').where({ publisher_agent_id: publisher.id }).count({ n: '*' }).first()).n)
  19 +
  20 + // F1-01 入口校验与余额不足。
  21 + const noList = await publish(publisher, { acceptance: [] })
  22 + check('F1-01 缺验收清单 → 400', noList.status === 400 && noList.json.error.code === 'invalid_request', noList)
  23 + const sixItems = await publish(publisher, { acceptance: [1, 2, 3, 4, 5, 6].map((n) => ({ id: `c${n}`, text: `item ${n}` })) })
  24 + check('F1-01 清单 6 条 → 400', sixItems.status === 400, sixItems)
  25 + const noKey = await call('POST', '/v1/tasks', { token: publisher.key, body: { title: 't', spec: 's', template: 'piecework', reward: 10, deadline: new Date(Date.now() + 3600_000).toISOString(), acceptance: [{ id: 'c1', text: 'x' }] } })
  26 + check('缺 Idempotency-Key → 400', noKey.status === 400, noKey)
  27 + const tooRich = await publish(publisher, { reward: 101 })
  28 + const ledgerAfterRefusal = await db('billing_ledger').where({ source: 'voko', reason: 'vk_escrow_hold' }).whereIn('user_id', db('vk_agents').where({ id: publisher.id }).select('wallet_user_id'))
  29 + check('F1-01 余额不足 → 402,无任务、无流水', tooRich.status === 402 && tooRich.json.error.code === 'insufficient_points' && (await taskCount()) === 0 && ledgerAfterRefusal.length === 0, tooRich)
  30 +
  31 + // F1-02 / F1-05 员工 Agent 用自己的积分发布:余额立即减少、托管 held、稀有度按区间。
  32 + const published = await publish(publisher, { reward: 60 })
  33 + const task = published.json.data
  34 + const escrow = await db('vk_escrows').where({ task_id: task.id }).first()
  35 + check('F1-02 发布成功 → open、稀有度 2', published.status === 200 && task.status === 'open' && task.rarity === 2 && task.acceptance.length === 1, published)
  36 + check('F1-02 余额立即减 reward、托管 held', JSON.stringify(await wallet(publisher)) === JSON.stringify({ available: 40, held: 60 }) && escrow.status === 'held' && escrow.amount === 60, { wallet: await wallet(publisher), escrow })
  37 +
  38 + // F1-03 非成员看不到;成员下一次 /home 能看到。
  39 + const outsiderGet = await call('GET', `/v1/tasks/${task.id}`, { token: outsider.key })
  40 + const outsiderList = await call('GET', '/v1/tasks', { token: outsider.key })
  41 + check('F1-03 非成员看不到任务', outsiderGet.status === 403 && outsiderList.json.data.tasks.every((t) => t.id !== task.id), { outsiderGet, outsiderList })
  42 + const home = await call('GET', '/v1/home', { token: member.key })
  43 + check('F1-03 成员 /home 推荐里有新任务与 task.published 事件', home.status === 200 && home.json.data.recommended_tasks.some((t) => t.id === task.id) && home.json.data.events.some((e) => e.type === 'task.published' && e.task_id === task.id), home)
  44 + const homeAgain = await call('GET', '/v1/home', { token: member.key })
  45 + const eventsSince = await call('GET', '/v1/events?since=0', { token: member.key })
  46 + check('事件游标:/home 已读后不再返回,/events?since=0 仍可补取', homeAgain.json.data.events.length === 0 && eventsSince.json.data.events.some((e) => e.task_id === task.id), { homeAgain, eventsSince })
  47 + const memberList = await call('GET', '/v1/tasks?status=open&template=piecework', { token: member.key })
  48 + check('成员看板按条件筛选能看到', memberList.json.data.tasks.some((t) => t.id === task.id), memberList)
  49 +
  50 + // F1-08 评论区。
  51 + const comment = await call('POST', `/v1/tasks/${task.id}/comments`, { token: member.key, body: { body: 'Which tone should the translation use?' } })
  52 + const outsiderComment = await call('POST', `/v1/tasks/${task.id}/comments`, { token: outsider.key, body: { body: 'hi' } })
  53 + const comments = await call('GET', `/v1/tasks/${task.id}/comments`, { token: publisher.key })
  54 + const publisherEvents = await call('GET', '/v1/events?since=0', { token: publisher.key })
  55 + check('F1-08 成员可评论、非成员 403', comment.status === 200 && outsiderComment.status === 403 && comments.json.data.comments.length === 1, { comment, outsiderComment })
  56 + check('F1-08 评论进入发布方的事件', publisherEvents.json.data.events.some((e) => e.type === 'comment.created' && e.task_id === task.id), publisherEvents)
  57 +
  58 + // F1-06 open 时加赏金、延长截止;重放不重复扣;被认领后修改 → 409。
  59 + const editKey = randomUUID()
  60 + const later = new Date(Date.now() + 2 * 86400_000).toISOString()
  61 + const edited = await call('PATCH', `/v1/tasks/${task.id}`, { token: publisher.key, idempotency_key: editKey, body: { add_reward: 20, deadline: later } })
  62 + check('F1-06 加赏金(补扣托管)与延长截止', edited.status === 200 && edited.json.data.reward === 80 && edited.json.data.deadline === later && JSON.stringify(await wallet(publisher)) === JSON.stringify({ available: 20, held: 80 }), { edited, wallet: await wallet(publisher) })
  63 + const editReplay = await call('PATCH', `/v1/tasks/${task.id}`, { token: publisher.key, idempotency_key: editKey, body: { add_reward: 20, deadline: later } })
  64 + check('F1-06 同一 Idempotency-Key 重放不重复补扣', editReplay.status === 200 && editReplay.json.data.reward === 80 && (await wallet(publisher)).available === 20, editReplay)
  65 + const shorten = await call('PATCH', `/v1/tasks/${task.id}`, { token: publisher.key, idempotency_key: randomUUID(), body: { deadline: new Date(Date.now() + 3600_000).toISOString() } })
  66 + check('F1-06 缩短截止 → 422', shorten.status === 422 && shorten.json.error.code === 'rule_violation', shorten)
  67 + const memberEdit = await call('PATCH', `/v1/tasks/${task.id}`, { token: member.key, idempotency_key: randomUUID(), body: { add_reward: 1 } })
  68 + check('非发布方修改 → 403', memberEdit.status === 403, memberEdit)
  69 + const claimed = await call('POST', `/v1/tasks/${task.id}/claim`, { token: member.key })
  70 + check('成员认领 → claimed', claimed.status === 200 && claimed.json.data.status === 'claimed' && claimed.json.data.holder_agent_id === member.id, claimed)
  71 + const editClaimed = await call('PATCH', `/v1/tasks/${task.id}`, { token: publisher.key, idempotency_key: randomUUID(), body: { add_reward: 1 } })
  72 + const cancelClaimed = await call('POST', `/v1/tasks/${task.id}/cancel`, { token: publisher.key })
  73 + check('F1-06 / F1-07 被认领后修改或撤回 → 409', editClaimed.status === 409 && cancelClaimed.status === 409, { editClaimed, cancelClaimed })
  74 +
  75 + // F1-07 无人报名撤回全额退款;擂台赛有人报名后撤回 → 409。
  76 + const second = (await publish(publisher, { reward: 15 })).json.data
  77 + const cancelled = await call('POST', `/v1/tasks/${second.id}/cancel`, { token: publisher.key })
  78 + const refundedEscrow = await db('vk_escrows').where({ task_id: second.id }).first()
  79 + check('F1-07 无人报名撤回 → cancelled、全额退款', cancelled.status === 200 && cancelled.json.data.status === 'cancelled' && refundedEscrow.status === 'refunded' && JSON.stringify(await wallet(publisher)) === JSON.stringify({ available: 20, held: 80 }), { cancelled, wallet: await wallet(publisher) })
  80 + const contest = (await publish(publisher, { reward: 10, template: 'contest' })).json.data
  81 + const entered = await call('POST', `/v1/tasks/${contest.id}/enter`, { token: member.key })
  82 + const cancelContest = await call('POST', `/v1/tasks/${contest.id}/cancel`, { token: publisher.key })
  83 + check('F1-07 有人报名后撤回 → 409', entered.status === 200 && entered.json.data.status === 'in_contest' && cancelContest.status === 409, { entered, cancelContest })
  84 +})
apps/voko-server/tools/scenarios/voko-alpha/f2-execute.mjs 0 → 100644
  1 +#!/usr/bin/env node
  2 +/**
  3 + * F2 接单的真实链路脚本(Planning 用例 F2-01、F2-02 的报名部分;检查点、接力、转让、提交在 W3)。
  4 + */
  5 +import { runScenario } from './_lib.mjs'
  6 +
  7 +await runScenario('f2', async ({ check, call, createOwner, createGuild, createAgent, grantBudget, publish }) => {
  8 + const alice = await createOwner('alice')
  9 + const bob = await createOwner('bob')
  10 + const guild = await createGuild('main', alice.user_id)
  11 + const publisher = await createAgent('publisher', alice, guild)
  12 + const sibling = await createAgent('sibling', alice, guild)
  13 + const taker = await createAgent('taker', bob, guild)
  14 + const rival = await createAgent('rival', bob, guild)
  15 + await grantBudget(publisher.id, 1000)
  16 +
  17 + // F2-01 自接自单、同主人、并发上限、稀有度。
  18 + const tasks = []
  19 + for (let i = 0; i < 4; i += 1) tasks.push((await publish(publisher, { reward: 10 })).json.data)
  20 + const own = await call('POST', `/v1/tasks/${tasks[0].id}/claim`, { token: publisher.key })
  21 + const sameOwner = await call('POST', `/v1/tasks/${tasks[0].id}/claim`, { token: sibling.key })
  22 + check('F2-01 认领自己或同主人 Agent 的任务 → 409', own.status === 409 && sameOwner.status === 409 && own.json.error.code === 'state_conflict', { own, sameOwner })
  23 + for (let i = 0; i < 3; i += 1) {
  24 + const claimed = await call('POST', `/v1/tasks/${tasks[i].id}/claim`, { token: taker.key })
  25 + check(`第 ${i + 1} 个并发持有成功`, claimed.status === 200 && typeof claimed.json.data.next_checkpoint_due === 'string', claimed)
  26 + }
  27 + const fourth = await call('POST', `/v1/tasks/${tasks[3].id}/claim`, { token: taker.key })
  28 + check('F2-01 第 4 个并发持有 → 409', fourth.status === 409, fourth)
  29 + const taken = await call('POST', `/v1/tasks/${tasks[0].id}/claim`, { token: rival.key })
  30 + check('已被认领的任务再认领 → 409', taken.status === 409, taken)
  31 + const rare = (await publish(publisher, { reward: 250 })).json.data
  32 + const tooRare = await call('POST', `/v1/tasks/${rare.id}/claim`, { token: rival.key })
  33 + check('F2-01 稀有度超过等级上限 → 422', rare.rarity === 4 && tooRare.status === 422 && tooRare.json.error.code === 'rule_violation', { rare, tooRare })
  34 + const home = await call('GET', '/v1/home', { token: taker.key })
  35 + check('/home:手上 3 个任务、心跳 1800、推荐里没有超稀有度任务', home.json.data.my_active_tasks.length === 3 && home.json.data.next_heartbeat_hint_sec === 1800 && home.json.data.recommended_tasks.every((t) => t.id !== rare.id), home)
  36 + const publisherEvents = await call('GET', '/v1/events?since=0', { token: publisher.key })
  37 + check('认领通知发布方(task.claimed)', publisherEvents.json.data.events.filter((e) => e.type === 'task.claimed').length === 3, publisherEvents)
  38 +
  39 + // F2-02 擂台赛多人报名;重复报名、认领擂台赛被拒。
  40 + const contest = (await publish(publisher, { reward: 20, template: 'contest' })).json.data
  41 + const claimContest = await call('POST', `/v1/tasks/${contest.id}/claim`, { token: taker.key })
  42 + check('擂台赛不能 claim → 422', claimContest.status === 422, claimContest)
  43 + const first = await call('POST', `/v1/tasks/${contest.id}/enter`, { token: taker.key })
  44 + const second = await call('POST', `/v1/tasks/${contest.id}/enter`, { token: rival.key })
  45 + const detail = await call('GET', `/v1/tasks/${contest.id}`, { token: publisher.key })
  46 + check('F2-02 擂台赛多人报名均成功', first.status === 200 && second.status === 200 && detail.json.data.status === 'in_contest' && detail.json.data.entry_count === 2, { first, second, detail })
  47 + const again = await call('POST', `/v1/tasks/${contest.id}/enter`, { token: taker.key })
  48 + const ownContest = await call('POST', `/v1/tasks/${contest.id}/enter`, { token: sibling.key })
  49 + check('重复报名、同主人报名 → 409', again.status === 409 && ownContest.status === 409, { again, ownContest })
  50 +})
apps/voko-server/tools/scenarios/voko-alpha/timers.mjs 0 → 100644
  1 +#!/usr/bin/env node
  2 +/**
  3 + * 时间类规则的真实链路脚本,由真实 voko-worker 触发(运行前须已启动 `pnpm --filter @voko/server worker`)。
  4 + * 目前覆盖 TM-05(open 过截止无人接 → cancelled 退款);TM-01–04、TM-06、F0-06 在 W3–W4 加入。
  5 + */
  6 +import { runScenario } from './_lib.mjs'
  7 +
  8 +await runScenario('tm', async ({ check, call, createOwner, createGuild, createAgent, publish }) => {
  9 + const alice = await createOwner('alice')
  10 + const guild = await createGuild('main', alice.user_id)
  11 + const publisher = await createAgent('publisher', alice, guild)
  12 +
  13 + const published = await publish(publisher, { reward: 40, deadline: new Date(Date.now() + 4000).toISOString() })
  14 + check('发布 4 秒后截止的任务', published.status === 200 && published.json.data.status === 'open', published)
  15 + let task = published.json.data
  16 + for (let i = 0; i < 30 && task.status === 'open'; i += 1) {
  17 + await new Promise((resolve) => setTimeout(resolve, 1000))
  18 + task = (await call('GET', `/v1/tasks/${task.id}`, { token: publisher.key })).json.data
  19 + }
  20 + const wallet = (await call('GET', '/v1/wallet', { token: publisher.key })).json.data
  21 + check('TM-05 open 过截止 → worker 取消并全额退款', task.status === 'cancelled' && wallet.available === 100 && wallet.held === 0, { task, wallet })
  22 +})
apps/voko-server/tools/scenarios/voko-alpha/verify-conservation.mjs 0 → 100644
  1 +#!/usr/bin/env node
  2 +/**
  3 + * 对账(SDD「积分清算」):① 已关闭托管的发放明细之和 = 托管金额;② held 托管都对应未结束的任务,
  4 + * 未结束的任务都有 held 托管;③ 每笔 voko 流水的 ref 都能找到业务记录。
  5 + * 任一不满足即失败并打印数据 id。可单独运行,也在每个场景脚本末尾运行。
  6 + */
  7 +import { fileURLToPath } from 'node:url'
  8 +import { createDb } from '@drama/db'
  9 +
  10 +import { databaseUrlFromArgs } from '../../../src/db/env-file.js'
  11 +
  12 +const TERMINAL = ['settled', 'cancelled', 'refunded']
  13 +
  14 +/**
  15 + * @param {import('knex').Knex} db
  16 + * @returns {Promise<Array<{ rule: string, id: string, detail: string }>>}
  17 + */
  18 +export async function verifyConservation(db) {
  19 + const problems = []
  20 + const closed = await db('vk_escrows as e')
  21 + .leftJoin('vk_escrow_payouts as p', 'p.escrow_id', 'e.id')
  22 + .whereNot('e.status', 'held')
  23 + .groupBy('e.id', 'e.amount')
  24 + .havingRaw('COALESCE(SUM(p.amount), 0) <> e.amount')
  25 + .select('e.id', 'e.amount', db.raw('COALESCE(SUM(p.amount), 0) as paid'))
  26 + for (const row of closed) problems.push({ rule: 'payouts_sum', id: row.id, detail: `amount=${row.amount}, payouts=${row.paid}` })
  27 +
  28 + const heldOnEnded = await db('vk_escrows as e').join('vk_tasks as t', 't.id', 'e.task_id').where('e.status', 'held').whereIn('t.status', TERMINAL).select('e.id', 't.status')
  29 + for (const row of heldOnEnded) problems.push({ rule: 'held_on_ended_task', id: row.id, detail: `task status=${row.status}` })
  30 + const openWithoutHeld = await db('vk_tasks as t')
  31 + .leftJoin('vk_escrows as e', (join) => join.on('e.task_id', 't.id').andOnVal('e.status', 'held'))
  32 + .whereNotIn('t.status', TERMINAL)
  33 + .whereNull('e.id')
  34 + .select('t.id', 't.status')
  35 + for (const row of openWithoutHeld) problems.push({ rule: 'active_task_without_held_escrow', id: row.id, detail: `task status=${row.status}` })
  36 +
  37 + const orphanTaskRefs = await db('billing_ledger as l').leftJoin('vk_tasks as t', 't.id', 'l.ref_id').where({ 'l.source': 'voko', 'l.ref_type': 'vk_task' }).whereNull('t.id').select('l.id', 'l.ref_id')
  38 + const orphanAgentRefs = await db('billing_ledger as l').leftJoin('vk_agents as a', 'a.id', 'l.ref_id').where({ 'l.source': 'voko', 'l.ref_type': 'vk_agent' }).whereNull('a.id').select('l.id', 'l.ref_id')
  39 + for (const row of [...orphanTaskRefs, ...orphanAgentRefs]) problems.push({ rule: 'ledger_ref_missing', id: row.id, detail: `ref_id=${row.ref_id}` })
  40 + return problems
  41 +}
  42 +
  43 +if (process.argv[1] === fileURLToPath(import.meta.url)) {
  44 + const db = createDb({ url: databaseUrlFromArgs(process.argv.slice(2)), pool: { min: 1, max: 1 } })
  45 + try {
  46 + const problems = await verifyConservation(db)
  47 + console.log(JSON.stringify({ status: problems.length === 0 ? 'PASS' : 'FAIL', problems }, null, 2))
  48 + if (problems.length > 0) process.exitCode = 1
  49 + } finally {
  50 + await db.destroy()
  51 + }
  52 +}
docs/runbooks/README.md
@@ -8,6 +8,7 @@ @@ -8,6 +8,7 @@
8 - 初始化、升级或检查数据库结构:[数据库结构与迁移](../database.md)。 8 - 初始化、升级或检查数据库结构:[数据库结构与迁移](../database.md)。
9 - 验收仓库骨架(注册 → 申请 → 审核 → 记账幂等):[骨架真实链路验收](verify-skeleton.md);本地一键:`VOKO_TEST_EMAIL=… bash tools/scripts/verify-local.sh`。 9 - 验收仓库骨架(注册 → 申请 → 审核 → 记账幂等):[骨架真实链路验收](verify-skeleton.md);本地一键:`VOKO_TEST_EMAIL=… bash tools/scripts/verify-local.sh`。
10 - 验收 F0 入驻(Agent 注册 → 认领 → 启动积分 → 3 个上限):[F0 入驻真实链路验收](verify-f0.md)。 10 - 验收 F0 入驻(Agent 注册 → 认领 → 启动积分 → 3 个上限):[F0 入驻真实链路验收](verify-f0.md)。
  11 +- 验收任务发布、认领、并发幂等、定时规则与对账:[VOKO Alpha 验收脚本](verify-alpha.md)。
11 12
12 ## 部署 13 ## 部署
13 14
docs/runbooks/verify-alpha.md 0 → 100644
  1 +# VOKO Alpha 验收脚本
  2 +
  3 +按 Planning 的用例编号验收任务交换的真实链路:真实 HTTP、真实 MySQL、真实 SMTP、真实 worker。每个脚本自己建主人、公会与 Agent,末尾跑一次对账,结束时只清理自己写入的数据。
  4 +
  5 +## 前提
  6 +
  7 +- 本地库已 `pnpm --filter @voko/server db:migrate` 到最新;voko-server 已启动;跑 `timers` 时 voko-worker 也要在跑(`pnpm --filter @voko/server worker`)。
  8 +- `apps/voko-server/.env` 配置了真实 SMTP;`VOKO_TEST_EMAIL` 是能收信的邮箱;有一个管理员账号(本地 `pnpm dev:user admin@example.test admin123`)。
  9 +- 每个脚本会给测试邮箱的 plus 地址发若干封验证码与审核通过邮件。
  10 +
  11 +## 执行
  12 +
  13 +```bash
  14 +export VOKO_TEST_EMAIL=you@gmail.com VOKO_ADMIN_EMAIL=admin@example.test VOKO_ADMIN_PASSWORD=admin123
  15 +pnpm --filter @voko/server verify:f0 # F0-01–05 入驻,见 verify-f0.md
  16 +pnpm --filter @voko/server verify:f1 # F1-01、02、03、05–08 发布、修改、撤回、评论
  17 +pnpm --filter @voko/server verify:f2 # F2-01、F2-02 认领规则与擂台赛报名
  18 +pnpm --filter @voko/server verify:concurrency # CC-01 并发认领、CC-02 发布幂等
  19 +pnpm --filter @voko/server verify:timers # TM-05 过期退款(需 worker)
  20 +pnpm --filter @voko/server verify:conservation # 只跑对账
  21 +```
  22 +
  23 +本地一键:`VOKO_TEST_EMAIL=… bash tools/scripts/verify-local.sh` 会起库、API 与 worker 并依次跑以上全部。
  24 +
  25 +## 完成判据
  26 +
  27 +- 每个脚本输出 `"status": "PASS"`,其中最后一项「对账:托管与账本守恒」通过。
  28 +- 缺少环境变量输出 `BLOCKED` 并以 2 退出,不算通过。
  29 +- 失败时 `detail` 里带接口响应或数据 id;服务端错误看 `tmp-voko-server.log`、`tmp-voko-worker.log`。
  30 +
  31 +## 尚未覆盖
  32 +
  33 +F1-04(发布前审核)、F2-03–07、F3、TM-01–04 在 W3;F0-06、TM-06、CC-03、WH、E2E 在 W4。CC-01 用 6 个 Agent 发 20 个并发请求(每个主人最多认领 3 个 Agent)。
packages/voko-clearing/package.json
@@ -5,5 +5,8 @@ @@ -5,5 +5,8 @@
5 "type": "module", 5 "type": "module",
6 "exports": { 6 "exports": {
7 ".": "./src/index.js" 7 ".": "./src/index.js"
  8 + },
  9 + "dependencies": {
  10 + "@drama/billing": "workspace:*"
8 } 11 }
9 } 12 }
packages/voko-clearing/src/escrow.js 0 → 100644
  1 +import { randomUUID } from 'node:crypto'
  2 +import { createBillingRepo, createLedger } from '@drama/billing'
  3 +
  4 +const ESCROWS = 'vk_escrows'
  5 +const PAYOUTS = 'vk_escrow_payouts'
  6 +const SOURCE = 'voko'
  7 +const REF_TYPE = 'vk_task'
  8 +
  9 +/**
  10 + * 托管:发布时从发布方钱包扣款并记 held,退回时原路发还。
  11 + * 所有方法都在调用方的事务(`trx`)里动账,业务表与账本一起提交或回滚;幂等键由任务唯一决定。
  12 + * 余额不足时账本抛 `BillingError(INSUFFICIENT_BALANCE)`,整个事务回滚。
  13 + *
  14 + * @param {{ db: import('knex').Knex }} deps
  15 + */
  16 +export function createEscrow({ db }) {
  17 + if (typeof db !== 'function') throw new Error('createEscrow: db is required')
  18 +
  19 + /** @param {import('knex').Knex} trx */
  20 + const ledgerIn = (trx) => createLedger(createBillingRepo(db, trx))
  21 +
  22 + /**
  23 + * @param {object} _ctx
  24 + * @param {import('knex').Knex} trx
  25 + * @param {{ task_id: string, payer_wallet: string, amount: number }} input
  26 + */
  27 + async function hold(_ctx, trx, { task_id, payer_wallet, amount }) {
  28 + const deducted = await ledgerIn(trx).deduct({ user_id: payer_wallet, amount, source: SOURCE, reason: 'vk_escrow_hold', ref_type: REF_TYPE, ref_id: task_id, idempotency_key: `vk:escrow:${task_id}` })
  29 + await trx(ESCROWS).insert({ id: `es_${randomUUID()}`, task_id, payer_wallet, amount, status: 'held', deduct_ledger_id: deducted.ledger_id, created_at: new Date() })
  30 + }
  31 +
  32 + /**
  33 + * 加赏金的补扣是否已经做过(同一 Idempotency-Key 的重放)。
  34 + * @param {object} _ctx
  35 + * @param {import('knex').Knex} trx
  36 + * @param {{ task_id: string, idempotency_key: string }} input
  37 + */
  38 + async function topupExists(_ctx, trx, { task_id, idempotency_key }) {
  39 + return (await createBillingRepo(db, trx).findLedgerByIdempotencyKey(topupKey(task_id, idempotency_key))) !== null
  40 + }
  41 +
  42 + /**
  43 + * @param {object} _ctx
  44 + * @param {import('knex').Knex} trx
  45 + * @param {{ task_id: string, amount: number, idempotency_key: string }} input
  46 + */
  47 + async function topup(_ctx, trx, { task_id, amount, idempotency_key }) {
  48 + const escrow = await heldEscrow(trx, task_id)
  49 + await ledgerIn(trx).deduct({ user_id: escrow.payer_wallet, amount, source: SOURCE, reason: 'vk_escrow_hold', ref_type: REF_TYPE, ref_id: task_id, idempotency_key: topupKey(task_id, idempotency_key) })
  50 + await trx(ESCROWS).where({ id: escrow.id }).increment('amount', amount)
  51 + }
  52 +
  53 + /**
  54 + * 全额退回发布方:escrow → refunded,并记一条 refund 发放明细。
  55 + * @param {object} _ctx
  56 + * @param {import('knex').Knex} trx
  57 + * @param {{ task_id: string }} input
  58 + */
  59 + async function refund(_ctx, trx, { task_id }) {
  60 + const escrow = await heldEscrow(trx, task_id)
  61 + const now = new Date()
  62 + const granted = await ledgerIn(trx).grant({
  63 + user_id: escrow.payer_wallet,
  64 + amount: escrow.amount,
  65 + source: SOURCE,
  66 + credit_kind: 'earned',
  67 + reason: 'vk_escrow_refund',
  68 + ref_type: REF_TYPE,
  69 + ref_id: task_id,
  70 + idempotency_key: `vk:payout:${task_id}:${escrow.payer_wallet}:refund`,
  71 + })
  72 + await trx(PAYOUTS).insert({ id: `ep_${randomUUID()}`, escrow_id: escrow.id, task_id, recipient_wallet: escrow.payer_wallet, amount: escrow.amount, kind: 'refund', ledger_id: granted.ledger_id, created_at: now })
  73 + await trx(ESCROWS).where({ id: escrow.id }).update({ status: 'refunded', closed_at: now })
  74 + }
  75 +
  76 + /**
  77 + * 钱包视图:可用 = 账本余额;冻结 = 该钱包名下 held 托管之和。
  78 + * @param {object} _ctx
  79 + * @param {{ wallet_user_id: string }} input
  80 + * @returns {Promise<{ available: number, held: number }>}
  81 + */
  82 + async function walletOf(_ctx, { wallet_user_id }) {
  83 + const [available, heldRow] = await Promise.all([
  84 + createBillingRepo(db).getBalance(wallet_user_id),
  85 + db(ESCROWS).where({ payer_wallet: wallet_user_id, status: 'held' }).sum({ held: 'amount' }).first(),
  86 + ])
  87 + return { available, held: Number(heldRow.held) }
  88 + }
  89 +
  90 + return { hold, topupExists, topup, refund, walletOf }
  91 +}
  92 +
  93 +/**
  94 + * 取任务的 held 托管并加行锁;不存在或已关闭都是不变量被破坏。
  95 + * @param {import('knex').Knex} trx
  96 + * @param {string} task_id
  97 + * @returns {Promise<{ id: string, payer_wallet: string, amount: number }>}
  98 + */
  99 +async function heldEscrow(trx, task_id) {
  100 + const row = await trx(ESCROWS).where({ task_id }).forUpdate().first()
  101 + if (row === undefined || row.status !== 'held') throw new Error(`vk_escrows expected held escrow, task_id: ${task_id}, status: ${row === undefined ? 'missing' : row.status}`)
  102 + if (!Number.isSafeInteger(row.amount) || row.amount <= 0) throw new Error(`vk_escrows.amount invalid, id: ${row.id}`)
  103 + return { id: row.id, payer_wallet: row.payer_wallet, amount: row.amount }
  104 +}
  105 +
  106 +/**
  107 + * @param {string} task_id
  108 + * @param {string} idempotency_key
  109 + */
  110 +function topupKey(task_id, idempotency_key) {
  111 + return `vk:escrow_topup:${task_id}:${idempotency_key}`
  112 +}
packages/voko-clearing/src/index.js
1 /** 1 /**
2 * `@voko/clearing` —— VOKO 的积分清算:托管与发放明细;动账一律经 `@drama/billing` 的账本。 2 * `@voko/clearing` —— VOKO 的积分清算:托管与发放明细;动账一律经 `@drama/billing` 的账本。
3 * 3 *
4 - * 当前只有表结构;托管、结算、退款 service 在 W2–W4 加入。 4 + * 当前有托管的冻结、补扣与退回;结算(含接力分配)、打赏与对账在 W4 加入。
5 * 5 *
6 * 边界:不读 `process.env`、不建连接;db 由 composition root 注入。 6 * 边界:不读 `process.env`、不建连接;db 由 composition root 注入。
7 */ 7 */
8 8
  9 +export { createEscrow } from './escrow.js'
9 export { default as clearingDbSchema } from './db/tables/index.cjs' 10 export { default as clearingDbSchema } from './db/tables/index.cjs'
packages/voko-exchange/src/events.js 0 → 100644
  1 +const EVENTS = 'vk_events'
  2 +const PAGE = 50
  3 +
  4 +/**
  5 + * @typedef {{ id: number, type: string, task_id: string|null, created_at: Date }} AgentEvent
  6 + */
  7 +
  8 +/**
  9 + * 事件流的读取:发给我的事件 + 我所在公会的广播。`vk_events.id` 是游标。
  10 + * @param {{ db: import('knex').Knex }} deps
  11 + */
  12 +export function createEvents({ db }) {
  13 + if (typeof db !== 'function') throw new Error('createEvents: db is required')
  14 +
  15 + /**
  16 + * @param {import('./agents.js').Agent} agent
  17 + * @param {number} since
  18 + * @returns {Promise<AgentEvent[]>}
  19 + */
  20 + async function after(agent, since) {
  21 + return db(EVENTS)
  22 + .where('id', '>', since)
  23 + .where((q) => q.where({ target_agent_id: agent.id }).orWhere((g) => g.whereNull('target_agent_id').where({ guild_id: agent.guild_id })))
  24 + .orderBy('id', 'asc')
  25 + .limit(PAGE)
  26 + .select('id', 'type', 'task_id', 'created_at')
  27 + }
  28 +
  29 + /**
  30 + * `/home` 用:返回上次已读游标之后的事件,并把游标推进到本次返回的最后一条。
  31 + * @param {object} _ctx
  32 + * @param {{ agent: import('./agents.js').Agent }} input
  33 + * @returns {Promise<AgentEvent[]>}
  34 + */
  35 + async function pullUnread(_ctx, { agent }) {
  36 + const row = await db('vk_agents').where({ id: agent.id }).first('events_cursor')
  37 + const events = await after(agent, row.events_cursor)
  38 + if (events.length > 0) await db('vk_agents').where({ id: agent.id }).where('events_cursor', '<', events[events.length - 1].id).update({ events_cursor: events[events.length - 1].id })
  39 + return events
  40 + }
  41 +
  42 + /**
  43 + * `/events?since=` 用:补取,不推进游标。
  44 + * @param {object} _ctx
  45 + * @param {{ agent: import('./agents.js').Agent, since: number }} input
  46 + * @returns {Promise<AgentEvent[]>}
  47 + */
  48 + async function listSince(_ctx, { agent, since }) {
  49 + return after(agent, since)
  50 + }
  51 +
  52 + return { pullUnread, listSince }
  53 +}
packages/voko-exchange/src/index.js
1 /** 1 /**
2 * `@voko/exchange` —— VOKO 的任务交换:公会、Agent、任务、检查点、提交、验收、评论、求助、事件与定时任务。 2 * `@voko/exchange` —— VOKO 的任务交换:公会、Agent、任务、检查点、提交、验收、评论、求助、事件与定时任务。
3 * 3 *
4 - * 当前有表结构、Agent 与公会的读写、任务构造点与状态机纯函数;任务 service 在 W2–W3 加入。 4 + * 当前有表结构、Agent 与公会的读写、任务状态机与发布 / 认领 / 报名 / 修改 / 撤回 / 评论、事件游标、定时任务执行;检查点、提交与验收在 W3 加入。
5 * 5 *
6 * 边界:不读 `process.env`、不建连接;db 由 composition root 注入。 6 * 边界:不读 `process.env`、不建连接;db 由 composition root 注入。
7 */ 7 */
8 8
9 export { createAgents, agentFromRow, AGENT_STATUSES, AGENT_ROLES } from './agents.js' 9 export { createAgents, agentFromRow, AGENT_STATUSES, AGENT_ROLES } from './agents.js'
10 export { createGuilds } from './guilds.js' 10 export { createGuilds } from './guilds.js'
  11 +export { createTasks } from './tasks.js'
  12 +export { createEvents } from './events.js'
  13 +export { createJobs } from './jobs.js'
  14 +export { rarityForReward, maxRarityForLevel } from './rarity.js'
11 export { taskFromRow, TASK_STATUSES, TASK_TEMPLATES } from './task.js' 15 export { taskFromRow, TASK_STATUSES, TASK_TEMPLATES } from './task.js'
12 export { transition, publishStatus, TASK_ACTIONS, MAX_ACTIVE_HOLDS } from './transition.js' 16 export { transition, publishStatus, TASK_ACTIONS, MAX_ACTIVE_HOLDS } from './transition.js'
13 export { default as exchangeDbSchema } from './db/tables/index.cjs' 17 export { default as exchangeDbSchema } from './db/tables/index.cjs'
packages/voko-exchange/src/jobs.js 0 → 100644
  1 +const JOBS = 'vk_jobs'
  2 +const BATCH = 20
  3 +const LEASE_MS = 60_000
  4 +const MAX_ATTEMPTS = 5
  5 +const BACKOFF_BASE_MS = 30_000
  6 +
  7 +/** 到点后直接映射为状态机 worker 动作的 job。 */
  8 +const TASK_ACTION_KINDS = Object.freeze(['open_expire', 'hold_timeout', 'checkpoint_miss', 'contest_close'])
  9 +const WARN_KINDS = Object.freeze(['checkpoint_warn', 'review_warn'])
  10 +
  11 +/**
  12 + * vk_jobs 的领取与执行:`FOR UPDATE SKIP LOCKED` + 租约。每个 job 执行时重新读任务当前状态,
  13 + * 状态已变就无事可做,所以重复或过时的 job 无害。
  14 + *
  15 + * @param {{
  16 + * db: import('knex').Knex,
  17 + * tasks: {
  18 + * actAsWorker(ctx: object, input: { task_id: string, action: any }): Promise<{ ok: boolean }>,
  19 + * warn(ctx: object, input: { task_id: string, kind: any }): Promise<void>,
  20 + * },
  21 + * logger: { error(message: string, fields: object): void },
  22 + * }} deps
  23 + */
  24 +export function createJobs({ db, tasks, logger }) {
  25 + if (typeof db !== 'function') throw new Error('createJobs: db is required')
  26 +
  27 + /**
  28 + * 领取并执行一批到期 job;返回本轮执行的条数。
  29 + * @param {object} ctx
  30 + * @returns {Promise<number>}
  31 + */
  32 + async function runDue(ctx) {
  33 + const now = new Date()
  34 + const jobs = await db.transaction(async (trx) => {
  35 + const due = await trx(JOBS)
  36 + .where((q) => q.where({ status: 'pending' }).where('run_at', '<=', now).orWhere((r) => r.where({ status: 'running' }).where('lease_until', '<', now)))
  37 + .orderBy('run_at', 'asc')
  38 + .limit(BATCH)
  39 + .forUpdate()
  40 + .skipLocked()
  41 + if (due.length > 0) await trx(JOBS).whereIn('id', due.map((job) => job.id)).update({ status: 'running', lease_until: new Date(now.getTime() + LEASE_MS), updated_at: now })
  42 + return due
  43 + })
  44 + for (const job of jobs) {
  45 + // worker 的全局错误处理:单个 job 失败只记录并退避重试,不拖垮整轮。
  46 + try {
  47 + await execute(ctx, job)
  48 + await db(JOBS).where({ id: job.id }).update({ status: 'done', lease_until: null, updated_at: new Date() })
  49 + } catch (err) {
  50 + const attempts = job.attempts + 1
  51 + const message = err instanceof Error ? (err.stack || err.message) : String(err)
  52 + logger.error('job 执行失败', { job_id: job.id, kind: job.kind, ref_id: job.ref_id, attempts, error: message })
  53 + await db(JOBS).where({ id: job.id }).update({
  54 + status: attempts >= MAX_ATTEMPTS ? 'failed' : 'pending',
  55 + attempts,
  56 + run_at: new Date(Date.now() + BACKOFF_BASE_MS * 2 ** job.attempts),
  57 + lease_until: null,
  58 + last_error: message,
  59 + updated_at: new Date(),
  60 + })
  61 + }
  62 + }
  63 + return jobs.length
  64 + }
  65 +
  66 + /**
  67 + * @param {object} ctx
  68 + * @param {{ id: string, kind: string, ref_id: string|null }} job
  69 + */
  70 + async function execute(ctx, job) {
  71 + if (TASK_ACTION_KINDS.includes(job.kind) || WARN_KINDS.includes(job.kind)) {
  72 + if (job.ref_id === null) throw new Error(`vk_jobs.ref_id missing for ${job.kind}, id: ${job.id}`)
  73 + if (WARN_KINDS.includes(job.kind)) await tasks.warn(ctx, { task_id: job.ref_id, kind: job.kind })
  74 + else await tasks.actAsWorker(ctx, { task_id: job.ref_id, action: { type: job.kind } })
  75 + return
  76 + }
  77 + // 其余类型(自动确认、仲裁、Webhook、日报、对账)在 W3–W4 接入。
  78 + throw new Error(`Unhandled job kind: ${job.kind}, id: ${job.id}`)
  79 + }
  80 +
  81 + return { runDue }
  82 +}
packages/voko-exchange/src/rarity.js 0 → 100644
  1 +// ponytail: 赏金区间与等级上限先写成常量;PRD 说「由运营配置」,要在后台改时换成配置表(W5 等级系统一起做)。
  2 +/** 稀有度 n(1–10)的赏金下限;低于第二档的都是 1。 */
  3 +const RARITY_MIN_REWARD = Object.freeze([1, 50, 100, 200, 400, 800, 1600, 3200, 6400, 12800])
  4 +
  5 +/**
  6 + * 稀有度按赏金区间自动得出:1 最小,10 最大。
  7 + * @param {number} reward 正整数
  8 + * @returns {number}
  9 + */
  10 +export function rarityForReward(reward) {
  11 + if (!Number.isSafeInteger(reward) || reward < 1) throw new Error(`rarityForReward: reward invalid: ${reward}`)
  12 + return RARITY_MIN_REWARD.findLastIndex((min) => reward >= min) + 1
  13 +}
  14 +
  15 +/**
  16 + * 等级决定可接的稀有度上限:1 级可接到 3,每升一级 +1,封顶 10。
  17 + * @param {number} level 从 1 起
  18 + * @returns {number}
  19 + */
  20 +export function maxRarityForLevel(level) {
  21 + if (!Number.isSafeInteger(level) || level < 1) throw new Error(`maxRarityForLevel: level invalid: ${level}`)
  22 + return Math.min(10, level + 2)
  23 +}
packages/voko-exchange/src/tasks.js 0 → 100644
  1 +import { randomUUID } from 'node:crypto'
  2 +
  3 +import { maxRarityForLevel, rarityForReward } from './rarity.js'
  4 +import { taskFromRow } from './task.js'
  5 +import { publishStatus, transition } from './transition.js'
  6 +
  7 +const TASKS = 'vk_tasks'
  8 +const ITEMS = 'vk_task_acceptance_items'
  9 +const HOLDS = 'vk_task_holds'
  10 +const ENTRIES = 'vk_task_entries'
  11 +const COMMENTS = 'vk_comments'
  12 +const EVENTS = 'vk_events'
  13 +const JOBS = 'vk_jobs'
  14 +
  15 +/**
  16 + * @typedef {import('./task.js').Task} Task
  17 + * @typedef {import('./agents.js').Agent} Agent
  18 + * @typedef {{ id: string, text: string }} AcceptanceItem
  19 + * @typedef {{ ok: false, code: 'not_found'|'forbidden'|'state_conflict'|'rule_violation', hint: string }} Refusal
  20 + */
  21 +
  22 +/**
  23 + * 任务的发布、推进、查询与评论。每次推进都先锁任务行,再跑状态机纯函数,并在同一事务里执行它返回的副作用。
  24 + *
  25 + * @param {{
  26 + * db: import('knex').Knex,
  27 + * agents: ReturnType<typeof import('./agents.js').createAgents>,
  28 + * escrow: {
  29 + * hold(ctx: object, trx: import('knex').Knex, input: { task_id: string, payer_wallet: string, amount: number }): Promise<void>,
  30 + * topupExists(ctx: object, trx: import('knex').Knex, input: { task_id: string, idempotency_key: string }): Promise<boolean>,
  31 + * topup(ctx: object, trx: import('knex').Knex, input: { task_id: string, amount: number, idempotency_key: string }): Promise<void>,
  32 + * refund(ctx: object, trx: import('knex').Knex, input: { task_id: string }): Promise<void>,
  33 + * },
  34 + * }} deps
  35 + */
  36 +export function createTasks({ db, agents, escrow }) {
  37 + if (typeof db !== 'function') throw new Error('createTasks: db is required')
  38 +
  39 + /**
  40 + * 发布:任务、验收清单、托管扣款在同一事务里。同一发布方的同一 `client_request_key` 重放返回首次的任务。
  41 + * 余额不足时账本抛 INSUFFICIENT_BALANCE,事务整体回滚(无任务、无流水)。
  42 + * @param {object} ctx
  43 + * @param {{ agent: Agent, title: string, spec: string, template: 'piecework'|'contest', reward: number, deadline_at: Date, acceptance: AcceptanceItem[], client_request_key: string }} input
  44 + * @returns {Promise<{ ok: true, task: Task, acceptance: AcceptanceItem[], replayed: boolean } | Refusal>}
  45 + */
  46 + async function publish(ctx, { agent, title, spec, template, reward, deadline_at, acceptance, client_request_key }) {
  47 + const now = new Date()
  48 + if (deadline_at <= now) return refuse('rule_violation', 'deadline must be in the future.')
  49 + const guildId = /** @type {string} */ (agent.guild_id)
  50 + const id = `t_${randomUUID()}`
  51 + return db.transaction(async (trx) => {
  52 + // 先锁发布方的 Agent 行:同一发布方的发布串行,并发重放在首个事务提交之后才建立快照,能读到已建的任务;
  53 + // 不这样做,重放的 INSERT 在唯一键上互等会死锁。
  54 + await trx('vk_agents').where({ id: agent.id }).forUpdate().first('id')
  55 + const guild = await guildRow(trx, guildId)
  56 + const status = publishStatus({ require_lead_approval: guild.require_lead_approval === 1, publisher_role: agent.role })
  57 + await trx(TASKS)
  58 + .insert({ id, guild_id: guildId, publisher_agent_id: agent.id, template, title, spec, reward, rarity: rarityForReward(reward), deadline_at, status, client_request_key, missed_checkpoints: 0, rework_count: 0, created_at: now })
  59 + .onConflict(['publisher_agent_id', 'client_request_key'])
  60 + .ignore()
  61 + const stored = await trx(TASKS).where({ publisher_agent_id: agent.id, client_request_key }).first()
  62 + if (stored === undefined) throw new Error(`vk_tasks insert was ignored without a conflicting row, publisher: ${agent.id}, key: ${client_request_key}`)
  63 + const task = taskFromRow(stored)
  64 + if (task.id !== id) return { ok: true, task, acceptance: await acceptanceOf(trx, task.id), replayed: true }
  65 +
  66 + await trx(ITEMS).insert(acceptance.map((item, index) => ({ task_id: id, item_key: item.id, ordinal: index + 1, text: item.text })))
  67 + await escrow.hold(ctx, trx, { task_id: id, payer_wallet: agent.wallet_user_id, amount: reward })
  68 + switch (status) {
  69 + case 'open':
  70 + await insertEvent(trx, task, 'task.published', null, now)
  71 + await insertJob(trx, task.id, 'open_expire', deadline_at, now)
  72 + if (template === 'contest') await insertJob(trx, task.id, 'contest_close', deadline_at, now)
  73 + break
  74 + case 'pending_approval':
  75 + break
  76 + default:
  77 + throw new Error(`Unhandled status: ${status}, id: ${id}`)
  78 + }
  79 + return { ok: true, task, acceptance, replayed: false }
  80 + })
  81 + }
  82 +
  83 + /**
  84 + * Agent 对任务做一个动作。`idempotency_key` 只用于 edit 的补扣托管。
  85 + * @param {object} ctx
  86 + * @param {{ agent: Agent, task_id: string, action: import('./transition.js').Action, idempotency_key?: string }} input
  87 + * @returns {Promise<{ ok: true, task: Task } | Refusal>}
  88 + */
  89 + async function act(ctx, { agent, task_id, action, idempotency_key }) {
  90 + return db.transaction(async (trx) => {
  91 + const task = await lockTask(trx, task_id)
  92 + const hidden = task === null ? refuse('not_found', `Task not found: ${task_id}`) : hiddenFrom(agent, task)
  93 + if (task === null || hidden !== null) return /** @type {Refusal} */ (hidden)
  94 + if (action.type === 'edit' && await escrow.topupExists(ctx, trx, { task_id, idempotency_key: /** @type {string} */ (idempotency_key) })) return { ok: true, task }
  95 + // 锁 Agent 行:同一 Agent 并发认领不同任务时,持有数的读取与新增串行。
  96 + await trx('vk_agents').where({ id: agent.id }).forUpdate().first('id')
  97 + const [holds, entry] = await Promise.all([
  98 + trx(HOLDS).where({ agent_id: agent.id }).whereNull('ended_at').count({ n: '*' }).first(),
  99 + trx(ENTRIES).where({ task_id, agent_id: agent.id }).first('id'),
  100 + ])
  101 + /** @type {import('./transition.js').Actor} */
  102 + const actor = {
  103 + kind: 'agent',
  104 + agent_id: agent.id,
  105 + owner_user_id: /** @type {string} */ (agent.owner_user_id),
  106 + max_rarity: maxRarityForLevel(agent.level),
  107 + active_hold_count: Number(holds.n),
  108 + is_entrant: entry !== undefined,
  109 + }
  110 + return apply(ctx, trx, task, action, actor, idempotency_key)
  111 + })
  112 + }
  113 +
  114 + /**
  115 + * worker 到点执行的动作(过期、超时、缺检查点、擂台赛关闭)。任务状态已变时状态机返回 state_conflict,job 视为无事可做。
  116 + * @param {object} ctx
  117 + * @param {{ task_id: string, action: import('./transition.js').Action }} input
  118 + * @returns {Promise<{ ok: true, task: Task } | Refusal>}
  119 + */
  120 + async function actAsWorker(ctx, { task_id, action }) {
  121 + return db.transaction(async (trx) => {
  122 + const task = await lockTask(trx, task_id)
  123 + if (task === null) return refuse('not_found', `Task not found: ${task_id}`)
  124 + return apply(ctx, trx, task, action, { kind: 'worker' }, undefined)
  125 + })
  126 + }
  127 +
  128 + /**
  129 + * @param {object} ctx
  130 + * @param {import('knex').Knex} trx
  131 + * @param {Task} task 已加行锁
  132 + * @param {import('./transition.js').Action} action
  133 + * @param {import('./transition.js').Actor} actor
  134 + * @param {string|undefined} idempotency_key
  135 + * @returns {Promise<{ ok: true, task: Task } | Refusal>}
  136 + */
  137 + async function apply(ctx, trx, task, action, actor, idempotency_key) {
  138 + const now = new Date()
  139 + const guild = await guildRow(trx, task.guild_id)
  140 + const publisher = await agents.getById(ctx, trx, { agent_id: task.publisher_agent_id })
  141 + const result = transition({ task, guild, publisher_owner_user_id: /** @type {string} */ (publisher.owner_user_id) }, action, actor, now)
  142 + if (!result.ok) return result
  143 + const next = result.task
  144 + await trx(TASKS).where({ id: task.id }).update({
  145 + status: next.status,
  146 + reward: next.reward,
  147 + rarity: next.rarity,
  148 + deadline_at: next.deadline_at,
  149 + holder_agent_id: next.holder_agent_id,
  150 + next_checkpoint_due_at: next.next_checkpoint_due_at,
  151 + missed_checkpoints: next.missed_checkpoints,
  152 + rework_count: next.rework_count,
  153 + review_due_at: next.review_due_at,
  154 + settled_at: next.settled_at,
  155 + updated_at: now,
  156 + })
  157 + for (const effect of result.effects) {
  158 + switch (effect.type) {
  159 + case 'hold_start':
  160 + await trx(HOLDS).insert({ id: `h_${randomUUID()}`, task_id: task.id, agent_id: effect.agent_id, started_at: now, has_valid_checkpoint: false })
  161 + break
  162 + case 'hold_end':
  163 + await trx(HOLDS).where({ task_id: task.id }).whereNull('ended_at').update({ ended_at: now, end_reason: effect.reason })
  164 + break
  165 + case 'hold_checkpoint':
  166 + await trx(HOLDS).where({ task_id: task.id }).whereNull('ended_at').update({ has_valid_checkpoint: true })
  167 + break
  168 + case 'entry_add':
  169 + await trx(ENTRIES).insert({ id: `en_${randomUUID()}`, task_id: task.id, agent_id: effect.agent_id, created_at: now })
  170 + break
  171 + case 'escrow_topup':
  172 + await escrow.topup(ctx, trx, { task_id: task.id, amount: effect.amount, idempotency_key: /** @type {string} */ (idempotency_key) })
  173 + break
  174 + case 'escrow_refund':
  175 + await escrow.refund(ctx, trx, { task_id: task.id })
  176 + break
  177 + case 'event':
  178 + await insertEvent(trx, task, effect.event_type, effect.target_agent_id, now)
  179 + break
  180 + case 'job':
  181 + await insertJob(trx, task.id, effect.kind, effect.run_at, now)
  182 + break
  183 + case 'escrow_settle':
  184 + case 'dispute_open':
  185 + // 结算(W4)与仲裁(W3)的执行尚未实现;现有接口到不了这里。
  186 + throw new Error(`Effect not implemented yet: ${effect.type}, id: ${task.id}`)
  187 + default:
  188 + throw new Error(`Unhandled effect: ${/** @type {any} */ (effect).type}, id: ${task.id}`)
  189 + }
  190 + }
  191 + return { ok: true, task: { ...next, updated_at: now } }
  192 + }
  193 +
  194 + /**
  195 + * 看板:只看得到自己公会的任务;待审核的任务只有发布方自己能看到。
  196 + * @param {object} _ctx
  197 + * @param {{ agent: Agent, status?: string, template?: string, rarity?: number, cursor?: string, limit: number }} input
  198 + * @returns {Promise<{ tasks: Task[], next_cursor: string|null }>}
  199 + */
  200 + async function list(_ctx, { agent, status, template, rarity, cursor, limit }) {
  201 + const query = db(TASKS)
  202 + .where({ guild_id: agent.guild_id })
  203 + .where((q) => q.whereNot({ status: 'pending_approval' }).orWhere({ publisher_agent_id: agent.id }))
  204 + .orderBy([{ column: 'created_at', order: 'desc' }, { column: 'id', order: 'desc' }])
  205 + .limit(limit + 1)
  206 + if (status !== undefined) query.where({ status })
  207 + if (template !== undefined) query.where({ template })
  208 + if (rarity !== undefined) query.where({ rarity })
  209 + if (cursor !== undefined) {
  210 + const [at, id] = cursor.split('|')
  211 + query.whereRaw('(created_at, id) < (?, ?)', [new Date(at), id])
  212 + }
  213 + const tasks = (await query).map(taskFromRow)
  214 + const page = tasks.slice(0, limit)
  215 + const last = page[page.length - 1]
  216 + return { tasks: page, next_cursor: tasks.length > limit ? `${last.created_at.toISOString()}|${last.id}` : null }
  217 + }
  218 +
  219 + /**
  220 + * @param {object} _ctx
  221 + * @param {{ agent: Agent, task_id: string }} input
  222 + * @returns {Promise<{ ok: true, task: Task, acceptance: AcceptanceItem[], entry_count: number } | Refusal>}
  223 + */
  224 + async function get(_ctx, { agent, task_id }) {
  225 + const row = await db(TASKS).where({ id: task_id }).first()
  226 + if (row === undefined) return refuse('not_found', `Task not found: ${task_id}`)
  227 + const task = taskFromRow(row)
  228 + const hidden = hiddenFrom(agent, task)
  229 + if (hidden !== null) return hidden
  230 + const entries = await db(ENTRIES).where({ task_id }).count({ n: '*' }).first()
  231 + return { ok: true, task, acceptance: await acceptanceOf(db, task_id), entry_count: Number(entries.n) }
  232 + }
  233 +
  234 + /**
  235 + * 评论(只用于澄清需求)。通知发布方与当前持有人,不通知作者自己。
  236 + * @param {object} _ctx
  237 + * @param {{ agent: Agent, task_id: string, body: string }} input
  238 + * @returns {Promise<{ ok: true, comment: { id: string, task_id: string, author_agent_id: string, body: string, created_at: Date } } | Refusal>}
  239 + */
  240 + async function addComment(_ctx, { agent, task_id, body }) {
  241 + const now = new Date()
  242 + return db.transaction(async (trx) => {
  243 + const task = await lockTask(trx, task_id)
  244 + const hidden = task === null ? refuse('not_found', `Task not found: ${task_id}`) : hiddenFrom(agent, task)
  245 + if (task === null || hidden !== null) return /** @type {Refusal} */ (hidden)
  246 + const comment = { id: `c_${randomUUID()}`, task_id, author_agent_id: agent.id, body, created_at: now }
  247 + await trx(COMMENTS).insert(comment)
  248 + const targets = new Set([task.publisher_agent_id, ...(task.holder_agent_id === null ? [] : [task.holder_agent_id])])
  249 + targets.delete(agent.id)
  250 + for (const target of targets) await insertEvent(trx, task, 'comment.created', target, now)
  251 + return { ok: true, comment }
  252 + })
  253 + }
  254 +
  255 + /**
  256 + * @param {object} _ctx
  257 + * @param {{ agent: Agent, task_id: string }} input
  258 + * @returns {Promise<{ ok: true, comments: Array<{ id: string, author_agent_id: string, body: string, created_at: Date }> } | Refusal>}
  259 + */
  260 + async function listComments(_ctx, { agent, task_id }) {
  261 + const row = await db(TASKS).where({ id: task_id }).first()
  262 + if (row === undefined) return refuse('not_found', `Task not found: ${task_id}`)
  263 + const hidden = hiddenFrom(agent, taskFromRow(row))
  264 + if (hidden !== null) return hidden
  265 + const comments = await db(COMMENTS).where({ task_id }).orderBy([{ column: 'created_at', order: 'asc' }, { column: 'id', order: 'asc' }]).select('id', 'author_agent_id', 'body', 'created_at')
  266 + return { ok: true, comments }
  267 + }
  268 +
  269 + /**
  270 + * `/home` 用的三组任务:我手上的、等我验收的、推荐给我的(本公会、非自己发布、稀有度不超过等级上限,按截止时间取前 10)。
  271 + * @param {object} _ctx
  272 + * @param {{ agent: Agent }} input
  273 + * @returns {Promise<{ my_active_tasks: Task[], to_review: Task[], recommended_tasks: Task[] }>}
  274 + */
  275 + async function homeFor(_ctx, { agent }) {
  276 + const [held, entered, toReview, recommended] = await Promise.all([
  277 + db(TASKS).where({ holder_agent_id: agent.id }).whereIn('status', ['claimed', 'rework', 'submitted']),
  278 + db(TASKS).whereIn('id', db(ENTRIES).where({ agent_id: agent.id }).select('task_id')).where({ status: 'in_contest' }),
  279 + db(TASKS).where({ publisher_agent_id: agent.id }).whereIn('status', ['submitted', 'reviewing']).orderBy('review_due_at', 'asc'),
  280 + db(TASKS)
  281 + .where({ guild_id: agent.guild_id, status: 'open' })
  282 + .whereNot({ publisher_agent_id: agent.id })
  283 + .where('rarity', '<=', maxRarityForLevel(agent.level))
  284 + .orderBy('deadline_at', 'asc')
  285 + .limit(10),
  286 + ])
  287 + return { my_active_tasks: [...held, ...entered].map(taskFromRow), to_review: toReview.map(taskFromRow), recommended_tasks: recommended.map(taskFromRow) }
  288 + }
  289 +
  290 + /**
  291 + * 到期前提醒:任务仍在等待对应动作时给当事人发事件;状态已变则无事可做。
  292 + * @param {object} _ctx
  293 + * @param {{ task_id: string, kind: 'checkpoint_warn'|'review_warn' }} input
  294 + */
  295 + async function warn(_ctx, { task_id, kind }) {
  296 + const row = await db(TASKS).where({ id: task_id }).first()
  297 + if (row === undefined) throw new Error(`vk_tasks row missing for ${kind}, id: ${task_id}`)
  298 + const task = taskFromRow(row)
  299 + const now = new Date()
  300 + switch (kind) {
  301 + case 'checkpoint_warn':
  302 + if (task.status === 'claimed') await insertEvent(db, task, 'task.checkpoint_due', task.holder_agent_id, now)
  303 + break
  304 + case 'review_warn':
  305 + if (task.status === 'submitted' || task.status === 'reviewing') await insertEvent(db, task, 'task.review_due', task.publisher_agent_id, now)
  306 + break
  307 + default:
  308 + throw new Error(`Unhandled warn kind: ${kind}, id: ${task_id}`)
  309 + }
  310 + }
  311 +
  312 + return { publish, act, actAsWorker, warn, list, get, addComment, listComments, homeFor }
  313 +}
  314 +
  315 +/**
  316 + * 成员可见性:别的公会的任务 → forbidden;待审核任务只对发布方可见,其他成员按不存在处理。
  317 + * @param {Agent} agent
  318 + * @param {Task} task
  319 + * @returns {Refusal|null}
  320 + */
  321 +function hiddenFrom(agent, task) {
  322 + if (agent.guild_id !== task.guild_id) return refuse('forbidden', 'You are not a member of this guild.')
  323 + if (task.status === 'pending_approval' && task.publisher_agent_id !== agent.id) return refuse('not_found', `Task not found: ${task.id}`)
  324 + return null
  325 +}
  326 +
  327 +/**
  328 + * @param {import('knex').Knex} trx
  329 + * @param {string} task_id
  330 + * @returns {Promise<Task|null>}
  331 + */
  332 +async function lockTask(trx, task_id) {
  333 + const row = await trx(TASKS).where({ id: task_id }).forUpdate().first()
  334 + return row === undefined ? null : taskFromRow(row)
  335 +}
  336 +
  337 +/**
  338 + * @param {import('knex').Knex} runner
  339 + * @param {string} guild_id
  340 + */
  341 +async function guildRow(runner, guild_id) {
  342 + const guild = await runner('vk_guilds').where({ id: guild_id }).first('require_lead_approval', 'checkpoint_interval_sec', 'piece_review_window_sec', 'contest_review_window_sec')
  343 + if (guild === undefined) throw new Error(`vk_guilds row missing, id: ${guild_id}`)
  344 + return guild
  345 +}
  346 +
  347 +/**
  348 + * @param {import('knex').Knex} runner
  349 + * @param {string} task_id
  350 + * @returns {Promise<AcceptanceItem[]>}
  351 + */
  352 +async function acceptanceOf(runner, task_id) {
  353 + const items = await runner(ITEMS).where({ task_id }).orderBy('ordinal', 'asc').select('item_key', 'text')
  354 + return items.map((item) => ({ id: item.item_key, text: item.text }))
  355 +}
  356 +
  357 +/**
  358 + * @param {import('knex').Knex} trx
  359 + * @param {Task} task
  360 + * @param {string} type
  361 + * @param {string|null} target_agent_id null 表示发给整个公会
  362 + * @param {Date} now
  363 + */
  364 +async function insertEvent(trx, task, type, target_agent_id, now) {
  365 + await trx(EVENTS).insert({ guild_id: task.guild_id, target_agent_id, type, task_id: task.id, payload: '{}', created_at: now })
  366 +}
  367 +
  368 +/**
  369 + * 同一任务同一类型同一时刻的 job 只排一条(dedupe_key UNIQUE)。
  370 + * @param {import('knex').Knex} trx
  371 + * @param {string} task_id
  372 + * @param {string} kind
  373 + * @param {Date} run_at
  374 + * @param {Date} now
  375 + */
  376 +async function insertJob(trx, task_id, kind, run_at, now) {
  377 + await trx(JOBS)
  378 + .insert({ id: `j_${randomUUID()}`, kind, ref_id: task_id, run_at, status: 'pending', attempts: 0, dedupe_key: `${kind}:${task_id}:${run_at.toISOString()}`, created_at: now })
  379 + .onConflict('dedupe_key')
  380 + .ignore()
  381 +}
  382 +
  383 +/**
  384 + * @template {Refusal['code']} C
  385 + * @param {C} code
  386 + * @param {string} hint
  387 + * @returns {{ ok: false, code: C, hint: string }}
  388 + */
  389 +function refuse(code, hint) {
  390 + return { ok: false, code, hint }
  391 +}
packages/voko-exchange/src/transition.js
@@ -8,6 +8,7 @@ @@ -8,6 +8,7 @@
8 * 重复报名(vk_task_entries UNIQUE)、转让请求是否 pending 且指向 actor、验收 checks 与清单的覆盖关系。 8 * 重复报名(vk_task_entries UNIQUE)、转让请求是否 pending 且指向 actor、验收 checks 与清单的覆盖关系。
9 */ 9 */
10 10
  11 +import { rarityForReward } from './rarity.js'
11 import { TASK_STATUSES } from './task.js' 12 import { TASK_STATUSES } from './task.js'
12 13
13 /** 除发布(新建,见 `publishStatus`)外的全部动作。 */ 14 /** 除发布(新建,见 `publishStatus`)外的全部动作。 */
@@ -160,7 +161,8 @@ const TRANSITIONS = { @@ -160,7 +161,8 @@ const TRANSITIONS = {
160 if (!Number.isSafeInteger(action.add_reward) || action.add_reward < 0) throw new Error(`edit.add_reward invalid: ${action.add_reward}, id: ${task.id}`) 161 if (!Number.isSafeInteger(action.add_reward) || action.add_reward < 0) throw new Error(`edit.add_reward invalid: ${action.add_reward}, id: ${task.id}`)
161 if (action.add_reward === 0 && action.deadline_at === null) return fail('rule_violation', 'Nothing to change: add reward or extend the deadline.') 162 if (action.add_reward === 0 && action.deadline_at === null) return fail('rule_violation', 'Nothing to change: add reward or extend the deadline.')
162 if (action.deadline_at !== null && action.deadline_at <= task.deadline_at) return fail('rule_violation', 'The deadline can only be extended.') 163 if (action.deadline_at !== null && action.deadline_at <= task.deadline_at) return fail('rule_violation', 'The deadline can only be extended.')
163 - const next = { ...task, reward: task.reward + action.add_reward, deadline_at: action.deadline_at === null ? task.deadline_at : action.deadline_at } 164 + const reward = task.reward + action.add_reward
  165 + const next = { ...task, reward, rarity: rarityForReward(reward), deadline_at: action.deadline_at === null ? task.deadline_at : action.deadline_at }
164 return ok(next, [ 166 return ok(next, [
165 ...(action.add_reward === 0 ? [] : [/** @type {Effect} */ ({ type: 'escrow_topup', amount: action.add_reward })]), 167 ...(action.add_reward === 0 ? [] : [/** @type {Effect} */ ({ type: 'escrow_topup', amount: action.add_reward })]),
166 ...(action.deadline_at === null ? [] : openJobs(next)), 168 ...(action.deadline_at === null ? [] : openJobs(next)),
@@ -217,6 +219,7 @@ const TRANSITIONS = { @@ -217,6 +219,7 @@ const TRANSITIONS = {
217 enter(state, _action, actor) { 219 enter(state, _action, actor) {
218 const refused = takerRefusal(state, actor) 220 const refused = takerRefusal(state, actor)
219 if (refused !== null) return refused 221 if (refused !== null) return refused
  222 + if (/** @type {AgentActor} */ (actor).is_entrant) return fail('state_conflict', 'You have already entered this contest.')
220 return ok(state.task, [{ type: 'entry_add', agent_id: /** @type {AgentActor} */ (actor).agent_id }]) 223 return ok(state.task, [{ type: 'entry_add', agent_id: /** @type {AgentActor} */ (actor).agent_id }])
221 }, 224 },
222 submit({ task }, _action, actor) { 225 submit({ task }, _action, actor) {
packages/voko-exchange/test/rarity.test.js 0 → 100644
  1 +import { describe, expect, it } from 'vitest'
  2 +import { maxRarityForLevel, rarityForReward } from '../src/rarity.js'
  3 +
  4 +describe('稀有度区间与等级上限的边界值', () => {
  5 + it.each([[1, 1], [49, 1], [50, 2], [99, 2], [100, 3], [12799, 9], [12800, 10], [1_000_000, 10]])('reward %i → rarity %i', (reward, rarity) => {
  6 + expect(rarityForReward(reward)).toBe(rarity)
  7 + })
  8 +
  9 + it.each([[1, 3], [2, 4], [8, 10], [50, 10]])('level %i → 上限 %i', (level, max) => {
  10 + expect(maxRarityForLevel(level)).toBe(max)
  11 + })
  12 +
  13 + it('非法输入直接 throw', () => {
  14 + expect(() => rarityForReward(0)).toThrow(/reward invalid/)
  15 + expect(() => maxRarityForLevel(0)).toThrow(/level invalid/)
  16 + })
  17 +})
packages/voko-exchange/test/transition.test.js
@@ -207,12 +207,16 @@ describe(&#39;SM-01 副作用与平台规则&#39;, () =&gt; { @@ -207,12 +207,16 @@ describe(&#39;SM-01 副作用与平台规则&#39;, () =&gt; {
207 207
208 it('修改只能加赏金或延长截止', () => { 208 it('修改只能加赏金或延长截止', () => {
209 const edited = run(stateFor('open'), 'edit') 209 const edited = run(stateFor('open'), 'edit')
210 - expect(edited.task).toMatchObject({ reward: 150, deadline_at: FUTURE }) 210 + expect(edited.task).toMatchObject({ reward: 150, rarity: 3, deadline_at: FUTURE })
211 expect(effectTypes(edited)).toEqual(['escrow_topup', 'job:open_expire']) 211 expect(effectTypes(edited)).toEqual(['escrow_topup', 'job:open_expire'])
212 expect(run(stateFor('open'), 'edit', publisher, { add_reward: 0, deadline_at: null })).toMatchObject({ ok: false, code: 'rule_violation' }) 212 expect(run(stateFor('open'), 'edit', publisher, { add_reward: 0, deadline_at: null })).toMatchObject({ ok: false, code: 'rule_violation' })
213 expect(run(stateFor('open', 'piecework', { deadline_at: FUTURE }), 'edit', publisher, { add_reward: 0, deadline_at: PAST })).toMatchObject({ ok: false, code: 'rule_violation' }) 213 expect(run(stateFor('open', 'piecework', { deadline_at: FUTURE }), 'edit', publisher, { add_reward: 0, deadline_at: PAST })).toMatchObject({ ok: false, code: 'rule_violation' })
214 }) 214 })
215 215
  216 + it('重复报名 → state_conflict', () => {
  217 + expect(run(stateFor('in_contest'), 'enter', { ...entrant, agent_id: 'ag_entrant' })).toMatchObject({ ok: false, code: 'state_conflict' })
  218 + })
  219 +
216 it('争议只能由发布方或接单方发起', () => { 220 it('争议只能由发布方或接单方发起', () => {
217 expect(run(stateFor('submitted'), 'dispute', holder).task.status).toBe('disputed') 221 expect(run(stateFor('submitted'), 'dispute', holder).task.status).toBe('disputed')
218 expect(run(stateFor('reviewing'), 'dispute', entrant).task.status).toBe('disputed') 222 expect(run(stateFor('reviewing'), 'dispute', entrant).task.status).toBe('disputed')
pnpm-lock.yaml
@@ -63,6 +63,9 @@ importers: @@ -63,6 +63,9 @@ importers:
63 '@drama/observability': 63 '@drama/observability':
64 specifier: workspace:* 64 specifier: workspace:*
65 version: link:../../packages/observability 65 version: link:../../packages/observability
  66 + '@drama/ratelimit':
  67 + specifier: workspace:*
  68 + version: link:../../packages/ratelimit
66 '@drama/server-kit': 69 '@drama/server-kit':
67 specifier: workspace:* 70 specifier: workspace:*
68 version: link:../../packages/server-kit 71 version: link:../../packages/server-kit
@@ -292,7 +295,11 @@ importers: @@ -292,7 +295,11 @@ importers:
292 specifier: ^2.1.4 295 specifier: ^2.1.4
293 version: 2.1.9(@types/node@24.13.2)(jsdom@25.0.1)(sass@1.101.0) 296 version: 2.1.9(@types/node@24.13.2)(jsdom@25.0.1)(sass@1.101.0)
294 297
295 - packages/voko-clearing: {} 298 + packages/voko-clearing:
  299 + dependencies:
  300 + '@drama/billing':
  301 + specifier: workspace:*
  302 + version: link:../billing
296 303
297 packages/voko-exchange: 304 packages/voko-exchange:
298 devDependencies: 305 devDependencies:
tools/scripts/verify-local.sh
1 #!/usr/bin/env bash 1 #!/usr/bin/env bash
2 -# 本地一键验收:起 Docker MySQL → 空库则建表 → 建本地管理员 → 启动 API → 跑骨架与 F0 真实链路脚本 → 停 API。 2 +# 本地一键验收:起 Docker MySQL → 空库则建表 → 建本地管理员 → 启动 API 与 worker → 跑全部真实链路脚本 → 停进程。
3 # 用法:VOKO_TEST_EMAIL=you@gmail.com bash tools/scripts/verify-local.sh 3 # 用法:VOKO_TEST_EMAIL=you@gmail.com bash tools/scripts/verify-local.sh
4 set -euo pipefail 4 set -euo pipefail
5 cd "$(dirname "$0")/../.." 5 cd "$(dirname "$0")/../.."
@@ -18,7 +18,9 @@ pnpm dev:user admin@example.test admin123 @@ -18,7 +18,9 @@ pnpm dev:user admin@example.test admin123
18 18
19 node apps/voko-server/src/main.js > tmp-voko-server.log 2>&1 & 19 node apps/voko-server/src/main.js > tmp-voko-server.log 2>&1 &
20 SERVER_PID=$! 20 SERVER_PID=$!
21 -trap 'kill $SERVER_PID 2>/dev/null || true' EXIT 21 +node apps/voko-server/src/voko-worker-main.js > tmp-voko-worker.log 2>&1 &
  22 +WORKER_PID=$!
  23 +trap 'kill $SERVER_PID $WORKER_PID 2>/dev/null || true' EXIT
22 for _ in $(seq 1 30); do 24 for _ in $(seq 1 30); do
23 curl -sf http://localhost:9201/healthz >/dev/null && break 25 curl -sf http://localhost:9201/healthz >/dev/null && break
24 sleep 1 26 sleep 1
@@ -27,5 +29,7 @@ curl -sf http://localhost:9201/healthz &gt;/dev/null || { echo &quot;API 未启动,日 @@ -27,5 +29,7 @@ curl -sf http://localhost:9201/healthz &gt;/dev/null || { echo &quot;API 未启动,日
27 29
28 VOKO_ADMIN_EMAIL=admin@example.test VOKO_ADMIN_PASSWORD=admin123 \ 30 VOKO_ADMIN_EMAIL=admin@example.test VOKO_ADMIN_PASSWORD=admin123 \
29 pnpm --filter @voko/server verify:skeleton 31 pnpm --filter @voko/server verify:skeleton
30 -VOKO_ADMIN_EMAIL=admin@example.test VOKO_ADMIN_PASSWORD=admin123 \  
31 - pnpm --filter @voko/server verify:f0 32 +export VOKO_ADMIN_EMAIL=admin@example.test VOKO_ADMIN_PASSWORD=admin123
  33 +for scenario in f0 f1 f2 concurrency timers; do
  34 + pnpm --filter @voko/server "verify:$scenario"
  35 +done