Commit 310d07adcce925d08da4e599e15d3e0480be563d
1 parent
118e366f
feat: W4 第一部分——求助与主人回复、安静时段、打赏、流水接口、认领确认邮件、主人设置
- 表:vk_tips、vk_owner_settings(migration 202610100003) - @voko/exchange:求助 service 与 help_notify job;@voko/clearing:打赏与钱包流水 - @voko/access:主人设置、安静时段纯函数;email:voko-notice 模板(en / zh) - /v1:help、tip、wallet/ledger,/home 带 help_answers;网页接口:求助收件箱、设置、Agent 流水 - 场景:E2E-01 完整闭环、F2-04、TM-06(安静时段)、F3-06、CC-03 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Showing
34 changed files
with
758 additions
and
30 deletions
apps/voko-server/package.json
| @@ -17,6 +17,7 @@ | @@ -17,6 +17,7 @@ | ||
| 17 | "verify:f1": "node tools/scenarios/voko-alpha/f1-publish.mjs", | 17 | "verify:f1": "node tools/scenarios/voko-alpha/f1-publish.mjs", |
| 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:timers": "node tools/scenarios/voko-alpha/timers.mjs", | 21 | "verify:timers": "node tools/scenarios/voko-alpha/timers.mjs", |
| 21 | "verify:concurrency": "node tools/scenarios/voko-alpha/concurrency.mjs", | 22 | "verify:concurrency": "node tools/scenarios/voko-alpha/concurrency.mjs", |
| 22 | "verify:conservation": "node tools/scenarios/voko-alpha/verify-conservation.mjs" | 23 | "verify:conservation": "node tools/scenarios/voko-alpha/verify-conservation.mjs" |
apps/voko-server/src/assembly/email.js
| @@ -79,7 +79,44 @@ export function createEmailPorts({ env, db, logger }) { | @@ -79,7 +79,44 @@ export function createEmailPorts({ env, db, logger }) { | ||
| 79 | }, | 79 | }, |
| 80 | } | 80 | } |
| 81 | 81 | ||
| 82 | - return { authEmail, accessNotifier } | 82 | + /** |
| 83 | + * 给主人发一封 voko-notice 并记投递日志。主人邮箱与语言取自 users 行。 | ||
| 84 | + * @param {string} user_id | ||
| 85 | + * @param {string} purpose | ||
| 86 | + * @param {{ kind: 'claimed'|'help', path: string, agent: string, task?: string, question?: string }} notice | ||
| 87 | + */ | ||
| 88 | + async function sendOwnerNotice(user_id, purpose, { path, ...notice }) { | ||
| 89 | + const owner = await db('users').where({ id: user_id }).first('email', 'locale') | ||
| 90 | + if (owner === undefined) throw new Error(`users row missing for owner notice, id: ${user_id}`) | ||
| 91 | + const result = await email.sendTemplate('voko-notice', { to: owner.email, from, locale: String(owner.locale).startsWith('zh') ? 'zh' : 'en', url: `${env.publicUrl}${path}`, ...notice }) | ||
| 92 | + await recordEmailDelivery(db, { provider: result.provider || 'nodemailer', messageId: result.message_id || null, purpose, toEmail: owner.email, status: 'sent', template: 'voko-notice' }) | ||
| 93 | + } | ||
| 94 | + | ||
| 95 | + const ownerNotifier = { | ||
| 96 | + /** | ||
| 97 | + * 认领确认。 | ||
| 98 | + * @param {object} _ctx | ||
| 99 | + * @param {{ user_id: string, agent_name: string }} input | ||
| 100 | + */ | ||
| 101 | + async sendAgentClaimed(_ctx, { user_id, agent_name }) { | ||
| 102 | + try { | ||
| 103 | + await sendOwnerNotice(user_id, 'agent_claimed', { kind: 'claimed', path: '/agents', agent: agent_name }) | ||
| 104 | + } catch (err) { | ||
| 105 | + // 外部 IO 的最小包裹:认领已提交,确认邮件失败只记录,不影响认领结果。 | ||
| 106 | + logger.error('认领确认邮件投递失败', { user_id, error: err instanceof Error ? err.message : String(err) }) | ||
| 107 | + } | ||
| 108 | + }, | ||
| 109 | + /** | ||
| 110 | + * 求助通知。由 worker 的 job 调用,失败直接抛出,由 job 退避重试。 | ||
| 111 | + * @param {object} _ctx | ||
| 112 | + * @param {{ user_id: string, agent_name: string, task_title: string, question: string }} input | ||
| 113 | + */ | ||
| 114 | + async sendHelp(_ctx, { user_id, agent_name, task_title, question }) { | ||
| 115 | + await sendOwnerNotice(user_id, 'help_requested', { kind: 'help', path: '/help', agent: agent_name, task: task_title, question }) | ||
| 116 | + }, | ||
| 117 | + } | ||
| 118 | + | ||
| 119 | + return { authEmail, accessNotifier, ownerNotifier } | ||
| 83 | } | 120 | } |
| 84 | 121 | ||
| 85 | /** | 122 | /** |
apps/voko-server/src/assembly/runtime.js
| @@ -9,10 +9,10 @@ import { createBillingRepo, createLedger } from '@drama/billing' | @@ -9,10 +9,10 @@ import { createBillingRepo, createLedger } from '@drama/billing' | ||
| 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 } from '@voko/access' | 12 | +import { createAccessRequests, createAgentAccess, createOwnerSettings, nextAllowedSendTime } from '@voko/access' |
| 13 | import { createMemoryStore, createRateLimiter } from '@drama/ratelimit' | 13 | import { createMemoryStore, createRateLimiter } from '@drama/ratelimit' |
| 14 | -import { createEscrow } from '@voko/clearing' | ||
| 15 | -import { createAgents, createEvents, createGuilds, createJobs, createJudge, createTasks } from '@voko/exchange' | 14 | +import { createEscrow, createTips } from '@voko/clearing' |
| 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' |
| 18 | import { createEmailPorts } from './email.js' | 18 | import { createEmailPorts } from './email.js' |
| @@ -34,7 +34,7 @@ export async function createRuntime({ service }) { | @@ -34,7 +34,7 @@ export async function createRuntime({ service }) { | ||
| 34 | 34 | ||
| 35 | // 2. 连接与基础端口。 | 35 | // 2. 连接与基础端口。 |
| 36 | const db = createDb({ url: env.databaseUrl }) | 36 | const db = createDb({ url: env.databaseUrl }) |
| 37 | - const { authEmail, accessNotifier } = createEmailPorts({ env, db, logger }) | 37 | + const { authEmail, accessNotifier, ownerNotifier } = createEmailPorts({ env, db, logger }) |
| 38 | const tokenSigner = createHmacTokenSigner({ secret: env.authTokenSecret }) | 38 | const tokenSigner = createHmacTokenSigner({ secret: env.authTokenSecret }) |
| 39 | const hasher = createScryptHasher() | 39 | const hasher = createScryptHasher() |
| 40 | const oauth = env.oauth.googleClientId === null | 40 | const oauth = env.oauth.googleClientId === null |
| @@ -57,7 +57,39 @@ export async function createRuntime({ service }) { | @@ -57,7 +57,39 @@ export async function createRuntime({ service }) { | ||
| 57 | const judge = createJudge({ chat: (request) => llm.chat(request) }) | 57 | const judge = createJudge({ chat: (request) => llm.chat(request) }) |
| 58 | const tasks = createTasks({ db, agents, escrow, judge }) | 58 | const tasks = createTasks({ db, agents, escrow, judge }) |
| 59 | const events = createEvents({ db }) | 59 | const events = createEvents({ db }) |
| 60 | - const jobs = createJobs({ db, tasks, logger }) | 60 | + const ownerSettings = createOwnerSettings({ db }) |
| 61 | + const help = createHelp({ | ||
| 62 | + db, | ||
| 63 | + mailer: ownerNotifier, | ||
| 64 | + // 求助邮件:主人关了就不发;在安静时段内推迟到结束时刻。 | ||
| 65 | + async sendAfter(ctx, { user_id, now }) { | ||
| 66 | + const settings = await ownerSettings.get(ctx, { user_id }) | ||
| 67 | + return settings.email_help ? nextAllowedSendTime(now, settings) : null | ||
| 68 | + }, | ||
| 69 | + }) | ||
| 70 | + const tips = createTips({ db }) | ||
| 71 | + const jobs = createJobs({ db, tasks, help, logger }) | ||
| 72 | + | ||
| 73 | + /** | ||
| 74 | + * 给已结算任务的赢家打赏;收款事件与动账同事务。 | ||
| 75 | + * @param {object} ctx | ||
| 76 | + * @param {{ agent: import('@voko/exchange').Agent, task_id: string, amount: number, client_request_key: string }} input | ||
| 77 | + */ | ||
| 78 | + async function tipTask(ctx, { agent, task_id, amount, client_request_key }) { | ||
| 79 | + const seen = await tasks.get(ctx, { agent, task_id }) | ||
| 80 | + if (!seen.ok) return seen | ||
| 81 | + return tips.send(ctx, { | ||
| 82 | + task_id, | ||
| 83 | + from_agent_id: agent.id, | ||
| 84 | + from_wallet: agent.wallet_user_id, | ||
| 85 | + amount, | ||
| 86 | + client_request_key, | ||
| 87 | + async onSent(trx, { to_wallet }) { | ||
| 88 | + const recipient = await agents.getByWallet(ctx, trx, { wallet_user_id: to_wallet }) | ||
| 89 | + await events.emit(ctx, trx, { guild_id: seen.task.guild_id, task_id, type: 'tip.received', target_agent_id: recipient.id }) | ||
| 90 | + }, | ||
| 91 | + }) | ||
| 92 | + } | ||
| 61 | // 公开 API 按 Agent 限流(接入说明:读 120/分、写 40/分、发任务 30/小时)。单进程部署用内存 store,多实例时换 MySQL 或 Redis store。 | 93 | // 公开 API 按 Agent 限流(接入说明:读 120/分、写 40/分、发任务 30/小时)。单进程部署用内存 store,多实例时换 MySQL 或 Redis store。 |
| 62 | const rateLimiter = createRateLimiter({ | 94 | const rateLimiter = createRateLimiter({ |
| 63 | store: createMemoryStore(), | 95 | store: createMemoryStore(), |
| @@ -71,6 +103,7 @@ export async function createRuntime({ service }) { | @@ -71,6 +103,7 @@ export async function createRuntime({ service }) { | ||
| 71 | db, | 103 | db, |
| 72 | agents, | 104 | agents, |
| 73 | public_url: env.publicUrl, | 105 | public_url: env.publicUrl, |
| 106 | + notifier: ownerNotifier, | ||
| 74 | // 启动积分 100:在认领事务里记账,幂等键由 agent_id 唯一决定。 | 107 | // 启动积分 100:在认领事务里记账,幂等键由 agent_id 唯一决定。 |
| 75 | async grantStarter(_ctx, trx, { agent_id, wallet_user_id }) { | 108 | async grantStarter(_ctx, trx, { agent_id, wallet_user_id }) { |
| 76 | await createLedger(createBillingRepo(db, trx)).grant({ | 109 | await createLedger(createBillingRepo(db, trx)).grant({ |
| @@ -101,7 +134,7 @@ export async function createRuntime({ service }) { | @@ -101,7 +134,7 @@ export async function createRuntime({ service }) { | ||
| 101 | return { | 134 | return { |
| 102 | env, | 135 | env, |
| 103 | logger, | 136 | logger, |
| 104 | - services: { db, auth, userRepo, access, agents, guilds, agentAccess, escrow, tasks, events, jobs, rateLimiter, register, oauthEnabled: env.oauth.googleClientId !== null }, | 137 | + services: { db, auth, userRepo, access, agents, guilds, agentAccess, escrow, tasks, events, jobs, help, ownerSettings, tipTask, rateLimiter, register, oauthEnabled: env.oauth.googleClientId !== null }, |
| 105 | async close() { | 138 | async close() { |
| 106 | await db.destroy() | 139 | await db.destroy() |
| 107 | }, | 140 | }, |
apps/voko-server/src/db/migrations/202610100003_tips_and_owner_settings.js
0 → 100644
| 1 | +/** W4:打赏记录 vk_tips;主人设置 vk_owner_settings(时区、安静时段、邮件开关)。与各包 tables/*.cjs 逐字一致。 */ | ||
| 2 | +export async function up(db) { | ||
| 3 | + await db.schema.createTable('vk_tips', (t) => { | ||
| 4 | + t.string('id', 64).primary() | ||
| 5 | + t.string('task_id', 64).notNullable() | ||
| 6 | + t.string('from_agent_id', 64).notNullable() | ||
| 7 | + t.string('from_wallet', 64).notNullable() | ||
| 8 | + t.string('to_wallet', 64).notNullable() | ||
| 9 | + t.bigInteger('amount').notNullable() | ||
| 10 | + t.string('out_ledger_id', 64).notNullable() | ||
| 11 | + t.string('in_ledger_id', 64).notNullable() | ||
| 12 | + t.string('client_request_key', 191).notNullable() | ||
| 13 | + t.datetime('created_at', { precision: 3 }).notNullable() | ||
| 14 | + t.unique(['from_agent_id', 'client_request_key'], { indexName: 'uq_vk_tips_sender_request' }) | ||
| 15 | + t.unique(['out_ledger_id'], { indexName: 'uq_vk_tips_out_ledger' }) | ||
| 16 | + t.unique(['in_ledger_id'], { indexName: 'uq_vk_tips_in_ledger' }) | ||
| 17 | + t.index(['task_id'], 'idx_vk_tips_task') | ||
| 18 | + t.check('amount > 0', [], 'ck_vk_tips_amount') | ||
| 19 | + t.check('from_wallet <> to_wallet', [], 'ck_vk_tips_parties') | ||
| 20 | + }) | ||
| 21 | + | ||
| 22 | + await db.schema.createTable('vk_owner_settings', (t) => { | ||
| 23 | + t.string('user_id', 64).primary() | ||
| 24 | + t.string('timezone', 64).notNullable() | ||
| 25 | + t.tinyint('quiet_start_hour').unsigned().notNullable() | ||
| 26 | + t.tinyint('quiet_end_hour').unsigned().notNullable() | ||
| 27 | + t.boolean('email_help').notNullable() | ||
| 28 | + t.boolean('email_digest').notNullable() | ||
| 29 | + t.datetime('updated_at', { precision: 3 }).notNullable() | ||
| 30 | + t.check('quiet_start_hour <= 23 and quiet_end_hour <= 23', [], 'ck_vk_owner_settings_hours') | ||
| 31 | + }) | ||
| 32 | +} | ||
| 33 | + | ||
| 34 | +export async function down(db) { | ||
| 35 | + await db.schema.dropTable('vk_owner_settings') | ||
| 36 | + await db.schema.dropTable('vk_tips') | ||
| 37 | +} |
apps/voko-server/src/routes/owner.js
| 1 | import * as v from 'valibot' | 1 | import * as v from 'valibot' |
| 2 | 2 | ||
| 3 | import { codedError, requireUser } from './auth.js' | 3 | import { codedError, requireUser } from './auth.js' |
| 4 | -import { agentView, IdempotencyKey, PublishBody, REFUSAL_STATUS, taskView } from './v1.js' | 4 | +import { agentView, IdempotencyKey, ledgerView, PublishBody, REFUSAL_STATUS, taskView } from './v1.js' |
| 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({ 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)) }) | ||
| 12 | +const AnswerBody = v.object({ answer: v.pipe(v.string(), v.minLength(1), v.maxLength(4000)) }) | ||
| 13 | +const Hour = v.pipe(v.number(), v.safeInteger(), v.minValue(0), v.maxValue(23)) | ||
| 14 | +const SettingsBody = v.object({ | ||
| 15 | + timezone: v.pipe(v.string(), v.check((zone) => Intl.supportedValuesOf('timeZone').includes(zone) || zone === 'UTC', 'timezone must be an IANA time zone')), | ||
| 16 | + quiet_start_hour: Hour, | ||
| 17 | + quiet_end_hour: Hour, | ||
| 18 | + email_help: v.boolean(), | ||
| 19 | + email_digest: v.boolean(), | ||
| 20 | +}) | ||
| 11 | const StatusBody = v.object({ status: v.picklist(['active', 'suspended']) }) | 21 | const StatusBody = v.object({ status: v.picklist(['active', 'suspended']) }) |
| 12 | 22 | ||
| 13 | /** | 23 | /** |
| @@ -17,7 +27,7 @@ const StatusBody = v.object({ status: v.picklist(['active', 'suspended']) }) | @@ -17,7 +27,7 @@ const StatusBody = v.object({ status: v.picklist(['active', 'suspended']) }) | ||
| 17 | * @param {Record<string, any>} services | 27 | * @param {Record<string, any>} services |
| 18 | */ | 28 | */ |
| 19 | export function registerOwnerRoutes(app, services) { | 29 | export function registerOwnerRoutes(app, services) { |
| 20 | - const { agents, agentAccess, guilds, escrow, tasks, db } = services | 30 | + const { agents, agentAccess, guilds, escrow, tasks, help, ownerSettings, db } = services |
| 21 | 31 | ||
| 22 | /** | 32 | /** |
| 23 | * 取当前用户名下的一个 Agent;不是自己的按不存在处理。 | 33 | * 取当前用户名下的一个 Agent;不是自己的按不存在处理。 |
| @@ -91,6 +101,39 @@ export function registerOwnerRoutes(app, services) { | @@ -91,6 +101,39 @@ export function registerOwnerRoutes(app, services) { | ||
| 91 | */ | 101 | */ |
| 92 | const refused = (reply, refusal) => reply.code(REFUSAL_STATUS[refusal.code]).send({ error: { code: refusal.code, message: refusal.hint, retryable: false } }) | 102 | const refused = (reply, refusal) => reply.code(REFUSAL_STATUS[refusal.code]).send({ error: { code: refusal.code, message: refusal.hint, retryable: false } }) |
| 93 | 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 | + | ||
| 94 | // 负责人:待审核任务(公司 Agent 发布、公会开启发布前审核时)。 | 137 | // 负责人:待审核任务(公司 Agent 发布、公会开启发布前审核时)。 |
| 95 | app.get('/api/voko/approvals', async (request) => { | 138 | app.get('/api/voko/approvals', async (request) => { |
| 96 | const user = await requireUser(request, services) | 139 | const user = await requireUser(request, services) |
apps/voko-server/src/routes/v1.js
| @@ -50,6 +50,9 @@ const ReviewBody = v.object({ | @@ -50,6 +50,9 @@ const ReviewBody = v.object({ | ||
| 50 | reason: v.optional(v.pipe(v.string(), v.minLength(1), v.maxLength(4000))), | 50 | reason: v.optional(v.pipe(v.string(), v.minLength(1), v.maxLength(4000))), |
| 51 | submission_id: v.optional(v.pipe(v.string(), v.minLength(1), v.maxLength(64))), | 51 | submission_id: v.optional(v.pipe(v.string(), v.minLength(1), v.maxLength(64))), |
| 52 | }) | 52 | }) |
| 53 | +const HelpBody = v.object({ question: v.pipe(v.string(), v.minLength(1), v.maxLength(4000)) }) | ||
| 54 | +const TipBody = v.object({ amount: Reward }) | ||
| 55 | +const LedgerQuery = v.object({ limit: v.optional(v.pipe(v.string(), v.transform(Number), v.integer(), v.minValue(1), v.maxValue(100)), '50') }) | ||
| 53 | const TransferBody = v.object({ to_agent_id: v.pipe(v.string(), v.minLength(1), v.maxLength(64)) }) | 56 | const TransferBody = v.object({ to_agent_id: v.pipe(v.string(), v.minLength(1), v.maxLength(64)) }) |
| 54 | const CommentBody = v.object({ body: v.pipe(v.string(), v.minLength(1), v.maxLength(4000)) }) | 57 | const CommentBody = v.object({ body: v.pipe(v.string(), v.minLength(1), v.maxLength(4000)) }) |
| 55 | const EventsQuery = v.object({ since: v.optional(v.pipe(v.string(), v.transform(Number), v.integer(), v.minValue(0)), '0') }) | 58 | const EventsQuery = v.object({ since: v.optional(v.pipe(v.string(), v.transform(Number), v.integer(), v.minValue(0)), '0') }) |
| @@ -67,7 +70,7 @@ const UpdateMeBody = v.object({ description: v.optional(Description), skills: v. | @@ -67,7 +70,7 @@ const UpdateMeBody = v.object({ description: v.optional(Description), skills: v. | ||
| 67 | * @param {Record<string, any>} services | 70 | * @param {Record<string, any>} services |
| 68 | */ | 71 | */ |
| 69 | export function registerV1Routes(app, services) { | 72 | export function registerV1Routes(app, services) { |
| 70 | - const { agentAccess, agents, db, tasks, events, escrow, rateLimiter } = services | 73 | + const { agentAccess, agents, db, tasks, events, escrow, help, tipTask, rateLimiter } = services |
| 71 | 74 | ||
| 72 | app.register(async (v1) => { | 75 | app.register(async (v1) => { |
| 73 | v1.setErrorHandler((/** @type {any} */ err, request, reply) => { | 76 | v1.setErrorHandler((/** @type {any} */ err, request, reply) => { |
| @@ -130,10 +133,11 @@ export function registerV1Routes(app, services) { | @@ -130,10 +133,11 @@ export function registerV1Routes(app, services) { | ||
| 130 | 133 | ||
| 131 | v1.get('/home', async (request) => { | 134 | v1.get('/home', async (request) => { |
| 132 | const agent = agentOf(request) | 135 | const agent = agentOf(request) |
| 133 | - const [wallet, home, unread] = await Promise.all([ | 136 | + const [wallet, home, unread, answers] = await Promise.all([ |
| 134 | escrow.walletOf(request.ctx, { wallet_user_id: agent.wallet_user_id }), | 137 | escrow.walletOf(request.ctx, { wallet_user_id: agent.wallet_user_id }), |
| 135 | tasks.homeFor(request.ctx, { agent }), | 138 | tasks.homeFor(request.ctx, { agent }), |
| 136 | events.pullUnread(request.ctx, { agent }), | 139 | events.pullUnread(request.ctx, { agent }), |
| 140 | + help.answersFor(request.ctx, { agent_id: agent.id }), | ||
| 137 | ]) | 141 | ]) |
| 138 | return ok({ | 142 | return ok({ |
| 139 | agent: { id: agent.id, role: agent.role, level: agent.level, xp: agent.xp }, | 143 | agent: { id: agent.id, role: agent.role, level: agent.level, xp: agent.xp }, |
| @@ -142,6 +146,7 @@ export function registerV1Routes(app, services) { | @@ -142,6 +146,7 @@ export function registerV1Routes(app, services) { | ||
| 142 | to_review: home.to_review.map(taskView), | 146 | to_review: home.to_review.map(taskView), |
| 143 | recommended_tasks: home.recommended_tasks.map(taskView), | 147 | recommended_tasks: home.recommended_tasks.map(taskView), |
| 144 | events: unread.map(eventView), | 148 | events: unread.map(eventView), |
| 149 | + help_answers: answers.map((item) => ({ help_id: item.id, task_id: item.task_id, question: item.question, answer: item.answer, answered_at: item.answered_at.toISOString() })), | ||
| 145 | announcements: [], | 150 | announcements: [], |
| 146 | skill_version: SKILL_VERSION, | 151 | skill_version: SKILL_VERSION, |
| 147 | next_heartbeat_hint_sec: home.my_active_tasks.length > 0 || home.to_review.length > 0 ? 1800 : 3600, | 152 | next_heartbeat_hint_sec: home.my_active_tasks.length > 0 || home.to_review.length > 0 ? 1800 : 3600, |
| @@ -155,6 +160,12 @@ export function registerV1Routes(app, services) { | @@ -155,6 +160,12 @@ export function registerV1Routes(app, services) { | ||
| 155 | 160 | ||
| 156 | v1.get('/wallet', async (request) => ok(await escrow.walletOf(request.ctx, { wallet_user_id: agentOf(request).wallet_user_id }))) | 161 | v1.get('/wallet', async (request) => ok(await escrow.walletOf(request.ctx, { wallet_user_id: agentOf(request).wallet_user_id }))) |
| 157 | 162 | ||
| 163 | + v1.get('/wallet/ledger', async (request) => { | ||
| 164 | + const query = v.parse(LedgerQuery, request.query) | ||
| 165 | + const rows = await escrow.ledgerOf(request.ctx, { wallet_user_id: agentOf(request).wallet_user_id, limit: query.limit }) | ||
| 166 | + return ok({ ledger: rows.map(ledgerView) }) | ||
| 167 | + }) | ||
| 168 | + | ||
| 158 | v1.get('/tasks', async (request) => { | 169 | v1.get('/tasks', async (request) => { |
| 159 | const query = v.parse(ListQuery, request.query) | 170 | const query = v.parse(ListQuery, request.query) |
| 160 | const page = await tasks.list(request.ctx, { agent: agentOf(request), ...query }) | 171 | const page = await tasks.list(request.ctx, { agent: agentOf(request), ...query }) |
| @@ -235,6 +246,24 @@ export function registerV1Routes(app, services) { | @@ -235,6 +246,24 @@ export function registerV1Routes(app, services) { | ||
| 235 | }) | 246 | }) |
| 236 | } | 247 | } |
| 237 | 248 | ||
| 249 | + v1.post('/tasks/:id/help', async (request, reply) => { | ||
| 250 | + const params = v.parse(TaskParams, request.params) | ||
| 251 | + const body = v.parse(HelpBody, request.body) | ||
| 252 | + const result = await help.request(request.ctx, { agent: agentOf(request), task_id: params.id, question: body.question }) | ||
| 253 | + if (!result.ok) return refused(reply, result) | ||
| 254 | + return ok({ help_id: result.help_id }) | ||
| 255 | + }) | ||
| 256 | + | ||
| 257 | + v1.post('/tasks/:id/tip', async (request, reply) => { | ||
| 258 | + const params = v.parse(TaskParams, request.params) | ||
| 259 | + const body = v.parse(TipBody, request.body) | ||
| 260 | + const key = v.parse(IdempotencyKey, request.headers['idempotency-key']) | ||
| 261 | + const agent = agentOf(request) | ||
| 262 | + const result = await tipTask(request.ctx, { agent, task_id: params.id, amount: body.amount, client_request_key: key }) | ||
| 263 | + if (!result.ok) return refused(reply, result) | ||
| 264 | + return ok({ tip_id: result.tip_id, wallet: await escrow.walletOf(request.ctx, { wallet_user_id: agent.wallet_user_id }) }) | ||
| 265 | + }) | ||
| 266 | + | ||
| 238 | v1.get('/tasks/:id/comments', async (request, reply) => { | 267 | v1.get('/tasks/:id/comments', async (request, reply) => { |
| 239 | const params = v.parse(TaskParams, request.params) | 268 | const params = v.parse(TaskParams, request.params) |
| 240 | const result = await tasks.listComments(request.ctx, { agent: agentOf(request), task_id: params.id }) | 269 | const result = await tasks.listComments(request.ctx, { agent: agentOf(request), task_id: params.id }) |
| @@ -311,6 +340,14 @@ function commentView(comment) { | @@ -311,6 +340,14 @@ function commentView(comment) { | ||
| 311 | } | 340 | } |
| 312 | 341 | ||
| 313 | /** | 342 | /** |
| 343 | + * 流水的对外形状:任务相关的流水带 task_id。 | ||
| 344 | + * @param {{ id: string, type: string, amount: number, reason: string, ref_type: string|null, ref_id: string|null, created_at: Date }} row | ||
| 345 | + */ | ||
| 346 | +export function ledgerView(row) { | ||
| 347 | + return { id: row.id, type: row.type, amount: row.amount, reason: row.reason, task_id: row.ref_type === 'vk_task' ? row.ref_id : null, at: row.created_at.toISOString() } | ||
| 348 | +} | ||
| 349 | + | ||
| 350 | +/** | ||
| 314 | * 鉴权钩子挂在请求上的 Agent。 | 351 | * 鉴权钩子挂在请求上的 Agent。 |
| 315 | * @param {import('fastify').FastifyRequest} request | 352 | * @param {import('fastify').FastifyRequest} request |
| 316 | * @returns {import('@voko/exchange').Agent} | 353 | * @returns {import('@voko/exchange').Agent} |
apps/voko-server/tools/scenarios/voko-alpha/_lib.mjs
| @@ -156,8 +156,9 @@ function createWorld(name, db) { | @@ -156,8 +156,9 @@ function createWorld(name, db) { | ||
| 156 | const wallets = agentIds.length === 0 ? [] : (await db('vk_agents').whereIn('id', agentIds).select('wallet_user_id')).map((row) => row.wallet_user_id) | 156 | const wallets = agentIds.length === 0 ? [] : (await db('vk_agents').whereIn('id', agentIds).select('wallet_user_id')).map((row) => row.wallet_user_id) |
| 157 | if (taskIds.length > 0) { | 157 | if (taskIds.length > 0) { |
| 158 | await db('vk_review_checks').whereIn('review_id', db('vk_reviews').whereIn('task_id', taskIds).select('id')).del() | 158 | await db('vk_review_checks').whereIn('review_id', db('vk_reviews').whereIn('task_id', taskIds).select('id')).del() |
| 159 | - for (const table of ['vk_task_acceptance_items', 'vk_task_holds', 'vk_task_entries', 'vk_comments', 'vk_checkpoints', 'vk_submissions', 'vk_reviews', 'vk_disputes', 'vk_transfers', 'vk_escrow_payouts', 'vk_escrows']) await db(table).whereIn('task_id', taskIds).del() | 159 | + for (const table of ['vk_task_acceptance_items', 'vk_task_holds', 'vk_task_entries', 'vk_comments', 'vk_checkpoints', 'vk_submissions', 'vk_reviews', 'vk_disputes', 'vk_transfers', 'vk_help_requests', 'vk_tips', 'vk_escrow_payouts', 'vk_escrows']) await db(table).whereIn('task_id', taskIds).del() |
| 160 | await db('vk_jobs').whereIn('ref_id', taskIds).del() | 160 | await db('vk_jobs').whereIn('ref_id', taskIds).del() |
| 161 | + await db('vk_jobs').where({ kind: 'help_notify' }).whereNotIn('ref_id', db('vk_help_requests').select('id')).del() | ||
| 161 | await db('vk_tasks').whereIn('id', taskIds).del() | 162 | await db('vk_tasks').whereIn('id', taskIds).del() |
| 162 | } | 163 | } |
| 163 | if (guildIds.length > 0) { | 164 | if (guildIds.length > 0) { |
| @@ -174,6 +175,7 @@ function createWorld(name, db) { | @@ -174,6 +175,7 @@ function createWorld(name, db) { | ||
| 174 | } | 175 | } |
| 175 | if (userIds.length > 0) { | 176 | if (userIds.length > 0) { |
| 176 | await db('vk_access_requests').whereIn('user_id', userIds).del() | 177 | await db('vk_access_requests').whereIn('user_id', userIds).del() |
| 178 | + await db('vk_owner_settings').whereIn('user_id', userIds).del() | ||
| 177 | await db('auth_sessions').whereIn('user_id', userIds).del() | 179 | await db('auth_sessions').whereIn('user_id', userIds).del() |
| 178 | await db('users').whereIn('id', userIds).del() | 180 | await db('users').whereIn('id', userIds).del() |
| 179 | } | 181 | } |
| @@ -204,5 +206,5 @@ function createWorld(name, db) { | @@ -204,5 +206,5 @@ function createWorld(name, db) { | ||
| 204 | body: { action, checks: ids.map((id) => ({ id, pass: !failing.includes(id), reason: failing.includes(id) ? 'Not met yet.' : 'Looks good.' })), ...extra }, | 206 | body: { action, checks: ids.map((id) => ({ id, pass: !failing.includes(id), reason: failing.includes(id) ? 'Not met yet.' : 'Looks good.' })), ...extra }, |
| 205 | }) | 207 | }) |
| 206 | 208 | ||
| 207 | - return { db, results, check, call, admin, createOwner, createGuild, createAgent, grantBudget, publish, waitForTask, wallet, submit, claim, review, cleanup } | 209 | + return { db, results, check, call, admin, waitForEmail, createOwner, createGuild, createAgent, grantBudget, publish, waitForTask, wallet, submit, claim, review, cleanup } |
| 208 | } | 210 | } |
apps/voko-server/tools/scenarios/voko-alpha/concurrency.mjs
| 1 | #!/usr/bin/env node | 1 | #!/usr/bin/env node |
| 2 | /** | 2 | /** |
| 3 | - * 并发与幂等的真实链路脚本(Planning 用例 CC-01、CC-02;CC-03 结算并发在 W4)。 | 3 | + * 并发与幂等的真实链路脚本(Planning 用例 CC-01、CC-02、CC-03)。CC-03 需要 voko-worker 在跑。 |
| 4 | * CC-01 用 6 个 Agent(每个主人最多 3 个)同时发 20 个认领请求,断言恰好 1 个成功。 | 4 | * CC-01 用 6 个 Agent(每个主人最多 3 个)同时发 20 个认领请求,断言恰好 1 个成功。 |
| 5 | */ | 5 | */ |
| 6 | import { randomUUID } from 'node:crypto' | 6 | import { randomUUID } from 'node:crypto' |
| 7 | 7 | ||
| 8 | import { runScenario } from './_lib.mjs' | 8 | import { runScenario } from './_lib.mjs' |
| 9 | 9 | ||
| 10 | -await runScenario('cc', async ({ db, check, call, createOwner, createGuild, createAgent, publish }) => { | 10 | +await runScenario('cc', async ({ db, check, call, createOwner, createGuild, createAgent, publish, waitForTask, wallet: walletOf, submit, claim, review }) => { |
| 11 | const alice = await createOwner('alice') | 11 | const alice = await createOwner('alice') |
| 12 | const bob = await createOwner('bob') | 12 | const bob = await createOwner('bob') |
| 13 | const carol = await createOwner('carol') | 13 | const carol = await createOwner('carol') |
| @@ -32,4 +32,22 @@ await runScenario('cc', async ({ db, check, call, createOwner, createGuild, crea | @@ -32,4 +32,22 @@ await runScenario('cc', async ({ db, check, call, createOwner, createGuild, crea | ||
| 32 | const won = claims.filter((r) => r.status === 200) | 32 | const won = claims.filter((r) => r.status === 200) |
| 33 | const openHolds = await db('vk_task_holds').where({ task_id: taskId }).whereNull('ended_at') | 33 | const openHolds = await db('vk_task_holds').where({ task_id: taskId }).whereNull('ended_at') |
| 34 | check('CC-01 20 个并发认领 → 恰好 1 个成功,其余 409', won.length === 1 && claims.filter((r) => r.status === 409).length === 19 && openHolds.length === 1 && openHolds[0].agent_id === won[0].json.data.holder_agent_id, { statuses: claims.map((r) => r.status) }) | 34 | check('CC-01 20 个并发认领 → 恰好 1 个成功,其余 409', won.length === 1 && claims.filter((r) => r.status === 409).length === 19 && openHolds.length === 1 && openHolds[0].agent_id === won[0].json.data.holder_agent_id, { statuses: claims.map((r) => r.status) }) |
| 35 | + | ||
| 36 | + // CC-03 发布方 accept 与自动确认 job 同时发生 → 只结算一次。验收窗口 3 秒的公会,在到期时刻连发 accept。 | ||
| 37 | + const fast = await createGuild('fast', alice.user_id, { piece_review_window_sec: 3 }) | ||
| 38 | + const fastPublisher = await createAgent('fast-publisher', alice, fast) | ||
| 39 | + const dave = await createOwner('dave') | ||
| 40 | + const fastTaker = await createAgent('fast-taker', dave, fast) | ||
| 41 | + const racing = (await publish(fastPublisher, { reward: 25 })).json.data | ||
| 42 | + await claim(fastTaker, racing.id) | ||
| 43 | + const submitted = await submit(fastTaker, racing.id) | ||
| 44 | + const dueIn = new Date(submitted.json.data.review_due).getTime() - Date.now() | ||
| 45 | + const accepts = [] | ||
| 46 | + for (const offset of [-150, 0, 150, 400, 900]) accepts.push(new Promise((resolve) => setTimeout(resolve, Math.max(0, dueIn + offset))).then(() => review(fastPublisher, racing.id, 'accept', ['c1']))) | ||
| 47 | + const acceptResults = await Promise.all(accepts) | ||
| 48 | + const settled = await waitForTask(fastPublisher, racing.id, (task) => task.status === 'settled') | ||
| 49 | + const racePayouts = await db('vk_escrow_payouts').where({ task_id: racing.id }) | ||
| 50 | + const raceLedger = await db('billing_ledger').where({ ref_id: racing.id, reason: 'vk_task_payout' }) | ||
| 51 | + check('CC-03 accept 与自动确认并发 → 只结算一次', settled.status === 'settled' && racePayouts.length === 1 && raceLedger.length === 1 && (await walletOf(fastTaker)).available === 125 && acceptResults.every((r) => r.status === 200 || r.status === 409) && acceptResults.filter((r) => r.status === 200).length <= 1, { statuses: acceptResults.map((r) => r.status), racePayouts }) | ||
| 52 | + | ||
| 35 | }) | 53 | }) |
apps/voko-server/tools/scenarios/voko-alpha/e2e-loop.mjs
0 → 100644
| 1 | +#!/usr/bin/env node | ||
| 2 | +/** | ||
| 3 | + * E2E-01 完整闭环:注册 → 认领 → 发布 → 认领 → 检查点 → 提交 → 驳回 → 重交 → 通过 → 打赏 → 流水与事件;末尾对账。 | ||
| 4 | + * (排行榜在 W5 加入本脚本。) | ||
| 5 | + */ | ||
| 6 | +import { randomUUID } from 'node:crypto' | ||
| 7 | + | ||
| 8 | +import { runScenario } from './_lib.mjs' | ||
| 9 | + | ||
| 10 | +await runScenario('e2e', async ({ check, call, createOwner, createGuild, createAgent, publish, wallet, submit, claim, review }) => { | ||
| 11 | + // 注册 → 认领(createAgent 走真实的注册与认领接口)。 | ||
| 12 | + const alice = await createOwner('alice') | ||
| 13 | + const bob = await createOwner('bob') | ||
| 14 | + const guild = await createGuild('main', alice.user_id) | ||
| 15 | + const publisher = await createAgent('publisher', alice, guild) | ||
| 16 | + const worker = await createAgent('worker', bob, guild) | ||
| 17 | + | ||
| 18 | + // 发布 → 接单方从 /home 的推荐里发现任务 → 认领。 | ||
| 19 | + const task = (await publish(publisher, { reward: 70, acceptance: [{ id: 'c1', text: 'Natural Japanese' }, { id: 'c2', text: 'Keeps all links' }] })).json.data | ||
| 20 | + const home = await call('GET', '/v1/home', { token: worker.key }) | ||
| 21 | + check('接单方在 /home 推荐里看到任务', home.json.data.recommended_tasks.some((t) => t.id === task.id), home) | ||
| 22 | + check('认领', (await claim(worker, task.id)).status === 200) | ||
| 23 | + | ||
| 24 | + // 检查点 → 提交 → 驳回 → 重交 → 通过。 | ||
| 25 | + check('检查点', (await call('POST', `/v1/tasks/${task.id}/checkpoints`, { token: worker.key, body: { progress: 60, note: 'First pass done' } })).status === 200) | ||
| 26 | + check('第一次提交', (await submit(worker, task.id)).json.data.status === 'submitted') | ||
| 27 | + const toReview = await call('GET', '/v1/home', { token: publisher.key }) | ||
| 28 | + check('发布方 /home 出现待验收', toReview.json.data.to_review.some((t) => t.id === task.id), toReview) | ||
| 29 | + const rejected = await review(publisher, task.id, 'reject', ['c1', 'c2'], ['c2'], { reason: 'Two links are missing.' }) | ||
| 30 | + check('驳回 → rework', rejected.json.data.status === 'rework', rejected) | ||
| 31 | + check('接单方收到 task.rejected', (await call('GET', '/v1/home', { token: worker.key })).json.data.events.some((e) => e.type === 'task.rejected' && e.task_id === task.id)) | ||
| 32 | + check('重交', (await submit(worker, task.id, 'Added the two missing links.')).json.data.status === 'submitted') | ||
| 33 | + const accepted = await review(publisher, task.id, 'accept', ['c1', 'c2']) | ||
| 34 | + check('通过 → settled', accepted.json.data.status === 'settled', accepted) | ||
| 35 | + | ||
| 36 | + // 打赏 → 钱包、流水、事件与等级经验一致。 | ||
| 37 | + const tipped = await call('POST', `/v1/tasks/${task.id}/tip`, { token: publisher.key, idempotency_key: randomUUID(), body: { amount: 10 } }) | ||
| 38 | + check('打赏', tipped.status === 200, tipped) | ||
| 39 | + check('钱包:发布方 100 - 70 - 10 = 20,接单方 100 + 70 + 10 = 180', (await wallet(publisher)).available === 20 && (await wallet(publisher)).held === 0 && (await wallet(worker)).available === 180, { publisher: await wallet(publisher), worker: await wallet(worker) }) | ||
| 40 | + const ledger = (await call('GET', '/v1/wallet/ledger', { token: worker.key })).json.data.ledger | ||
| 41 | + check('接单方流水:启动积分、任务收入、打赏各一笔', ['vk_starter', 'vk_task_payout', 'vk_tip'].every((reason) => ledger.filter((row) => row.reason === reason).length === 1), ledger) | ||
| 42 | + const me = (await call('GET', '/v1/agents/me', { token: worker.key })).json.data | ||
| 43 | + check('接单方 XP = 结算积分 70', me.xp === 70, me) | ||
| 44 | + const events = (await call('GET', '/v1/events?since=0', { token: worker.key })).json.data.events.map((e) => e.type) | ||
| 45 | + check('接单方事件流完整', ['task.published', 'task.rejected', 'task.accepted', 'tip.received'].every((type) => events.includes(type)), events) | ||
| 46 | +}) |
apps/voko-server/tools/scenarios/voko-alpha/f2-execute.mjs
| 1 | #!/usr/bin/env node | 1 | #!/usr/bin/env node |
| 2 | /** | 2 | /** |
| 3 | - * F2 接单与执行的真实链路脚本(Planning 用例 F2-01、02、03、05、06、07;F2-04 求助在 W4)。 | 3 | + * F2 接单与执行的真实链路脚本(Planning 用例 F2-01 – F2-07,以及 TM-06 的安静时段部分)。F2-04 需要 voko-worker 在跑(求助邮件由 job 发出)。 |
| 4 | */ | 4 | */ |
| 5 | import { runScenario } from './_lib.mjs' | 5 | import { runScenario } from './_lib.mjs' |
| 6 | 6 | ||
| 7 | -await runScenario('f2', async ({ db, check, call, createOwner, createGuild, createAgent, grantBudget, publish, submit, claim }) => { | 7 | +await runScenario('f2', async ({ db, check, call, waitForEmail, createOwner, createGuild, createAgent, grantBudget, publish, submit, claim }) => { |
| 8 | const alice = await createOwner('alice') | 8 | const alice = await createOwner('alice') |
| 9 | const bob = await createOwner('bob') | 9 | const bob = await createOwner('bob') |
| 10 | const guild = await createGuild('main', alice.user_id) | 10 | const guild = await createGuild('main', alice.user_id) |
| @@ -101,4 +101,37 @@ await runScenario('f2', async ({ db, check, call, createOwner, createGuild, crea | @@ -101,4 +101,37 @@ await runScenario('f2', async ({ db, check, call, createOwner, createGuild, crea | ||
| 101 | const submitted = await submit(taker, delivering.id) | 101 | const submitted = await submit(taker, delivering.id) |
| 102 | const publisherHome = await call('GET', '/v1/home', { token: publisher.key }) | 102 | const publisherHome = await call('GET', '/v1/home', { token: publisher.key }) |
| 103 | check('F2-07 提交 → submitted,发布方 /home 的 to_review 出现', submitted.status === 200 && submitted.json.data.status === 'submitted' && typeof submitted.json.data.review_due === 'string' && publisherHome.json.data.to_review.some((t) => t.id === delivering.id), { submitted, to_review: publisherHome.json.data.to_review }) | 103 | check('F2-07 提交 → submitted,发布方 /home 的 to_review 出现', submitted.status === 200 && submitted.json.data.status === 'submitted' && typeof submitted.json.data.review_due === 'string' && publisherHome.json.data.to_review.some((t) => t.id === delivering.id), { submitted, to_review: publisherHome.json.data.to_review }) |
| 104 | + | ||
| 105 | + // F2-04 求助 → 主人收到邮件;主人网页回复后 Agent 下次 /home 拿到答复。(rival 手上有接力来的 working 任务) | ||
| 106 | + // 默认安静时段是 23:00–08:00(UTC);先关掉安静时段,保证这封邮件现在就发。 | ||
| 107 | + await call('PUT', '/api/voko/me/settings', { token: bob.token, body: { timezone: 'UTC', quiet_start_hour: 0, quiet_end_hour: 0, email_help: true, email_digest: true } }) | ||
| 108 | + const asked = await call('POST', `/v1/tasks/${working.id}/help`, { token: rival.key, body: { question: 'Should the tone be formal or casual?' } }) | ||
| 109 | + const notHolderHelp = await call('POST', `/v1/tasks/${working.id}/help`, { token: taker.key, body: { question: 'hi' } }) | ||
| 110 | + check('求助 → help_id;非持有人求助 → 403', asked.status === 200 && typeof asked.json.data.help_id === 'string' && notHolderHelp.status === 403, { asked, notHolderHelp }) | ||
| 111 | + const helpMail = await waitForEmail({ to_email: bob.email, purpose: 'help_requested', status: 'sent' }) | ||
| 112 | + check('F2-04 主人收到求助邮件', helpMail.template === 'voko-notice', helpMail) | ||
| 113 | + const inbox = await call('GET', '/api/voko/help', { token: bob.token }) | ||
| 114 | + const aliceInbox = await call('GET', '/api/voko/help', { token: alice.token }) | ||
| 115 | + check('求助出现在主人的收件箱,别人看不到', inbox.json.help_requests.some((h) => h.id === asked.json.data.help_id && h.status === 'open') && aliceInbox.json.help_requests.length === 0, { inbox, aliceInbox }) | ||
| 116 | + const stranger = await call('POST', `/api/voko/help/${asked.json.data.help_id}/answer`, { token: alice.token, body: { answer: 'x' } }) | ||
| 117 | + const answered = await call('POST', `/api/voko/help/${asked.json.data.help_id}/answer`, { token: bob.token, body: { answer: 'Formal, please.' } }) | ||
| 118 | + const answeredTwice = await call('POST', `/api/voko/help/${asked.json.data.help_id}/answer`, { token: bob.token, body: { answer: 'again' } }) | ||
| 119 | + check('主人回复成功;别人回复 → 404;重复回复 → 409', answered.status === 200 && stranger.status === 404 && answeredTwice.status === 409, { answered, stranger, answeredTwice }) | ||
| 120 | + const rivalHome = await call('GET', '/v1/home', { token: rival.key }) | ||
| 121 | + check('F2-04 Agent 下次 /home 拿到答复', rivalHome.json.data.help_answers.some((h) => h.help_id === asked.json.data.help_id && h.answer === 'Formal, please.') && rivalHome.json.data.events.some((e) => e.type === 'help.answered'), rivalHome) | ||
| 122 | + | ||
| 123 | + // TM-06 安静时段内的求助邮件推迟到安静时段结束:把主人的安静时段设成从当前小时起的两小时。 | ||
| 124 | + const hourNow = new Date().getUTCHours() | ||
| 125 | + const quiet = { timezone: 'UTC', quiet_start_hour: hourNow, quiet_end_hour: (hourNow + 2) % 24, email_help: true, email_digest: true } | ||
| 126 | + const saved = await call('PUT', '/api/voko/me/settings', { token: bob.token, body: quiet }) | ||
| 127 | + const badZone = await call('PUT', '/api/voko/me/settings', { token: bob.token, body: { ...quiet, timezone: 'Mars/Olympus' } }) | ||
| 128 | + check('保存设置;非法时区 → 400', saved.status === 200 && (await call('GET', '/api/voko/me/settings', { token: bob.token })).json.settings.quiet_start_hour === hourNow && badZone.status === 400, { saved, badZone }) | ||
| 129 | + const quietHelp = await call('POST', `/v1/tasks/${working.id}/help`, { token: rival.key, body: { question: 'One more question during quiet hours.' } }) | ||
| 130 | + const quietJob = await db('vk_jobs').where({ kind: 'help_notify', ref_id: quietHelp.json.data.help_id }).first() | ||
| 131 | + const expected = new Date(); expected.setUTCHours(hourNow + 2, 0, 0, 0) | ||
| 132 | + check('TM-06 安静时段内的求助邮件推迟到安静时段结束的整点', quietJob.status === 'pending' && quietJob.run_at.getTime() === expected.getTime(), { run_at: quietJob.run_at, expected }) | ||
| 133 | + await call('PUT', '/api/voko/me/settings', { token: bob.token, body: { ...quiet, email_help: false } }) | ||
| 134 | + const mutedHelp = await call('POST', `/v1/tasks/${working.id}/help`, { token: rival.key, body: { question: 'Emails are off now.' } }) | ||
| 135 | + check('关闭求助邮件后不再排通知', (await db('vk_jobs').where({ kind: 'help_notify', ref_id: mutedHelp.json.data.help_id }).first()) === undefined, mutedHelp) | ||
| 136 | + | ||
| 104 | }) | 137 | }) |
apps/voko-server/tools/scenarios/voko-alpha/f3-review-settle.mjs
| 1 | #!/usr/bin/env node | 1 | #!/usr/bin/env node |
| 2 | /** | 2 | /** |
| 3 | - * F3 验收与结算的真实链路脚本(Planning 用例 F3-01、02、03、04、05;F3-06 打赏在 W4)。 | 3 | + * F3 验收与结算的真实链路脚本(Planning 用例 F3-01 – F3-06)。 |
| 4 | * 需要 voko-worker 在跑:F3-04 的 AI 仲裁意见会真实调用大模型,F3-05 的擂台赛到截止由 worker 关闭。 | 4 | * 需要 voko-worker 在跑:F3-04 的 AI 仲裁意见会真实调用大模型,F3-05 的擂台赛到截止由 worker 关闭。 |
| 5 | */ | 5 | */ |
| 6 | +import { randomUUID } from 'node:crypto' | ||
| 7 | + | ||
| 6 | import { runScenario } from './_lib.mjs' | 8 | import { runScenario } from './_lib.mjs' |
| 7 | 9 | ||
| 8 | await runScenario('f3', async ({ db, check, call, admin, createOwner, createGuild, createAgent, grantBudget, publish, waitForTask, wallet, submit, claim, review }) => { | 10 | await runScenario('f3', async ({ db, check, call, admin, createOwner, createGuild, createAgent, grantBudget, publish, waitForTask, wallet, submit, claim, review }) => { |
| @@ -104,4 +106,21 @@ await runScenario('f3', async ({ db, check, call, admin, createOwner, createGuil | @@ -104,4 +106,21 @@ await runScenario('f3', async ({ db, check, call, admin, createOwner, createGuil | ||
| 104 | const won = await review(publisher, contest.id, 'select_winner', ids, [], { submission_id: takerSubmission.json.data.submission_id }) | 106 | const won = await review(publisher, contest.id, 'select_winner', ids, [], { submission_id: takerSubmission.json.data.submission_id }) |
| 105 | const contestPayouts = await db('vk_escrow_payouts').where({ task_id: contest.id }) | 107 | const contestPayouts = await db('vk_escrow_payouts').where({ task_id: contest.id }) |
| 106 | check('F3-05 选赢家 → 赢家全额 40,其他人无流水', won.status === 200 && won.json.data.status === 'settled' && (await wallet(taker)).available === 291 && (await wallet(helper)).available === helperBefore && contestPayouts.length === 1, { won, contestPayouts }) | 108 | check('F3-05 选赢家 → 赢家全额 40,其他人无流水', won.status === 200 && won.json.data.status === 'settled' && (await wallet(taker)).available === 291 && (await wallet(helper)).available === helperBefore && contestPayouts.length === 1, { won, contestPayouts }) |
| 109 | + | ||
| 110 | + // F3-06 打赏:打赏人减、收款人加;重放不重复;余额不足 → 402;未结算任务与打赏自己被拒。 | ||
| 111 | + const tipKey = randomUUID() | ||
| 112 | + const tipperBefore = (await wallet(helper)).available | ||
| 113 | + const takerBeforeTip = (await wallet(taker)).available | ||
| 114 | + const tipped = await call('POST', `/v1/tasks/${first.id}/tip`, { token: helper.key, idempotency_key: tipKey, body: { amount: 15 } }) | ||
| 115 | + const tipReplay = await call('POST', `/v1/tasks/${first.id}/tip`, { token: helper.key, idempotency_key: tipKey, body: { amount: 15 } }) | ||
| 116 | + check('F3-06 打赏 → 打赏人 -15、收款人 +15,重放不重复', tipped.status === 200 && tipped.json.data.wallet.available === tipperBefore - 15 && tipReplay.json.data.tip_id === tipped.json.data.tip_id && (await wallet(helper)).available === tipperBefore - 15 && (await wallet(taker)).available === takerBeforeTip + 15, { tipped, tipReplay }) | ||
| 117 | + const tooMuch = await call('POST', `/v1/tasks/${first.id}/tip`, { token: helper.key, idempotency_key: randomUUID(), body: { amount: 100000 } }) | ||
| 118 | + check('F3-06 余额不足 → 402,余额不变', tooMuch.status === 402 && (await wallet(helper)).available === tipperBefore - 15, tooMuch) | ||
| 119 | + const selfTip = await call('POST', `/v1/tasks/${first.id}/tip`, { token: taker.key, idempotency_key: randomUUID(), body: { amount: 1 } }) | ||
| 120 | + const unsettled = await call('POST', `/v1/tasks/${disputed.id}/tip`, { token: helper.key, idempotency_key: randomUUID(), body: { amount: 1 } }) | ||
| 121 | + check('打赏自己 → 422;未结算(已退款)任务 → 409', selfTip.status === 422 && unsettled.status === 409, { selfTip, unsettled }) | ||
| 122 | + const ledger = await call('GET', '/v1/wallet/ledger', { token: taker.key }) | ||
| 123 | + check('流水接口:收款人能查到打赏与任务收入,每笔带类型、金额、任务与时间', ledger.status === 200 && ledger.json.data.ledger.some((row) => row.reason === 'vk_tip' && row.amount === 15) && ledger.json.data.ledger.some((row) => row.reason === 'vk_task_payout' && row.task_id === first.id && typeof row.at === 'string'), ledger) | ||
| 124 | + check('收款人收到 tip.received', (await call('GET', '/v1/events?since=0', { token: taker.key })).json.data.events.some((e) => e.type === 'tip.received' && e.task_id === first.id)) | ||
| 125 | + | ||
| 107 | }) | 126 | }) |
apps/voko-server/tools/scenarios/voko-alpha/verify-conservation.mjs
| @@ -36,7 +36,8 @@ export async function verifyConservation(db) { | @@ -36,7 +36,8 @@ export async function verifyConservation(db) { | ||
| 36 | 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') | 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') | 38 | const orphanAgentRefs = await db('billing_ledger as l').leftJoin('vk_agents as a', 'a.id', 'l.ref_id').where({ 'l.source': 'voko', 'l.ref_type': 'vk_agent' }).whereNull('a.id').select('l.id', 'l.ref_id') |
| 39 | - for (const row of [...orphanTaskRefs, ...orphanAgentRefs]) problems.push({ rule: 'ledger_ref_missing', id: row.id, detail: `ref_id=${row.ref_id}` }) | 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}` }) | ||
| 40 | return problems | 41 | return problems |
| 41 | } | 42 | } |
| 42 | 43 |
docs/voko/upstream.md
| @@ -19,6 +19,7 @@ | @@ -19,6 +19,7 @@ | ||
| 19 | | `pnpm-workspace.yaml` | 只保留 `apps/*`、`packages/*` | | 19 | | `pnpm-workspace.yaml` | 只保留 `apps/*`、`packages/*` | |
| 20 | | `packages/email/src/templates/waitlist-invite.js` | `inviteUrl` 允许 `http://localhost`(本地验收用),其余仍须 https(2026-10-10) | | 20 | | `packages/email/src/templates/waitlist-invite.js` | `inviteUrl` 允许 `http://localhost`(本地验收用),其余仍须 https(2026-10-10) | |
| 21 | | `packages/ui/src/components/Textarea.vue` | 加 `box-sizing: border-box`,否则 `width: 100%` 加内边距会撑出容器(2026-10-10;上游同样有此问题) | | 21 | | `packages/ui/src/components/Textarea.vue` | 加 `box-sizing: border-box`,否则 `width: 100%` 加内边距会撑出容器(2026-10-10;上游同样有此问题) | |
| 22 | +| `packages/email/src/templates/voko-notice.js` | 新增模板(本仓库独有):Agent 已认领、Agent 求助,en / zh | | ||
| 22 | | `apps/voko-server/src/assembly/security.js`、`google-oauth.js`、`email-delivery-log.js` | 从 visual-game-server 原样复制(security.js 只改了一条报错文案) | | 23 | | `apps/voko-server/src/assembly/security.js`、`google-oauth.js`、`email-delivery-log.js` | 从 visual-game-server 原样复制(security.js 只改了一条报错文案) | |
| 23 | 24 | ||
| 24 | ## 待裁剪(原样搬入,后续处理) | 25 | ## 待裁剪(原样搬入,后续处理) |
packages/email/src/templates/index.js
| @@ -7,6 +7,7 @@ import * as verifyCode from './verify-code.js' | @@ -7,6 +7,7 @@ import * as verifyCode from './verify-code.js' | ||
| 7 | import * as videoReady from './video-ready.js' | 7 | import * as videoReady from './video-ready.js' |
| 8 | import * as waitlistInvite from './waitlist-invite.js' | 8 | import * as waitlistInvite from './waitlist-invite.js' |
| 9 | import * as orderNotice from './order-notice.js' | 9 | import * as orderNotice from './order-notice.js' |
| 10 | +import * as vokoNotice from './voko-notice.js' | ||
| 10 | import { EmailError } from '../map-error.js' | 11 | import { EmailError } from '../map-error.js' |
| 11 | 12 | ||
| 12 | const REGISTRY = { | 13 | const REGISTRY = { |
| @@ -14,6 +15,7 @@ const REGISTRY = { | @@ -14,6 +15,7 @@ const REGISTRY = { | ||
| 14 | 'video-ready': videoReady.render, | 15 | 'video-ready': videoReady.render, |
| 15 | 'waitlist-invite': waitlistInvite.render, | 16 | 'waitlist-invite': waitlistInvite.render, |
| 16 | 'order-notice': orderNotice.render, | 17 | 'order-notice': orderNotice.render, |
| 18 | + 'voko-notice': vokoNotice.render, | ||
| 17 | } | 19 | } |
| 18 | 20 | ||
| 19 | /** | 21 | /** |
packages/email/src/templates/voko-notice.js
0 → 100644
| 1 | +import { EmailError } from '../map-error.js' | ||
| 2 | + | ||
| 3 | +/** VOKO 给主人的通知:Agent 已认领、Agent 求助。每种通知一组文案,`{name}` 等占位符由调用方数据填入。 */ | ||
| 4 | +const COPY = Object.freeze({ | ||
| 5 | + en: { | ||
| 6 | + claimed: { subject: 'You claimed {agent} on VOKO', heading: '{agent} is now active', body: 'Your agent joined its guild and received 100 starter points. You can manage it, reset its API key or set a daily spend limit from your agents page.', action: 'Open my agents' }, | ||
| 7 | + help: { subject: '{agent} needs your help', heading: '{agent} asked for help', body: 'Task: {task}\n\n{question}', action: 'Reply in your inbox' }, | ||
| 8 | + }, | ||
| 9 | + zh: { | ||
| 10 | + claimed: { subject: '你已在 VOKO 认领 {agent}', heading: '{agent} 已激活', body: '你的 Agent 已加入公会并收到 100 启动积分。你可以在「我的 Agent」里管理它、重置 API key 或设置单日支出上限。', action: '打开我的 Agent' }, | ||
| 11 | + help: { subject: '{agent} 需要你的帮助', heading: '{agent} 向你求助', body: '任务:{task}\n\n{question}', action: '去收件箱回复' }, | ||
| 12 | + }, | ||
| 13 | +}) | ||
| 14 | + | ||
| 15 | +/** | ||
| 16 | + * @param {{ locale: 'en'|'zh', kind: 'claimed'|'help', url: string, agent: string, task?: string, question?: string }} data | ||
| 17 | + */ | ||
| 18 | +export function render(data) { | ||
| 19 | + if (!data || typeof data !== 'object') fail('data is required') | ||
| 20 | + const byKind = COPY[data.locale] | ||
| 21 | + if (!byKind) fail(`unsupported locale: ${String(data.locale)}`) | ||
| 22 | + const copy = byKind[data.kind] | ||
| 23 | + if (!copy) fail(`unsupported kind: ${String(data.kind)}`) | ||
| 24 | + const url = requiredUrl(data.url) | ||
| 25 | + const fill = (/** @type {string} */ text) => text.replace(/\{(agent|task|question)\}/g, (_, key) => requiredText(data[key], key)) | ||
| 26 | + const heading = fill(copy.heading) | ||
| 27 | + const body = fill(copy.body) | ||
| 28 | + const text = [heading, '', body, '', `${copy.action}: ${url}`].join('\n') | ||
| 29 | + const html = [ | ||
| 30 | + '<!doctype html><html><body style="font-family:Arial,Helvetica,sans-serif;color:#1a1a1a;line-height:1.6;">', | ||
| 31 | + `<h2 style="margin:0 0 16px;">${escapeHtml(heading)}</h2>`, | ||
| 32 | + `<p style="margin:0 0 16px;white-space:pre-wrap;">${escapeHtml(body)}</p>`, | ||
| 33 | + `<p style="margin:0;"><a href="${escapeHtml(url)}">${escapeHtml(copy.action)}</a></p>`, | ||
| 34 | + '</body></html>', | ||
| 35 | + ].join('') | ||
| 36 | + return { subject: fill(copy.subject), text, html } | ||
| 37 | +} | ||
| 38 | + | ||
| 39 | +function requiredText(value, field) { | ||
| 40 | + if (typeof value === 'string' && value.trim() !== '') return value | ||
| 41 | + fail(`missing field "${field}"`) | ||
| 42 | +} | ||
| 43 | + | ||
| 44 | +function requiredUrl(value) { | ||
| 45 | + const url = requiredText(value, 'url') | ||
| 46 | + const parsed = new URL(url) | ||
| 47 | + // 本地开发的前端是 http://localhost,其余环境必须 https。 | ||
| 48 | + if (parsed.protocol === 'https:' || parsed.hostname === 'localhost') return url | ||
| 49 | + fail('url must use https') | ||
| 50 | +} | ||
| 51 | + | ||
| 52 | +function escapeHtml(value) { | ||
| 53 | + return String(value).replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"') | ||
| 54 | +} | ||
| 55 | + | ||
| 56 | +function fail(message) { | ||
| 57 | + throw new EmailError({ provider: 'template', reason: 'badTemplate', retryable: false, message: `voko-notice: ${message}` }) | ||
| 58 | +} |
packages/email/test/voko-notice.test.js
0 → 100644
| 1 | +import { describe, expect, it } from 'vitest' | ||
| 2 | +import { renderTemplate } from '../src/templates/index.js' | ||
| 3 | + | ||
| 4 | +describe('voko-notice 模板', () => { | ||
| 5 | + it.each(['en', 'zh'])('%s:认领与求助两种通知都能渲染,内容被转义', (locale) => { | ||
| 6 | + const claimed = renderTemplate('voko-notice', { locale, kind: 'claimed', agent: 'Scout', url: 'https://alpha.voko.world/agents' }) | ||
| 7 | + expect(claimed.subject).toContain('Scout') | ||
| 8 | + const help = renderTemplate('voko-notice', { locale, kind: 'help', agent: 'Scout', task: 'Translate <b>', question: 'Which tone?', url: 'http://localhost:5201/help' }) | ||
| 9 | + expect(help.text).toContain('Which tone?') | ||
| 10 | + expect(help.html).toContain('Translate <b>') | ||
| 11 | + expect(help.html).not.toContain('<b>') | ||
| 12 | + }) | ||
| 13 | + | ||
| 14 | + it('缺字段、未知类型、非 https 链接直接报错', () => { | ||
| 15 | + expect(() => renderTemplate('voko-notice', { locale: 'en', kind: 'help', agent: 'Scout', url: 'https://x.test' })).toThrow(/missing field "task"/) | ||
| 16 | + expect(() => renderTemplate('voko-notice', { locale: 'en', kind: 'digest', agent: 'Scout', url: 'https://x.test' })).toThrow(/unsupported kind/) | ||
| 17 | + expect(() => renderTemplate('voko-notice', { locale: 'en', kind: 'claimed', agent: 'Scout', url: 'http://evil.test' })).toThrow(/must use https/) | ||
| 18 | + }) | ||
| 19 | +}) |
packages/voko-access/src/agent-access.js
| @@ -15,9 +15,10 @@ export const API_KEY_PREFIX = 'vk_live_' | @@ -15,9 +15,10 @@ export const API_KEY_PREFIX = 'vk_live_' | ||
| 15 | * agents: ReturnType<typeof import('@voko/exchange').createAgents>, | 15 | * agents: ReturnType<typeof import('@voko/exchange').createAgents>, |
| 16 | * grantStarter(ctx: object, trx: import('knex').Knex, input: { agent_id: string, wallet_user_id: string }): Promise<void>, | 16 | * grantStarter(ctx: object, trx: import('knex').Knex, input: { agent_id: string, wallet_user_id: string }): Promise<void>, |
| 17 | * public_url: string, | 17 | * public_url: string, |
| 18 | + * notifier: { sendAgentClaimed(ctx: object, input: { user_id: string, agent_name: string }): Promise<void> }, | ||
| 18 | * }} deps `grantStarter` 在认领事务内给新 Agent 的钱包发启动积分,必须按 agent_id 幂等。 | 19 | * }} deps `grantStarter` 在认领事务内给新 Agent 的钱包发启动积分,必须按 agent_id 幂等。 |
| 19 | */ | 20 | */ |
| 20 | -export function createAgentAccess({ db, agents, grantStarter, public_url }) { | 21 | +export function createAgentAccess({ db, agents, grantStarter, public_url, notifier }) { |
| 21 | if (typeof db !== 'function') throw new Error('createAgentAccess: db is required') | 22 | if (typeof db !== 'function') throw new Error('createAgentAccess: db is required') |
| 22 | if (typeof grantStarter !== 'function') throw new Error('createAgentAccess: grantStarter is required') | 23 | if (typeof grantStarter !== 'function') throw new Error('createAgentAccess: grantStarter is required') |
| 23 | if (typeof public_url !== 'string' || public_url === '') throw new Error('createAgentAccess: public_url is required') | 24 | if (typeof public_url !== 'string' || public_url === '') throw new Error('createAgentAccess: public_url is required') |
| @@ -72,7 +73,8 @@ export function createAgentAccess({ db, agents, grantStarter, public_url }) { | @@ -72,7 +73,8 @@ export function createAgentAccess({ db, agents, grantStarter, public_url }) { | ||
| 72 | */ | 73 | */ |
| 73 | async function claim(ctx, { claim_token, user_id, verification_code, guild_id, role }) { | 74 | async function claim(ctx, { claim_token, user_id, verification_code, guild_id, role }) { |
| 74 | const now = new Date() | 75 | const now = new Date() |
| 75 | - return db.transaction(async (trx) => { | 76 | + let fresh = false |
| 77 | + const result = await db.transaction(async (trx) => { | ||
| 76 | const claimRow = await trx('vk_agent_claims').where({ claim_token_hash: sha256(claim_token) }).forUpdate().first() | 78 | const claimRow = await trx('vk_agent_claims').where({ claim_token_hash: sha256(claim_token) }).forUpdate().first() |
| 77 | if (claimRow === undefined) return refuse('NOT_FOUND', 'Claim link not found') | 79 | if (claimRow === undefined) return refuse('NOT_FOUND', 'Claim link not found') |
| 78 | if (claimRow.claimed_by_user_id !== null) { | 80 | if (claimRow.claimed_by_user_id !== null) { |
| @@ -115,8 +117,12 @@ export function createAgentAccess({ db, agents, grantStarter, public_url }) { | @@ -115,8 +117,12 @@ export function createAgentAccess({ db, agents, grantStarter, public_url }) { | ||
| 115 | await trx('vk_guild_members').insert({ id: `gm_${randomUUID()}`, guild_id, user_id, role: 'member', created_at: now }).onConflict(['guild_id', 'user_id']).ignore() | 117 | await trx('vk_guild_members').insert({ id: `gm_${randomUUID()}`, guild_id, user_id, role: 'member', created_at: now }).onConflict(['guild_id', 'user_id']).ignore() |
| 116 | const agent = await agents.getById(ctx, trx, { agent_id: claimRow.agent_id }) | 118 | const agent = await agents.getById(ctx, trx, { agent_id: claimRow.agent_id }) |
| 117 | await grantStarter(ctx, trx, { agent_id: agent.id, wallet_user_id: agent.wallet_user_id }) | 119 | await grantStarter(ctx, trx, { agent_id: agent.id, wallet_user_id: agent.wallet_user_id }) |
| 120 | + fresh = true | ||
| 118 | return { ok: true, agent } | 121 | return { ok: true, agent } |
| 119 | }) | 122 | }) |
| 123 | + // 认领确认邮件在事务提交后发;重放的认领不再发。 | ||
| 124 | + if (result.ok && fresh) await notifier.sendAgentClaimed(ctx, { user_id, agent_name: result.agent.name }) | ||
| 125 | + return result | ||
| 120 | } | 126 | } |
| 121 | 127 | ||
| 122 | /** | 128 | /** |
packages/voko-access/src/db/tables/index.cjs
| @@ -3,6 +3,7 @@ const tableModules = [ | @@ -3,6 +3,7 @@ const tableModules = [ | ||
| 3 | require('./vk_agent_claims.cjs'), | 3 | require('./vk_agent_claims.cjs'), |
| 4 | require('./vk_api_keys.cjs'), | 4 | require('./vk_api_keys.cjs'), |
| 5 | require('./vk_webhook_deliveries.cjs'), | 5 | require('./vk_webhook_deliveries.cjs'), |
| 6 | + require('./vk_owner_settings.cjs'), | ||
| 6 | ] | 7 | ] |
| 7 | 8 | ||
| 8 | const targetTables = tableModules.map(({ tableName, buildTable }) => [tableName, buildTable]) | 9 | const targetTables = tableModules.map(({ tableName, buildTable }) => [tableName, buildTable]) |
packages/voko-access/src/db/tables/vk_owner_settings.cjs
0 → 100644
| 1 | +const tableName = 'vk_owner_settings' | ||
| 2 | + | ||
| 3 | +function buildTable(t) { | ||
| 4 | + t.string('user_id', 64).primary() | ||
| 5 | + t.string('timezone', 64).notNullable() | ||
| 6 | + t.tinyint('quiet_start_hour').unsigned().notNullable() | ||
| 7 | + t.tinyint('quiet_end_hour').unsigned().notNullable() | ||
| 8 | + t.boolean('email_help').notNullable() | ||
| 9 | + t.boolean('email_digest').notNullable() | ||
| 10 | + t.datetime('updated_at', { precision: 3 }).notNullable() | ||
| 11 | + t.check('quiet_start_hour <= 23 and quiet_end_hour <= 23', [], 'ck_vk_owner_settings_hours') | ||
| 12 | +} | ||
| 13 | + | ||
| 14 | +module.exports = { tableName, buildTable } |
packages/voko-access/src/index.js
| @@ -9,4 +9,6 @@ | @@ -9,4 +9,6 @@ | ||
| 9 | 9 | ||
| 10 | export { createAccessRequests, accessRequestFromRow, ACCESS_REQUEST_STATUSES } from './access-requests.js' | 10 | export { createAccessRequests, accessRequestFromRow, ACCESS_REQUEST_STATUSES } from './access-requests.js' |
| 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' | ||
| 13 | +export { nextAllowedSendTime } from './quiet-hours.js' | ||
| 12 | export { default as accessDbSchema } from './db/tables/index.cjs' | 14 | export { default as accessDbSchema } from './db/tables/index.cjs' |
packages/voko-access/src/owner-settings.js
0 → 100644
| 1 | +const TABLE = 'vk_owner_settings' | ||
| 2 | + | ||
| 3 | +/** 没有保存过设置的主人使用的产品默认值:UTC、23:00–08:00 安静、两类邮件都开。 */ | ||
| 4 | +export const DEFAULT_OWNER_SETTINGS = Object.freeze({ timezone: 'UTC', quiet_start_hour: 23, quiet_end_hour: 8, email_help: true, email_digest: true }) | ||
| 5 | + | ||
| 6 | +/** | ||
| 7 | + * @typedef {{ timezone: string, quiet_start_hour: number, quiet_end_hour: number, email_help: boolean, email_digest: boolean }} OwnerSettings | ||
| 8 | + */ | ||
| 9 | + | ||
| 10 | +/** | ||
| 11 | + * 主人的通知设置:时区、安静时段、邮件开关。 | ||
| 12 | + * @param {{ db: import('knex').Knex }} deps | ||
| 13 | + */ | ||
| 14 | +export function createOwnerSettings({ db }) { | ||
| 15 | + if (typeof db !== 'function') throw new Error('createOwnerSettings: db is required') | ||
| 16 | + | ||
| 17 | + /** | ||
| 18 | + * @param {object} _ctx | ||
| 19 | + * @param {{ user_id: string }} input | ||
| 20 | + * @returns {Promise<OwnerSettings>} | ||
| 21 | + */ | ||
| 22 | + async function get(_ctx, { user_id }) { | ||
| 23 | + const row = await db(TABLE).where({ user_id }).first() | ||
| 24 | + if (row === undefined) return { ...DEFAULT_OWNER_SETTINGS } | ||
| 25 | + return { timezone: row.timezone, quiet_start_hour: row.quiet_start_hour, quiet_end_hour: row.quiet_end_hour, email_help: row.email_help === 1, email_digest: row.email_digest === 1 } | ||
| 26 | + } | ||
| 27 | + | ||
| 28 | + /** | ||
| 29 | + * @param {object} _ctx | ||
| 30 | + * @param {{ user_id: string, settings: OwnerSettings }} input | ||
| 31 | + */ | ||
| 32 | + async function save(_ctx, { user_id, settings }) { | ||
| 33 | + await db(TABLE).insert({ user_id, ...settings, updated_at: new Date() }).onConflict('user_id').merge() | ||
| 34 | + } | ||
| 35 | + | ||
| 36 | + return { get, save } | ||
| 37 | +} |
packages/voko-access/src/quiet-hours.js
0 → 100644
| 1 | +/** | ||
| 2 | + * 安静时段内的通知推迟到安静时段结束的整点;不在安静时段则立即发送。 | ||
| 3 | + * 起止相同表示没有安静时段。 | ||
| 4 | + * ponytail: 用「距结束还有几小时几分」推算,夏令时切换当晚会差一小时;要精确就换成按时区构造目标时刻。 | ||
| 5 | + * | ||
| 6 | + * @param {Date} now | ||
| 7 | + * @param {{ timezone: string, quiet_start_hour: number, quiet_end_hour: number }} settings | ||
| 8 | + * @returns {Date} | ||
| 9 | + */ | ||
| 10 | +export function nextAllowedSendTime(now, { timezone, quiet_start_hour, quiet_end_hour }) { | ||
| 11 | + const parts = new Intl.DateTimeFormat('en-US', { timeZone: timezone, hour: 'numeric', minute: 'numeric', second: 'numeric', hourCycle: 'h23' }).formatToParts(now) | ||
| 12 | + const part = (/** @type {string} */ type) => Number(parts.find((item) => item.type === type).value) | ||
| 13 | + const hour = part('hour') | ||
| 14 | + const quiet = quiet_start_hour < quiet_end_hour ? hour >= quiet_start_hour && hour < quiet_end_hour : quiet_start_hour > quiet_end_hour && (hour >= quiet_start_hour || hour < quiet_end_hour) | ||
| 15 | + if (!quiet) return now | ||
| 16 | + const secondsLeft = ((quiet_end_hour - hour + 24) % 24) * 3600 - part('minute') * 60 - part('second') | ||
| 17 | + return new Date(now.getTime() - now.getMilliseconds() + secondsLeft * 1000) | ||
| 18 | +} |
packages/voko-access/test/quiet-hours.test.js
0 → 100644
| 1 | +import { describe, expect, it } from 'vitest' | ||
| 2 | +import { nextAllowedSendTime } from '../src/quiet-hours.js' | ||
| 3 | + | ||
| 4 | +const night = { timezone: 'Asia/Shanghai', quiet_start_hour: 23, quiet_end_hour: 8 } | ||
| 5 | + | ||
| 6 | +describe('安静时段', () => { | ||
| 7 | + it('不在安静时段 → 立即', () => { | ||
| 8 | + const now = new Date('2026-10-12T04:00:00.000Z') // 上海 12:00 | ||
| 9 | + expect(nextAllowedSendTime(now, night)).toBe(now) | ||
| 10 | + }) | ||
| 11 | + | ||
| 12 | + it('跨午夜的安静时段:23:30 与 02:15 都推迟到当地 08:00', () => { | ||
| 13 | + expect(nextAllowedSendTime(new Date('2026-10-12T15:30:00.000Z'), night)).toEqual(new Date('2026-10-13T00:00:00.000Z')) | ||
| 14 | + expect(nextAllowedSendTime(new Date('2026-10-12T18:15:20.500Z'), night)).toEqual(new Date('2026-10-13T00:00:00.000Z')) | ||
| 15 | + }) | ||
| 16 | + | ||
| 17 | + it('边界:23:00 整算安静,08:00 整不算', () => { | ||
| 18 | + expect(nextAllowedSendTime(new Date('2026-10-12T15:00:00.000Z'), night)).toEqual(new Date('2026-10-13T00:00:00.000Z')) | ||
| 19 | + const eight = new Date('2026-10-13T00:00:00.000Z') | ||
| 20 | + expect(nextAllowedSendTime(eight, night)).toBe(eight) | ||
| 21 | + }) | ||
| 22 | + | ||
| 23 | + it('不跨午夜的安静时段,以及起止相同表示不设安静时段', () => { | ||
| 24 | + const noon = new Date('2026-10-12T12:30:00.000Z') | ||
| 25 | + expect(nextAllowedSendTime(noon, { timezone: 'UTC', quiet_start_hour: 12, quiet_end_hour: 14 })).toEqual(new Date('2026-10-12T14:00:00.000Z')) | ||
| 26 | + expect(nextAllowedSendTime(noon, { timezone: 'UTC', quiet_start_hour: 9, quiet_end_hour: 9 })).toBe(noon) | ||
| 27 | + }) | ||
| 28 | +}) |
packages/voko-clearing/src/db/tables/index.cjs
| 1 | const tableModules = [ | 1 | const tableModules = [ |
| 2 | require('./vk_escrows.cjs'), | 2 | require('./vk_escrows.cjs'), |
| 3 | require('./vk_escrow_payouts.cjs'), | 3 | require('./vk_escrow_payouts.cjs'), |
| 4 | + require('./vk_tips.cjs'), | ||
| 4 | ] | 5 | ] |
| 5 | 6 | ||
| 6 | const targetTables = tableModules.map(({ tableName, buildTable }) => [tableName, buildTable]) | 7 | const targetTables = tableModules.map(({ tableName, buildTable }) => [tableName, buildTable]) |
packages/voko-clearing/src/db/tables/vk_tips.cjs
0 → 100644
| 1 | +const tableName = 'vk_tips' | ||
| 2 | + | ||
| 3 | +function buildTable(t) { | ||
| 4 | + t.string('id', 64).primary() | ||
| 5 | + t.string('task_id', 64).notNullable() | ||
| 6 | + t.string('from_agent_id', 64).notNullable() | ||
| 7 | + t.string('from_wallet', 64).notNullable() | ||
| 8 | + t.string('to_wallet', 64).notNullable() | ||
| 9 | + t.bigInteger('amount').notNullable() | ||
| 10 | + t.string('out_ledger_id', 64).notNullable() | ||
| 11 | + t.string('in_ledger_id', 64).notNullable() | ||
| 12 | + t.string('client_request_key', 191).notNullable() | ||
| 13 | + t.datetime('created_at', { precision: 3 }).notNullable() | ||
| 14 | + t.unique(['from_agent_id', 'client_request_key'], { indexName: 'uq_vk_tips_sender_request' }) | ||
| 15 | + t.unique(['out_ledger_id'], { indexName: 'uq_vk_tips_out_ledger' }) | ||
| 16 | + t.unique(['in_ledger_id'], { indexName: 'uq_vk_tips_in_ledger' }) | ||
| 17 | + t.index(['task_id'], 'idx_vk_tips_task') | ||
| 18 | + t.check('amount > 0', [], 'ck_vk_tips_amount') | ||
| 19 | + t.check('from_wallet <> to_wallet', [], 'ck_vk_tips_parties') | ||
| 20 | +} | ||
| 21 | + | ||
| 22 | +module.exports = { tableName, buildTable } |
packages/voko-clearing/src/escrow.js
| @@ -130,7 +130,17 @@ export function createEscrow({ db }) { | @@ -130,7 +130,17 @@ export function createEscrow({ db }) { | ||
| 130 | return -Number(row.spent) | 130 | return -Number(row.spent) |
| 131 | } | 131 | } |
| 132 | 132 | ||
| 133 | - return { hold, topupExists, topup, refund, settle, walletOf, spentToday } | 133 | + /** |
| 134 | + * 钱包流水,最近的在前。 | ||
| 135 | + * @param {object} _ctx | ||
| 136 | + * @param {{ wallet_user_id: string, limit: number }} input | ||
| 137 | + * @returns {Promise<Array<{ id: string, type: string, amount: number, reason: string, ref_type: string|null, ref_id: string|null, created_at: Date }>>} | ||
| 138 | + */ | ||
| 139 | + async function ledgerOf(_ctx, { wallet_user_id, limit }) { | ||
| 140 | + return db('billing_ledger').where({ user_id: wallet_user_id }).orderBy([{ column: 'created_at', order: 'desc' }, { column: 'id', order: 'desc' }]).limit(limit).select('id', 'type', 'amount', 'reason', 'ref_type', 'ref_id', 'created_at') | ||
| 141 | + } | ||
| 142 | + | ||
| 143 | + return { hold, topupExists, topup, refund, settle, walletOf, ledgerOf, spentToday } | ||
| 134 | } | 144 | } |
| 135 | 145 | ||
| 136 | /** | 146 | /** |
packages/voko-clearing/src/index.js
| 1 | /** | 1 | /** |
| 2 | * `@voko/clearing` —— VOKO 的积分清算:托管与发放明细;动账一律经 `@drama/billing` 的账本。 | 2 | * `@voko/clearing` —— VOKO 的积分清算:托管与发放明细;动账一律经 `@drama/billing` 的账本。 |
| 3 | * | 3 | * |
| 4 | - * 当前有托管的冻结、补扣、结算与退回;打赏在 W4 加入。 | 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 { default as clearingDbSchema } from './db/tables/index.cjs' | 11 | export { default as clearingDbSchema } from './db/tables/index.cjs' |
packages/voko-clearing/src/tips.js
0 → 100644
| 1 | +import { randomUUID } from 'node:crypto' | ||
| 2 | +import { createBillingRepo, createLedger } from '@drama/billing' | ||
| 3 | + | ||
| 4 | +const TIPS = 'vk_tips' | ||
| 5 | + | ||
| 6 | +/** | ||
| 7 | + * 打赏:任何成员可给已结算任务的赢家打赏,从自己的可用积分划转,不可撤回。 | ||
| 8 | + * 打赏人扣款与收款人入账在同一事务;余额不足时账本抛 INSUFFICIENT_BALANCE,事务回滚。 | ||
| 9 | + * | ||
| 10 | + * @param {{ db: import('knex').Knex }} deps | ||
| 11 | + */ | ||
| 12 | +export function createTips({ db }) { | ||
| 13 | + if (typeof db !== 'function') throw new Error('createTips: db is required') | ||
| 14 | + | ||
| 15 | + /** | ||
| 16 | + * @param {object} _ctx | ||
| 17 | + * @param {{ | ||
| 18 | + * task_id: string, | ||
| 19 | + * from_agent_id: string, | ||
| 20 | + * from_wallet: string, | ||
| 21 | + * amount: number, | ||
| 22 | + * client_request_key: string, | ||
| 23 | + * onSent(trx: import('knex').Knex, sent: { to_wallet: string }): Promise<void>, | ||
| 24 | + * }} input `onSent` 在同一事务里执行(写事件);重放时不再调用。 | ||
| 25 | + * @returns {Promise<{ ok: true, tip_id: string, replayed: boolean } | { ok: false, code: 'state_conflict'|'rule_violation', hint: string }>} | ||
| 26 | + */ | ||
| 27 | + async function send(_ctx, { task_id, from_agent_id, from_wallet, amount, client_request_key, onSent }) { | ||
| 28 | + return db.transaction(async (trx) => { | ||
| 29 | + // 锁打赏人的钱包行:同一打赏人的打赏串行,重放在首个事务提交后读到已有记录。 | ||
| 30 | + await trx('users').where({ id: from_wallet }).forUpdate().first('id') | ||
| 31 | + const existing = await trx(TIPS).where({ from_agent_id, client_request_key }).first('id') | ||
| 32 | + if (existing !== undefined) return { ok: true, tip_id: existing.id, replayed: true } | ||
| 33 | + const winner = await trx('vk_escrow_payouts').where({ task_id, kind: 'winner' }).first('recipient_wallet') | ||
| 34 | + if (winner === undefined) return { ok: false, code: 'state_conflict', hint: 'Only settled tasks can be tipped.' } | ||
| 35 | + const to_wallet = winner.recipient_wallet | ||
| 36 | + if (to_wallet === from_wallet) return { ok: false, code: 'rule_violation', hint: 'You cannot tip yourself.' } | ||
| 37 | + const id = `tp_${randomUUID()}` | ||
| 38 | + const ledger = createLedger(createBillingRepo(db, trx)) | ||
| 39 | + const base = { amount, source: 'voko', reason: 'vk_tip', ref_type: 'vk_tip', ref_id: id } | ||
| 40 | + const out = await ledger.deduct({ ...base, user_id: from_wallet, idempotency_key: `vk:tip:${id}:out` }) | ||
| 41 | + const received = await ledger.grant({ ...base, user_id: to_wallet, credit_kind: 'earned', idempotency_key: `vk:tip:${id}:in` }) | ||
| 42 | + await trx(TIPS).insert({ id, task_id, from_agent_id, from_wallet, to_wallet, amount, out_ledger_id: out.ledger_id, in_ledger_id: received.ledger_id, client_request_key, created_at: new Date() }) | ||
| 43 | + await onSent(trx, { to_wallet }) | ||
| 44 | + return { ok: true, tip_id: id, replayed: false } | ||
| 45 | + }) | ||
| 46 | + } | ||
| 47 | + | ||
| 48 | + return { send } | ||
| 49 | +} |
packages/voko-exchange/src/agents.js
| @@ -163,7 +163,19 @@ export function createAgents() { | @@ -163,7 +163,19 @@ export function createAgents() { | ||
| 163 | return (await runner(TABLE).where({ id: agent_id, owner_user_id }).update({ daily_spend_limit, updated_at: new Date() })) === 1 | 163 | return (await runner(TABLE).where({ id: agent_id, owner_user_id }).update({ daily_spend_limit, updated_at: new Date() })) === 1 |
| 164 | } | 164 | } |
| 165 | 165 | ||
| 166 | - return { insertPending, getById, activate, updateProfile, listByOwner, setStatus, setDailySpendLimit } | 166 | + /** |
| 167 | + * @param {object} _ctx | ||
| 168 | + * @param {import('knex').Knex} runner | ||
| 169 | + * @param {{ wallet_user_id: string }} input | ||
| 170 | + * @returns {Promise<Agent>} | ||
| 171 | + */ | ||
| 172 | + async function getByWallet(_ctx, runner, { wallet_user_id }) { | ||
| 173 | + const row = await runner(TABLE).where({ wallet_user_id }).first() | ||
| 174 | + if (row === undefined) throw new Error(`vk_agents row missing for wallet: ${wallet_user_id}`) | ||
| 175 | + return agentFromRow(row) | ||
| 176 | + } | ||
| 177 | + | ||
| 178 | + return { insertPending, getById, getByWallet, activate, updateProfile, listByOwner, setStatus, setDailySpendLimit } | ||
| 167 | } | 179 | } |
| 168 | 180 | ||
| 169 | /** | 181 | /** |
packages/voko-exchange/src/events.js
| @@ -49,5 +49,15 @@ export function createEvents({ db }) { | @@ -49,5 +49,15 @@ export function createEvents({ db }) { | ||
| 49 | return after(agent, since) | 49 | return after(agent, since) |
| 50 | } | 50 | } |
| 51 | 51 | ||
| 52 | - return { pullUnread, listSince } | 52 | + /** |
| 53 | + * 在调用方的事务里写一条事件(任务状态机之外的来源,如打赏)。 | ||
| 54 | + * @param {object} _ctx | ||
| 55 | + * @param {import('knex').Knex} trx | ||
| 56 | + * @param {{ guild_id: string, task_id: string, type: string, target_agent_id: string|null }} input | ||
| 57 | + */ | ||
| 58 | + 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() }) | ||
| 60 | + } | ||
| 61 | + | ||
| 62 | + return { pullUnread, listSince, emit } | ||
| 53 | } | 63 | } |
packages/voko-exchange/src/help.js
0 → 100644
| 1 | +import { randomUUID } from 'node:crypto' | ||
| 2 | + | ||
| 3 | +import { taskFromRow } from './task.js' | ||
| 4 | +import { insertEvent, insertJob } from './tasks.js' | ||
| 5 | + | ||
| 6 | +const HELP = 'vk_help_requests' | ||
| 7 | + | ||
| 8 | +/** | ||
| 9 | + * 求助主人:Agent 卡住时提问,主人在网页收件箱回复,Agent 下次 `/home` 拿到答复。 | ||
| 10 | + * | ||
| 11 | + * @param {{ | ||
| 12 | + * db: import('knex').Knex, | ||
| 13 | + * sendAfter(ctx: object, input: { user_id: string, now: Date }): Promise<Date|null>, | ||
| 14 | + * mailer: { sendHelp(ctx: object, input: { user_id: string, agent_name: string, task_title: string, question: string }): Promise<void> }, | ||
| 15 | + * }} deps `sendAfter` 给出通知主人的时刻(安静时段内推迟),主人关闭了求助邮件时返回 null。 | ||
| 16 | + */ | ||
| 17 | +export function createHelp({ db, sendAfter, mailer }) { | ||
| 18 | + if (typeof db !== 'function') throw new Error('createHelp: db is required') | ||
| 19 | + | ||
| 20 | + /** | ||
| 21 | + * @param {object} ctx | ||
| 22 | + * @param {{ agent: import('./agents.js').Agent, task_id: string, question: string }} input | ||
| 23 | + * @returns {Promise<{ ok: true, help_id: string } | { ok: false, code: 'not_found'|'forbidden'|'state_conflict', hint: string }>} | ||
| 24 | + */ | ||
| 25 | + async function request(ctx, { agent, task_id, question }) { | ||
| 26 | + const now = new Date() | ||
| 27 | + const owner = /** @type {string} */ (agent.owner_user_id) | ||
| 28 | + const notifyAt = await sendAfter(ctx, { user_id: owner, now }) | ||
| 29 | + return db.transaction(async (trx) => { | ||
| 30 | + const row = await trx('vk_tasks').where({ id: task_id }).forUpdate().first() | ||
| 31 | + if (row === undefined) return refuse('not_found', `Task not found: ${task_id}`) | ||
| 32 | + const task = taskFromRow(row) | ||
| 33 | + if (task.holder_agent_id !== agent.id) return refuse('forbidden', 'Only the current holder can ask for help on a task.') | ||
| 34 | + switch (task.status) { | ||
| 35 | + case 'claimed': | ||
| 36 | + case 'rework': | ||
| 37 | + break | ||
| 38 | + case 'pending_approval': | ||
| 39 | + case 'open': | ||
| 40 | + case 'in_contest': | ||
| 41 | + case 'submitted': | ||
| 42 | + case 'reviewing': | ||
| 43 | + case 'disputed': | ||
| 44 | + case 'settled': | ||
| 45 | + case 'cancelled': | ||
| 46 | + case 'refunded': | ||
| 47 | + return refuse('state_conflict', `Task is ${task.status}; help is for work in progress.`) | ||
| 48 | + default: | ||
| 49 | + throw new Error(`Unhandled status: ${task.status}, id: ${task.id}`) | ||
| 50 | + } | ||
| 51 | + const id = `hp_${randomUUID()}` | ||
| 52 | + await trx(HELP).insert({ id, task_id, agent_id: agent.id, owner_user_id: owner, question, status: 'open', created_at: now }) | ||
| 53 | + await insertEvent(trx, task, 'help.requested', null, now) | ||
| 54 | + if (notifyAt !== null) await insertJob(trx, id, 'help_notify', notifyAt, now) | ||
| 55 | + return { ok: true, help_id: id } | ||
| 56 | + }) | ||
| 57 | + } | ||
| 58 | + | ||
| 59 | + /** | ||
| 60 | + * worker:给主人发求助邮件;已经回复过的不再打扰。 | ||
| 61 | + * @param {object} ctx | ||
| 62 | + * @param {{ help_id: string }} input | ||
| 63 | + */ | ||
| 64 | + async function notify(ctx, { help_id }) { | ||
| 65 | + const help = await db(HELP).where({ id: help_id }).first() | ||
| 66 | + if (help === undefined) throw new Error(`vk_help_requests row missing, id: ${help_id}`) | ||
| 67 | + if (help.status !== 'open') return | ||
| 68 | + const [agent, task] = await Promise.all([db('vk_agents').where({ id: help.agent_id }).first('name'), db('vk_tasks').where({ id: help.task_id }).first('title')]) | ||
| 69 | + await mailer.sendHelp(ctx, { user_id: help.owner_user_id, agent_name: agent.name, task_title: task.title, question: help.question }) | ||
| 70 | + } | ||
| 71 | + | ||
| 72 | + /** | ||
| 73 | + * 主人的求助收件箱:未回复的在前。 | ||
| 74 | + * @param {object} _ctx | ||
| 75 | + * @param {{ user_id: string }} input | ||
| 76 | + */ | ||
| 77 | + async function listForOwner(_ctx, { user_id }) { | ||
| 78 | + return db(`${HELP} as h`) | ||
| 79 | + .join('vk_agents as a', 'a.id', 'h.agent_id') | ||
| 80 | + .join('vk_tasks as t', 't.id', 'h.task_id') | ||
| 81 | + .where('h.owner_user_id', user_id) | ||
| 82 | + .orderBy([{ column: 'h.status', order: 'desc' }, { column: 'h.created_at', order: 'desc' }]) | ||
| 83 | + .limit(100) | ||
| 84 | + .select('h.id', 'h.task_id', 'h.agent_id', 'h.question', 'h.status', 'h.answer', 'h.answered_at', 'h.created_at', 'a.name as agent_name', 't.title as task_title') | ||
| 85 | + } | ||
| 86 | + | ||
| 87 | + /** | ||
| 88 | + * 主人回复。只能回复自己名下 Agent 的、尚未回复的求助。 | ||
| 89 | + * @param {object} _ctx | ||
| 90 | + * @param {{ user_id: string, help_id: string, answer: string }} input | ||
| 91 | + * @returns {Promise<{ ok: true } | { ok: false, code: 'not_found'|'state_conflict', hint: string }>} | ||
| 92 | + */ | ||
| 93 | + async function answer(_ctx, { user_id, help_id, answer: text }) { | ||
| 94 | + const now = new Date() | ||
| 95 | + return db.transaction(async (trx) => { | ||
| 96 | + const help = await trx(HELP).where({ id: help_id, owner_user_id: user_id }).forUpdate().first() | ||
| 97 | + if (help === undefined) return refuse('not_found', `Help request not found: ${help_id}`) | ||
| 98 | + if (help.status !== 'open') return refuse('state_conflict', 'This help request has already been answered.') | ||
| 99 | + await trx(HELP).where({ id: help_id }).update({ status: 'answered', answer: text, answered_at: now }) | ||
| 100 | + const task = taskFromRow(await trx('vk_tasks').where({ id: help.task_id }).first()) | ||
| 101 | + await insertEvent(trx, task, 'help.answered', help.agent_id, now) | ||
| 102 | + return { ok: true } | ||
| 103 | + }) | ||
| 104 | + } | ||
| 105 | + | ||
| 106 | + /** | ||
| 107 | + * `/home` 用:这个 Agent 最近收到的答复。 | ||
| 108 | + * @param {object} _ctx | ||
| 109 | + * @param {{ agent_id: string }} input | ||
| 110 | + * @returns {Promise<Array<{ id: string, task_id: string, question: string, answer: string, answered_at: Date }>>} | ||
| 111 | + */ | ||
| 112 | + async function answersFor(_ctx, { agent_id }) { | ||
| 113 | + return db(HELP).where({ agent_id, status: 'answered' }).orderBy('answered_at', 'desc').limit(10).select('id', 'task_id', 'question', 'answer', 'answered_at') | ||
| 114 | + } | ||
| 115 | + | ||
| 116 | + return { request, notify, listForOwner, answer, answersFor } | ||
| 117 | +} | ||
| 118 | + | ||
| 119 | +/** | ||
| 120 | + * @template {string} C | ||
| 121 | + * @param {C} code | ||
| 122 | + * @param {string} hint | ||
| 123 | + * @returns {{ ok: false, code: C, hint: string }} | ||
| 124 | + */ | ||
| 125 | +function refuse(code, hint) { | ||
| 126 | + return { ok: false, code, hint } | ||
| 127 | +} |
packages/voko-exchange/src/index.js
| @@ -11,6 +11,7 @@ export { createGuilds } from './guilds.js' | @@ -11,6 +11,7 @@ export { createGuilds } from './guilds.js' | ||
| 11 | export { createTasks } from './tasks.js' | 11 | export { createTasks } from './tasks.js' |
| 12 | export { createEvents } from './events.js' | 12 | export { createEvents } from './events.js' |
| 13 | export { createJobs } from './jobs.js' | 13 | export { createJobs } from './jobs.js' |
| 14 | +export { createHelp } from './help.js' | ||
| 14 | export { createJudge } from './judge.js' | 15 | export { createJudge } from './judge.js' |
| 15 | export { rarityForReward, maxRarityForLevel } from './rarity.js' | 16 | export { rarityForReward, maxRarityForLevel } from './rarity.js' |
| 16 | export { taskFromRow, TASK_STATUSES, TASK_TEMPLATES } from './task.js' | 17 | export { taskFromRow, TASK_STATUSES, TASK_TEMPLATES } from './task.js' |
packages/voko-exchange/src/jobs.js
| @@ -21,10 +21,11 @@ const WARN_KINDS = Object.freeze(['checkpoint_warn', 'review_warn']) | @@ -21,10 +21,11 @@ const WARN_KINDS = Object.freeze(['checkpoint_warn', 'review_warn']) | ||
| 21 | * autoSelectWinner(ctx: object, input: { task_id: string }): Promise<void>, | 21 | * autoSelectWinner(ctx: object, input: { task_id: string }): Promise<void>, |
| 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 | * logger: { error(message: string, fields: object): void }, | 25 | * logger: { error(message: string, fields: object): void }, |
| 25 | * }} deps | 26 | * }} deps |
| 26 | */ | 27 | */ |
| 27 | -export function createJobs({ db, tasks, logger }) { | 28 | +export function createJobs({ db, tasks, help, logger }) { |
| 28 | if (typeof db !== 'function') throw new Error('createJobs: db is required') | 29 | if (typeof db !== 'function') throw new Error('createJobs: db is required') |
| 29 | 30 | ||
| 30 | /** | 31 | /** |
| @@ -84,6 +85,7 @@ export function createJobs({ db, tasks, logger }) { | @@ -84,6 +85,7 @@ export function createJobs({ db, tasks, logger }) { | ||
| 84 | return void await tasks.actAsWorker(ctx, { task_id, action: { type: 'review_auto_accept', winner_agent_id: null } }) | 85 | return void await tasks.actAsWorker(ctx, { task_id, action: { type: 'review_auto_accept', winner_agent_id: null } }) |
| 85 | } | 86 | } |
| 86 | if (job.kind === 'dispute_arbitrate') return tasks.arbitrate(ctx, { task_id }) | 87 | if (job.kind === 'dispute_arbitrate') return tasks.arbitrate(ctx, { task_id }) |
| 88 | + if (job.kind === 'help_notify') return help.notify(ctx, { help_id: job.ref_id }) | ||
| 87 | // 其余类型(Webhook、日报、对账)在 W4 接入。 | 89 | // 其余类型(Webhook、日报、对账)在 W4 接入。 |
| 88 | throw new Error(`Unhandled job kind: ${job.kind}, id: ${job.id}`) | 90 | throw new Error(`Unhandled job kind: ${job.kind}, id: ${job.id}`) |
| 89 | } | 91 | } |
packages/voko-exchange/src/tasks.js
| @@ -691,19 +691,19 @@ async function acceptanceOf(runner, task_id) { | @@ -691,19 +691,19 @@ async function acceptanceOf(runner, task_id) { | ||
| 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 | -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 | await trx(EVENTS).insert({ guild_id: task.guild_id, target_agent_id, type, task_id: task.id, payload: '{}', created_at: now }) |
| 696 | } | 696 | } |
| 697 | 697 | ||
| 698 | /** | 698 | /** |
| 699 | - * 同一任务同一类型同一时刻的 job 只排一条(dedupe_key UNIQUE)。 | 699 | + * 同一对象同一类型同一时刻的 job 只排一条(dedupe_key UNIQUE)。 |
| 700 | * @param {import('knex').Knex} trx | 700 | * @param {import('knex').Knex} trx |
| 701 | - * @param {string} task_id | 701 | + * @param {string} task_id job 的 ref_id:任务类 job 是任务 id,求助通知是求助 id |
| 702 | * @param {string} kind | 702 | * @param {string} kind |
| 703 | * @param {Date} run_at | 703 | * @param {Date} run_at |
| 704 | * @param {Date} now | 704 | * @param {Date} now |
| 705 | */ | 705 | */ |
| 706 | -async function insertJob(trx, task_id, kind, run_at, now) { | 706 | +export async function insertJob(trx, task_id, kind, run_at, now) { |
| 707 | await trx(JOBS) | 707 | await trx(JOBS) |
| 708 | .insert({ id: `j_${randomUUID()}`, kind, ref_id: task_id, run_at, status: 'pending', attempts: 0, dedupe_key: `${kind}:${task_id}:${run_at.toISOString()}`, created_at: now }) | 708 | .insert({ id: `j_${randomUUID()}`, kind, ref_id: task_id, run_at, status: 'pending', attempts: 0, dedupe_key: `${kind}:${task_id}:${run_at.toISOString()}`, created_at: now }) |
| 709 | .onConflict('dedupe_key') | 709 | .onConflict('dedupe_key') |