diff --git a/apps/voko-server/package.json b/apps/voko-server/package.json index fdd5e03..fa3ec92 100644 --- a/apps/voko-server/package.json +++ b/apps/voko-server/package.json @@ -17,6 +17,7 @@ "verify:f1": "node tools/scenarios/voko-alpha/f1-publish.mjs", "verify:f2": "node tools/scenarios/voko-alpha/f2-execute.mjs", "verify:f3": "node tools/scenarios/voko-alpha/f3-review-settle.mjs", + "verify:e2e": "node tools/scenarios/voko-alpha/e2e-loop.mjs", "verify:timers": "node tools/scenarios/voko-alpha/timers.mjs", "verify:concurrency": "node tools/scenarios/voko-alpha/concurrency.mjs", "verify:conservation": "node tools/scenarios/voko-alpha/verify-conservation.mjs" diff --git a/apps/voko-server/src/assembly/email.js b/apps/voko-server/src/assembly/email.js index c31f814..28162e1 100644 --- a/apps/voko-server/src/assembly/email.js +++ b/apps/voko-server/src/assembly/email.js @@ -79,7 +79,44 @@ export function createEmailPorts({ env, db, logger }) { }, } - return { authEmail, accessNotifier } + /** + * 给主人发一封 voko-notice 并记投递日志。主人邮箱与语言取自 users 行。 + * @param {string} user_id + * @param {string} purpose + * @param {{ kind: 'claimed'|'help', path: string, agent: string, task?: string, question?: string }} notice + */ + async function sendOwnerNotice(user_id, purpose, { path, ...notice }) { + const owner = await db('users').where({ id: user_id }).first('email', 'locale') + if (owner === undefined) throw new Error(`users row missing for owner notice, id: ${user_id}`) + const result = await email.sendTemplate('voko-notice', { to: owner.email, from, locale: String(owner.locale).startsWith('zh') ? 'zh' : 'en', url: `${env.publicUrl}${path}`, ...notice }) + await recordEmailDelivery(db, { provider: result.provider || 'nodemailer', messageId: result.message_id || null, purpose, toEmail: owner.email, status: 'sent', template: 'voko-notice' }) + } + + const ownerNotifier = { + /** + * 认领确认。 + * @param {object} _ctx + * @param {{ user_id: string, agent_name: string }} input + */ + async sendAgentClaimed(_ctx, { user_id, agent_name }) { + try { + await sendOwnerNotice(user_id, 'agent_claimed', { kind: 'claimed', path: '/agents', agent: agent_name }) + } catch (err) { + // 外部 IO 的最小包裹:认领已提交,确认邮件失败只记录,不影响认领结果。 + logger.error('认领确认邮件投递失败', { user_id, error: err instanceof Error ? err.message : String(err) }) + } + }, + /** + * 求助通知。由 worker 的 job 调用,失败直接抛出,由 job 退避重试。 + * @param {object} _ctx + * @param {{ user_id: string, agent_name: string, task_title: string, question: string }} input + */ + async sendHelp(_ctx, { user_id, agent_name, task_title, question }) { + await sendOwnerNotice(user_id, 'help_requested', { kind: 'help', path: '/help', agent: agent_name, task: task_title, question }) + }, + } + + return { authEmail, accessNotifier, ownerNotifier } } /** diff --git a/apps/voko-server/src/assembly/runtime.js b/apps/voko-server/src/assembly/runtime.js index 5e68443..09a81df 100644 --- a/apps/voko-server/src/assembly/runtime.js +++ b/apps/voko-server/src/assembly/runtime.js @@ -9,10 +9,10 @@ import { createBillingRepo, createLedger } from '@drama/billing' import { createDb } from '@drama/db' import { createLLM, createStaticKeyProvider } from '@drama/llm' import { createLogger } from '@drama/observability' -import { createAccessRequests, createAgentAccess } from '@voko/access' +import { createAccessRequests, createAgentAccess, createOwnerSettings, nextAllowedSendTime } from '@voko/access' import { createMemoryStore, createRateLimiter } from '@drama/ratelimit' -import { createEscrow } from '@voko/clearing' -import { createAgents, createEvents, createGuilds, createJobs, createJudge, createTasks } from '@voko/exchange' +import { createEscrow, createTips } from '@voko/clearing' +import { createAgents, createEvents, createGuilds, createHelp, createJobs, createJudge, createTasks } from '@voko/exchange' import { readEnv } from '../env.js' import { createEmailPorts } from './email.js' @@ -34,7 +34,7 @@ export async function createRuntime({ service }) { // 2. 连接与基础端口。 const db = createDb({ url: env.databaseUrl }) - const { authEmail, accessNotifier } = createEmailPorts({ env, db, logger }) + const { authEmail, accessNotifier, ownerNotifier } = createEmailPorts({ env, db, logger }) const tokenSigner = createHmacTokenSigner({ secret: env.authTokenSecret }) const hasher = createScryptHasher() const oauth = env.oauth.googleClientId === null @@ -57,7 +57,39 @@ export async function createRuntime({ service }) { const judge = createJudge({ chat: (request) => llm.chat(request) }) const tasks = createTasks({ db, agents, escrow, judge }) const events = createEvents({ db }) - const jobs = createJobs({ db, tasks, logger }) + const ownerSettings = createOwnerSettings({ db }) + const help = createHelp({ + db, + mailer: ownerNotifier, + // 求助邮件:主人关了就不发;在安静时段内推迟到结束时刻。 + async sendAfter(ctx, { user_id, now }) { + const settings = await ownerSettings.get(ctx, { user_id }) + return settings.email_help ? nextAllowedSendTime(now, settings) : null + }, + }) + const tips = createTips({ db }) + const jobs = createJobs({ db, tasks, help, logger }) + + /** + * 给已结算任务的赢家打赏;收款事件与动账同事务。 + * @param {object} ctx + * @param {{ agent: import('@voko/exchange').Agent, task_id: string, amount: number, client_request_key: string }} input + */ + async function tipTask(ctx, { agent, task_id, amount, client_request_key }) { + const seen = await tasks.get(ctx, { agent, task_id }) + if (!seen.ok) return seen + return tips.send(ctx, { + task_id, + from_agent_id: agent.id, + from_wallet: agent.wallet_user_id, + amount, + client_request_key, + async onSent(trx, { to_wallet }) { + const recipient = await agents.getByWallet(ctx, trx, { wallet_user_id: to_wallet }) + await events.emit(ctx, trx, { guild_id: seen.task.guild_id, task_id, type: 'tip.received', target_agent_id: recipient.id }) + }, + }) + } // 公开 API 按 Agent 限流(接入说明:读 120/分、写 40/分、发任务 30/小时)。单进程部署用内存 store,多实例时换 MySQL 或 Redis store。 const rateLimiter = createRateLimiter({ store: createMemoryStore(), @@ -71,6 +103,7 @@ export async function createRuntime({ service }) { db, agents, public_url: env.publicUrl, + notifier: ownerNotifier, // 启动积分 100:在认领事务里记账,幂等键由 agent_id 唯一决定。 async grantStarter(_ctx, trx, { agent_id, wallet_user_id }) { await createLedger(createBillingRepo(db, trx)).grant({ @@ -101,7 +134,7 @@ export async function createRuntime({ service }) { return { env, logger, - services: { db, auth, userRepo, access, agents, guilds, agentAccess, escrow, tasks, events, jobs, rateLimiter, register, oauthEnabled: env.oauth.googleClientId !== null }, + services: { db, auth, userRepo, access, agents, guilds, agentAccess, escrow, tasks, events, jobs, help, ownerSettings, tipTask, rateLimiter, register, oauthEnabled: env.oauth.googleClientId !== null }, async close() { await db.destroy() }, diff --git a/apps/voko-server/src/db/migrations/202610100003_tips_and_owner_settings.js b/apps/voko-server/src/db/migrations/202610100003_tips_and_owner_settings.js new file mode 100644 index 0000000..b7a2fec --- /dev/null +++ b/apps/voko-server/src/db/migrations/202610100003_tips_and_owner_settings.js @@ -0,0 +1,37 @@ +/** W4:打赏记录 vk_tips;主人设置 vk_owner_settings(时区、安静时段、邮件开关)。与各包 tables/*.cjs 逐字一致。 */ +export async function up(db) { + await db.schema.createTable('vk_tips', (t) => { + t.string('id', 64).primary() + t.string('task_id', 64).notNullable() + t.string('from_agent_id', 64).notNullable() + t.string('from_wallet', 64).notNullable() + t.string('to_wallet', 64).notNullable() + t.bigInteger('amount').notNullable() + t.string('out_ledger_id', 64).notNullable() + t.string('in_ledger_id', 64).notNullable() + t.string('client_request_key', 191).notNullable() + t.datetime('created_at', { precision: 3 }).notNullable() + t.unique(['from_agent_id', 'client_request_key'], { indexName: 'uq_vk_tips_sender_request' }) + t.unique(['out_ledger_id'], { indexName: 'uq_vk_tips_out_ledger' }) + t.unique(['in_ledger_id'], { indexName: 'uq_vk_tips_in_ledger' }) + t.index(['task_id'], 'idx_vk_tips_task') + t.check('amount > 0', [], 'ck_vk_tips_amount') + t.check('from_wallet <> to_wallet', [], 'ck_vk_tips_parties') + }) + + await db.schema.createTable('vk_owner_settings', (t) => { + t.string('user_id', 64).primary() + t.string('timezone', 64).notNullable() + t.tinyint('quiet_start_hour').unsigned().notNullable() + t.tinyint('quiet_end_hour').unsigned().notNullable() + t.boolean('email_help').notNullable() + t.boolean('email_digest').notNullable() + t.datetime('updated_at', { precision: 3 }).notNullable() + t.check('quiet_start_hour <= 23 and quiet_end_hour <= 23', [], 'ck_vk_owner_settings_hours') + }) +} + +export async function down(db) { + await db.schema.dropTable('vk_owner_settings') + await db.schema.dropTable('vk_tips') +} diff --git a/apps/voko-server/src/routes/owner.js b/apps/voko-server/src/routes/owner.js index 9152b62..2b0f70b 100644 --- a/apps/voko-server/src/routes/owner.js +++ b/apps/voko-server/src/routes/owner.js @@ -1,13 +1,23 @@ import * as v from 'valibot' import { codedError, requireUser } from './auth.js' -import { agentView, IdempotencyKey, PublishBody, REFUSAL_STATUS, taskView } from './v1.js' +import { agentView, IdempotencyKey, ledgerView, PublishBody, REFUSAL_STATUS, taskView } from './v1.js' const AgentParams = v.object({ id: v.pipe(v.string(), v.minLength(1), v.maxLength(64)) }) const LimitBody = v.object({ daily_spend_limit: v.nullable(v.pipe(v.number(), v.safeInteger(), v.minValue(0))) }) const TaskIdParams = v.object({ task_id: v.pipe(v.string(), v.minLength(1), v.maxLength(64)) }) const ApprovalBody = v.object({ decision: v.picklist(['approve', 'reject']) }) const ResolveBody = v.object({ outcome: v.picklist(['settled', 'refunded']), winner_agent_id: v.optional(v.pipe(v.string(), v.minLength(1), v.maxLength(64))) }) +const HelpParams = v.object({ id: v.pipe(v.string(), v.minLength(1), v.maxLength(64)) }) +const AnswerBody = v.object({ answer: v.pipe(v.string(), v.minLength(1), v.maxLength(4000)) }) +const Hour = v.pipe(v.number(), v.safeInteger(), v.minValue(0), v.maxValue(23)) +const SettingsBody = v.object({ + timezone: v.pipe(v.string(), v.check((zone) => Intl.supportedValuesOf('timeZone').includes(zone) || zone === 'UTC', 'timezone must be an IANA time zone')), + quiet_start_hour: Hour, + quiet_end_hour: Hour, + email_help: v.boolean(), + email_digest: v.boolean(), +}) const StatusBody = v.object({ status: v.picklist(['active', 'suspended']) }) /** @@ -17,7 +27,7 @@ const StatusBody = v.object({ status: v.picklist(['active', 'suspended']) }) * @param {Record} services */ export function registerOwnerRoutes(app, services) { - const { agents, agentAccess, guilds, escrow, tasks, db } = services + const { agents, agentAccess, guilds, escrow, tasks, help, ownerSettings, db } = services /** * 取当前用户名下的一个 Agent;不是自己的按不存在处理。 @@ -91,6 +101,39 @@ export function registerOwnerRoutes(app, services) { */ const refused = (reply, refusal) => reply.code(REFUSAL_STATUS[refusal.code]).send({ error: { code: refusal.code, message: refusal.hint, retryable: false } }) + app.get('/api/voko/agents/:id/ledger', async (request) => { + const { agent } = await ownAgent(request) + return { ledger: (await escrow.ledgerOf(request.ctx, { wallet_user_id: agent.wallet_user_id, limit: 100 })).map(ledgerView) } + }) + + // 求助收件箱:主人回复自己 Agent 的求助。 + app.get('/api/voko/help', async (request) => { + const user = await requireUser(request, services) + const rows = await help.listForOwner(request.ctx, { user_id: user.id }) + return { help_requests: rows.map((row) => ({ ...row, created_at: row.created_at.toISOString(), answered_at: row.answered_at === null ? null : row.answered_at.toISOString() })) } + }) + + app.post('/api/voko/help/:id/answer', async (request, reply) => { + const user = await requireUser(request, services) + const params = v.parse(HelpParams, request.params) + const body = v.parse(AnswerBody, request.body) + const result = await help.answer(request.ctx, { user_id: user.id, help_id: params.id, answer: body.answer }) + return result.ok ? { ok: true } : refused(reply, result) + }) + + // 设置:时区、安静时段、邮件开关。 + app.get('/api/voko/me/settings', async (request) => { + const user = await requireUser(request, services) + return { settings: await ownerSettings.get(request.ctx, { user_id: user.id }) } + }) + + app.put('/api/voko/me/settings', async (request) => { + const user = await requireUser(request, services) + const settings = v.parse(SettingsBody, request.body) + await ownerSettings.save(request.ctx, { user_id: user.id, settings }) + return { settings } + }) + // 负责人:待审核任务(公司 Agent 发布、公会开启发布前审核时)。 app.get('/api/voko/approvals', async (request) => { const user = await requireUser(request, services) diff --git a/apps/voko-server/src/routes/v1.js b/apps/voko-server/src/routes/v1.js index 43e906f..c217a22 100644 --- a/apps/voko-server/src/routes/v1.js +++ b/apps/voko-server/src/routes/v1.js @@ -50,6 +50,9 @@ const ReviewBody = v.object({ reason: v.optional(v.pipe(v.string(), v.minLength(1), v.maxLength(4000))), submission_id: v.optional(v.pipe(v.string(), v.minLength(1), v.maxLength(64))), }) +const HelpBody = v.object({ question: v.pipe(v.string(), v.minLength(1), v.maxLength(4000)) }) +const TipBody = v.object({ amount: Reward }) +const LedgerQuery = v.object({ limit: v.optional(v.pipe(v.string(), v.transform(Number), v.integer(), v.minValue(1), v.maxValue(100)), '50') }) const TransferBody = v.object({ to_agent_id: v.pipe(v.string(), v.minLength(1), v.maxLength(64)) }) const CommentBody = v.object({ body: v.pipe(v.string(), v.minLength(1), v.maxLength(4000)) }) 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. * @param {Record} services */ export function registerV1Routes(app, services) { - const { agentAccess, agents, db, tasks, events, escrow, rateLimiter } = services + const { agentAccess, agents, db, tasks, events, escrow, help, tipTask, rateLimiter } = services app.register(async (v1) => { v1.setErrorHandler((/** @type {any} */ err, request, reply) => { @@ -130,10 +133,11 @@ export function registerV1Routes(app, services) { v1.get('/home', async (request) => { const agent = agentOf(request) - const [wallet, home, unread] = await Promise.all([ + const [wallet, home, unread, answers] = await Promise.all([ escrow.walletOf(request.ctx, { wallet_user_id: agent.wallet_user_id }), tasks.homeFor(request.ctx, { agent }), events.pullUnread(request.ctx, { agent }), + help.answersFor(request.ctx, { agent_id: agent.id }), ]) return ok({ agent: { id: agent.id, role: agent.role, level: agent.level, xp: agent.xp }, @@ -142,6 +146,7 @@ export function registerV1Routes(app, services) { to_review: home.to_review.map(taskView), recommended_tasks: home.recommended_tasks.map(taskView), events: unread.map(eventView), + 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() })), announcements: [], skill_version: SKILL_VERSION, 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) { v1.get('/wallet', async (request) => ok(await escrow.walletOf(request.ctx, { wallet_user_id: agentOf(request).wallet_user_id }))) + v1.get('/wallet/ledger', async (request) => { + const query = v.parse(LedgerQuery, request.query) + const rows = await escrow.ledgerOf(request.ctx, { wallet_user_id: agentOf(request).wallet_user_id, limit: query.limit }) + return ok({ ledger: rows.map(ledgerView) }) + }) + v1.get('/tasks', async (request) => { const query = v.parse(ListQuery, request.query) const page = await tasks.list(request.ctx, { agent: agentOf(request), ...query }) @@ -235,6 +246,24 @@ export function registerV1Routes(app, services) { }) } + v1.post('/tasks/:id/help', async (request, reply) => { + const params = v.parse(TaskParams, request.params) + const body = v.parse(HelpBody, request.body) + const result = await help.request(request.ctx, { agent: agentOf(request), task_id: params.id, question: body.question }) + if (!result.ok) return refused(reply, result) + return ok({ help_id: result.help_id }) + }) + + v1.post('/tasks/:id/tip', async (request, reply) => { + const params = v.parse(TaskParams, request.params) + const body = v.parse(TipBody, request.body) + const key = v.parse(IdempotencyKey, request.headers['idempotency-key']) + const agent = agentOf(request) + const result = await tipTask(request.ctx, { agent, task_id: params.id, amount: body.amount, client_request_key: key }) + if (!result.ok) return refused(reply, result) + return ok({ tip_id: result.tip_id, wallet: await escrow.walletOf(request.ctx, { wallet_user_id: agent.wallet_user_id }) }) + }) + v1.get('/tasks/:id/comments', async (request, reply) => { const params = v.parse(TaskParams, request.params) const result = await tasks.listComments(request.ctx, { agent: agentOf(request), task_id: params.id }) @@ -311,6 +340,14 @@ function commentView(comment) { } /** + * 流水的对外形状:任务相关的流水带 task_id。 + * @param {{ id: string, type: string, amount: number, reason: string, ref_type: string|null, ref_id: string|null, created_at: Date }} row + */ +export function ledgerView(row) { + 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() } +} + +/** * 鉴权钩子挂在请求上的 Agent。 * @param {import('fastify').FastifyRequest} request * @returns {import('@voko/exchange').Agent} diff --git a/apps/voko-server/tools/scenarios/voko-alpha/_lib.mjs b/apps/voko-server/tools/scenarios/voko-alpha/_lib.mjs index 9a68b32..bd6bf54 100644 --- a/apps/voko-server/tools/scenarios/voko-alpha/_lib.mjs +++ b/apps/voko-server/tools/scenarios/voko-alpha/_lib.mjs @@ -156,8 +156,9 @@ function createWorld(name, db) { const wallets = agentIds.length === 0 ? [] : (await db('vk_agents').whereIn('id', agentIds).select('wallet_user_id')).map((row) => row.wallet_user_id) if (taskIds.length > 0) { await db('vk_review_checks').whereIn('review_id', db('vk_reviews').whereIn('task_id', taskIds).select('id')).del() - 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() + 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() await db('vk_jobs').whereIn('ref_id', taskIds).del() + await db('vk_jobs').where({ kind: 'help_notify' }).whereNotIn('ref_id', db('vk_help_requests').select('id')).del() await db('vk_tasks').whereIn('id', taskIds).del() } if (guildIds.length > 0) { @@ -174,6 +175,7 @@ function createWorld(name, db) { } if (userIds.length > 0) { await db('vk_access_requests').whereIn('user_id', userIds).del() + await db('vk_owner_settings').whereIn('user_id', userIds).del() await db('auth_sessions').whereIn('user_id', userIds).del() await db('users').whereIn('id', userIds).del() } @@ -204,5 +206,5 @@ function createWorld(name, db) { body: { action, checks: ids.map((id) => ({ id, pass: !failing.includes(id), reason: failing.includes(id) ? 'Not met yet.' : 'Looks good.' })), ...extra }, }) - return { db, results, check, call, admin, createOwner, createGuild, createAgent, grantBudget, publish, waitForTask, wallet, submit, claim, review, cleanup } + return { db, results, check, call, admin, waitForEmail, createOwner, createGuild, createAgent, grantBudget, publish, waitForTask, wallet, submit, claim, review, cleanup } } diff --git a/apps/voko-server/tools/scenarios/voko-alpha/concurrency.mjs b/apps/voko-server/tools/scenarios/voko-alpha/concurrency.mjs index 5424e19..73dfbd3 100644 --- a/apps/voko-server/tools/scenarios/voko-alpha/concurrency.mjs +++ b/apps/voko-server/tools/scenarios/voko-alpha/concurrency.mjs @@ -1,13 +1,13 @@ #!/usr/bin/env node /** - * 并发与幂等的真实链路脚本(Planning 用例 CC-01、CC-02;CC-03 结算并发在 W4)。 + * 并发与幂等的真实链路脚本(Planning 用例 CC-01、CC-02、CC-03)。CC-03 需要 voko-worker 在跑。 * CC-01 用 6 个 Agent(每个主人最多 3 个)同时发 20 个认领请求,断言恰好 1 个成功。 */ import { randomUUID } from 'node:crypto' import { runScenario } from './_lib.mjs' -await runScenario('cc', async ({ db, check, call, createOwner, createGuild, createAgent, publish }) => { +await runScenario('cc', async ({ db, check, call, createOwner, createGuild, createAgent, publish, waitForTask, wallet: walletOf, submit, claim, review }) => { const alice = await createOwner('alice') const bob = await createOwner('bob') const carol = await createOwner('carol') @@ -32,4 +32,22 @@ await runScenario('cc', async ({ db, check, call, createOwner, createGuild, crea const won = claims.filter((r) => r.status === 200) const openHolds = await db('vk_task_holds').where({ task_id: taskId }).whereNull('ended_at') 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) }) + + // CC-03 发布方 accept 与自动确认 job 同时发生 → 只结算一次。验收窗口 3 秒的公会,在到期时刻连发 accept。 + const fast = await createGuild('fast', alice.user_id, { piece_review_window_sec: 3 }) + const fastPublisher = await createAgent('fast-publisher', alice, fast) + const dave = await createOwner('dave') + const fastTaker = await createAgent('fast-taker', dave, fast) + const racing = (await publish(fastPublisher, { reward: 25 })).json.data + await claim(fastTaker, racing.id) + const submitted = await submit(fastTaker, racing.id) + const dueIn = new Date(submitted.json.data.review_due).getTime() - Date.now() + const accepts = [] + 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']))) + const acceptResults = await Promise.all(accepts) + const settled = await waitForTask(fastPublisher, racing.id, (task) => task.status === 'settled') + const racePayouts = await db('vk_escrow_payouts').where({ task_id: racing.id }) + const raceLedger = await db('billing_ledger').where({ ref_id: racing.id, reason: 'vk_task_payout' }) + 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 }) + }) diff --git a/apps/voko-server/tools/scenarios/voko-alpha/e2e-loop.mjs b/apps/voko-server/tools/scenarios/voko-alpha/e2e-loop.mjs new file mode 100644 index 0000000..3a1c642 --- /dev/null +++ b/apps/voko-server/tools/scenarios/voko-alpha/e2e-loop.mjs @@ -0,0 +1,46 @@ +#!/usr/bin/env node +/** + * E2E-01 完整闭环:注册 → 认领 → 发布 → 认领 → 检查点 → 提交 → 驳回 → 重交 → 通过 → 打赏 → 流水与事件;末尾对账。 + * (排行榜在 W5 加入本脚本。) + */ +import { randomUUID } from 'node:crypto' + +import { runScenario } from './_lib.mjs' + +await runScenario('e2e', async ({ check, call, createOwner, createGuild, createAgent, publish, wallet, submit, claim, review }) => { + // 注册 → 认领(createAgent 走真实的注册与认领接口)。 + const alice = await createOwner('alice') + const bob = await createOwner('bob') + const guild = await createGuild('main', alice.user_id) + const publisher = await createAgent('publisher', alice, guild) + const worker = await createAgent('worker', bob, guild) + + // 发布 → 接单方从 /home 的推荐里发现任务 → 认领。 + const task = (await publish(publisher, { reward: 70, acceptance: [{ id: 'c1', text: 'Natural Japanese' }, { id: 'c2', text: 'Keeps all links' }] })).json.data + const home = await call('GET', '/v1/home', { token: worker.key }) + check('接单方在 /home 推荐里看到任务', home.json.data.recommended_tasks.some((t) => t.id === task.id), home) + check('认领', (await claim(worker, task.id)).status === 200) + + // 检查点 → 提交 → 驳回 → 重交 → 通过。 + check('检查点', (await call('POST', `/v1/tasks/${task.id}/checkpoints`, { token: worker.key, body: { progress: 60, note: 'First pass done' } })).status === 200) + check('第一次提交', (await submit(worker, task.id)).json.data.status === 'submitted') + const toReview = await call('GET', '/v1/home', { token: publisher.key }) + check('发布方 /home 出现待验收', toReview.json.data.to_review.some((t) => t.id === task.id), toReview) + const rejected = await review(publisher, task.id, 'reject', ['c1', 'c2'], ['c2'], { reason: 'Two links are missing.' }) + check('驳回 → rework', rejected.json.data.status === 'rework', rejected) + 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)) + check('重交', (await submit(worker, task.id, 'Added the two missing links.')).json.data.status === 'submitted') + const accepted = await review(publisher, task.id, 'accept', ['c1', 'c2']) + check('通过 → settled', accepted.json.data.status === 'settled', accepted) + + // 打赏 → 钱包、流水、事件与等级经验一致。 + const tipped = await call('POST', `/v1/tasks/${task.id}/tip`, { token: publisher.key, idempotency_key: randomUUID(), body: { amount: 10 } }) + check('打赏', tipped.status === 200, tipped) + 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) }) + const ledger = (await call('GET', '/v1/wallet/ledger', { token: worker.key })).json.data.ledger + check('接单方流水:启动积分、任务收入、打赏各一笔', ['vk_starter', 'vk_task_payout', 'vk_tip'].every((reason) => ledger.filter((row) => row.reason === reason).length === 1), ledger) + const me = (await call('GET', '/v1/agents/me', { token: worker.key })).json.data + check('接单方 XP = 结算积分 70', me.xp === 70, me) + const events = (await call('GET', '/v1/events?since=0', { token: worker.key })).json.data.events.map((e) => e.type) + check('接单方事件流完整', ['task.published', 'task.rejected', 'task.accepted', 'tip.received'].every((type) => events.includes(type)), events) +}) diff --git a/apps/voko-server/tools/scenarios/voko-alpha/f2-execute.mjs b/apps/voko-server/tools/scenarios/voko-alpha/f2-execute.mjs index ad91c98..3284adb 100644 --- a/apps/voko-server/tools/scenarios/voko-alpha/f2-execute.mjs +++ b/apps/voko-server/tools/scenarios/voko-alpha/f2-execute.mjs @@ -1,10 +1,10 @@ #!/usr/bin/env node /** - * F2 接单与执行的真实链路脚本(Planning 用例 F2-01、02、03、05、06、07;F2-04 求助在 W4)。 + * F2 接单与执行的真实链路脚本(Planning 用例 F2-01 – F2-07,以及 TM-06 的安静时段部分)。F2-04 需要 voko-worker 在跑(求助邮件由 job 发出)。 */ import { runScenario } from './_lib.mjs' -await runScenario('f2', async ({ db, check, call, createOwner, createGuild, createAgent, grantBudget, publish, submit, claim }) => { +await runScenario('f2', async ({ db, check, call, waitForEmail, createOwner, createGuild, createAgent, grantBudget, publish, submit, claim }) => { const alice = await createOwner('alice') const bob = await createOwner('bob') const guild = await createGuild('main', alice.user_id) @@ -101,4 +101,37 @@ await runScenario('f2', async ({ db, check, call, createOwner, createGuild, crea const submitted = await submit(taker, delivering.id) const publisherHome = await call('GET', '/v1/home', { token: publisher.key }) 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 }) + + // F2-04 求助 → 主人收到邮件;主人网页回复后 Agent 下次 /home 拿到答复。(rival 手上有接力来的 working 任务) + // 默认安静时段是 23:00–08:00(UTC);先关掉安静时段,保证这封邮件现在就发。 + 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 } }) + const asked = await call('POST', `/v1/tasks/${working.id}/help`, { token: rival.key, body: { question: 'Should the tone be formal or casual?' } }) + const notHolderHelp = await call('POST', `/v1/tasks/${working.id}/help`, { token: taker.key, body: { question: 'hi' } }) + check('求助 → help_id;非持有人求助 → 403', asked.status === 200 && typeof asked.json.data.help_id === 'string' && notHolderHelp.status === 403, { asked, notHolderHelp }) + const helpMail = await waitForEmail({ to_email: bob.email, purpose: 'help_requested', status: 'sent' }) + check('F2-04 主人收到求助邮件', helpMail.template === 'voko-notice', helpMail) + const inbox = await call('GET', '/api/voko/help', { token: bob.token }) + const aliceInbox = await call('GET', '/api/voko/help', { token: alice.token }) + 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 }) + const stranger = await call('POST', `/api/voko/help/${asked.json.data.help_id}/answer`, { token: alice.token, body: { answer: 'x' } }) + const answered = await call('POST', `/api/voko/help/${asked.json.data.help_id}/answer`, { token: bob.token, body: { answer: 'Formal, please.' } }) + const answeredTwice = await call('POST', `/api/voko/help/${asked.json.data.help_id}/answer`, { token: bob.token, body: { answer: 'again' } }) + check('主人回复成功;别人回复 → 404;重复回复 → 409', answered.status === 200 && stranger.status === 404 && answeredTwice.status === 409, { answered, stranger, answeredTwice }) + const rivalHome = await call('GET', '/v1/home', { token: rival.key }) + 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) + + // TM-06 安静时段内的求助邮件推迟到安静时段结束:把主人的安静时段设成从当前小时起的两小时。 + const hourNow = new Date().getUTCHours() + const quiet = { timezone: 'UTC', quiet_start_hour: hourNow, quiet_end_hour: (hourNow + 2) % 24, email_help: true, email_digest: true } + const saved = await call('PUT', '/api/voko/me/settings', { token: bob.token, body: quiet }) + const badZone = await call('PUT', '/api/voko/me/settings', { token: bob.token, body: { ...quiet, timezone: 'Mars/Olympus' } }) + 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 }) + const quietHelp = await call('POST', `/v1/tasks/${working.id}/help`, { token: rival.key, body: { question: 'One more question during quiet hours.' } }) + const quietJob = await db('vk_jobs').where({ kind: 'help_notify', ref_id: quietHelp.json.data.help_id }).first() + const expected = new Date(); expected.setUTCHours(hourNow + 2, 0, 0, 0) + check('TM-06 安静时段内的求助邮件推迟到安静时段结束的整点', quietJob.status === 'pending' && quietJob.run_at.getTime() === expected.getTime(), { run_at: quietJob.run_at, expected }) + await call('PUT', '/api/voko/me/settings', { token: bob.token, body: { ...quiet, email_help: false } }) + const mutedHelp = await call('POST', `/v1/tasks/${working.id}/help`, { token: rival.key, body: { question: 'Emails are off now.' } }) + check('关闭求助邮件后不再排通知', (await db('vk_jobs').where({ kind: 'help_notify', ref_id: mutedHelp.json.data.help_id }).first()) === undefined, mutedHelp) + }) diff --git a/apps/voko-server/tools/scenarios/voko-alpha/f3-review-settle.mjs b/apps/voko-server/tools/scenarios/voko-alpha/f3-review-settle.mjs index 89af1ec..1b8d81e 100644 --- a/apps/voko-server/tools/scenarios/voko-alpha/f3-review-settle.mjs +++ b/apps/voko-server/tools/scenarios/voko-alpha/f3-review-settle.mjs @@ -1,8 +1,10 @@ #!/usr/bin/env node /** - * F3 验收与结算的真实链路脚本(Planning 用例 F3-01、02、03、04、05;F3-06 打赏在 W4)。 + * F3 验收与结算的真实链路脚本(Planning 用例 F3-01 – F3-06)。 * 需要 voko-worker 在跑:F3-04 的 AI 仲裁意见会真实调用大模型,F3-05 的擂台赛到截止由 worker 关闭。 */ +import { randomUUID } from 'node:crypto' + import { runScenario } from './_lib.mjs' 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 const won = await review(publisher, contest.id, 'select_winner', ids, [], { submission_id: takerSubmission.json.data.submission_id }) const contestPayouts = await db('vk_escrow_payouts').where({ task_id: contest.id }) 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 }) + + // F3-06 打赏:打赏人减、收款人加;重放不重复;余额不足 → 402;未结算任务与打赏自己被拒。 + const tipKey = randomUUID() + const tipperBefore = (await wallet(helper)).available + const takerBeforeTip = (await wallet(taker)).available + const tipped = await call('POST', `/v1/tasks/${first.id}/tip`, { token: helper.key, idempotency_key: tipKey, body: { amount: 15 } }) + const tipReplay = await call('POST', `/v1/tasks/${first.id}/tip`, { token: helper.key, idempotency_key: tipKey, body: { amount: 15 } }) + 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 }) + const tooMuch = await call('POST', `/v1/tasks/${first.id}/tip`, { token: helper.key, idempotency_key: randomUUID(), body: { amount: 100000 } }) + check('F3-06 余额不足 → 402,余额不变', tooMuch.status === 402 && (await wallet(helper)).available === tipperBefore - 15, tooMuch) + const selfTip = await call('POST', `/v1/tasks/${first.id}/tip`, { token: taker.key, idempotency_key: randomUUID(), body: { amount: 1 } }) + const unsettled = await call('POST', `/v1/tasks/${disputed.id}/tip`, { token: helper.key, idempotency_key: randomUUID(), body: { amount: 1 } }) + check('打赏自己 → 422;未结算(已退款)任务 → 409', selfTip.status === 422 && unsettled.status === 409, { selfTip, unsettled }) + const ledger = await call('GET', '/v1/wallet/ledger', { token: taker.key }) + 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) + 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)) + }) diff --git a/apps/voko-server/tools/scenarios/voko-alpha/verify-conservation.mjs b/apps/voko-server/tools/scenarios/voko-alpha/verify-conservation.mjs index d390a6d..56d4a64 100644 --- a/apps/voko-server/tools/scenarios/voko-alpha/verify-conservation.mjs +++ b/apps/voko-server/tools/scenarios/voko-alpha/verify-conservation.mjs @@ -36,7 +36,8 @@ export async function verifyConservation(db) { 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') 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') - for (const row of [...orphanTaskRefs, ...orphanAgentRefs]) problems.push({ rule: 'ledger_ref_missing', id: row.id, detail: `ref_id=${row.ref_id}` }) + 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') + for (const row of [...orphanTaskRefs, ...orphanAgentRefs, ...orphanTipRefs]) problems.push({ rule: 'ledger_ref_missing', id: row.id, detail: `ref_id=${row.ref_id}` }) return problems } diff --git a/docs/voko/upstream.md b/docs/voko/upstream.md index dab61aa..ff38a6d 100644 --- a/docs/voko/upstream.md +++ b/docs/voko/upstream.md @@ -19,6 +19,7 @@ | `pnpm-workspace.yaml` | 只保留 `apps/*`、`packages/*` | | `packages/email/src/templates/waitlist-invite.js` | `inviteUrl` 允许 `http://localhost`(本地验收用),其余仍须 https(2026-10-10) | | `packages/ui/src/components/Textarea.vue` | 加 `box-sizing: border-box`,否则 `width: 100%` 加内边距会撑出容器(2026-10-10;上游同样有此问题) | +| `packages/email/src/templates/voko-notice.js` | 新增模板(本仓库独有):Agent 已认领、Agent 求助,en / zh | | `apps/voko-server/src/assembly/security.js`、`google-oauth.js`、`email-delivery-log.js` | 从 visual-game-server 原样复制(security.js 只改了一条报错文案) | ## 待裁剪(原样搬入,后续处理) diff --git a/packages/email/src/templates/index.js b/packages/email/src/templates/index.js index 1ef6537..38cb69a 100644 --- a/packages/email/src/templates/index.js +++ b/packages/email/src/templates/index.js @@ -7,6 +7,7 @@ import * as verifyCode from './verify-code.js' import * as videoReady from './video-ready.js' import * as waitlistInvite from './waitlist-invite.js' import * as orderNotice from './order-notice.js' +import * as vokoNotice from './voko-notice.js' import { EmailError } from '../map-error.js' const REGISTRY = { @@ -14,6 +15,7 @@ const REGISTRY = { 'video-ready': videoReady.render, 'waitlist-invite': waitlistInvite.render, 'order-notice': orderNotice.render, + 'voko-notice': vokoNotice.render, } /** diff --git a/packages/email/src/templates/voko-notice.js b/packages/email/src/templates/voko-notice.js new file mode 100644 index 0000000..6e21ac6 --- /dev/null +++ b/packages/email/src/templates/voko-notice.js @@ -0,0 +1,58 @@ +import { EmailError } from '../map-error.js' + +/** VOKO 给主人的通知:Agent 已认领、Agent 求助。每种通知一组文案,`{name}` 等占位符由调用方数据填入。 */ +const COPY = Object.freeze({ + en: { + 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' }, + help: { subject: '{agent} needs your help', heading: '{agent} asked for help', body: 'Task: {task}\n\n{question}', action: 'Reply in your inbox' }, + }, + zh: { + claimed: { subject: '你已在 VOKO 认领 {agent}', heading: '{agent} 已激活', body: '你的 Agent 已加入公会并收到 100 启动积分。你可以在「我的 Agent」里管理它、重置 API key 或设置单日支出上限。', action: '打开我的 Agent' }, + help: { subject: '{agent} 需要你的帮助', heading: '{agent} 向你求助', body: '任务:{task}\n\n{question}', action: '去收件箱回复' }, + }, +}) + +/** + * @param {{ locale: 'en'|'zh', kind: 'claimed'|'help', url: string, agent: string, task?: string, question?: string }} data + */ +export function render(data) { + if (!data || typeof data !== 'object') fail('data is required') + const byKind = COPY[data.locale] + if (!byKind) fail(`unsupported locale: ${String(data.locale)}`) + const copy = byKind[data.kind] + if (!copy) fail(`unsupported kind: ${String(data.kind)}`) + const url = requiredUrl(data.url) + const fill = (/** @type {string} */ text) => text.replace(/\{(agent|task|question)\}/g, (_, key) => requiredText(data[key], key)) + const heading = fill(copy.heading) + const body = fill(copy.body) + const text = [heading, '', body, '', `${copy.action}: ${url}`].join('\n') + const html = [ + '', + `

${escapeHtml(heading)}

`, + `

${escapeHtml(body)}

`, + `

${escapeHtml(copy.action)}

`, + '', + ].join('') + return { subject: fill(copy.subject), text, html } +} + +function requiredText(value, field) { + if (typeof value === 'string' && value.trim() !== '') return value + fail(`missing field "${field}"`) +} + +function requiredUrl(value) { + const url = requiredText(value, 'url') + const parsed = new URL(url) + // 本地开发的前端是 http://localhost,其余环境必须 https。 + if (parsed.protocol === 'https:' || parsed.hostname === 'localhost') return url + fail('url must use https') +} + +function escapeHtml(value) { + return String(value).replace(/&/g, '&').replace(//g, '>').replace(/"/g, '"') +} + +function fail(message) { + throw new EmailError({ provider: 'template', reason: 'badTemplate', retryable: false, message: `voko-notice: ${message}` }) +} diff --git a/packages/email/test/voko-notice.test.js b/packages/email/test/voko-notice.test.js new file mode 100644 index 0000000..bf56e36 --- /dev/null +++ b/packages/email/test/voko-notice.test.js @@ -0,0 +1,19 @@ +import { describe, expect, it } from 'vitest' +import { renderTemplate } from '../src/templates/index.js' + +describe('voko-notice 模板', () => { + it.each(['en', 'zh'])('%s:认领与求助两种通知都能渲染,内容被转义', (locale) => { + const claimed = renderTemplate('voko-notice', { locale, kind: 'claimed', agent: 'Scout', url: 'https://alpha.voko.world/agents' }) + expect(claimed.subject).toContain('Scout') + const help = renderTemplate('voko-notice', { locale, kind: 'help', agent: 'Scout', task: 'Translate ', question: 'Which tone?', url: 'http://localhost:5201/help' }) + expect(help.text).toContain('Which tone?') + expect(help.html).toContain('Translate <b>') + expect(help.html).not.toContain('') + }) + + it('缺字段、未知类型、非 https 链接直接报错', () => { + expect(() => renderTemplate('voko-notice', { locale: 'en', kind: 'help', agent: 'Scout', url: 'https://x.test' })).toThrow(/missing field "task"/) + expect(() => renderTemplate('voko-notice', { locale: 'en', kind: 'digest', agent: 'Scout', url: 'https://x.test' })).toThrow(/unsupported kind/) + expect(() => renderTemplate('voko-notice', { locale: 'en', kind: 'claimed', agent: 'Scout', url: 'http://evil.test' })).toThrow(/must use https/) + }) +}) diff --git a/packages/voko-access/src/agent-access.js b/packages/voko-access/src/agent-access.js index d8133ac..c067c50 100644 --- a/packages/voko-access/src/agent-access.js +++ b/packages/voko-access/src/agent-access.js @@ -15,9 +15,10 @@ export const API_KEY_PREFIX = 'vk_live_' * agents: ReturnType, * grantStarter(ctx: object, trx: import('knex').Knex, input: { agent_id: string, wallet_user_id: string }): Promise, * public_url: string, + * notifier: { sendAgentClaimed(ctx: object, input: { user_id: string, agent_name: string }): Promise }, * }} deps `grantStarter` 在认领事务内给新 Agent 的钱包发启动积分,必须按 agent_id 幂等。 */ -export function createAgentAccess({ db, agents, grantStarter, public_url }) { +export function createAgentAccess({ db, agents, grantStarter, public_url, notifier }) { if (typeof db !== 'function') throw new Error('createAgentAccess: db is required') if (typeof grantStarter !== 'function') throw new Error('createAgentAccess: grantStarter is required') 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 }) { */ async function claim(ctx, { claim_token, user_id, verification_code, guild_id, role }) { const now = new Date() - return db.transaction(async (trx) => { + let fresh = false + const result = await db.transaction(async (trx) => { const claimRow = await trx('vk_agent_claims').where({ claim_token_hash: sha256(claim_token) }).forUpdate().first() if (claimRow === undefined) return refuse('NOT_FOUND', 'Claim link not found') if (claimRow.claimed_by_user_id !== null) { @@ -115,8 +117,12 @@ export function createAgentAccess({ db, agents, grantStarter, public_url }) { await trx('vk_guild_members').insert({ id: `gm_${randomUUID()}`, guild_id, user_id, role: 'member', created_at: now }).onConflict(['guild_id', 'user_id']).ignore() const agent = await agents.getById(ctx, trx, { agent_id: claimRow.agent_id }) await grantStarter(ctx, trx, { agent_id: agent.id, wallet_user_id: agent.wallet_user_id }) + fresh = true return { ok: true, agent } }) + // 认领确认邮件在事务提交后发;重放的认领不再发。 + if (result.ok && fresh) await notifier.sendAgentClaimed(ctx, { user_id, agent_name: result.agent.name }) + return result } /** diff --git a/packages/voko-access/src/db/tables/index.cjs b/packages/voko-access/src/db/tables/index.cjs index d376981..4168b21 100644 --- a/packages/voko-access/src/db/tables/index.cjs +++ b/packages/voko-access/src/db/tables/index.cjs @@ -3,6 +3,7 @@ const tableModules = [ require('./vk_agent_claims.cjs'), require('./vk_api_keys.cjs'), require('./vk_webhook_deliveries.cjs'), + require('./vk_owner_settings.cjs'), ] const targetTables = tableModules.map(({ tableName, buildTable }) => [tableName, buildTable]) diff --git a/packages/voko-access/src/db/tables/vk_owner_settings.cjs b/packages/voko-access/src/db/tables/vk_owner_settings.cjs new file mode 100644 index 0000000..92e4fc7 --- /dev/null +++ b/packages/voko-access/src/db/tables/vk_owner_settings.cjs @@ -0,0 +1,14 @@ +const tableName = 'vk_owner_settings' + +function buildTable(t) { + t.string('user_id', 64).primary() + t.string('timezone', 64).notNullable() + t.tinyint('quiet_start_hour').unsigned().notNullable() + t.tinyint('quiet_end_hour').unsigned().notNullable() + t.boolean('email_help').notNullable() + t.boolean('email_digest').notNullable() + t.datetime('updated_at', { precision: 3 }).notNullable() + t.check('quiet_start_hour <= 23 and quiet_end_hour <= 23', [], 'ck_vk_owner_settings_hours') +} + +module.exports = { tableName, buildTable } diff --git a/packages/voko-access/src/index.js b/packages/voko-access/src/index.js index ef93521..b2d2276 100644 --- a/packages/voko-access/src/index.js +++ b/packages/voko-access/src/index.js @@ -9,4 +9,6 @@ export { createAccessRequests, accessRequestFromRow, ACCESS_REQUEST_STATUSES } from './access-requests.js' export { createAgentAccess, MAX_AGENTS_PER_OWNER, API_KEY_PREFIX } from './agent-access.js' +export { createOwnerSettings, DEFAULT_OWNER_SETTINGS } from './owner-settings.js' +export { nextAllowedSendTime } from './quiet-hours.js' export { default as accessDbSchema } from './db/tables/index.cjs' diff --git a/packages/voko-access/src/owner-settings.js b/packages/voko-access/src/owner-settings.js new file mode 100644 index 0000000..9c3e8cb --- /dev/null +++ b/packages/voko-access/src/owner-settings.js @@ -0,0 +1,37 @@ +const TABLE = 'vk_owner_settings' + +/** 没有保存过设置的主人使用的产品默认值:UTC、23:00–08:00 安静、两类邮件都开。 */ +export const DEFAULT_OWNER_SETTINGS = Object.freeze({ timezone: 'UTC', quiet_start_hour: 23, quiet_end_hour: 8, email_help: true, email_digest: true }) + +/** + * @typedef {{ timezone: string, quiet_start_hour: number, quiet_end_hour: number, email_help: boolean, email_digest: boolean }} OwnerSettings + */ + +/** + * 主人的通知设置:时区、安静时段、邮件开关。 + * @param {{ db: import('knex').Knex }} deps + */ +export function createOwnerSettings({ db }) { + if (typeof db !== 'function') throw new Error('createOwnerSettings: db is required') + + /** + * @param {object} _ctx + * @param {{ user_id: string }} input + * @returns {Promise} + */ + async function get(_ctx, { user_id }) { + const row = await db(TABLE).where({ user_id }).first() + if (row === undefined) return { ...DEFAULT_OWNER_SETTINGS } + 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 } + } + + /** + * @param {object} _ctx + * @param {{ user_id: string, settings: OwnerSettings }} input + */ + async function save(_ctx, { user_id, settings }) { + await db(TABLE).insert({ user_id, ...settings, updated_at: new Date() }).onConflict('user_id').merge() + } + + return { get, save } +} diff --git a/packages/voko-access/src/quiet-hours.js b/packages/voko-access/src/quiet-hours.js new file mode 100644 index 0000000..78663fd --- /dev/null +++ b/packages/voko-access/src/quiet-hours.js @@ -0,0 +1,18 @@ +/** + * 安静时段内的通知推迟到安静时段结束的整点;不在安静时段则立即发送。 + * 起止相同表示没有安静时段。 + * ponytail: 用「距结束还有几小时几分」推算,夏令时切换当晚会差一小时;要精确就换成按时区构造目标时刻。 + * + * @param {Date} now + * @param {{ timezone: string, quiet_start_hour: number, quiet_end_hour: number }} settings + * @returns {Date} + */ +export function nextAllowedSendTime(now, { timezone, quiet_start_hour, quiet_end_hour }) { + const parts = new Intl.DateTimeFormat('en-US', { timeZone: timezone, hour: 'numeric', minute: 'numeric', second: 'numeric', hourCycle: 'h23' }).formatToParts(now) + const part = (/** @type {string} */ type) => Number(parts.find((item) => item.type === type).value) + const hour = part('hour') + 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) + if (!quiet) return now + const secondsLeft = ((quiet_end_hour - hour + 24) % 24) * 3600 - part('minute') * 60 - part('second') + return new Date(now.getTime() - now.getMilliseconds() + secondsLeft * 1000) +} diff --git a/packages/voko-access/test/quiet-hours.test.js b/packages/voko-access/test/quiet-hours.test.js new file mode 100644 index 0000000..ff2e477 --- /dev/null +++ b/packages/voko-access/test/quiet-hours.test.js @@ -0,0 +1,28 @@ +import { describe, expect, it } from 'vitest' +import { nextAllowedSendTime } from '../src/quiet-hours.js' + +const night = { timezone: 'Asia/Shanghai', quiet_start_hour: 23, quiet_end_hour: 8 } + +describe('安静时段', () => { + it('不在安静时段 → 立即', () => { + const now = new Date('2026-10-12T04:00:00.000Z') // 上海 12:00 + expect(nextAllowedSendTime(now, night)).toBe(now) + }) + + it('跨午夜的安静时段:23:30 与 02:15 都推迟到当地 08:00', () => { + expect(nextAllowedSendTime(new Date('2026-10-12T15:30:00.000Z'), night)).toEqual(new Date('2026-10-13T00:00:00.000Z')) + expect(nextAllowedSendTime(new Date('2026-10-12T18:15:20.500Z'), night)).toEqual(new Date('2026-10-13T00:00:00.000Z')) + }) + + it('边界:23:00 整算安静,08:00 整不算', () => { + expect(nextAllowedSendTime(new Date('2026-10-12T15:00:00.000Z'), night)).toEqual(new Date('2026-10-13T00:00:00.000Z')) + const eight = new Date('2026-10-13T00:00:00.000Z') + expect(nextAllowedSendTime(eight, night)).toBe(eight) + }) + + it('不跨午夜的安静时段,以及起止相同表示不设安静时段', () => { + const noon = new Date('2026-10-12T12:30:00.000Z') + expect(nextAllowedSendTime(noon, { timezone: 'UTC', quiet_start_hour: 12, quiet_end_hour: 14 })).toEqual(new Date('2026-10-12T14:00:00.000Z')) + expect(nextAllowedSendTime(noon, { timezone: 'UTC', quiet_start_hour: 9, quiet_end_hour: 9 })).toBe(noon) + }) +}) diff --git a/packages/voko-clearing/src/db/tables/index.cjs b/packages/voko-clearing/src/db/tables/index.cjs index 6ac3eae..9b6f7a4 100644 --- a/packages/voko-clearing/src/db/tables/index.cjs +++ b/packages/voko-clearing/src/db/tables/index.cjs @@ -1,6 +1,7 @@ const tableModules = [ require('./vk_escrows.cjs'), require('./vk_escrow_payouts.cjs'), + require('./vk_tips.cjs'), ] const targetTables = tableModules.map(({ tableName, buildTable }) => [tableName, buildTable]) diff --git a/packages/voko-clearing/src/db/tables/vk_tips.cjs b/packages/voko-clearing/src/db/tables/vk_tips.cjs new file mode 100644 index 0000000..4654b69 --- /dev/null +++ b/packages/voko-clearing/src/db/tables/vk_tips.cjs @@ -0,0 +1,22 @@ +const tableName = 'vk_tips' + +function buildTable(t) { + t.string('id', 64).primary() + t.string('task_id', 64).notNullable() + t.string('from_agent_id', 64).notNullable() + t.string('from_wallet', 64).notNullable() + t.string('to_wallet', 64).notNullable() + t.bigInteger('amount').notNullable() + t.string('out_ledger_id', 64).notNullable() + t.string('in_ledger_id', 64).notNullable() + t.string('client_request_key', 191).notNullable() + t.datetime('created_at', { precision: 3 }).notNullable() + t.unique(['from_agent_id', 'client_request_key'], { indexName: 'uq_vk_tips_sender_request' }) + t.unique(['out_ledger_id'], { indexName: 'uq_vk_tips_out_ledger' }) + t.unique(['in_ledger_id'], { indexName: 'uq_vk_tips_in_ledger' }) + t.index(['task_id'], 'idx_vk_tips_task') + t.check('amount > 0', [], 'ck_vk_tips_amount') + t.check('from_wallet <> to_wallet', [], 'ck_vk_tips_parties') +} + +module.exports = { tableName, buildTable } diff --git a/packages/voko-clearing/src/escrow.js b/packages/voko-clearing/src/escrow.js index b940e2b..71149a6 100644 --- a/packages/voko-clearing/src/escrow.js +++ b/packages/voko-clearing/src/escrow.js @@ -130,7 +130,17 @@ export function createEscrow({ db }) { return -Number(row.spent) } - return { hold, topupExists, topup, refund, settle, walletOf, spentToday } + /** + * 钱包流水,最近的在前。 + * @param {object} _ctx + * @param {{ wallet_user_id: string, limit: number }} input + * @returns {Promise>} + */ + async function ledgerOf(_ctx, { wallet_user_id, limit }) { + 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') + } + + return { hold, topupExists, topup, refund, settle, walletOf, ledgerOf, spentToday } } /** diff --git a/packages/voko-clearing/src/index.js b/packages/voko-clearing/src/index.js index 2a33729..f103fd5 100644 --- a/packages/voko-clearing/src/index.js +++ b/packages/voko-clearing/src/index.js @@ -1,10 +1,11 @@ /** * `@voko/clearing` —— VOKO 的积分清算:托管与发放明细;动账一律经 `@drama/billing` 的账本。 * - * 当前有托管的冻结、补扣、结算与退回;打赏在 W4 加入。 + * 托管的冻结、补扣、结算与退回,钱包余额与流水,打赏。 * * 边界:不读 `process.env`、不建连接;db 由 composition root 注入。 */ export { createEscrow } from './escrow.js' +export { createTips } from './tips.js' export { default as clearingDbSchema } from './db/tables/index.cjs' diff --git a/packages/voko-clearing/src/tips.js b/packages/voko-clearing/src/tips.js new file mode 100644 index 0000000..220e8fd --- /dev/null +++ b/packages/voko-clearing/src/tips.js @@ -0,0 +1,49 @@ +import { randomUUID } from 'node:crypto' +import { createBillingRepo, createLedger } from '@drama/billing' + +const TIPS = 'vk_tips' + +/** + * 打赏:任何成员可给已结算任务的赢家打赏,从自己的可用积分划转,不可撤回。 + * 打赏人扣款与收款人入账在同一事务;余额不足时账本抛 INSUFFICIENT_BALANCE,事务回滚。 + * + * @param {{ db: import('knex').Knex }} deps + */ +export function createTips({ db }) { + if (typeof db !== 'function') throw new Error('createTips: db is required') + + /** + * @param {object} _ctx + * @param {{ + * task_id: string, + * from_agent_id: string, + * from_wallet: string, + * amount: number, + * client_request_key: string, + * onSent(trx: import('knex').Knex, sent: { to_wallet: string }): Promise, + * }} input `onSent` 在同一事务里执行(写事件);重放时不再调用。 + * @returns {Promise<{ ok: true, tip_id: string, replayed: boolean } | { ok: false, code: 'state_conflict'|'rule_violation', hint: string }>} + */ + async function send(_ctx, { task_id, from_agent_id, from_wallet, amount, client_request_key, onSent }) { + return db.transaction(async (trx) => { + // 锁打赏人的钱包行:同一打赏人的打赏串行,重放在首个事务提交后读到已有记录。 + await trx('users').where({ id: from_wallet }).forUpdate().first('id') + const existing = await trx(TIPS).where({ from_agent_id, client_request_key }).first('id') + if (existing !== undefined) return { ok: true, tip_id: existing.id, replayed: true } + const winner = await trx('vk_escrow_payouts').where({ task_id, kind: 'winner' }).first('recipient_wallet') + if (winner === undefined) return { ok: false, code: 'state_conflict', hint: 'Only settled tasks can be tipped.' } + const to_wallet = winner.recipient_wallet + if (to_wallet === from_wallet) return { ok: false, code: 'rule_violation', hint: 'You cannot tip yourself.' } + const id = `tp_${randomUUID()}` + const ledger = createLedger(createBillingRepo(db, trx)) + const base = { amount, source: 'voko', reason: 'vk_tip', ref_type: 'vk_tip', ref_id: id } + const out = await ledger.deduct({ ...base, user_id: from_wallet, idempotency_key: `vk:tip:${id}:out` }) + const received = await ledger.grant({ ...base, user_id: to_wallet, credit_kind: 'earned', idempotency_key: `vk:tip:${id}:in` }) + 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() }) + await onSent(trx, { to_wallet }) + return { ok: true, tip_id: id, replayed: false } + }) + } + + return { send } +} diff --git a/packages/voko-exchange/src/agents.js b/packages/voko-exchange/src/agents.js index 9ddf520..0988e6c 100644 --- a/packages/voko-exchange/src/agents.js +++ b/packages/voko-exchange/src/agents.js @@ -163,7 +163,19 @@ export function createAgents() { return (await runner(TABLE).where({ id: agent_id, owner_user_id }).update({ daily_spend_limit, updated_at: new Date() })) === 1 } - return { insertPending, getById, activate, updateProfile, listByOwner, setStatus, setDailySpendLimit } + /** + * @param {object} _ctx + * @param {import('knex').Knex} runner + * @param {{ wallet_user_id: string }} input + * @returns {Promise} + */ + async function getByWallet(_ctx, runner, { wallet_user_id }) { + const row = await runner(TABLE).where({ wallet_user_id }).first() + if (row === undefined) throw new Error(`vk_agents row missing for wallet: ${wallet_user_id}`) + return agentFromRow(row) + } + + return { insertPending, getById, getByWallet, activate, updateProfile, listByOwner, setStatus, setDailySpendLimit } } /** diff --git a/packages/voko-exchange/src/events.js b/packages/voko-exchange/src/events.js index fc2ad86..665ec8e 100644 --- a/packages/voko-exchange/src/events.js +++ b/packages/voko-exchange/src/events.js @@ -49,5 +49,15 @@ export function createEvents({ db }) { return after(agent, since) } - return { pullUnread, listSince } + /** + * 在调用方的事务里写一条事件(任务状态机之外的来源,如打赏)。 + * @param {object} _ctx + * @param {import('knex').Knex} trx + * @param {{ guild_id: string, task_id: string, type: string, target_agent_id: string|null }} input + */ + async function emit(_ctx, trx, { guild_id, task_id, type, target_agent_id }) { + await trx(EVENTS).insert({ guild_id, target_agent_id, type, task_id, payload: '{}', created_at: new Date() }) + } + + return { pullUnread, listSince, emit } } diff --git a/packages/voko-exchange/src/help.js b/packages/voko-exchange/src/help.js new file mode 100644 index 0000000..e2b6bc4 --- /dev/null +++ b/packages/voko-exchange/src/help.js @@ -0,0 +1,127 @@ +import { randomUUID } from 'node:crypto' + +import { taskFromRow } from './task.js' +import { insertEvent, insertJob } from './tasks.js' + +const HELP = 'vk_help_requests' + +/** + * 求助主人:Agent 卡住时提问,主人在网页收件箱回复,Agent 下次 `/home` 拿到答复。 + * + * @param {{ + * db: import('knex').Knex, + * sendAfter(ctx: object, input: { user_id: string, now: Date }): Promise, + * mailer: { sendHelp(ctx: object, input: { user_id: string, agent_name: string, task_title: string, question: string }): Promise }, + * }} deps `sendAfter` 给出通知主人的时刻(安静时段内推迟),主人关闭了求助邮件时返回 null。 + */ +export function createHelp({ db, sendAfter, mailer }) { + if (typeof db !== 'function') throw new Error('createHelp: db is required') + + /** + * @param {object} ctx + * @param {{ agent: import('./agents.js').Agent, task_id: string, question: string }} input + * @returns {Promise<{ ok: true, help_id: string } | { ok: false, code: 'not_found'|'forbidden'|'state_conflict', hint: string }>} + */ + async function request(ctx, { agent, task_id, question }) { + const now = new Date() + const owner = /** @type {string} */ (agent.owner_user_id) + const notifyAt = await sendAfter(ctx, { user_id: owner, now }) + return db.transaction(async (trx) => { + const row = await trx('vk_tasks').where({ id: task_id }).forUpdate().first() + if (row === undefined) return refuse('not_found', `Task not found: ${task_id}`) + const task = taskFromRow(row) + if (task.holder_agent_id !== agent.id) return refuse('forbidden', 'Only the current holder can ask for help on a task.') + switch (task.status) { + case 'claimed': + case 'rework': + break + case 'pending_approval': + case 'open': + case 'in_contest': + case 'submitted': + case 'reviewing': + case 'disputed': + case 'settled': + case 'cancelled': + case 'refunded': + return refuse('state_conflict', `Task is ${task.status}; help is for work in progress.`) + default: + throw new Error(`Unhandled status: ${task.status}, id: ${task.id}`) + } + const id = `hp_${randomUUID()}` + await trx(HELP).insert({ id, task_id, agent_id: agent.id, owner_user_id: owner, question, status: 'open', created_at: now }) + await insertEvent(trx, task, 'help.requested', null, now) + if (notifyAt !== null) await insertJob(trx, id, 'help_notify', notifyAt, now) + return { ok: true, help_id: id } + }) + } + + /** + * worker:给主人发求助邮件;已经回复过的不再打扰。 + * @param {object} ctx + * @param {{ help_id: string }} input + */ + async function notify(ctx, { help_id }) { + const help = await db(HELP).where({ id: help_id }).first() + if (help === undefined) throw new Error(`vk_help_requests row missing, id: ${help_id}`) + if (help.status !== 'open') return + 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')]) + await mailer.sendHelp(ctx, { user_id: help.owner_user_id, agent_name: agent.name, task_title: task.title, question: help.question }) + } + + /** + * 主人的求助收件箱:未回复的在前。 + * @param {object} _ctx + * @param {{ user_id: string }} input + */ + async function listForOwner(_ctx, { user_id }) { + return db(`${HELP} as h`) + .join('vk_agents as a', 'a.id', 'h.agent_id') + .join('vk_tasks as t', 't.id', 'h.task_id') + .where('h.owner_user_id', user_id) + .orderBy([{ column: 'h.status', order: 'desc' }, { column: 'h.created_at', order: 'desc' }]) + .limit(100) + .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') + } + + /** + * 主人回复。只能回复自己名下 Agent 的、尚未回复的求助。 + * @param {object} _ctx + * @param {{ user_id: string, help_id: string, answer: string }} input + * @returns {Promise<{ ok: true } | { ok: false, code: 'not_found'|'state_conflict', hint: string }>} + */ + async function answer(_ctx, { user_id, help_id, answer: text }) { + const now = new Date() + return db.transaction(async (trx) => { + const help = await trx(HELP).where({ id: help_id, owner_user_id: user_id }).forUpdate().first() + if (help === undefined) return refuse('not_found', `Help request not found: ${help_id}`) + if (help.status !== 'open') return refuse('state_conflict', 'This help request has already been answered.') + await trx(HELP).where({ id: help_id }).update({ status: 'answered', answer: text, answered_at: now }) + const task = taskFromRow(await trx('vk_tasks').where({ id: help.task_id }).first()) + await insertEvent(trx, task, 'help.answered', help.agent_id, now) + return { ok: true } + }) + } + + /** + * `/home` 用:这个 Agent 最近收到的答复。 + * @param {object} _ctx + * @param {{ agent_id: string }} input + * @returns {Promise>} + */ + async function answersFor(_ctx, { agent_id }) { + return db(HELP).where({ agent_id, status: 'answered' }).orderBy('answered_at', 'desc').limit(10).select('id', 'task_id', 'question', 'answer', 'answered_at') + } + + return { request, notify, listForOwner, answer, answersFor } +} + +/** + * @template {string} C + * @param {C} code + * @param {string} hint + * @returns {{ ok: false, code: C, hint: string }} + */ +function refuse(code, hint) { + return { ok: false, code, hint } +} diff --git a/packages/voko-exchange/src/index.js b/packages/voko-exchange/src/index.js index 12c1661..a2175dd 100644 --- a/packages/voko-exchange/src/index.js +++ b/packages/voko-exchange/src/index.js @@ -11,6 +11,7 @@ export { createGuilds } from './guilds.js' export { createTasks } from './tasks.js' export { createEvents } from './events.js' export { createJobs } from './jobs.js' +export { createHelp } from './help.js' export { createJudge } from './judge.js' export { rarityForReward, maxRarityForLevel } from './rarity.js' export { taskFromRow, TASK_STATUSES, TASK_TEMPLATES } from './task.js' diff --git a/packages/voko-exchange/src/jobs.js b/packages/voko-exchange/src/jobs.js index e1453b8..f63671e 100644 --- a/packages/voko-exchange/src/jobs.js +++ b/packages/voko-exchange/src/jobs.js @@ -21,10 +21,11 @@ const WARN_KINDS = Object.freeze(['checkpoint_warn', 'review_warn']) * autoSelectWinner(ctx: object, input: { task_id: string }): Promise, * arbitrate(ctx: object, input: { task_id: string }): Promise, * }, + * help: { notify(ctx: object, input: { help_id: string }): Promise }, * logger: { error(message: string, fields: object): void }, * }} deps */ -export function createJobs({ db, tasks, logger }) { +export function createJobs({ db, tasks, help, logger }) { if (typeof db !== 'function') throw new Error('createJobs: db is required') /** @@ -84,6 +85,7 @@ export function createJobs({ db, tasks, logger }) { return void await tasks.actAsWorker(ctx, { task_id, action: { type: 'review_auto_accept', winner_agent_id: null } }) } if (job.kind === 'dispute_arbitrate') return tasks.arbitrate(ctx, { task_id }) + if (job.kind === 'help_notify') return help.notify(ctx, { help_id: job.ref_id }) // 其余类型(Webhook、日报、对账)在 W4 接入。 throw new Error(`Unhandled job kind: ${job.kind}, id: ${job.id}`) } diff --git a/packages/voko-exchange/src/tasks.js b/packages/voko-exchange/src/tasks.js index 2d8ea24..5487c0a 100644 --- a/packages/voko-exchange/src/tasks.js +++ b/packages/voko-exchange/src/tasks.js @@ -691,19 +691,19 @@ async function acceptanceOf(runner, task_id) { * @param {string|null} target_agent_id null 表示发给整个公会 * @param {Date} now */ -async function insertEvent(trx, task, type, target_agent_id, now) { +export async function insertEvent(trx, task, type, target_agent_id, now) { await trx(EVENTS).insert({ guild_id: task.guild_id, target_agent_id, type, task_id: task.id, payload: '{}', created_at: now }) } /** - * 同一任务同一类型同一时刻的 job 只排一条(dedupe_key UNIQUE)。 + * 同一对象同一类型同一时刻的 job 只排一条(dedupe_key UNIQUE)。 * @param {import('knex').Knex} trx - * @param {string} task_id + * @param {string} task_id job 的 ref_id:任务类 job 是任务 id,求助通知是求助 id * @param {string} kind * @param {Date} run_at * @param {Date} now */ -async function insertJob(trx, task_id, kind, run_at, now) { +export async function insertJob(trx, task_id, kind, run_at, now) { await trx(JOBS) .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 }) .onConflict('dedupe_key')