Commit 69968036abd66ca07a08f65c0b99836b10a54682

Authored by yibo
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>
apps/voko-server/.env.example
@@ -6,6 +6,8 @@ LOG_LEVEL=info @@ -6,6 +6,8 @@ LOG_LEVEL=info
6 DATABASE_URL= 6 DATABASE_URL=
7 # 生产必须设置,开发缺省使用固定开发值 7 # 生产必须设置,开发缺省使用固定开发值
8 AUTH_TOKEN_SECRET= 8 AUTH_TOKEN_SECRET=
  9 +# 加密存储各 Agent 的 Webhook 签名 secret。生产必须设置,换掉后已登记的 Webhook 需重新登记
  10 +WEBHOOK_SECRET_KEY=
9 # 前端地址,用于邮件里的链接 11 # 前端地址,用于邮件里的链接
10 PUBLIC_URL=http://localhost:5201 12 PUBLIC_URL=http://localhost:5201
11 # 发信(验证码、申请通过通知)。缺任何一项服务拒绝启动。 13 # 发信(验证码、申请通过通知)。缺任何一项服务拒绝启动。
apps/voko-server/package.json
@@ -18,6 +18,7 @@ @@ -18,6 +18,7 @@
18 "verify:f2": "node tools/scenarios/voko-alpha/f2-execute.mjs", 18 "verify:f2": "node tools/scenarios/voko-alpha/f2-execute.mjs",
19 "verify:f3": "node tools/scenarios/voko-alpha/f3-review-settle.mjs", 19 "verify:f3": "node tools/scenarios/voko-alpha/f3-review-settle.mjs",
20 "verify:e2e": "node tools/scenarios/voko-alpha/e2e-loop.mjs", 20 "verify:e2e": "node tools/scenarios/voko-alpha/e2e-loop.mjs",
  21 + "verify:webhook": "node tools/scenarios/voko-alpha/webhook.mjs",
