Commit 69968036abd66ca07a08f65c0b99836b10a54682
1 parent
310d07ad
feat: W4 第二部分——Webhook、每日对账 job、任务详情 / 流水 / 求助收件箱 / 设置页
- @voko/access:Webhook 登记(仅 https + 公网地址,secret 加密存储)、签名投递与 1m/5m/30m/2h 重试 - 事件写入时给登记了 Webhook 的 Agent 排投递;worker 每轮投递到期项 - 对账函数移入 @voko/clearing,worker 每天 UTC 01:00 跑一次 - 网页:任务详情、积分流水、求助收件箱、设置 - 场景 webhook(WH-03);运行手册更新 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Showing
28 changed files
with
752 additions
and
101 deletions
apps/voko-server/.env.example
apps/voko-server/package.json
| ... | ... | @@ -18,6 +18,7 @@ |
| 18 | 18 | "verify:f2": "node tools/scenarios/voko-alpha/f2-execute.mjs", |
| 19 | 19 | "verify:f3": "node tools/scenarios/voko-alpha/f3-review-settle.mjs", |
| 20 | 20 | "verify:e2e": "node tools/scenarios/voko-alpha/e2e-loop.mjs", |
| 21 | + "verify:webhook": "node tools/scenarios/voko-alpha/webhook.mjs", | |
| 21 | 22 | "verify:timers": "node tools/scenarios/voko-alpha/timers.mjs", |
| 22 | 23 | "verify:concurrency": "node tools/scenarios/voko-alpha/concurrency.mjs", |
| 23 | 24 | "verify:conservation": "node tools/scenarios/voko-alpha/verify-conservation.mjs" | ... | ... |
apps/voko-server/src/assembly/runtime.js
| ... | ... | @@ -9,9 +9,9 @@ import { createBillingRepo, createLedger } from '@drama/billing' |
| 9 | 9 | import { createDb } from '@drama/db' |
| 10 | 10 | import { createLLM, createStaticKeyProvider } from '@drama/llm' |
| 11 | 11 | import { createLogger } from '@drama/observability' |
| 12 | -import { createAccessRequests, createAgentAccess, createOwnerSettings, nextAllowedSendTime } from '@voko/access' | |
| 12 | +import { createAccessRequests, createAgentAccess, createOwnerSettings, createWebhooks, nextAllowedSendTime } from '@voko/access' | |
| 13 | 13 | import { createMemoryStore, createRateLimiter } from '@drama/ratelimit' |
| 14 | -import { createEscrow, createTips } from '@voko/clearing' | |
| 14 | +import { createEscrow, createTips, verifyConservation } from '@voko/clearing' | |
| 15 | 15 | import { createAgents, createEvents, createGuilds, createHelp, createJobs, createJudge, createTasks } from '@voko/exchange' |
| 16 | 16 | |
| 17 | 17 | import { readEnv } from '../env.js' |
| ... | ... | @@ -68,7 +68,8 @@ export async function createRuntime({ service }) { |
| 68 | 68 | }, |
| 69 | 69 | }) |
| 70 | 70 | const tips = createTips({ db }) |
| 71 | - const jobs = createJobs({ db, tasks, help, logger }) | |
| 71 | + const webhooks = createWebhooks({ db, secret_key: env.webhookSecretKey }) | |
| 72 | + const jobs = createJobs({ db, tasks, help, conservationProblems: () => verifyConservation(db), logger }) | |
| 72 | 73 | |
| 73 | 74 | /** |
| 74 | 75 | * 给已结算任务的赢家打赏;收款事件与动账同事务。 |
| ... | ... | @@ -134,7 +135,7 @@ export async function createRuntime({ service }) { |
| 134 | 135 | return { |
| 135 | 136 | env, |
| 136 | 137 | logger, |
| 137 | - services: { db, auth, userRepo, access, agents, guilds, agentAccess, escrow, tasks, events, jobs, help, ownerSettings, tipTask, rateLimiter, register, oauthEnabled: env.oauth.googleClientId !== null }, | |
| 138 | + services: { db, auth, userRepo, access, agents, guilds, agentAccess, escrow, tasks, events, jobs, help, ownerSettings, webhooks, tipTask, rateLimiter, register, oauthEnabled: env.oauth.googleClientId !== null }, | |
| 138 | 139 | async close() { |
| 139 | 140 | await db.destroy() |
| 140 | 141 | }, | ... | ... |
apps/voko-server/src/env.js
| ... | ... | @@ -11,6 +11,7 @@ import { devMysqlUrl } from '@drama/db' |
| 11 | 11 | * databaseUrl: string, |
| 12 | 12 | * logLevel: 'debug'|'info'|'warn'|'error', |
| 13 | 13 | * authTokenSecret: string, |
| 14 | + * webhookSecretKey: string, | |
| 14 | 15 | * publicUrl: string, |
| 15 | 16 | * email: { host: string, port: number, secure: boolean, user: string, pass: string, from: string }, |
| 16 | 17 | * oauth: { googleClientId: string|null, googleClientSecret: string|null }, |
| ... | ... | @@ -28,6 +29,7 @@ export function readEnv(rawEnv) { |
| 28 | 29 | const scheme = new URL(databaseUrl).protocol |
| 29 | 30 | if (scheme !== 'mysql:' && scheme !== 'mysql2:') throw new Error(`voko-server: DATABASE_URL 必须是 MySQL,收到 ${scheme}`) |
| 30 | 31 | if (nodeEnv === 'production' && !rawEnv.AUTH_TOKEN_SECRET) throw new Error('voko-server: 生产环境必须设置 AUTH_TOKEN_SECRET') |
| 32 | + if (nodeEnv === 'production' && !rawEnv.WEBHOOK_SECRET_KEY) throw new Error('voko-server: 生产环境必须设置 WEBHOOK_SECRET_KEY') | |
| 31 | 33 | |
| 32 | 34 | const email = { |
| 33 | 35 | host: rawEnv.SMTP_HOST, |
| ... | ... | @@ -52,6 +54,7 @@ export function readEnv(rawEnv) { |
| 52 | 54 | databaseUrl, |
| 53 | 55 | logLevel: parseLogLevel(rawEnv.LOG_LEVEL), |
| 54 | 56 | authTokenSecret: rawEnv.AUTH_TOKEN_SECRET || 'voko-dev-token-secret', |
| 57 | + webhookSecretKey: rawEnv.WEBHOOK_SECRET_KEY || 'voko-dev-webhook-secret-key', | |
| 55 | 58 | publicUrl: (rawEnv.PUBLIC_URL || 'http://localhost:5201').replace(/\/$/, ''), |
| 56 | 59 | email: /** @type {VokoEnv['email']} */ (email), |
| 57 | 60 | llm: /** @type {VokoEnv['llm']} */ (llm), | ... | ... |
apps/voko-server/src/routes/owner.js
| ... | ... | @@ -5,7 +5,7 @@ import { agentView, IdempotencyKey, ledgerView, PublishBody, REFUSAL_STATUS, tas |
| 5 | 5 | |
| 6 | 6 | const AgentParams = v.object({ id: v.pipe(v.string(), v.minLength(1), v.maxLength(64)) }) |
| 7 | 7 | const LimitBody = v.object({ daily_spend_limit: v.nullable(v.pipe(v.number(), v.safeInteger(), v.minValue(0))) }) |
| 8 | -const TaskIdParams = v.object({ task_id: v.pipe(v.string(), v.minLength(1), v.maxLength(64)) }) | |
| 8 | +const TaskIdParams = v.object({ id: v.optional(v.string()), task_id: v.pipe(v.string(), v.minLength(1), v.maxLength(64)) }) | |
| 9 | 9 | const ApprovalBody = v.object({ decision: v.picklist(['approve', 'reject']) }) |
| 10 | 10 | const ResolveBody = v.object({ outcome: v.picklist(['settled', 'refunded']), winner_agent_id: v.optional(v.pipe(v.string(), v.minLength(1), v.maxLength(64))) }) |
| 11 | 11 | const HelpParams = v.object({ id: v.pipe(v.string(), v.minLength(1), v.maxLength(64)) }) |
| ... | ... | @@ -30,6 +30,58 @@ export function registerOwnerRoutes(app, services) { |
| 30 | 30 | const { agents, agentAccess, guilds, escrow, tasks, help, ownerSettings, db } = services |
| 31 | 31 | |
| 32 | 32 | /** |
| 33 | + * 业务拒绝 → 网页接口的错误形状。 | |
| 34 | + * @param {import('fastify').FastifyReply} reply | |
| 35 | + * @param {{ code: keyof typeof REFUSAL_STATUS, hint: string }} refusal | |
| 36 | + */ | |
| 37 | + const refused = (reply, refusal) => reply.code(REFUSAL_STATUS[refusal.code]).send({ error: { code: refusal.code, message: refusal.hint, retryable: false } }) | |
| 38 | + | |
| 39 | + // 任务详情:以自己某个 Agent 的身份查看(可见范围与该 Agent 调公开 API 相同)。 | |
| 40 | + app.get('/api/voko/agents/:id/tasks/:task_id', async (request, reply) => { | |
| 41 | + const { agent } = await ownAgent(request) | |
| 42 | + const params = v.parse(TaskIdParams, request.params) | |
| 43 | + const result = await tasks.get(request.ctx, { agent, task_id: params.task_id }) | |
| 44 | + if (!result.ok) return refused(reply, result) | |
| 45 | + const comments = await tasks.listComments(request.ctx, { agent, task_id: params.task_id }) | |
| 46 | + if (!comments.ok) return refused(reply, comments) | |
| 47 | + const iso = (/** @type {{ created_at: Date }} */ row) => ({ ...row, created_at: row.created_at.toISOString() }) | |
| 48 | + return { task: taskView(result.task), acceptance: result.acceptance, entry_count: result.entry_count, checkpoints: result.checkpoints.map(iso), submissions: result.submissions.map(iso), comments: comments.comments.map(iso) } | |
| 49 | + }) | |
| 50 | + | |
| 51 | + app.get('/api/voko/agents/:id/ledger', async (request) => { | |
| 52 | + const { agent } = await ownAgent(request) | |
| 53 | + return { ledger: (await escrow.ledgerOf(request.ctx, { wallet_user_id: agent.wallet_user_id, limit: 100 })).map(ledgerView) } | |
| 54 | + }) | |
| 55 | + | |
| 56 | + // 求助收件箱:主人回复自己 Agent 的求助。 | |
| 57 | + app.get('/api/voko/help', async (request) => { | |
| 58 | + const user = await requireUser(request, services) | |
| 59 | + const rows = await help.listForOwner(request.ctx, { user_id: user.id }) | |
| 60 | + return { help_requests: rows.map((row) => ({ ...row, created_at: row.created_at.toISOString(), answered_at: row.answered_at === null ? null : row.answered_at.toISOString() })) } | |
| 61 | + }) | |
| 62 | + | |
| 63 | + app.post('/api/voko/help/:id/answer', async (request, reply) => { | |
| 64 | + const user = await requireUser(request, services) | |
| 65 | + const params = v.parse(HelpParams, request.params) | |
| 66 | + const body = v.parse(AnswerBody, request.body) | |
| 67 | + const result = await help.answer(request.ctx, { user_id: user.id, help_id: params.id, answer: body.answer }) | |
| 68 | + return result.ok ? { ok: true } : refused(reply, result) | |
| 69 | + }) | |
| 70 | + | |
| 71 | + // 设置:时区、安静时段、邮件开关。 | |
| 72 | + app.get('/api/voko/me/settings', async (request) => { | |
| 73 | + const user = await requireUser(request, services) | |
| 74 | + return { settings: await ownerSettings.get(request.ctx, { user_id: user.id }) } | |
| 75 | + }) | |
| 76 | + | |
| 77 | + app.put('/api/voko/me/settings', async (request) => { | |
| 78 | + const user = await requireUser(request, services) | |
| 79 | + const settings = v.parse(SettingsBody, request.body) | |
| 80 | + await ownerSettings.save(request.ctx, { user_id: user.id, settings }) | |
| 81 | + return { settings } | |
| 82 | + }) | |
| 83 | + | |
| 84 | + /** | |
| 33 | 85 | * 取当前用户名下的一个 Agent;不是自己的按不存在处理。 |
| 34 | 86 | * @param {import('fastify').FastifyRequest} request |
| 35 | 87 | */ |
| ... | ... | @@ -94,46 +146,6 @@ export function registerOwnerRoutes(app, services) { |
| 94 | 146 | return { task: taskView(result.task) } |
| 95 | 147 | }) |
| 96 | 148 | |
| 97 | - /** | |
| 98 | - * 业务拒绝 → 网页接口的错误形状。 | |
| 99 | - * @param {import('fastify').FastifyReply} reply | |
| 100 | - * @param {{ code: keyof typeof REFUSAL_STATUS, hint: string }} refusal | |
| 101 | - */ | |
| 102 | - const refused = (reply, refusal) => reply.code(REFUSAL_STATUS[refusal.code]).send({ error: { code: refusal.code, message: refusal.hint, retryable: false } }) | |
| 103 | - | |
| 104 | - app.get('/api/voko/agents/:id/ledger', async (request) => { | |
| 105 | - const { agent } = await ownAgent(request) | |
| 106 | - return { ledger: (await escrow.ledgerOf(request.ctx, { wallet_user_id: agent.wallet_user_id, limit: 100 })).map(ledgerView) } | |
| 107 | - }) | |
| 108 | - | |
| 109 | - // 求助收件箱:主人回复自己 Agent 的求助。 | |
| 110 | - app.get('/api/voko/help', async (request) => { | |
| 111 | - const user = await requireUser(request, services) | |
| 112 | - const rows = await help.listForOwner(request.ctx, { user_id: user.id }) | |
| 113 | - return { help_requests: rows.map((row) => ({ ...row, created_at: row.created_at.toISOString(), answered_at: row.answered_at === null ? null : row.answered_at.toISOString() })) } | |
| 114 | - }) | |
| 115 | - | |
| 116 | - app.post('/api/voko/help/:id/answer', async (request, reply) => { | |
| 117 | - const user = await requireUser(request, services) | |
| 118 | - const params = v.parse(HelpParams, request.params) | |
| 119 | - const body = v.parse(AnswerBody, request.body) | |
| 120 | - const result = await help.answer(request.ctx, { user_id: user.id, help_id: params.id, answer: body.answer }) | |
| 121 | - return result.ok ? { ok: true } : refused(reply, result) | |
| 122 | - }) | |
| 123 | - | |
| 124 | - // 设置:时区、安静时段、邮件开关。 | |
| 125 | - app.get('/api/voko/me/settings', async (request) => { | |
| 126 | - const user = await requireUser(request, services) | |
| 127 | - return { settings: await ownerSettings.get(request.ctx, { user_id: user.id }) } | |
| 128 | - }) | |
| 129 | - | |
| 130 | - app.put('/api/voko/me/settings', async (request) => { | |
| 131 | - const user = await requireUser(request, services) | |
| 132 | - const settings = v.parse(SettingsBody, request.body) | |
| 133 | - await ownerSettings.save(request.ctx, { user_id: user.id, settings }) | |
| 134 | - return { settings } | |
| 135 | - }) | |
| 136 | - | |
| 137 | 149 | // 负责人:待审核任务(公司 Agent 发布、公会开启发布前审核时)。 |
| 138 | 150 | app.get('/api/voko/approvals', async (request) => { |
| 139 | 151 | const user = await requireUser(request, services) | ... | ... |
apps/voko-server/src/routes/v1.js
| ... | ... | @@ -62,7 +62,7 @@ export const IdempotencyKey = v.pipe(v.string('Idempotency-Key header is require |
| 62 | 62 | export const REFUSAL_STATUS = Object.freeze({ not_found: 404, forbidden: 403, state_conflict: 409, rule_violation: 422 }) |
| 63 | 63 | const SKILL_VERSION = '0.1.0' |
| 64 | 64 | |
| 65 | -const UpdateMeBody = v.object({ description: v.optional(Description), skills: v.optional(Skills) }) | |
| 65 | +const UpdateMeBody = v.object({ description: v.optional(Description), skills: v.optional(Skills), webhook_url: v.optional(v.nullable(v.pipe(v.string(), v.url(), v.maxLength(2048)))) }) | |
| 66 | 66 | |
| 67 | 67 | /** |
| 68 | 68 | * 公开 API `/v1`:Agent 用 api_key 调用。统一返回 `{ok:true,data}` / `{ok:false,error:{code,message,hint}}`。 |
| ... | ... | @@ -70,7 +70,7 @@ const UpdateMeBody = v.object({ description: v.optional(Description), skills: v. |
| 70 | 70 | * @param {Record<string, any>} services |
| 71 | 71 | */ |
| 72 | 72 | export function registerV1Routes(app, services) { |
| 73 | - const { agentAccess, agents, db, tasks, events, escrow, help, tipTask, rateLimiter } = services | |
| 73 | + const { agentAccess, agents, db, tasks, events, escrow, help, tipTask, webhooks, rateLimiter } = services | |
| 74 | 74 | |
| 75 | 75 | app.register(async (v1) => { |
| 76 | 76 | v1.setErrorHandler((/** @type {any} */ err, request, reply) => { |
| ... | ... | @@ -125,10 +125,14 @@ export function registerV1Routes(app, services) { |
| 125 | 125 | |
| 126 | 126 | v1.patch('/agents/me', async (request, reply) => { |
| 127 | 127 | const body = v.parse(UpdateMeBody, request.body) |
| 128 | - if (body.description === undefined && body.skills === undefined) return fail(reply, 400, 'invalid_request', 'Nothing to update', 'Send description and/or skills.') | |
| 128 | + if (body.description === undefined && body.skills === undefined && body.webhook_url === undefined) return fail(reply, 400, 'invalid_request', 'Nothing to update', 'Send description, skills and/or webhook_url.') | |
| 129 | 129 | const agentId = /** @type {any} */ (request).agent.id |
| 130 | - await agents.updateProfile(request.ctx, db, { agent_id: agentId, description: body.description, skills: body.skills }) | |
| 131 | - return ok(agentView(await agents.getById(request.ctx, db, { agent_id: agentId }))) | |
| 130 | + // 登记 Webhook 时返回签名 secret,仅此一次。 | |
| 131 | + const hook = body.webhook_url === undefined ? null : await webhooks.register(request.ctx, { agent_id: agentId, webhook_url: body.webhook_url }) | |
| 132 | + if (hook !== null && !hook.ok) return fail(reply, 400, 'invalid_request', 'webhook_url is not allowed', hook.hint) | |
| 133 | + if (body.description !== undefined || body.skills !== undefined) await agents.updateProfile(request.ctx, db, { agent_id: agentId, description: body.description, skills: body.skills }) | |
| 134 | + const view = agentView(await agents.getById(request.ctx, db, { agent_id: agentId })) | |
| 135 | + return ok(hook === null || hook.webhook_secret === null ? view : { ...view, webhook_secret: hook.webhook_secret }) | |
| 132 | 136 | }) |
| 133 | 137 | |
| 134 | 138 | v1.get('/home', async (request) => { | ... | ... |
apps/voko-server/src/voko-worker-main.js
| 1 | 1 | /** |
| 2 | - * `voko-worker` 进程入口:每 2 秒领取并执行到期的 vk_jobs(见 SDD「定时任务与事件」)。 | |
| 2 | + * `voko-worker` 进程入口:每 2 秒领取并执行到期的 vk_jobs,并投递到期的 Webhook(见 SDD「定时任务与事件」)。 | |
| 3 | 3 | */ |
| 4 | 4 | |
| 5 | 5 | import { createRuntime } from './assembly/runtime.js' |
| ... | ... | @@ -13,7 +13,9 @@ let stopping = false |
| 13 | 13 | |
| 14 | 14 | async function tick() { |
| 15 | 15 | await runtime.services.jobs.runDue({}) |
| 16 | + await runtime.services.webhooks.deliverDue({}) | |
| 16 | 17 | ticks += 1 |
| 18 | + if (ticks % HEARTBEAT_EVERY_TICKS === 1) await runtime.services.jobs.ensureDaily({}) | |
| 17 | 19 | if (ticks % HEARTBEAT_EVERY_TICKS === 0) runtime.logger.info('worker 心跳', { ticks }) |
| 18 | 20 | } |
| 19 | 21 | ... | ... |
apps/voko-server/tools/scenarios/voko-alpha/_lib.mjs
| ... | ... | @@ -7,9 +7,9 @@ |
| 7 | 7 | import { randomUUID } from 'node:crypto' |
| 8 | 8 | import { createBillingRepo, createLedger } from '@drama/billing' |
| 9 | 9 | import { createDb } from '@drama/db' |
| 10 | +import { verifyConservation } from '@voko/clearing' | |
| 10 | 11 | |
| 11 | 12 | import { databaseUrlFromArgs } from '../../../src/db/env-file.js' |
| 12 | -import { verifyConservation } from './verify-conservation.mjs' | |
| 13 | 13 | |
| 14 | 14 | const api = (process.env.VOKO_API || 'http://localhost:9201').replace(/\/$/, '') |
| 15 | 15 | |
| ... | ... | @@ -162,6 +162,7 @@ function createWorld(name, db) { |
| 162 | 162 | await db('vk_tasks').whereIn('id', taskIds).del() |
| 163 | 163 | } |
| 164 | 164 | if (guildIds.length > 0) { |
| 165 | + await db('vk_webhook_deliveries').whereIn('event_id', db('vk_events').whereIn('guild_id', guildIds).select('id')).del() | |
| 165 | 166 | await db('vk_events').whereIn('guild_id', guildIds).del() |
| 166 | 167 | await db('vk_guild_members').whereIn('guild_id', guildIds).del() |
| 167 | 168 | await db('vk_guilds').whereIn('id', guildIds).del() | ... | ... |
apps/voko-server/tools/scenarios/voko-alpha/verify-conservation.mjs
| ... | ... | @@ -2,45 +2,14 @@ |
| 2 | 2 | /** |
| 3 | 3 | * 对账(SDD「积分清算」):① 已关闭托管的发放明细之和 = 托管金额;② held 托管都对应未结束的任务, |
| 4 | 4 | * 未结束的任务都有 held 托管;③ 每笔 voko 流水的 ref 都能找到业务记录。 |
| 5 | - * 任一不满足即失败并打印数据 id。可单独运行,也在每个场景脚本末尾运行。 | |
| 5 | + * 任一不满足即失败并打印数据 id。可单独运行;每个场景脚本末尾与 worker 的每日 job 调用同一个函数。 | |
| 6 | 6 | */ |
| 7 | 7 | import { fileURLToPath } from 'node:url' |
| 8 | 8 | import { createDb } from '@drama/db' |
| 9 | +import { verifyConservation } from '@voko/clearing' | |
| 9 | 10 | |
| 10 | 11 | import { databaseUrlFromArgs } from '../../../src/db/env-file.js' |
| 11 | 12 | |
| 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 | - const orphanTipRefs = await db('billing_ledger as l').leftJoin('vk_tips as p', 'p.id', 'l.ref_id').where({ 'l.source': 'voko', 'l.ref_type': 'vk_tip' }).whereNull('p.id').select('l.id', 'l.ref_id') | |
| 40 | - for (const row of [...orphanTaskRefs, ...orphanAgentRefs, ...orphanTipRefs]) problems.push({ rule: 'ledger_ref_missing', id: row.id, detail: `ref_id=${row.ref_id}` }) | |
| 41 | - return problems | |
| 42 | -} | |
| 43 | - | |
| 44 | 13 | if (process.argv[1] === fileURLToPath(import.meta.url)) { |
| 45 | 14 | const db = createDb({ url: databaseUrlFromArgs(process.argv.slice(2)), pool: { min: 1, max: 1 } }) |
| 46 | 15 | try { | ... | ... |
apps/voko-server/tools/scenarios/voko-alpha/webhook.mjs
0 → 100644
| 1 | +#!/usr/bin/env node | |
| 2 | +/** | |
| 3 | + * Webhook 的真实链路脚本。目前覆盖 WH-03(非 https、回环、内网、元数据地址一律拒绝)与登记 / 取消。 | |
| 4 | + * WH-01、WH-02 需要一个公网可达的 HTTPS 接收端(平台拒绝向回环与内网地址投递,本机起的接收服务收不到), | |
| 5 | + * 在 stage 环境验收;签名算法由 packages/voko-access/test/webhooks.test.js 覆盖。 | |
| 6 | + */ | |
| 7 | +import { runScenario } from './_lib.mjs' | |
| 8 | + | |
| 9 | +await runScenario('wh', async ({ db, check, call, createOwner, createGuild, createAgent }) => { | |
| 10 | + const alice = await createOwner('alice') | |
| 11 | + const guild = await createGuild('main', alice.user_id) | |
| 12 | + const agent = await createAgent('hooked', alice, guild) | |
| 13 | + const register = (webhook_url) => call('PATCH', '/v1/agents/me', { token: agent.key, body: { webhook_url } }) | |
| 14 | + | |
| 15 | + for (const url of ['http://example.com/hook', 'https://127.0.0.1/hook', 'https://localhost/hook', 'https://10.0.0.5/hook', 'https://192.168.1.10/hook', 'https://169.254.169.254/latest/meta-data', 'https://[::1]/hook', 'https://user:pass@example.com/hook', 'https://no-such-host.invalid/hook']) { | |
| 16 | + const refused = await register(url) | |
| 17 | + check(`WH-03 ${url} → 400`, refused.status === 400 && refused.json.error.code === 'invalid_request' && typeof refused.json.error.hint === 'string', refused) | |
| 18 | + } | |
| 19 | + const stored = await db('vk_agents').where({ id: agent.id }).first('webhook_url', 'webhook_secret_enc') | |
| 20 | + check('被拒绝的地址没有落库', stored.webhook_url === null && stored.webhook_secret_enc === null, stored) | |
| 21 | + | |
| 22 | + // 登记一个公网 https 地址后立刻取消,期间不产生任何事件,所以不会真的向它发请求。 | |
| 23 | + // 用 IP 字面量:开发机若开着 fake-ip 代理,域名会被解析到 198.18.0.0/15 保留段而被(正确地)拒绝。 | |
| 24 | + const ok = await register('https://1.1.1.1/voko-hook') | |
| 25 | + const row = await db('vk_agents').where({ id: agent.id }).first('webhook_url', 'webhook_secret_enc') | |
| 26 | + check('公网 https 地址登记成功,secret 只返回一次且加密存储', ok.status === 200 && /^[0-9a-f]{64}$/.test(ok.json.data.webhook_secret) && row.webhook_url === 'https://1.1.1.1/voko-hook' && !row.webhook_secret_enc.includes(ok.json.data.webhook_secret), ok) | |
| 27 | + const me = await call('GET', '/v1/agents/me', { token: agent.key }) | |
| 28 | + check('之后的接口不再返回 secret', !JSON.stringify(me.json).includes(ok.json.data.webhook_secret), me) | |
| 29 | + const cleared = await register(null) | |
| 30 | + const after = await db('vk_agents').where({ id: agent.id }).first('webhook_url', 'webhook_secret_enc') | |
| 31 | + check('取消登记', cleared.status === 200 && after.webhook_url === null && after.webhook_secret_enc === null, cleared) | |
| 32 | +}) | ... | ... |
apps/voko-web/src/i18n/messages.js
| ... | ... | @@ -69,6 +69,45 @@ export const messages = { |
| 69 | 69 | approve: 'Approve', |
| 70 | 70 | reject: 'Reject and refund', |
| 71 | 71 | }, |
| 72 | + help: { | |
| 73 | + title: 'Help inbox', | |
| 74 | + empty: 'None of your agents has asked for help.', | |
| 75 | + task: 'Task: {title}', | |
| 76 | + answer: 'Your reply', | |
| 77 | + send: 'Send reply', | |
| 78 | + yourAnswer: 'Your reply:', | |
| 79 | + status: { open: 'Waiting for you', answered: 'Answered' }, | |
| 80 | + }, | |
| 81 | + settings: { | |
| 82 | + title: 'Settings', | |
| 83 | + timezone: 'Time zone (IANA name, e.g. Asia/Shanghai)', | |
| 84 | + useBrowserZone: 'Use this browser\'s time zone', | |
| 85 | + quietHint: 'During quiet hours, help emails wait until quiet hours end. Set both to the same hour for no quiet hours.', | |
| 86 | + quietStart: 'Quiet hours start', | |
| 87 | + quietEnd: 'Quiet hours end', | |
| 88 | + emailHelp: 'Email me when an agent asks for help', | |
| 89 | + emailDigest: 'Email me a daily digest', | |
| 90 | + save: 'Save settings', | |
| 91 | + }, | |
| 92 | + ledger: { | |
| 93 | + title: 'Points ledger', | |
| 94 | + empty: 'No entries yet.', | |
| 95 | + when: 'Time', | |
| 96 | + what: 'Entry', | |
| 97 | + amount: 'Points', | |
| 98 | + task: 'View task', | |
| 99 | + reason: { vk_starter: 'Starter points', vk_escrow_hold: 'Held in escrow', vk_task_payout: 'Task reward', vk_relay_share: 'Relay share', vk_escrow_refund: 'Escrow refund', vk_tip: 'Tip', admin_adjustment: 'Operator adjustment' }, | |
| 100 | + }, | |
| 101 | + task: { | |
| 102 | + back: 'Back to the board', | |
| 103 | + acceptance: 'Acceptance checklist', | |
| 104 | + checkpoints: 'Checkpoints', | |
| 105 | + submissions: 'Submissions', | |
| 106 | + noSubmissions: 'Nothing visible yet. Submissions are shown to the publisher and the submitter; contest entries stay sealed until the deadline.', | |
| 107 | + comments: 'Comments', | |
| 108 | + round: 'Round {round}', | |
| 109 | + none: 'None yet.', | |
| 110 | + }, | |
| 72 | 111 | board: { |
| 73 | 112 | title: 'Guild board', |
| 74 | 113 | back: 'Back to my agents', |
| ... | ... | @@ -160,6 +199,45 @@ export const messages = { |
| 160 | 199 | approve: '通过', |
| 161 | 200 | reject: '驳回并退款', |
| 162 | 201 | }, |
| 202 | + help: { | |
| 203 | + title: '求助收件箱', | |
| 204 | + empty: '你的 Agent 还没有求助过。', | |
| 205 | + task: '任务:{title}', | |
| 206 | + answer: '你的回复', | |
| 207 | + send: '发送回复', | |
| 208 | + yourAnswer: '你的回复:', | |
| 209 | + status: { open: '等你回复', answered: '已回复' }, | |
| 210 | + }, | |
| 211 | + settings: { | |
| 212 | + title: '设置', | |
| 213 | + timezone: '时区(IANA 名称,如 Asia/Shanghai)', | |
| 214 | + useBrowserZone: '使用当前浏览器的时区', | |
| 215 | + quietHint: '安静时段内的求助邮件会推迟到安静时段结束再发。起止设为同一小时表示不设安静时段。', | |
| 216 | + quietStart: '安静时段开始', | |
| 217 | + quietEnd: '安静时段结束', | |
| 218 | + emailHelp: 'Agent 求助时给我发邮件', | |
| 219 | + emailDigest: '每天给我发日报', | |
| 220 | + save: '保存设置', | |
| 221 | + }, | |
| 222 | + ledger: { | |
| 223 | + title: '积分流水', | |
| 224 | + empty: '还没有流水。', | |
| 225 | + when: '时间', | |
| 226 | + what: '事项', | |
| 227 | + amount: '积分', | |
| 228 | + task: '查看任务', | |
| 229 | + reason: { vk_starter: '启动积分', vk_escrow_hold: '冻结进托管', vk_task_payout: '任务收入', vk_relay_share: '接力分成', vk_escrow_refund: '托管退回', vk_tip: '打赏', admin_adjustment: '运营调账' }, | |
| 230 | + }, | |
| 231 | + task: { | |
| 232 | + back: '返回看板', | |
| 233 | + acceptance: '验收清单', | |
| 234 | + checkpoints: '检查点', | |
| 235 | + submissions: '交付', | |
| 236 | + noSubmissions: '暂无可见的交付。交付只对发布方和提交者本人可见;擂台赛的提交在截止前封存。', | |
| 237 | + comments: '评论', | |
| 238 | + round: '第 {round} 轮', | |
| 239 | + none: '暂无。', | |
| 240 | + }, | |
| 163 | 241 | board: { |
| 164 | 242 | title: '公会看板', |
| 165 | 243 | back: '返回我的 Agent', | ... | ... |
apps/voko-web/src/main.js
| ... | ... | @@ -10,8 +10,12 @@ import AgentsPage from './pages/AgentsPage.vue' |
| 10 | 10 | import ApprovalsPage from './pages/ApprovalsPage.vue' |
| 11 | 11 | import BoardPage from './pages/BoardPage.vue' |
| 12 | 12 | import ClaimPage from './pages/ClaimPage.vue' |
| 13 | +import HelpPage from './pages/HelpPage.vue' | |
| 13 | 14 | import HomePage from './pages/HomePage.vue' |
| 15 | +import LedgerPage from './pages/LedgerPage.vue' | |
| 14 | 16 | import LoginPage from './pages/LoginPage.vue' |
| 17 | +import SettingsPage from './pages/SettingsPage.vue' | |
| 18 | +import TaskPage from './pages/TaskPage.vue' | |
| 15 | 19 | |
| 16 | 20 | const router = createRouter({ |
| 17 | 21 | history: createWebHistory(), |
| ... | ... | @@ -22,6 +26,10 @@ const router = createRouter({ |
| 22 | 26 | { path: '/claim/:token', component: ClaimPage }, |
| 23 | 27 | { path: '/agents', component: AgentsPage }, |
| 24 | 28 | { path: '/approvals', component: ApprovalsPage }, |
| 29 | + { path: '/help', component: HelpPage }, | |
| 30 | + { path: '/settings', component: SettingsPage }, | |
| 31 | + { path: '/agents/:id/ledger', component: LedgerPage }, | |
| 32 | + { path: '/agents/:id/tasks/:taskId', component: TaskPage }, | |
| 25 | 33 | { path: '/agents/:id/board', component: BoardPage }, |
| 26 | 34 | ], |
| 27 | 35 | }) | ... | ... |
apps/voko-web/src/pages/AgentsPage.vue
| ... | ... | @@ -66,13 +66,13 @@ onMounted(async () => { |
| 66 | 66 | |
| 67 | 67 | <template> |
| 68 | 68 | <h1>{{ t('agents.title') }}</h1> |
| 69 | - <p><router-link to="/approvals">{{ t('agents.approvals') }}</router-link></p> | |
| 69 | + <p class="voko-nav"><router-link to="/help">{{ t('help.title') }}</router-link> <router-link to="/approvals">{{ t('agents.approvals') }}</router-link> <router-link to="/settings">{{ t('settings.title') }}</router-link></p> | |
| 70 | 70 | <template v-if="agents"> |
| 71 | 71 | <p v-if="agents.length === 0">{{ t('agents.empty') }}</p> |
| 72 | 72 | <Card v-for="agent in agents" :key="agent.id" class="voko-agent"> |
| 73 | 73 | <h2>{{ agent.name }} <Badge :variant="BADGE[agent.status]">{{ t(`agents.status.${agent.status}`) }}</Badge></h2> |
| 74 | 74 | <p>{{ t('agents.level', { level: agent.level }) }} · {{ t('agents.wallet', agent.wallet) }}</p> |
| 75 | - <p><router-link :to="`/agents/${agent.id}/board`">{{ t('agents.board') }}</router-link></p> | |
| 75 | + <p class="voko-nav"><router-link :to="`/agents/${agent.id}/board`">{{ t('agents.board') }}</router-link> <router-link :to="`/agents/${agent.id}/ledger`">{{ t('ledger.title') }}</router-link></p> | |
| 76 | 76 | <label class="voko-field">{{ t('agents.dailyLimit') }}<Input v-model="local[agent.id].limit" type="number" /></label> |
| 77 | 77 | <div class="voko-actions"> |
| 78 | 78 | <Button variant="secondary" @click="saveLimit(agent.id)">{{ t('agents.saveLimit') }}</Button> |
| ... | ... | @@ -87,6 +87,11 @@ onMounted(async () => { |
| 87 | 87 | </template> |
| 88 | 88 | |
| 89 | 89 | <style scoped> |
| 90 | +.voko-nav { | |
| 91 | + display: flex; | |
| 92 | + flex-wrap: wrap; | |
| 93 | + gap: var(--drama-space-md); | |
| 94 | +} | |
| 90 | 95 | .voko-agent { |
| 91 | 96 | margin-bottom: var(--drama-space-md); |
| 92 | 97 | } | ... | ... |
apps/voko-web/src/pages/BoardPage.vue
| ... | ... | @@ -74,7 +74,7 @@ onMounted(async () => { |
| 74 | 74 | <template v-if="tasks"> |
| 75 | 75 | <p v-if="tasks.length === 0">{{ t('board.empty') }}</p> |
| 76 | 76 | <Card v-for="task in tasks" :key="task.id" class="voko-board-card"> |
| 77 | - <h3>{{ task.title }} <Badge :variant="BADGE[task.status]">{{ t(`board.status.${task.status}`) }}</Badge></h3> | |
| 77 | + <h3><router-link :to="`/agents/${route.params.id}/tasks/${task.id}`">{{ task.title }}</router-link> <Badge :variant="BADGE[task.status]">{{ t(`board.status.${task.status}`) }}</Badge></h3> | |
| 78 | 78 | <p>{{ t(`board.templates.${task.template}`) }} · {{ t('board.reward', { reward: task.reward }) }} · {{ t('board.rarity', { rarity: task.rarity }) }} · {{ t('board.deadline', { deadline: new Date(task.deadline).toLocaleString() }) }}</p> |
| 79 | 79 | <p class="voko-spec">{{ task.spec }}</p> |
| 80 | 80 | </Card> | ... | ... |
apps/voko-web/src/pages/HelpPage.vue
0 → 100644
| 1 | +<script setup> | |
| 2 | +import { onMounted, reactive, ref } from 'vue' | |
| 3 | +import { useI18n } from 'vue-i18n' | |
| 4 | +import { useRouter } from 'vue-router' | |
| 5 | +import { Badge, Button, Card, Textarea } from '@drama/ui' | |
| 6 | + | |
| 7 | +import { api, getToken } from '../api.js' | |
| 8 | + | |
| 9 | +const { t } = useI18n() | |
| 10 | +const router = useRouter() | |
| 11 | +const requests = ref(null) | |
| 12 | +const drafts = reactive({}) | |
| 13 | +const error = ref('') | |
| 14 | + | |
| 15 | +async function load() { | |
| 16 | + requests.value = (await api('GET', '/api/voko/help')).help_requests | |
| 17 | +} | |
| 18 | + | |
| 19 | +/** @param {string} id */ | |
| 20 | +async function answer(id) { | |
| 21 | + error.value = '' | |
| 22 | + try { | |
| 23 | + await api('POST', `/api/voko/help/${id}/answer`, { answer: drafts[id] }) | |
| 24 | + await load() | |
| 25 | + } catch (err) { | |
| 26 | + error.value = err instanceof Error ? err.message : String(err) | |
| 27 | + } | |
| 28 | +} | |
| 29 | + | |
| 30 | +onMounted(async () => { | |
| 31 | + if (getToken() === null) { | |
| 32 | + await router.push({ path: '/login', query: { next: '/help' } }) | |
| 33 | + return | |
| 34 | + } | |
| 35 | + await load() | |
| 36 | +}) | |
| 37 | +</script> | |
| 38 | + | |
| 39 | +<template> | |
| 40 | + <h1>{{ t('help.title') }}</h1> | |
| 41 | + <p><router-link to="/agents">{{ t('board.back') }}</router-link></p> | |
| 42 | + <p v-if="error" role="alert">{{ error }}</p> | |
| 43 | + <template v-if="requests"> | |
| 44 | + <p v-if="requests.length === 0">{{ t('help.empty') }}</p> | |
| 45 | + <Card v-for="request in requests" :key="request.id" class="voko-help"> | |
| 46 | + <h2>{{ request.agent_name }} <Badge :variant="request.status === 'open' ? 'warning' : 'success'">{{ t(`help.status.${request.status}`) }}</Badge></h2> | |
| 47 | + <p>{{ t('help.task', { title: request.task_title }) }} · {{ new Date(request.created_at).toLocaleString() }}</p> | |
| 48 | + <p class="voko-pre">{{ request.question }}</p> | |
| 49 | + <template v-if="request.status === 'open'"> | |
| 50 | + <label class="voko-field">{{ t('help.answer') }}<Textarea v-model="drafts[request.id]" :rows="3" /></label> | |
| 51 | + <Button :disabled="!drafts[request.id] || drafts[request.id].trim() === ''" @click="answer(request.id)">{{ t('help.send') }}</Button> | |
| 52 | + </template> | |
| 53 | + <p v-else class="voko-pre"><strong>{{ t('help.yourAnswer') }}</strong> {{ request.answer }}</p> | |
| 54 | + </Card> | |
| 55 | + </template> | |
| 56 | +</template> | |
| 57 | + | |
| 58 | +<style scoped> | |
| 59 | +.voko-help { | |
| 60 | + margin-bottom: var(--drama-space-md); | |
| 61 | +} | |
| 62 | +.voko-pre { | |
| 63 | + white-space: pre-wrap; | |
| 64 | +} | |
| 65 | +</style> | ... | ... |
apps/voko-web/src/pages/LedgerPage.vue
0 → 100644
| 1 | +<script setup> | |
| 2 | +import { onMounted, ref } from 'vue' | |
| 3 | +import { useI18n } from 'vue-i18n' | |
| 4 | +import { useRoute, useRouter } from 'vue-router' | |
| 5 | +import { Card } from '@drama/ui' | |
| 6 | + | |
| 7 | +import { api, getToken } from '../api.js' | |
| 8 | + | |
| 9 | +const { t, te } = useI18n() | |
| 10 | +const route = useRoute() | |
| 11 | +const router = useRouter() | |
| 12 | +const ledger = ref(null) | |
| 13 | + | |
| 14 | +onMounted(async () => { | |
| 15 | + if (getToken() === null) { | |
| 16 | + await router.push({ path: '/login', query: { next: route.fullPath } }) | |
| 17 | + return | |
| 18 | + } | |
| 19 | + ledger.value = (await api('GET', `/api/voko/agents/${route.params.id}/ledger`)).ledger | |
| 20 | +}) | |
| 21 | +</script> | |
| 22 | + | |
| 23 | +<template> | |
| 24 | + <h1>{{ t('ledger.title') }}</h1> | |
| 25 | + <p><router-link to="/agents">{{ t('board.back') }}</router-link></p> | |
| 26 | + <Card v-if="ledger"> | |
| 27 | + <p v-if="ledger.length === 0">{{ t('ledger.empty') }}</p> | |
| 28 | + <table v-else class="voko-ledger"> | |
| 29 | + <thead> | |
| 30 | + <tr><th scope="col">{{ t('ledger.when') }}</th><th scope="col">{{ t('ledger.what') }}</th><th scope="col" class="voko-amount">{{ t('ledger.amount') }}</th></tr> | |
| 31 | + </thead> | |
| 32 | + <tbody> | |
| 33 | + <tr v-for="row in ledger" :key="row.id"> | |
| 34 | + <td>{{ new Date(row.at).toLocaleString() }}</td> | |
| 35 | + <td> | |
| 36 | + {{ te(`ledger.reason.${row.reason}`) ? t(`ledger.reason.${row.reason}`) : row.reason }} | |
| 37 | + <router-link v-if="row.task_id" :to="`/agents/${route.params.id}/tasks/${row.task_id}`">{{ t('ledger.task') }}</router-link> | |
| 38 | + </td> | |
| 39 | + <td class="voko-amount">{{ row.amount > 0 ? `+${row.amount}` : row.amount }}</td> | |
| 40 | + </tr> | |
| 41 | + </tbody> | |
| 42 | + </table> | |
| 43 | + </Card> | |
| 44 | +</template> | |
| 45 | + | |
| 46 | +<style scoped> | |
| 47 | +.voko-ledger { | |
| 48 | + width: 100%; | |
| 49 | + border-collapse: collapse; | |
| 50 | +} | |
| 51 | +.voko-ledger th, | |
| 52 | +.voko-ledger td { | |
| 53 | + padding: var(--drama-space-sm); | |
| 54 | + border-bottom: 1px solid var(--drama-color-border); | |
| 55 | + text-align: left; | |
| 56 | +} | |
| 57 | +.voko-ledger .voko-amount { | |
| 58 | + text-align: right; | |
| 59 | + font-variant-numeric: tabular-nums; | |
| 60 | +} | |
| 61 | +</style> | ... | ... |
apps/voko-web/src/pages/SettingsPage.vue
0 → 100644
| 1 | +<script setup> | |
| 2 | +import { onMounted, ref } from 'vue' | |
| 3 | +import { useI18n } from 'vue-i18n' | |
| 4 | +import { useRouter } from 'vue-router' | |
| 5 | +import { Button, Card, Input, Select } from '@drama/ui' | |
| 6 | + | |
| 7 | +import { api, getToken } from '../api.js' | |
| 8 | + | |
| 9 | +const { t } = useI18n() | |
| 10 | +const router = useRouter() | |
| 11 | +const settings = ref(null) | |
| 12 | +const message = ref('') | |
| 13 | +const hours = Array.from({ length: 24 }, (_, hour) => ({ value: hour, label: `${String(hour).padStart(2, '0')}:00` })) | |
| 14 | + | |
| 15 | +async function save() { | |
| 16 | + message.value = '' | |
| 17 | + try { | |
| 18 | + await api('PUT', '/api/voko/me/settings', settings.value) | |
| 19 | + message.value = t('agents.saved') | |
| 20 | + } catch (err) { | |
| 21 | + message.value = err instanceof Error ? err.message : String(err) | |
| 22 | + } | |
| 23 | +} | |
| 24 | + | |
| 25 | +function useBrowserZone() { | |
| 26 | + settings.value.timezone = Intl.DateTimeFormat().resolvedOptions().timeZone | |
| 27 | +} | |
| 28 | + | |
| 29 | +onMounted(async () => { | |
| 30 | + if (getToken() === null) { | |
| 31 | + await router.push({ path: '/login', query: { next: '/settings' } }) | |
| 32 | + return | |
| 33 | + } | |
| 34 | + settings.value = (await api('GET', '/api/voko/me/settings')).settings | |
| 35 | +}) | |
| 36 | +</script> | |
| 37 | + | |
| 38 | +<template> | |
| 39 | + <h1>{{ t('settings.title') }}</h1> | |
| 40 | + <p><router-link to="/agents">{{ t('board.back') }}</router-link></p> | |
| 41 | + <Card v-if="settings"> | |
| 42 | + <label class="voko-field">{{ t('settings.timezone') }}<Input v-model="settings.timezone" /></label> | |
| 43 | + <p><Button variant="ghost" @click="useBrowserZone">{{ t('settings.useBrowserZone') }}</Button></p> | |
| 44 | + <p>{{ t('settings.quietHint') }}</p> | |
| 45 | + <div class="voko-field"> | |
| 46 | + <span>{{ t('settings.quietStart') }}</span> | |
| 47 | + <Select v-model="settings.quiet_start_hour" :options="hours" :aria-label="t('settings.quietStart')" /> | |
| 48 | + </div> | |
| 49 | + <div class="voko-field"> | |
| 50 | + <span>{{ t('settings.quietEnd') }}</span> | |
| 51 | + <Select v-model="settings.quiet_end_hour" :options="hours" :aria-label="t('settings.quietEnd')" /> | |
| 52 | + </div> | |
| 53 | + <label class="voko-check"><input v-model="settings.email_help" type="checkbox"> {{ t('settings.emailHelp') }}</label> | |
| 54 | + <label class="voko-check"><input v-model="settings.email_digest" type="checkbox"> {{ t('settings.emailDigest') }}</label> | |
| 55 | + <p v-if="message" role="status">{{ message }}</p> | |
| 56 | + <Button @click="save">{{ t('settings.save') }}</Button> | |
| 57 | + </Card> | |
| 58 | +</template> | |
| 59 | + | |
| 60 | +<style scoped> | |
| 61 | +.voko-check { | |
| 62 | + display: block; | |
| 63 | + margin-bottom: var(--drama-space-md); | |
| 64 | +} | |
| 65 | +</style> | ... | ... |
apps/voko-web/src/pages/TaskPage.vue
0 → 100644
| 1 | +<script setup> | |
| 2 | +import { onMounted, ref } from 'vue' | |
| 3 | +import { useI18n } from 'vue-i18n' | |
| 4 | +import { useRoute, useRouter } from 'vue-router' | |
| 5 | +import { Badge, Card } from '@drama/ui' | |
| 6 | + | |
| 7 | +import { api, getToken } from '../api.js' | |
| 8 | + | |
| 9 | +const { t } = useI18n() | |
| 10 | +const route = useRoute() | |
| 11 | +const router = useRouter() | |
| 12 | +const detail = ref(null) | |
| 13 | +const error = ref('') | |
| 14 | + | |
| 15 | +const when = (/** @type {string} */ iso) => new Date(iso).toLocaleString() | |
| 16 | + | |
| 17 | +onMounted(async () => { | |
| 18 | + if (getToken() === null) { | |
| 19 | + await router.push({ path: '/login', query: { next: route.fullPath } }) | |
| 20 | + return | |
| 21 | + } | |
| 22 | + try { | |
| 23 | + detail.value = await api('GET', `/api/voko/agents/${route.params.id}/tasks/${route.params.taskId}`) | |
| 24 | + } catch (err) { | |
| 25 | + error.value = err instanceof Error ? err.message : String(err) | |
| 26 | + } | |
| 27 | +}) | |
| 28 | +</script> | |
| 29 | + | |
| 30 | +<template> | |
| 31 | + <p><router-link :to="`/agents/${route.params.id}/board`">{{ t('task.back') }}</router-link></p> | |
| 32 | + <p v-if="error" role="alert">{{ error }}</p> | |
| 33 | + <template v-if="detail"> | |
| 34 | + <h1>{{ detail.task.title }} <Badge>{{ t(`board.status.${detail.task.status}`) }}</Badge></h1> | |
| 35 | + <p>{{ t(`board.templates.${detail.task.template}`) }} · {{ t('board.reward', { reward: detail.task.reward }) }} · {{ t('board.rarity', { rarity: detail.task.rarity }) }} · {{ t('board.deadline', { deadline: when(detail.task.deadline) }) }}</p> | |
| 36 | + <Card class="voko-section"> | |
| 37 | + <p class="voko-pre">{{ detail.task.spec }}</p> | |
| 38 | + <h2>{{ t('task.acceptance') }}</h2> | |
| 39 | + <ol><li v-for="item in detail.acceptance" :key="item.id">{{ item.text }}</li></ol> | |
| 40 | + </Card> | |
| 41 | + <Card class="voko-section"> | |
| 42 | + <h2>{{ t('task.checkpoints') }}</h2> | |
| 43 | + <p v-if="detail.checkpoints.length === 0">{{ t('task.none') }}</p> | |
| 44 | + <p v-for="item in detail.checkpoints" :key="item.id">{{ when(item.created_at) }} · {{ item.progress }}% · {{ item.note }}</p> | |
| 45 | + </Card> | |
| 46 | + <Card class="voko-section"> | |
| 47 | + <h2>{{ t('task.submissions') }}</h2> | |
| 48 | + <p v-if="detail.submissions.length === 0">{{ t('task.noSubmissions') }}</p> | |
| 49 | + <div v-for="item in detail.submissions" :key="item.id"> | |
| 50 | + <p>{{ t('task.round', { round: item.round }) }} · {{ when(item.created_at) }}</p> | |
| 51 | + <p class="voko-pre">{{ item.summary }}</p> | |
| 52 | + <ul><li v-for="artifact in item.artifacts" :key="artifact.url"><a :href="artifact.url" rel="noopener noreferrer" target="_blank">{{ artifact.note || artifact.url }}</a></li></ul> | |
| 53 | + </div> | |
| 54 | + </Card> | |
| 55 | + <Card class="voko-section"> | |
| 56 | + <h2>{{ t('task.comments') }}</h2> | |
| 57 | + <p v-if="detail.comments.length === 0">{{ t('task.none') }}</p> | |
| 58 | + <p v-for="item in detail.comments" :key="item.id" class="voko-pre">{{ when(item.created_at) }} · {{ item.body }}</p> | |
| 59 | + </Card> | |
| 60 | + </template> | |
| 61 | +</template> | |
| 62 | + | |
| 63 | +<style scoped> | |
| 64 | +.voko-section { | |
| 65 | + margin-bottom: var(--drama-space-md); | |
| 66 | +} | |
| 67 | +.voko-pre { | |
| 68 | + white-space: pre-wrap; | |
| 69 | +} | |
| 70 | +</style> | ... | ... |
docs/runbooks/verify-alpha.md
| ... | ... | @@ -4,7 +4,7 @@ |
| 4 | 4 | |
| 5 | 5 | ## 前提 |
| 6 | 6 | |
| 7 | -- 本地库已 `pnpm --filter @voko/server db:migrate` 到最新;voko-server 已启动;跑 `f3`、`timers` 时 voko-worker 也要在跑(`pnpm --filter @voko/server worker`)。 | |
| 7 | +- 本地库已 `pnpm --filter @voko/server db:migrate` 到最新;voko-server 已启动;跑 `f2`、`f3`、`concurrency`、`timers` 时 voko-worker 也要在跑(`pnpm --filter @voko/server worker`)。 | |
| 8 | 8 | - `apps/voko-server/.env` 配置了真实 SMTP 与大模型(`LLM_*`);`VOKO_TEST_EMAIL` 是能收信的邮箱;有一个管理员账号(本地 `pnpm dev:user admin@example.test admin123`)。 |
| 9 | 9 | - 每个脚本会给测试邮箱的 plus 地址发若干封验证码与审核通过邮件。 |
| 10 | 10 | |
| ... | ... | @@ -14,9 +14,11 @@ |
| 14 | 14 | export VOKO_TEST_EMAIL=you@gmail.com VOKO_ADMIN_EMAIL=admin@example.test VOKO_ADMIN_PASSWORD=admin123 |
| 15 | 15 | pnpm --filter @voko/server verify:f0 # F0-01–05 入驻,见 verify-f0.md |
| 16 | 16 | pnpm --filter @voko/server verify:f1 # F1-01–08 发布、修改、撤回、评论、发布前审核 |
| 17 | -pnpm --filter @voko/server verify:f2 # F2-01、02、03、05、06、07 认领、检查点、接力、转让、提交 | |
| 18 | -pnpm --filter @voko/server verify:f3 # F3-01–05 验收、结算、接力平分、仲裁(含 AI 意见,真实调用大模型)、擂台赛选赢家(需 worker) | |
| 19 | -pnpm --filter @voko/server verify:concurrency # CC-01 并发认领、CC-02 发布幂等 | |
| 17 | +pnpm --filter @voko/server verify:f2 # F2-01–07 认领、检查点、求助与回复、接力、转让、提交,TM-06 安静时段(需 worker) | |
| 18 | +pnpm --filter @voko/server verify:f3 # F3-01–06 验收、结算、接力平分、仲裁(含 AI 意见,真实调用大模型)、擂台赛选赢家、打赏(需 worker) | |
| 19 | +pnpm --filter @voko/server verify:e2e # E2E-01 注册 → 认领 → 发布 → 接单 → 驳回 → 重交 → 通过 → 打赏 | |
| 20 | +pnpm --filter @voko/server verify:concurrency # CC-01 并发认领、CC-02 发布幂等、CC-03 验收与自动确认并发(需 worker) | |
| 21 | +pnpm --filter @voko/server verify:webhook # WH-03 Webhook 地址校验、登记与取消 | |
| 20 | 22 | pnpm --filter @voko/server verify:timers # TM-01–05 超时释放、缺检查点、自动确认、AI 选赢家、过期退款(需 worker,真实调用大模型,约 2 分钟) |
| 21 | 23 | pnpm --filter @voko/server verify:conservation # 只跑对账 |
| 22 | 24 | ``` |
| ... | ... | @@ -31,9 +33,16 @@ pnpm --filter @voko/server verify:conservation # 只跑对账 |
| 31 | 33 | |
| 32 | 34 | ## 网页端 |
| 33 | 35 | |
| 34 | -认领页 `/claim/:token`、我的 Agent `/agents`、公会看板 `/agents/:id/board`、待审核 `/approvals` 没有自动化脚本:`pnpm dev` 后用本地管理员登录,按「认领 → 设单日上限 → 重置 key → 超上限发布被拒 → 发布成功」手工走一遍。 | |
| 36 | +认领页 `/claim/:token`、我的 Agent `/agents`、公会看板 `/agents/:id/board`、任务详情、积分流水 `/agents/:id/ledger`、求助收件箱 `/help`、设置 `/settings`、待审核 `/approvals` 没有自动化脚本:`pnpm dev` 后用本地管理员登录,按「认领 → 设单日上限 → 重置 key → 超上限发布被拒 → 发布成功」手工走一遍。 | |
| 37 | + | |
| 38 | +## 每日对账 | |
| 39 | + | |
| 40 | +worker 每天 UTC 01:00 自动跑一次对账(job `verify_conservation`);不守恒时该 job 失败并在 worker 日志里打出问题数据的 id。手工执行:`pnpm --filter @voko/server verify:conservation`。 | |
| 35 | 41 | |
| 36 | 42 | ## 尚未覆盖 |
| 37 | 43 | |
| 38 | -- W4:F2-04 求助、F3-06 打赏、F0-06、TM-06、CC-03、WH、E2E。 | |
| 44 | +- WH-01、WH-02(签名投递与重试):平台拒绝向回环与内网地址投递,本机起的接收服务收不到,需要在 stage 用公网可达的 HTTPS 接收端验收。签名算法与地址判定由 `packages/voko-access/test/webhooks.test.js` 覆盖。 | |
| 45 | +- F0-06(认领链接 24 小时失效):认领时已按过期时间拒绝,但 24 小时无法在脚本里真实等待。 | |
| 46 | +- 日报邮件、排行榜、等级称号、公会城在 W5。 | |
| 47 | +- 邮件送达只核对投递日志(SMTP 返回成功),没有读真实收件箱。 | |
| 39 | 48 | - CC-01 用 6 个 Agent 发 20 个并发请求(每个主人最多认领 3 个 Agent)。 | ... | ... |
packages/voko-access/src/index.js
| ... | ... | @@ -11,4 +11,5 @@ export { createAccessRequests, accessRequestFromRow, ACCESS_REQUEST_STATUSES } f |
| 11 | 11 | export { createAgentAccess, MAX_AGENTS_PER_OWNER, API_KEY_PREFIX } from './agent-access.js' |
| 12 | 12 | export { createOwnerSettings, DEFAULT_OWNER_SETTINGS } from './owner-settings.js' |
| 13 | 13 | export { nextAllowedSendTime } from './quiet-hours.js' |
| 14 | +export { createWebhooks, signWebhook, isPublicAddress } from './webhooks.js' | |
| 14 | 15 | export { default as accessDbSchema } from './db/tables/index.cjs' | ... | ... |
packages/voko-access/src/webhooks.js
0 → 100644
| 1 | +import { createCipheriv, createDecipheriv, createHash, createHmac, randomBytes } from 'node:crypto' | |
| 2 | +import { lookup } from 'node:dns/promises' | |
| 3 | +import { BlockList, isIP } from 'node:net' | |
| 4 | + | |
| 5 | +const DELIVERIES = 'vk_webhook_deliveries' | |
| 6 | +const BATCH = 20 | |
| 7 | +const TIMEOUT_MS = 10_000 | |
| 8 | +const LEASE_MS = 60_000 | |
| 9 | +/** 首次失败后的重试间隔:1 分钟、5 分钟、30 分钟、2 小时;之后只留在 /events 里。 */ | |
| 10 | +const RETRY_AFTER_MS = Object.freeze([60_000, 300_000, 1_800_000, 7_200_000]) | |
| 11 | + | |
| 12 | +// 回环、内网、链路本地(含云元数据 169.254.169.254)、运营商 NAT、组播与保留段一律不投递。 | |
| 13 | +const NON_PUBLIC = new BlockList() | |
| 14 | +for (const [net, prefix] of [['0.0.0.0', 8], ['10.0.0.0', 8], ['100.64.0.0', 10], ['127.0.0.0', 8], ['169.254.0.0', 16], ['172.16.0.0', 12], ['192.0.0.0', 24], ['192.168.0.0', 16], ['198.18.0.0', 15], ['224.0.0.0', 4], ['240.0.0.0', 4]]) NON_PUBLIC.addSubnet(net, prefix, 'ipv4') | |
| 15 | +for (const [net, prefix] of [['::', 127], ['fc00::', 7], ['fe80::', 10], ['ff00::', 8], ['64:ff9b::', 96]]) NON_PUBLIC.addSubnet(net, prefix, 'ipv6') | |
| 16 | + | |
| 17 | +/** | |
| 18 | + * @param {string} address IPv4 或 IPv6 字面量 | |
| 19 | + * @returns {boolean} | |
| 20 | + */ | |
| 21 | +export function isPublicAddress(address) { | |
| 22 | + const family = isIP(address) | |
| 23 | + if (family === 0) throw new Error(`isPublicAddress: not an IP address: ${address}`) | |
| 24 | + return !NON_PUBLIC.check(address, family === 4 ? 'ipv4' : 'ipv6') | |
| 25 | +} | |
| 26 | + | |
| 27 | +/** | |
| 28 | + * `X-VOKO-Signature` 的值:sha256=HMAC(secret, timestamp + "." + body)。 | |
| 29 | + * @param {string} secret | |
| 30 | + * @param {string} timestamp | |
| 31 | + * @param {string} body | |
| 32 | + * @returns {string} | |
| 33 | + */ | |
| 34 | +export function signWebhook(secret, timestamp, body) { | |
| 35 | + return `sha256=${createHmac('sha256', secret).update(`${timestamp}.${body}`).digest('hex')}` | |
| 36 | +} | |
| 37 | + | |
| 38 | +/** | |
| 39 | + * Webhook:Agent 登记 HTTPS 地址后,事件写入时生成投递记录,由 worker 签名投递并按退避重试。 | |
| 40 | + * Webhook 只是「提醒你来拉」,漏收的事件都能从 /home 或 /events 补到。 | |
| 41 | + * | |
| 42 | + * @param {{ db: import('knex').Knex, secret_key: string }} deps `secret_key` 用于加密存储各 Agent 的签名 secret | |
| 43 | + */ | |
| 44 | +export function createWebhooks({ db, secret_key }) { | |
| 45 | + if (typeof db !== 'function') throw new Error('createWebhooks: db is required') | |
| 46 | + if (typeof secret_key !== 'string' || secret_key === '') throw new Error('createWebhooks: secret_key is required') | |
| 47 | + const key = createHash('sha256').update(secret_key).digest() | |
| 48 | + | |
| 49 | + /** @param {string} secret */ | |
| 50 | + function encrypt(secret) { | |
| 51 | + const iv = randomBytes(12) | |
| 52 | + const cipher = createCipheriv('aes-256-gcm', key, iv) | |
| 53 | + const data = Buffer.concat([cipher.update(secret, 'utf8'), cipher.final()]) | |
| 54 | + return ['v1', iv.toString('base64url'), cipher.getAuthTag().toString('base64url'), data.toString('base64url')].join('.') | |
| 55 | + } | |
| 56 | + | |
| 57 | + /** @param {string} stored */ | |
| 58 | + function decrypt(stored) { | |
| 59 | + const [version, iv, tag, data] = stored.split('.') | |
| 60 | + if (version !== 'v1') throw new Error(`webhook secret has unknown version: ${version}`) | |
| 61 | + const decipher = createDecipheriv('aes-256-gcm', key, Buffer.from(iv, 'base64url')) | |
| 62 | + decipher.setAuthTag(Buffer.from(tag, 'base64url')) | |
| 63 | + return Buffer.concat([decipher.update(Buffer.from(data, 'base64url')), decipher.final()]).toString('utf8') | |
| 64 | + } | |
| 65 | + | |
| 66 | + /** | |
| 67 | + * 只允许 HTTPS,且域名解析出的全部地址都必须是公网地址(防 SSRF)。 | |
| 68 | + * ponytail: 校验与实际请求各解析一次 DNS,存在重绑定窗口;要堵死就给 fetch 换成固定到已校验 IP 的 dispatcher。 | |
| 69 | + * @param {string} webhook_url | |
| 70 | + * @returns {Promise<string|null>} 不合规时返回原因 | |
| 71 | + */ | |
| 72 | + async function urlProblem(webhook_url) { | |
| 73 | + const url = new URL(webhook_url) | |
| 74 | + if (url.protocol !== 'https:') return 'webhook_url must use https.' | |
| 75 | + if (url.username !== '' || url.password !== '') return 'webhook_url must not contain credentials.' | |
| 76 | + const host = url.hostname.replace(/^\[|\]$/g, '') | |
| 77 | + // 外部 IO 的最小包裹:解析不了的域名是调用方的输入错误,不是程序 bug。 | |
| 78 | + let addresses | |
| 79 | + try { | |
| 80 | + addresses = isIP(host) === 0 ? (await lookup(host, { all: true })).map((item) => item.address) : [host] | |
| 81 | + } catch { | |
| 82 | + return 'webhook_url host cannot be resolved.' | |
| 83 | + } | |
| 84 | + return addresses.length > 0 && addresses.every(isPublicAddress) ? null : 'webhook_url must resolve to public addresses only.' | |
| 85 | + } | |
| 86 | + | |
| 87 | + /** | |
| 88 | + * 登记或取消(传 null)Webhook。登记成功返回签名 secret,仅此一次。 | |
| 89 | + * @param {object} _ctx | |
| 90 | + * @param {{ agent_id: string, webhook_url: string|null }} input | |
| 91 | + * @returns {Promise<{ ok: true, webhook_secret: string|null } | { ok: false, hint: string }>} | |
| 92 | + */ | |
| 93 | + async function register(_ctx, { agent_id, webhook_url }) { | |
| 94 | + if (webhook_url === null) { | |
| 95 | + await db('vk_agents').where({ id: agent_id }).update({ webhook_url: null, webhook_secret_enc: null, updated_at: new Date() }) | |
| 96 | + return { ok: true, webhook_secret: null } | |
| 97 | + } | |
| 98 | + const problem = await urlProblem(webhook_url) | |
| 99 | + if (problem !== null) return { ok: false, hint: problem } | |
| 100 | + const webhook_secret = randomBytes(32).toString('hex') | |
| 101 | + await db('vk_agents').where({ id: agent_id }).update({ webhook_url, webhook_secret_enc: encrypt(webhook_secret), updated_at: new Date() }) | |
| 102 | + return { ok: true, webhook_secret } | |
| 103 | + } | |
| 104 | + | |
| 105 | + /** | |
| 106 | + * worker:领取并投递一批到期的 Webhook;返回本轮处理的条数。 | |
| 107 | + * @param {object} _ctx | |
| 108 | + * @returns {Promise<number>} | |
| 109 | + */ | |
| 110 | + async function deliverDue(_ctx) { | |
| 111 | + const now = new Date() | |
| 112 | + const due = await db.transaction(async (trx) => { | |
| 113 | + const rows = await trx(DELIVERIES).where({ status: 'pending' }).where('next_attempt_at', '<=', now).orderBy('next_attempt_at', 'asc').limit(BATCH).forUpdate().skipLocked() | |
| 114 | + // 把下次尝试时间推后当作租约,避免另一个 worker 同时投递同一条。 | |
| 115 | + if (rows.length > 0) await trx(DELIVERIES).whereIn('id', rows.map((row) => row.id)).update({ next_attempt_at: new Date(now.getTime() + LEASE_MS) }) | |
| 116 | + return rows | |
| 117 | + }) | |
| 118 | + for (const delivery of due) { | |
| 119 | + const failure = await attempt(delivery) | |
| 120 | + if (failure === null) { | |
| 121 | + await db(DELIVERIES).where({ id: delivery.id }).update({ status: 'delivered', attempts: delivery.attempts + 1, next_attempt_at: null, delivered_at: new Date() }) | |
| 122 | + continue | |
| 123 | + } | |
| 124 | + const retryAfter = RETRY_AFTER_MS[delivery.attempts] | |
| 125 | + await db(DELIVERIES).where({ id: delivery.id }).update(retryAfter === undefined | |
| 126 | + ? { status: 'failed', attempts: delivery.attempts + 1, next_attempt_at: null } | |
| 127 | + : { attempts: delivery.attempts + 1, next_attempt_at: new Date(Date.now() + retryAfter) }) | |
| 128 | + } | |
| 129 | + return due.length | |
| 130 | + } | |
| 131 | + | |
| 132 | + /** | |
| 133 | + * 投递一次。成功返回 null,失败返回原因。 | |
| 134 | + * @param {{ event_id: number, agent_id: string }} delivery | |
| 135 | + * @returns {Promise<string|null>} | |
| 136 | + */ | |
| 137 | + async function attempt(delivery) { | |
| 138 | + const [agent, event] = await Promise.all([ | |
| 139 | + db('vk_agents').where({ id: delivery.agent_id }).first('webhook_url', 'webhook_secret_enc'), | |
| 140 | + db('vk_events').where({ id: delivery.event_id }).first('id', 'type', 'task_id', 'created_at'), | |
| 141 | + ]) | |
| 142 | + if (agent === undefined || event === undefined) throw new Error(`vk_webhook_deliveries refers to a missing agent or event, event: ${delivery.event_id}, agent: ${delivery.agent_id}`) | |
| 143 | + if (agent.webhook_url === null) return 'webhook was unregistered' | |
| 144 | + const problem = await urlProblem(agent.webhook_url) | |
| 145 | + if (problem !== null) return problem | |
| 146 | + const body = JSON.stringify({ id: String(event.id), type: event.type, created_at: event.created_at.toISOString(), data: { task_id: event.task_id } }) | |
| 147 | + const timestamp = String(Math.floor(Date.now() / 1000)) | |
| 148 | + // 对外部 IO 的最小包裹:对方不可达、超时、非 2xx 都是预期失败,按退避重试。 | |
| 149 | + try { | |
| 150 | + const response = await fetch(agent.webhook_url, { | |
| 151 | + method: 'POST', | |
| 152 | + redirect: 'manual', | |
| 153 | + signal: AbortSignal.timeout(TIMEOUT_MS), | |
| 154 | + headers: { | |
| 155 | + 'content-type': 'application/json', | |
| 156 | + 'x-voko-event': event.type, | |
| 157 | + 'x-voko-delivery': String(event.id), | |
| 158 | + 'x-voko-timestamp': timestamp, | |
| 159 | + 'x-voko-signature': signWebhook(decrypt(agent.webhook_secret_enc), timestamp, body), | |
| 160 | + }, | |
| 161 | + body, | |
| 162 | + }) | |
| 163 | + return response.status >= 200 && response.status < 300 ? null : `receiver returned ${response.status}` | |
| 164 | + } catch (err) { | |
| 165 | + return err instanceof Error ? err.message : String(err) | |
| 166 | + } | |
| 167 | + } | |
| 168 | + | |
| 169 | + return { register, deliverDue } | |
| 170 | +} | ... | ... |
packages/voko-access/test/webhooks.test.js
0 → 100644
| 1 | +import { createHmac } from 'node:crypto' | |
| 2 | +import { describe, expect, it } from 'vitest' | |
| 3 | +import { isPublicAddress, signWebhook } from '../src/webhooks.js' | |
| 4 | + | |
| 5 | +describe('Webhook 签名', () => { | |
| 6 | + it('sha256=HMAC(secret, timestamp + "." + body),接收方用同一 secret 可验证', () => { | |
| 7 | + const body = '{"id":"42","type":"task.accepted"}' | |
| 8 | + const signature = signWebhook('s3cret', '1791600000', body) | |
| 9 | + expect(signature).toBe(`sha256=${createHmac('sha256', 's3cret').update(`1791600000.${body}`).digest('hex')}`) | |
| 10 | + expect(signature).toMatch(/^sha256=[0-9a-f]{64}$/) | |
| 11 | + expect(signWebhook('s3cret', '1791600001', body)).not.toBe(signature) | |
| 12 | + expect(signWebhook('other', '1791600000', body)).not.toBe(signature) | |
| 13 | + }) | |
| 14 | +}) | |
| 15 | + | |
| 16 | +describe('Webhook 目标地址(防 SSRF)', () => { | |
| 17 | + it.each(['127.0.0.1', '10.1.2.3', '172.16.0.1', '172.31.255.255', '192.168.1.1', '169.254.169.254', '100.64.0.1', '0.0.0.0', '224.0.0.1', '::1', '::', 'fe80::1', 'fc00::1', 'fd12:3456::1', '::ffff:127.0.0.1', '::ffff:10.0.0.1'])('%s 不是公网地址', (address) => { | |
| 18 | + expect(isPublicAddress(address)).toBe(false) | |
| 19 | + }) | |
| 20 | + | |
| 21 | + it.each(['8.8.8.8', '1.1.1.1', '172.32.0.1', '93.184.216.34', '2606:4700:4700::1111'])('%s 是公网地址', (address) => { | |
| 22 | + expect(isPublicAddress(address)).toBe(true) | |
| 23 | + }) | |
| 24 | + | |
| 25 | + it('不是 IP 直接 throw', () => { | |
| 26 | + expect(() => isPublicAddress('example.com')).toThrow(/not an IP address/) | |
| 27 | + }) | |
| 28 | +}) | ... | ... |
packages/voko-clearing/src/conservation.js
0 → 100644
| 1 | +/** | |
| 2 | + * 对账(SDD「积分清算」):① 已关闭托管的发放明细之和 = 托管金额;② held 托管都对应未结束的任务, | |
| 3 | + * 未结束的任务都有 held 托管;③ 每笔 voko 流水的 ref 都能找到业务记录。返回全部不满足项,带数据 id。 | |
| 4 | + */ | |
| 5 | + | |
| 6 | +const TERMINAL = ['settled', 'cancelled', 'refunded'] | |
| 7 | + | |
| 8 | +/** | |
| 9 | + * @param {import('knex').Knex} db | |
| 10 | + * @returns {Promise<Array<{ rule: string, id: string, detail: string }>>} | |
| 11 | + */ | |
| 12 | +export async function verifyConservation(db) { | |
| 13 | + const problems = [] | |
| 14 | + const closed = await db('vk_escrows as e') | |
| 15 | + .leftJoin('vk_escrow_payouts as p', 'p.escrow_id', 'e.id') | |
| 16 | + .whereNot('e.status', 'held') | |
| 17 | + .groupBy('e.id', 'e.amount') | |
| 18 | + .havingRaw('COALESCE(SUM(p.amount), 0) <> e.amount') | |
| 19 | + .select('e.id', 'e.amount', db.raw('COALESCE(SUM(p.amount), 0) as paid')) | |
| 20 | + for (const row of closed) problems.push({ rule: 'payouts_sum', id: row.id, detail: `amount=${row.amount}, payouts=${row.paid}` }) | |
| 21 | + | |
| 22 | + 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') | |
| 23 | + for (const row of heldOnEnded) problems.push({ rule: 'held_on_ended_task', id: row.id, detail: `task status=${row.status}` }) | |
| 24 | + const openWithoutHeld = await db('vk_tasks as t') | |
| 25 | + .leftJoin('vk_escrows as e', (join) => join.on('e.task_id', 't.id').andOnVal('e.status', 'held')) | |
| 26 | + .whereNotIn('t.status', TERMINAL) | |
| 27 | + .whereNull('e.id') | |
| 28 | + .select('t.id', 't.status') | |
| 29 | + for (const row of openWithoutHeld) problems.push({ rule: 'active_task_without_held_escrow', id: row.id, detail: `task status=${row.status}` }) | |
| 30 | + | |
| 31 | + 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') | |
| 32 | + 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') | |
| 33 | + const orphanTipRefs = await db('billing_ledger as l').leftJoin('vk_tips as p', 'p.id', 'l.ref_id').where({ 'l.source': 'voko', 'l.ref_type': 'vk_tip' }).whereNull('p.id').select('l.id', 'l.ref_id') | |
| 34 | + for (const row of [...orphanTaskRefs, ...orphanAgentRefs, ...orphanTipRefs]) problems.push({ rule: 'ledger_ref_missing', id: row.id, detail: `ref_id=${row.ref_id}` }) | |
| 35 | + return problems | |
| 36 | +} | ... | ... |
packages/voko-clearing/src/index.js
| 1 | 1 | /** |
| 2 | 2 | * `@voko/clearing` —— VOKO 的积分清算:托管与发放明细;动账一律经 `@drama/billing` 的账本。 |
| 3 | 3 | * |
| 4 | - * 托管的冻结、补扣、结算与退回,钱包余额与流水,打赏。 | |
| 4 | + * 托管的冻结、补扣、结算与退回,钱包余额与流水,打赏,对账。 | |
| 5 | 5 | * |
| 6 | 6 | * 边界:不读 `process.env`、不建连接;db 由 composition root 注入。 |
| 7 | 7 | */ |
| 8 | 8 | |
| 9 | 9 | export { createEscrow } from './escrow.js' |
| 10 | 10 | export { createTips } from './tips.js' |
| 11 | +export { verifyConservation } from './conservation.js' | |
| 11 | 12 | export { default as clearingDbSchema } from './db/tables/index.cjs' | ... | ... |
packages/voko-exchange/src/events.js
| 1 | +import { insertEvent } from './tasks.js' | |
| 2 | + | |
| 1 | 3 | const EVENTS = 'vk_events' |
| 2 | 4 | const PAGE = 50 |
| 3 | 5 | |
| ... | ... | @@ -56,7 +58,7 @@ export function createEvents({ db }) { |
| 56 | 58 | * @param {{ guild_id: string, task_id: string, type: string, target_agent_id: string|null }} input |
| 57 | 59 | */ |
| 58 | 60 | async function emit(_ctx, trx, { guild_id, task_id, type, target_agent_id }) { |
| 59 | - await trx(EVENTS).insert({ guild_id, target_agent_id, type, task_id, payload: '{}', created_at: new Date() }) | |
| 61 | + await insertEvent(trx, { id: task_id, guild_id }, type, target_agent_id, new Date()) | |
| 60 | 62 | } |
| 61 | 63 | |
| 62 | 64 | return { pullUnread, listSince, emit } | ... | ... |
packages/voko-exchange/src/jobs.js
| ... | ... | @@ -22,10 +22,11 @@ const WARN_KINDS = Object.freeze(['checkpoint_warn', 'review_warn']) |
| 22 | 22 | * arbitrate(ctx: object, input: { task_id: string }): Promise<void>, |
| 23 | 23 | * }, |
| 24 | 24 | * help: { notify(ctx: object, input: { help_id: string }): Promise<void> }, |
| 25 | + * conservationProblems(): Promise<Array<{ rule: string, id: string, detail: string }>>, | |
| 25 | 26 | * logger: { error(message: string, fields: object): void }, |
| 26 | 27 | * }} deps |
| 27 | 28 | */ |
| 28 | -export function createJobs({ db, tasks, help, logger }) { | |
| 29 | +export function createJobs({ db, tasks, help, conservationProblems, logger }) { | |
| 29 | 30 | if (typeof db !== 'function') throw new Error('createJobs: db is required') |
| 30 | 31 | |
| 31 | 32 | /** |
| ... | ... | @@ -72,6 +73,12 @@ export function createJobs({ db, tasks, help, logger }) { |
| 72 | 73 | * @param {{ id: string, kind: string, ref_id: string|null }} job |
| 73 | 74 | */ |
| 74 | 75 | async function execute(ctx, job) { |
| 76 | + if (job.kind === 'verify_conservation') { | |
| 77 | + // 每日对账:有任何不守恒就让 job 失败(记错误日志即告警),并附上出问题的数据 id。 | |
| 78 | + const problems = await conservationProblems() | |
| 79 | + if (problems.length > 0) throw new Error(`conservation check failed: ${JSON.stringify(problems.slice(0, 20))}`) | |
| 80 | + return | |
| 81 | + } | |
| 75 | 82 | if (job.ref_id === null) throw new Error(`vk_jobs.ref_id missing for ${job.kind}, id: ${job.id}`) |
| 76 | 83 | const task_id = job.ref_id |
| 77 | 84 | if (TASK_ACTION_KINDS.includes(job.kind)) return void await tasks.actAsWorker(ctx, { task_id, action: { type: job.kind } }) |
| ... | ... | @@ -90,5 +97,19 @@ export function createJobs({ db, tasks, help, logger }) { |
| 90 | 97 | throw new Error(`Unhandled job kind: ${job.kind}, id: ${job.id}`) |
| 91 | 98 | } |
| 92 | 99 | |
| 93 | - return { runDue } | |
| 100 | + /** | |
| 101 | + * 确保下一次每日对账(UTC 01:00)已经排上;重复调用无害(dedupe_key 按日期唯一)。 | |
| 102 | + * @param {object} _ctx | |
| 103 | + */ | |
| 104 | + async function ensureDaily(_ctx) { | |
| 105 | + const now = new Date() | |
| 106 | + const next = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate(), 1)) | |
| 107 | + if (next <= now) next.setUTCDate(next.getUTCDate() + 1) | |
| 108 | + await db(JOBS) | |
| 109 | + .insert({ id: `j_conservation_${next.toISOString().slice(0, 10)}`, kind: 'verify_conservation', ref_id: null, run_at: next, status: 'pending', attempts: 0, dedupe_key: `verify_conservation:${next.toISOString().slice(0, 10)}`, created_at: now }) | |
| 110 | + .onConflict('dedupe_key') | |
| 111 | + .ignore() | |
| 112 | + } | |
| 113 | + | |
| 114 | + return { runDue, ensureDaily } | |
| 94 | 115 | } | ... | ... |
packages/voko-exchange/src/tasks.js
| ... | ... | @@ -686,13 +686,17 @@ async function acceptanceOf(runner, task_id) { |
| 686 | 686 | |
| 687 | 687 | /** |
| 688 | 688 | * @param {import('knex').Knex} trx |
| 689 | - * @param {Task} task | |
| 689 | + * @param {{ id: string, guild_id: string }} task | |
| 690 | 690 | * @param {string} type |
| 691 | 691 | * @param {string|null} target_agent_id null 表示发给整个公会 |
| 692 | 692 | * @param {Date} now |
| 693 | 693 | */ |
| 694 | 694 | export async function insertEvent(trx, task, type, target_agent_id, now) { |
| 695 | - await trx(EVENTS).insert({ guild_id: task.guild_id, target_agent_id, type, task_id: task.id, payload: '{}', created_at: now }) | |
| 695 | + const [event_id] = await trx(EVENTS).insert({ guild_id: task.guild_id, target_agent_id, type, task_id: task.id, payload: '{}', created_at: now }) | |
| 696 | + // 给登记了 Webhook 的目标 Agent(公会广播则是全体成员)各排一条投递,由 worker 签名发送。 | |
| 697 | + const receivers = trx('vk_agents').whereNotNull('webhook_url').where({ status: 'active' }).where(target_agent_id === null ? { guild_id: task.guild_id } : { id: target_agent_id }).select('id') | |
| 698 | + const deliveries = (await receivers).map((agent) => ({ id: `wd_${randomUUID()}`, event_id, agent_id: agent.id, status: 'pending', attempts: 0, next_attempt_at: now, created_at: now })) | |
| 699 | + if (deliveries.length > 0) await trx('vk_webhook_deliveries').insert(deliveries) | |
| 696 | 700 | } |
| 697 | 701 | |
| 698 | 702 | /** | ... | ... |
tools/scripts/verify-local.sh
| ... | ... | @@ -30,6 +30,6 @@ curl -sf http://localhost:9201/healthz >/dev/null || { echo "API 未启动,日 |
| 30 | 30 | VOKO_ADMIN_EMAIL=admin@example.test VOKO_ADMIN_PASSWORD=admin123 \ |
| 31 | 31 | pnpm --filter @voko/server verify:skeleton |
| 32 | 32 | export VOKO_ADMIN_EMAIL=admin@example.test VOKO_ADMIN_PASSWORD=admin123 |
| 33 | -for scenario in f0 f1 f2 f3 concurrency timers; do | |
| 33 | +for scenario in f0 f1 f2 f3 e2e concurrency webhook timers; do | |
| 34 | 34 | pnpm --filter @voko/server "verify:$scenario" |
| 35 | 35 | done | ... | ... |