Commit 14da0adad7cbeb6119ed7fe19f66cc9c205564ca
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>
Showing
25 changed files
with
1386 additions
and
16 deletions
apps/voko-server/package.json
| ... | ... | @@ -13,7 +13,12 @@ |
| 13 | 13 | "db:status": "node src/db/migration-status.js", |
| 14 | 14 | "admin:grant": "node tools/grant-admin.mjs", |
| 15 | 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 | 23 | "dependencies": { |
| 19 | 24 | "@drama/auth": "workspace:*", |
| ... | ... | @@ -22,6 +27,7 @@ |
| 22 | 27 | "@drama/db": "workspace:*", |
| 23 | 28 | "@drama/email": "workspace:*", |
| 24 | 29 | "@drama/observability": "workspace:*", |
| 30 | + "@drama/ratelimit": "workspace:*", | |
| 25 | 31 | "@drama/server-kit": "workspace:*", |
| 26 | 32 | "@voko/access": "workspace:*", |
| 27 | 33 | "@voko/clearing": "workspace:*", | ... | ... |
apps/voko-server/src/assembly/runtime.js
| ... | ... | @@ -9,7 +9,9 @@ import { createBillingRepo, createLedger } from '@drama/billing' |
| 9 | 9 | import { createDb } from '@drama/db' |
| 10 | 10 | import { createLogger } from '@drama/observability' |
| 11 | 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 | 16 | import { readEnv } from '../env.js' |
| 15 | 17 | import { createEmailPorts } from './email.js' |
| ... | ... | @@ -44,6 +46,19 @@ export async function createRuntime({ service }) { |
| 44 | 46 | const access = createAccessRequests({ db, notifier: accessNotifier }) |
| 45 | 47 | const agents = createAgents() |
| 46 | 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 | 62 | const agentAccess = createAgentAccess({ |
| 48 | 63 | db, |
| 49 | 64 | agents, |
| ... | ... | @@ -78,7 +93,7 @@ export async function createRuntime({ service }) { |
| 78 | 93 | return { |
| 79 | 94 | env, |
| 80 | 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 | 97 | async close() { |
| 83 | 98 | await db.destroy() |
| 84 | 99 | }, | ... | ... |
apps/voko-server/src/routes/v1.js
| 1 | 1 | import * as v from 'valibot' |
| 2 | +import { TASK_STATUSES } from '@voko/exchange' | |
| 2 | 3 | |
| 3 | 4 | const Skills = v.pipe(v.array(v.pipe(v.string(), v.minLength(1), v.maxLength(40))), v.maxLength(20)) |
| 4 | 5 | const Description = v.pipe(v.string(), v.minLength(1), v.maxLength(1000)) |
| ... | ... | @@ -8,6 +9,38 @@ const RegisterBody = v.object({ |
| 8 | 9 | skills: Skills, |
| 9 | 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 | 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 | 49 | * @param {Record<string, any>} services |
| 17 | 50 | */ |
| 18 | 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 | 54 | app.register(async (v1) => { |
| 22 | 55 | v1.setErrorHandler((/** @type {any} */ err, request, reply) => { |
| ... | ... | @@ -24,6 +57,7 @@ export function registerV1Routes(app, services) { |
| 24 | 57 | const hint = err.issues.map((/** @type {any} */ issue) => `${(issue.path || []).map((/** @type {any} */ p) => p.key).join('.')}: ${issue.message}`).join('; ') |
| 25 | 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 | 61 | // Fastify 自己的请求错误(如 JSON 解析失败)带 4xx statusCode;其余都是程序 bug。 |
| 28 | 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 | 63 | const traced = /** @type {any} */ (request) |
| ... | ... | @@ -49,6 +83,16 @@ export function registerV1Routes(app, services) { |
| 49 | 83 | throw new Error(`Unhandled status: ${agent.status}, id: ${agent.id}`) |
| 50 | 84 | } |
| 51 | 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 | 98 | v1.post('/agents/register', async (request) => { |
| ... | ... | @@ -65,6 +109,83 @@ export function registerV1Routes(app, services) { |
| 65 | 109 | await agents.updateProfile(request.ctx, db, { agent_id: agentId, description: body.description, skills: body.skills }) |
| 66 | 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 | 189 | }, { prefix: '/v1' }) |
| 69 | 190 | } |
| 70 | 191 | |
| ... | ... | @@ -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 | 276 | * @param {unknown} data |
| 93 | 277 | */ |
| 94 | 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 | 5 | import { createRuntime } from './assembly/runtime.js' |
| ... | ... | @@ -12,8 +12,7 @@ let ticks = 0 |
| 12 | 12 | let stopping = false |
| 13 | 13 | |
| 14 | 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 | 16 | ticks += 1 |
| 18 | 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 | 8 | - 初始化、升级或检查数据库结构:[数据库结构与迁移](../database.md)。 |
| 9 | 9 | - 验收仓库骨架(注册 → 申请 → 审核 → 记账幂等):[骨架真实链路验收](verify-skeleton.md);本地一键:`VOKO_TEST_EMAIL=… bash tools/scripts/verify-local.sh`。 |
| 10 | 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
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 | 2 | * `@voko/clearing` —— VOKO 的积分清算:托管与发放明细;动账一律经 `@drama/billing` 的账本。 |
| 3 | 3 | * |
| 4 | - * 当前只有表结构;托管、结算、退款 service 在 W2–W4 加入。 | |
| 4 | + * 当前有托管的冻结、补扣与退回;结算(含接力分配)、打赏与对账在 W4 加入。 | |
| 5 | 5 | * |
| 6 | 6 | * 边界:不读 `process.env`、不建连接;db 由 composition root 注入。 |
| 7 | 7 | */ |
| 8 | 8 | |
| 9 | +export { createEscrow } from './escrow.js' | |
| 9 | 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 | 2 | * `@voko/exchange` —— VOKO 的任务交换:公会、Agent、任务、检查点、提交、验收、评论、求助、事件与定时任务。 |
| 3 | 3 | * |
| 4 | - * 当前有表结构、Agent 与公会的读写、任务构造点与状态机纯函数;任务 service 在 W2–W3 加入。 | |
| 4 | + * 当前有表结构、Agent 与公会的读写、任务状态机与发布 / 认领 / 报名 / 修改 / 撤回 / 评论、事件游标、定时任务执行;检查点、提交与验收在 W3 加入。 | |
| 5 | 5 | * |
| 6 | 6 | * 边界:不读 `process.env`、不建连接;db 由 composition root 注入。 |
| 7 | 7 | */ |
| 8 | 8 | |
| 9 | 9 | export { createAgents, agentFromRow, AGENT_STATUSES, AGENT_ROLES } from './agents.js' |
| 10 | 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 | 15 | export { taskFromRow, TASK_STATUSES, TASK_TEMPLATES } from './task.js' |
| 12 | 16 | export { transition, publishStatus, TASK_ACTIONS, MAX_ACTIVE_HOLDS } from './transition.js' |
| 13 | 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 | 8 | * 重复报名(vk_task_entries UNIQUE)、转让请求是否 pending 且指向 actor、验收 checks 与清单的覆盖关系。 |
| 9 | 9 | */ |
| 10 | 10 | |
| 11 | +import { rarityForReward } from './rarity.js' | |
| 11 | 12 | import { TASK_STATUSES } from './task.js' |
| 12 | 13 | |
| 13 | 14 | /** 除发布(新建,见 `publishStatus`)外的全部动作。 */ |
| ... | ... | @@ -160,7 +161,8 @@ const TRANSITIONS = { |
| 160 | 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 | 162 | if (action.add_reward === 0 && action.deadline_at === null) return fail('rule_violation', 'Nothing to change: add reward or extend the deadline.') |
| 162 | 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 | 166 | return ok(next, [ |
| 165 | 167 | ...(action.add_reward === 0 ? [] : [/** @type {Effect} */ ({ type: 'escrow_topup', amount: action.add_reward })]), |
| 166 | 168 | ...(action.deadline_at === null ? [] : openJobs(next)), |
| ... | ... | @@ -217,6 +219,7 @@ const TRANSITIONS = { |
| 217 | 219 | enter(state, _action, actor) { |
| 218 | 220 | const refused = takerRefusal(state, actor) |
| 219 | 221 | if (refused !== null) return refused |
| 222 | + if (/** @type {AgentActor} */ (actor).is_entrant) return fail('state_conflict', 'You have already entered this contest.') | |
| 220 | 223 | return ok(state.task, [{ type: 'entry_add', agent_id: /** @type {AgentActor} */ (actor).agent_id }]) |
| 221 | 224 | }, |
| 222 | 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('SM-01 副作用与平台规则', () => { |
| 207 | 207 | |
| 208 | 208 | it('修改只能加赏金或延长截止', () => { |
| 209 | 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 | 211 | expect(effectTypes(edited)).toEqual(['escrow_topup', 'job:open_expire']) |
| 212 | 212 | expect(run(stateFor('open'), 'edit', publisher, { add_reward: 0, deadline_at: null })).toMatchObject({ ok: false, code: 'rule_violation' }) |
| 213 | 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 | 220 | it('争议只能由发布方或接单方发起', () => { |
| 217 | 221 | expect(run(stateFor('submitted'), 'dispute', holder).task.status).toBe('disputed') |
| 218 | 222 | expect(run(stateFor('reviewing'), 'dispute', entrant).task.status).toBe('disputed') | ... | ... |
pnpm-lock.yaml
| ... | ... | @@ -63,6 +63,9 @@ importers: |
| 63 | 63 | '@drama/observability': |
| 64 | 64 | specifier: workspace:* |
| 65 | 65 | version: link:../../packages/observability |
| 66 | + '@drama/ratelimit': | |
| 67 | + specifier: workspace:* | |
| 68 | + version: link:../../packages/ratelimit | |
| 66 | 69 | '@drama/server-kit': |
| 67 | 70 | specifier: workspace:* |
| 68 | 71 | version: link:../../packages/server-kit |
| ... | ... | @@ -292,7 +295,11 @@ importers: |
| 292 | 295 | specifier: ^2.1.4 |
| 293 | 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 | 304 | packages/voko-exchange: |
| 298 | 305 | devDependencies: | ... | ... |
tools/scripts/verify-local.sh
| 1 | 1 | #!/usr/bin/env bash |
| 2 | -# 本地一键验收:起 Docker MySQL → 空库则建表 → 建本地管理员 → 启动 API → 跑骨架与 F0 真实链路脚本 → 停 API。 | |
| 2 | +# 本地一键验收:起 Docker MySQL → 空库则建表 → 建本地管理员 → 启动 API 与 worker → 跑全部真实链路脚本 → 停进程。 | |
| 3 | 3 | # 用法:VOKO_TEST_EMAIL=you@gmail.com bash tools/scripts/verify-local.sh |
| 4 | 4 | set -euo pipefail |
| 5 | 5 | cd "$(dirname "$0")/../.." |
| ... | ... | @@ -18,7 +18,9 @@ pnpm dev:user admin@example.test admin123 |
| 18 | 18 | |
| 19 | 19 | node apps/voko-server/src/main.js > tmp-voko-server.log 2>&1 & |
| 20 | 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 | 24 | for _ in $(seq 1 30); do |
| 23 | 25 | curl -sf http://localhost:9201/healthz >/dev/null && break |
| 24 | 26 | sleep 1 |
| ... | ... | @@ -27,5 +29,7 @@ curl -sf http://localhost:9201/healthz >/dev/null || { echo "API 未启动,日 |
| 27 | 29 | |
| 28 | 30 | VOKO_ADMIN_EMAIL=admin@example.test VOKO_ADMIN_PASSWORD=admin123 \ |
| 29 | 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 | ... | ... |