Commit 310d07adcce925d08da4e599e15d3e0480be563d

Authored by yibo
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 &#39;@drama/billing&#39; @@ -9,10 +9,10 @@ import { createBillingRepo, createLedger } from &#39;@drama/billing&#39;
9 import { createDb } from '@drama/db' 9 import { createDb } from '@drama/db'
10 import { createLLM, createStaticKeyProvider } from '@drama/llm' 10 import { createLLM, createStaticKeyProvider } from '@drama/llm'
11 import { createLogger } from '@drama/observability' 11 import { createLogger } from '@drama/observability'
12 -import { createAccessRequests, createAgentAccess } 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([&#39;active&#39;, &#39;suspended&#39;]) }) @@ -17,7 +27,7 @@ const StatusBody = v.object({ status: v.picklist([&#39;active&#39;, &#39;suspended&#39;]) })
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(&#39;cc&#39;, async ({ db, check, call, createOwner, createGuild, crea @@ -32,4 +32,22 @@ await runScenario(&#39;cc&#39;, 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(&#39;f2&#39;, async ({ db, check, call, createOwner, createGuild, crea @@ -101,4 +101,37 @@ await runScenario(&#39;f2&#39;, 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(&#39;f3&#39;, async ({ db, check, call, admin, createOwner, createGuil @@ -104,4 +106,21 @@ await runScenario(&#39;f3&#39;, 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 &#39;./verify-code.js&#39; @@ -7,6 +7,7 @@ import * as verifyCode from &#39;./verify-code.js&#39;
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, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;')
  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 &lt;b&gt;')
  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 = &#39;vk_live_&#39; @@ -15,9 +15,10 @@ export const API_KEY_PREFIX = &#39;vk_live_&#39;
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 &#39;./guilds.js&#39; @@ -11,6 +11,7 @@ export { createGuilds } from &#39;./guilds.js&#39;
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([&#39;checkpoint_warn&#39;, &#39;review_warn&#39;]) @@ -21,10 +21,11 @@ const WARN_KINDS = Object.freeze([&#39;checkpoint_warn&#39;, &#39;review_warn&#39;])
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')