21 "verify:timers": "node tools/scenarios/voko-alpha/timers.mjs", 22 "verify:timers": "node tools/scenarios/voko-alpha/timers.mjs",
22 "verify:concurrency": "node tools/scenarios/voko-alpha/concurrency.mjs", 23 "verify:concurrency": "node tools/scenarios/voko-alpha/concurrency.mjs",
23 "verify:conservation": "node tools/scenarios/voko-alpha/verify-conservation.mjs" 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 &#39;@drama/billing&#39; @@ -9,9 +9,9 @@ import { createBillingRepo, createLedger } from &#39;@drama/billing&#39;
9 import { createDb } from '@drama/db' 9 import { createDb } from '@drama/db'
10 import { createLLM, createStaticKeyProvider } from '@drama/llm' 10 import { createLLM, createStaticKeyProvider } from '@drama/llm'
11 import { createLogger } from '@drama/observability' 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 import { createMemoryStore, createRateLimiter } from '@drama/ratelimit' 13 import { createMemoryStore, createRateLimiter } from '@drama/ratelimit'
14 -import { createEscrow, createTips } from '@voko/clearing' 14 +import { createEscrow, createTips, verifyConservation } from '@voko/clearing'
15 import { createAgents, createEvents, createGuilds, createHelp, createJobs, createJudge, createTasks } from '@voko/exchange' 15 import { createAgents, createEvents, createGuilds, createHelp, createJobs, createJudge, createTasks } from '@voko/exchange'
16 16
17 import { readEnv } from '../env.js' 17 import { readEnv } from '../env.js'
@@ -68,7 +68,8 @@ export async function createRuntime({ service }) { @@ -68,7 +68,8 @@ export async function createRuntime({ service }) {
68 }, 68 },
69 }) 69 })
70 const tips = createTips({ db }) 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,7 +135,7 @@ export async function createRuntime({ service }) {
134 return { 135 return {
135 env, 136 env,
136 logger, 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 async close() { 139 async close() {
139 await db.destroy() 140 await db.destroy()
140 }, 141 },
apps/voko-server/src/env.js
@@ -11,6 +11,7 @@ import { devMysqlUrl } from &#39;@drama/db&#39; @@ -11,6 +11,7 @@ import { devMysqlUrl } from &#39;@drama/db&#39;
11 * databaseUrl: string, 11 * databaseUrl: string,
12 * logLevel: 'debug'|'info'|'warn'|'error', 12 * logLevel: 'debug'|'info'|'warn'|'error',
13 * authTokenSecret: string, 13 * authTokenSecret: string,
  14 + * webhookSecretKey: string,
14 * publicUrl: string, 15 * publicUrl: string,
15 * email: { host: string, port: number, secure: boolean, user: string, pass: string, from: string }, 16 * email: { host: string, port: number, secure: boolean, user: string, pass: string, from: string },
16 * oauth: { googleClientId: string|null, googleClientSecret: string|null }, 17 * oauth: { googleClientId: string|null, googleClientSecret: string|null },
@@ -28,6 +29,7 @@ export function readEnv(rawEnv) { @@ -28,6 +29,7 @@ export function readEnv(rawEnv) {
28 const scheme = new URL(databaseUrl).protocol 29 const scheme = new URL(databaseUrl).protocol
29 if (scheme !== 'mysql:' && scheme !== 'mysql2:') throw new Error(`voko-server: DATABASE_URL 必须是 MySQL,收到 ${scheme}`) 30 if (scheme !== 'mysql:' && scheme !== 'mysql2:') throw new Error(`voko-server: DATABASE_URL 必须是 MySQL,收到 ${scheme}`)
30 if (nodeEnv === 'production' && !rawEnv.AUTH_TOKEN_SECRET) throw new Error('voko-server: 生产环境必须设置 AUTH_TOKEN_SECRET') 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 const email = { 34 const email = {
33 host: rawEnv.SMTP_HOST, 35 host: rawEnv.SMTP_HOST,
@@ -52,6 +54,7 @@ export function readEnv(rawEnv) { @@ -52,6 +54,7 @@ export function readEnv(rawEnv) {
52 databaseUrl, 54 databaseUrl,
53 logLevel: parseLogLevel(rawEnv.LOG_LEVEL), 55 logLevel: parseLogLevel(rawEnv.LOG_LEVEL),
54 authTokenSecret: rawEnv.AUTH_TOKEN_SECRET || 'voko-dev-token-secret', 56 authTokenSecret: rawEnv.AUTH_TOKEN_SECRET || 'voko-dev-token-secret',
  57 + webhookSecretKey: rawEnv.WEBHOOK_SECRET_KEY || 'voko-dev-webhook-secret-key',
55 publicUrl: (rawEnv.PUBLIC_URL || 'http://localhost:5201').replace(/\/$/, ''), 58 publicUrl: (rawEnv.PUBLIC_URL || 'http://localhost:5201').replace(/\/$/, ''),
56 email: /** @type {VokoEnv['email']} */ (email), 59 email: /** @type {VokoEnv['email']} */ (email),
57 llm: /** @type {VokoEnv['llm']} */ (llm), 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,7 +5,7 @@ import { agentView, IdempotencyKey, ledgerView, PublishBody, REFUSAL_STATUS, tas
5 5
6 const AgentParams = v.object({ id: v.pipe(v.string(), v.minLength(1), v.maxLength(64)) }) 6 const AgentParams = v.object({ id: v.pipe(v.string(), v.minLength(1), v.maxLength(64)) })
7 const LimitBody = v.object({ daily_spend_limit: v.nullable(v.pipe(v.number(), v.safeInteger(), v.minValue(0))) }) 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 const ApprovalBody = v.object({ decision: v.picklist(['approve', 'reject']) }) 9 const ApprovalBody = v.object({ decision: v.picklist(['approve', 'reject']) })
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))) }) 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 const HelpParams = v.object({ id: v.pipe(v.string(), v.minLength(1), v.maxLength(64)) }) 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,6 +30,58 @@ export function registerOwnerRoutes(app, services) {
30 const { agents, agentAccess, guilds, escrow, tasks, help, ownerSettings, db } = services 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 * 取当前用户名下的一个 Agent;不是自己的按不存在处理。 85 * 取当前用户名下的一个 Agent;不是自己的按不存在处理。
34 * @param {import('fastify').FastifyRequest} request 86 * @param {import('fastify').FastifyRequest} request
35 */ 87 */
@@ -94,46 +146,6 @@ export function registerOwnerRoutes(app, services) { @@ -94,46 +146,6 @@ export function registerOwnerRoutes(app, services) {
94 return { task: taskView(result.task) } 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 // 负责人:待审核任务(公司 Agent 发布、公会开启发布前审核时)。 149 // 负责人:待审核任务(公司 Agent 发布、公会开启发布前审核时)。
138 app.get('/api/voko/approvals', async (request) => { 150 app.get('/api/voko/approvals', async (request) => {
139 const user = await requireUser(request, services) 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(&#39;Idempotency-Key header is require @@ -62,7 +62,7 @@ export const IdempotencyKey = v.pipe(v.string(&#39;Idempotency-Key header is require
62 export const REFUSAL_STATUS = Object.freeze({ not_found: 404, forbidden: 403, state_conflict: 409, rule_violation: 422 }) 62 export const REFUSAL_STATUS = Object.freeze({ not_found: 404, forbidden: 403, state_conflict: 409, rule_violation: 422 })
63 const SKILL_VERSION = '0.1.0' 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 * 公开 API `/v1`:Agent 用 api_key 调用。统一返回 `{ok:true,data}` / `{ok:false,error:{code,message,hint}}`。 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,7 +70,7 @@ const UpdateMeBody = v.object({ description: v.optional(Description), skills: v.
70 * @param {Record<string, any>} services 70 * @param {Record<string, any>} services
71 */ 71 */
72 export function registerV1Routes(app, services) { 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 app.register(async (v1) => { 75 app.register(async (v1) => {
76 v1.setErrorHandler((/** @type {any} */ err, request, reply) => { 76 v1.setErrorHandler((/** @type {any} */ err, request, reply) => {
@@ -125,10 +125,14 @@ export function registerV1Routes(app, services) { @@ -125,10 +125,14 @@ export function registerV1Routes(app, services) {
125 125
126 v1.patch('/agents/me', async (request, reply) => { 126 v1.patch('/agents/me', async (request, reply) => {
127 const body = v.parse(UpdateMeBody, request.body) 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 const agentId = /** @type {any} */ (request).agent.id 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 v1.get('/home', async (request) => { 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 import { createRuntime } from './assembly/runtime.js' 5 import { createRuntime } from './assembly/runtime.js'
@@ -13,7 +13,9 @@ let stopping = false @@ -13,7 +13,9 @@ let stopping = false
13 13
14 async function tick() { 14 async function tick() {
15 await runtime.services.jobs.runDue({}) 15 await runtime.services.jobs.runDue({})
  16 + await runtime.services.webhooks.deliverDue({})
16 ticks += 1 17 ticks += 1
  18 + if (ticks % HEARTBEAT_EVERY_TICKS === 1) await runtime.services.jobs.ensureDaily({})
17 if (ticks % HEARTBEAT_EVERY_TICKS === 0) runtime.logger.info('worker 心跳', { ticks }) 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,9 +7,9 @@
7 import { randomUUID } from 'node:crypto' 7 import { randomUUID } from 'node:crypto'
8 import { createBillingRepo, createLedger } from '@drama/billing' 8 import { createBillingRepo, createLedger } from '@drama/billing'
9 import { createDb } from '@drama/db' 9 import { createDb } from '@drama/db'
  10 +import { verifyConservation } from '@voko/clearing'
10 11
11 import { databaseUrlFromArgs } from '../../../src/db/env-file.js' 12 import { databaseUrlFromArgs } from '../../../src/db/env-file.js'
12 -import { verifyConservation } from './verify-conservation.mjs'  
13 13
14 const api = (process.env.VOKO_API || 'http://localhost:9201').replace(/\/$/, '') 14 const api = (process.env.VOKO_API || 'http://localhost:9201').replace(/\/$/, '')
15 15
@@ -162,6 +162,7 @@ function createWorld(name, db) { @@ -162,6 +162,7 @@ function createWorld(name, db) {
162 await db('vk_tasks').whereIn('id', taskIds).del() 162 await db('vk_tasks').whereIn('id', taskIds).del()
163 } 163 }
164 if (guildIds.length > 0) { 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 await db('vk_events').whereIn('guild_id', guildIds).del() 166 await db('vk_events').whereIn('guild_id', guildIds).del()
166 await db('vk_guild_members').whereIn('guild_id', guildIds).del() 167 await db('vk_guild_members').whereIn('guild_id', guildIds).del()
167 await db('vk_guilds').whereIn('id', guildIds).del() 168 await db('vk_guilds').whereIn('id', guildIds).del()
apps/voko-server/tools/scenarios/voko-alpha/verify-conservation.mjs
@@ -2,45 +2,14 @@ @@ -2,45 +2,14 @@
2 /** 2 /**
3 * 对账(SDD「积分清算」):① 已关闭托管的发放明细之和 = 托管金额;② held 托管都对应未结束的任务, 3 * 对账(SDD「积分清算」):① 已关闭托管的发放明细之和 = 托管金额;② held 托管都对应未结束的任务,
4 * 未结束的任务都有 held 托管;③ 每笔 voko 流水的 ref 都能找到业务记录。 4 * 未结束的任务都有 held 托管;③ 每笔 voko 流水的 ref 都能找到业务记录。
5 - * 任一不满足即失败并打印数据 id。可单独运行,也在每个场景脚本末尾运行。 5 + * 任一不满足即失败并打印数据 id。可单独运行;每个场景脚本末尾与 worker 的每日 job 调用同一个函数。
6 */ 6 */
7 import { fileURLToPath } from 'node:url' 7 import { fileURLToPath } from 'node:url'
8 import { createDb } from '@drama/db' 8 import { createDb } from '@drama/db'
  9 +import { verifyConservation } from '@voko/clearing'
9 10
10 import { databaseUrlFromArgs } from '../../../src/db/env-file.js' 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 if (process.argv[1] === fileURLToPath(import.meta.url)) { 13 if (process.argv[1] === fileURLToPath(import.meta.url)) {
45 const db = createDb({ url: databaseUrlFromArgs(process.argv.slice(2)), pool: { min: 1, max: 1 } }) 14 const db = createDb({ url: databaseUrlFromArgs(process.argv.slice(2)), pool: { min: 1, max: 1 } })
46 try { 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,6 +69,45 @@ export const messages = {
69 approve: 'Approve', 69 approve: 'Approve',
70 reject: 'Reject and refund', 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 board: { 111 board: {
73 title: 'Guild board', 112 title: 'Guild board',
74 back: 'Back to my agents', 113 back: 'Back to my agents',
@@ -160,6 +199,45 @@ export const messages = { @@ -160,6 +199,45 @@ export const messages = {
160 approve: '通过', 199 approve: '通过',
161 reject: '驳回并退款', 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 board: { 241 board: {
164 title: '公会看板', 242 title: '公会看板',
165 back: '返回我的 Agent', 243 back: '返回我的 Agent',
apps/voko-web/src/main.js
@@ -10,8 +10,12 @@ import AgentsPage from &#39;./pages/AgentsPage.vue&#39; @@ -10,8 +10,12 @@ import AgentsPage from &#39;./pages/AgentsPage.vue&#39;
10 import ApprovalsPage from './pages/ApprovalsPage.vue' 10 import ApprovalsPage from './pages/ApprovalsPage.vue'
11 import BoardPage from './pages/BoardPage.vue' 11 import BoardPage from './pages/BoardPage.vue'
12 import ClaimPage from './pages/ClaimPage.vue' 12 import ClaimPage from './pages/ClaimPage.vue'
  13 +import HelpPage from './pages/HelpPage.vue'
13 import HomePage from './pages/HomePage.vue' 14 import HomePage from './pages/HomePage.vue'
  15 +import LedgerPage from './pages/LedgerPage.vue'
14 import LoginPage from './pages/LoginPage.vue' 16 import LoginPage from './pages/LoginPage.vue'
  17 +import SettingsPage from './pages/SettingsPage.vue'
  18 +import TaskPage from './pages/TaskPage.vue'
15 19
16 const router = createRouter({ 20 const router = createRouter({
17 history: createWebHistory(), 21 history: createWebHistory(),
@@ -22,6 +26,10 @@ const router = createRouter({ @@ -22,6 +26,10 @@ const router = createRouter({
22 { path: '/claim/:token', component: ClaimPage }, 26 { path: '/claim/:token', component: ClaimPage },
23 { path: '/agents', component: AgentsPage }, 27 { path: '/agents', component: AgentsPage },
24 { path: '/approvals', component: ApprovalsPage }, 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 { path: '/agents/:id/board', component: BoardPage }, 33 { path: '/agents/:id/board', component: BoardPage },
26 ], 34 ],
27 }) 35 })
apps/voko-web/src/pages/AgentsPage.vue
@@ -66,13 +66,13 @@ onMounted(async () =&gt; { @@ -66,13 +66,13 @@ onMounted(async () =&gt; {
66 66
67 <template> 67 <template>
68 <h1>{{ t('agents.title') }}</h1> 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 <template v-if="agents"> 70 <template v-if="agents">
71 <p v-if="agents.length === 0">{{ t('agents.empty') }}</p> 71 <p v-if="agents.length === 0">{{ t('agents.empty') }}</p>
72 <Card v-for="agent in agents" :key="agent.id" class="voko-agent"> 72 <Card v-for="agent in agents" :key="agent.id" class="voko-agent">
73 <h2>{{ agent.name }} <Badge :variant="BADGE[agent.status]">{{ t(`agents.status.${agent.status}`) }}</Badge></h2> 73 <h2>{{ agent.name }} <Badge :variant="BADGE[agent.status]">{{ t(`agents.status.${agent.status}`) }}</Badge></h2>
74 <p>{{ t('agents.level', { level: agent.level }) }} · {{ t('agents.wallet', agent.wallet) }}</p> 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 <label class="voko-field">{{ t('agents.dailyLimit') }}<Input v-model="local[agent.id].limit" type="number" /></label> 76 <label class="voko-field">{{ t('agents.dailyLimit') }}<Input v-model="local[agent.id].limit" type="number" /></label>
77 <div class="voko-actions"> 77 <div class="voko-actions">
78 <Button variant="secondary" @click="saveLimit(agent.id)">{{ t('agents.saveLimit') }}</Button> 78 <Button variant="secondary" @click="saveLimit(agent.id)">{{ t('agents.saveLimit') }}</Button>
@@ -87,6 +87,11 @@ onMounted(async () =&gt; { @@ -87,6 +87,11 @@ onMounted(async () =&gt; {
87 </template> 87 </template>
88 88
89 <style scoped> 89 <style scoped>
  90 +.voko-nav {
  91 + display: flex;
  92 + flex-wrap: wrap;
  93 + gap: var(--drama-space-md);
  94 +}
90 .voko-agent { 95 .voko-agent {
91 margin-bottom: var(--drama-space-md); 96 margin-bottom: var(--drama-space-md);
92 } 97 }
apps/voko-web/src/pages/BoardPage.vue
@@ -74,7 +74,7 @@ onMounted(async () =&gt; { @@ -74,7 +74,7 @@ onMounted(async () =&gt; {
74 <template v-if="tasks"> 74 <template v-if="tasks">
75 <p v-if="tasks.length === 0">{{ t('board.empty') }}</p> 75 <p v-if="tasks.length === 0">{{ t('board.empty') }}</p>
76 <Card v-for="task in tasks" :key="task.id" class="voko-board-card"> 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 <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> 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 <p class="voko-spec">{{ task.spec }}</p> 79 <p class="voko-spec">{{ task.spec }}</p>
80 </Card> 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,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 - `apps/voko-server/.env` 配置了真实 SMTP 与大模型(`LLM_*`);`VOKO_TEST_EMAIL` 是能收信的邮箱;有一个管理员账号(本地 `pnpm dev:user admin@example.test admin123`)。 8 - `apps/voko-server/.env` 配置了真实 SMTP 与大模型(`LLM_*`);`VOKO_TEST_EMAIL` 是能收信的邮箱;有一个管理员账号(本地 `pnpm dev:user admin@example.test admin123`)。
9 - 每个脚本会给测试邮箱的 plus 地址发若干封验证码与审核通过邮件。 9 - 每个脚本会给测试邮箱的 plus 地址发若干封验证码与审核通过邮件。
10 10
@@ -14,9 +14,11 @@ @@ -14,9 +14,11 @@
14 export VOKO_TEST_EMAIL=you@gmail.com VOKO_ADMIN_EMAIL=admin@example.test VOKO_ADMIN_PASSWORD=admin123 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 15 pnpm --filter @voko/server verify:f0 # F0-01–05 入驻,见 verify-f0.md
16 pnpm --filter @voko/server verify:f1 # F1-01–08 发布、修改、撤回、评论、发布前审核 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 pnpm --filter @voko/server verify:timers # TM-01–05 超时释放、缺检查点、自动确认、AI 选赢家、过期退款(需 worker,真实调用大模型,约 2 分钟) 22 pnpm --filter @voko/server verify:timers # TM-01–05 超时释放、缺检查点、自动确认、AI 选赢家、过期退款(需 worker,真实调用大模型,约 2 分钟)
21 pnpm --filter @voko/server verify:conservation # 只跑对账 23 pnpm --filter @voko/server verify:conservation # 只跑对账
22 ``` 24 ```
@@ -31,9 +33,16 @@ pnpm --filter @voko/server verify:conservation # 只跑对账 @@ -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 - CC-01 用 6 个 Agent 发 20 个并发请求(每个主人最多认领 3 个 Agent)。 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,4 +11,5 @@ export { createAccessRequests, accessRequestFromRow, ACCESS_REQUEST_STATUSES } f
11 export { createAgentAccess, MAX_AGENTS_PER_OWNER, API_KEY_PREFIX } from './agent-access.js' 11 export { createAgentAccess, MAX_AGENTS_PER_OWNER, API_KEY_PREFIX } from './agent-access.js'
12 export { createOwnerSettings, DEFAULT_OWNER_SETTINGS } from './owner-settings.js' 12 export { createOwnerSettings, DEFAULT_OWNER_SETTINGS } from './owner-settings.js'
13 export { nextAllowedSendTime } from './quiet-hours.js' 13 export { nextAllowedSendTime } from './quiet-hours.js'
  14 +export { createWebhooks, signWebhook, isPublicAddress } from './webhooks.js'
14 export { default as accessDbSchema } from './db/tables/index.cjs' 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 * `@voko/clearing` —— VOKO 的积分清算:托管与发放明细;动账一律经 `@drama/billing` 的账本。 2 * `@voko/clearing` —— VOKO 的积分清算:托管与发放明细;动账一律经 `@drama/billing` 的账本。
3 * 3 *
4 - * 托管的冻结、补扣、结算与退回,钱包余额与流水,打赏。 4 + * 托管的冻结、补扣、结算与退回,钱包余额与流水,打赏,对账。
5 * 5 *
6 * 边界:不读 `process.env`、不建连接;db 由 composition root 注入。 6 * 边界:不读 `process.env`、不建连接;db 由 composition root 注入。
7 */ 7 */
8 8
9 export { createEscrow } from './escrow.js' 9 export { createEscrow } from './escrow.js'
10 export { createTips } from './tips.js' 10 export { createTips } from './tips.js'
  11 +export { verifyConservation } from './conservation.js'
11 export { default as clearingDbSchema } from './db/tables/index.cjs' 12 export { default as clearingDbSchema } from './db/tables/index.cjs'
packages/voko-exchange/src/events.js
  1 +import { insertEvent } from './tasks.js'
  2 +
1 const EVENTS = 'vk_events' 3 const EVENTS = 'vk_events'
2 const PAGE = 50 4 const PAGE = 50
3 5
@@ -56,7 +58,7 @@ export function createEvents({ db }) { @@ -56,7 +58,7 @@ export function createEvents({ db }) {
56 * @param {{ guild_id: string, task_id: string, type: string, target_agent_id: string|null }} input 58 * @param {{ guild_id: string, task_id: string, type: string, target_agent_id: string|null }} input
57 */ 59 */
58 async function emit(_ctx, trx, { guild_id, task_id, type, target_agent_id }) { 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 return { pullUnread, listSince, emit } 64 return { pullUnread, listSince, emit }
packages/voko-exchange/src/jobs.js
@@ -22,10 +22,11 @@ const WARN_KINDS = Object.freeze([&#39;checkpoint_warn&#39;, &#39;review_warn&#39;]) @@ -22,10 +22,11 @@ const WARN_KINDS = Object.freeze([&#39;checkpoint_warn&#39;, &#39;review_warn&#39;])
22 * arbitrate(ctx: object, input: { task_id: string }): Promise<void>, 22 * arbitrate(ctx: object, input: { task_id: string }): Promise<void>,
23 * }, 23 * },
24 * help: { notify(ctx: object, input: { help_id: string }): Promise<void> }, 24 * help: { notify(ctx: object, input: { help_id: string }): Promise<void> },
  25 + * conservationProblems(): Promise<Array<{ rule: string, id: string, detail: string }>>,
25 * logger: { error(message: string, fields: object): void }, 26 * logger: { error(message: string, fields: object): void },
26 * }} deps 27 * }} deps
27 */ 28 */
28 -export function createJobs({ db, tasks, help, logger }) { 29 +export function createJobs({ db, tasks, help, conservationProblems, logger }) {
29 if (typeof db !== 'function') throw new Error('createJobs: db is required') 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,6 +73,12 @@ export function createJobs({ db, tasks, help, logger }) {
72 * @param {{ id: string, kind: string, ref_id: string|null }} job 73 * @param {{ id: string, kind: string, ref_id: string|null }} job
73 */ 74 */
74 async function execute(ctx, job) { 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 if (job.ref_id === null) throw new Error(`vk_jobs.ref_id missing for ${job.kind}, id: ${job.id}`) 82 if (job.ref_id === null) throw new Error(`vk_jobs.ref_id missing for ${job.kind}, id: ${job.id}`)
76 const task_id = job.ref_id 83 const task_id = job.ref_id
77 if (TASK_ACTION_KINDS.includes(job.kind)) return void await tasks.actAsWorker(ctx, { task_id, action: { type: job.kind } }) 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,5 +97,19 @@ export function createJobs({ db, tasks, help, logger }) {
90 throw new Error(`Unhandled job kind: ${job.kind}, id: ${job.id}`) 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,13 +686,17 @@ async function acceptanceOf(runner, task_id) {
686 686
687 /** 687 /**
688 * @param {import('knex').Knex} trx 688 * @param {import('knex').Knex} trx
689 - * @param {Task} task 689 + * @param {{ id: string, guild_id: string }} task
690 * @param {string} type 690 * @param {string} type
691 * @param {string|null} target_agent_id null 表示发给整个公会 691 * @param {string|null} target_agent_id null 表示发给整个公会
692 * @param {Date} now 692 * @param {Date} now
693 */ 693 */
694 export async function insertEvent(trx, task, type, target_agent_id, now) { 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 &gt;/dev/null || { echo &quot;API 未启动,日 @@ -30,6 +30,6 @@ curl -sf http://localhost:9201/healthz &gt;/dev/null || { echo &quot;API 未启动,日
30 VOKO_ADMIN_EMAIL=admin@example.test VOKO_ADMIN_PASSWORD=admin123 \ 30 VOKO_ADMIN_EMAIL=admin@example.test VOKO_ADMIN_PASSWORD=admin123 \
31 pnpm --filter @voko/server verify:skeleton 31 pnpm --filter @voko/server verify:skeleton
32 export VOKO_ADMIN_EMAIL=admin@example.test VOKO_ADMIN_PASSWORD=admin123 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 pnpm --filter @voko/server "verify:$scenario" 34 pnpm --filter @voko/server "verify:$scenario"
35 done 35 done