You need to sign in before continuing.

Commit 756b14d2402a06dd2d23eb4b98a3a41b21e9b002

Authored by yibo
1 parent 69968036

feat: W5 后端——等级与称号、排行榜、公会周目标、日报、公会城只读接口、后台接口

- 默认时区改为 Asia/Shanghai
- 表:vk_agent_titles;vk_guilds 增 weekly_goal / level / goal_reached_week(migration 202610100004)
- 结算后升级、接力称号、周目标达成公会升级;每日称号结算 job
- /v1:leaderboard、world/state、world/feed;后台:公会与预算、周目标、数据看板
- 日报邮件(当地 09:00);场景 f4-growth(F4-02–05)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
apps/voko-server/package.json
... ... @@ -17,6 +17,7 @@
17 17 "verify:f1": "node tools/scenarios/voko-alpha/f1-publish.mjs",
18 18 "verify:f2": "node tools/scenarios/voko-alpha/f2-execute.mjs",
19 19 "verify:f3": "node tools/scenarios/voko-alpha/f3-review-settle.mjs",
  20 + "verify:f4": "node tools/scenarios/voko-alpha/f4-growth.mjs",
20 21 "verify:e2e": "node tools/scenarios/voko-alpha/e2e-loop.mjs",
21 22 "verify:webhook": "node tools/scenarios/voko-alpha/webhook.mjs",
22 23 "verify:timers": "node tools/scenarios/voko-alpha/timers.mjs",
... ...
apps/voko-server/src/assembly/email.js
... ... @@ -83,12 +83,13 @@ export function createEmailPorts({ env, db, logger }) {
83 83 * 给主人发一封 voko-notice 并记投递日志。主人邮箱与语言取自 users 行。
84 84 * @param {string} user_id
85 85 * @param {string} purpose
86   - * @param {{ kind: 'claimed'|'help', path: string, agent: string, task?: string, question?: string }} notice
  86 + * @param {{ kind: 'claimed'|'help'|'digest', path: string, agent?: string, task?: string, question?: string, summary?: (locale: 'en'|'zh') => string }} notice `summary` 按主人的语言生成正文
87 87 */
88 88 async function sendOwnerNotice(user_id, purpose, { path, ...notice }) {
89 89 const owner = await db('users').where({ id: user_id }).first('email', 'locale')
90 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 })
  91 + const locale = String(owner.locale).startsWith('zh') ? 'zh' : 'en'
  92 + const result = await email.sendTemplate('voko-notice', { to: owner.email, from, locale, url: `${env.publicUrl}${path}`, ...notice, ...(notice.summary === undefined ? {} : { summary: notice.summary(locale) }) })
92 93 await recordEmailDelivery(db, { provider: result.provider || 'nodemailer', messageId: result.message_id || null, purpose, toEmail: owner.email, status: 'sent', template: 'voko-notice' })
93 94 }
94 95  
... ... @@ -114,6 +115,23 @@ export function createEmailPorts({ env, db, logger }) {
114 115 async sendHelp(_ctx, { user_id, agent_name, task_title, question }) {
115 116 await sendOwnerNotice(user_id, 'help_requested', { kind: 'help', path: '/help', agent: agent_name, task: task_title, question })
116 117 },
  118 + /**
  119 + * 日报。由 worker 的 job 调用,失败直接抛出,由 job 退避重试。
  120 + * @param {object} _ctx
  121 + * @param {{ user_id: string, digest: { open_help: number, agents: Array<{ name: string, level: number, settled: number, points: number, active_tasks: number }> } }} input
  122 + */
  123 + async sendDigest(_ctx, { user_id, digest }) {
  124 + await sendOwnerNotice(user_id, 'daily_digest', {
  125 + kind: 'digest',
  126 + path: '/agents',
  127 + summary: (locale) => [
  128 + ...digest.agents.map((agent) => (locale === 'zh'
  129 + ? `${agent.name}(${agent.level} 级):结算 ${agent.settled} 笔,赚取 ${agent.points} 积分,在手任务 ${agent.active_tasks} 个`
  130 + : `${agent.name} (level ${agent.level}): ${agent.settled} payouts, ${agent.points} points earned, ${agent.active_tasks} tasks in hand`)),
  131 + locale === 'zh' ? `等你回复的求助:${digest.open_help} 条` : `Help requests waiting for you: ${digest.open_help}`,
  132 + ].join('\n'),
  133 + })
  134 + },
117 135 }
118 136  
119 137 return { authEmail, accessNotifier, ownerNotifier }
... ...
apps/voko-server/src/assembly/runtime.js
... ... @@ -9,10 +9,10 @@ import { createBillingRepo, createLedger } from &#39;@drama/billing&#39;
9 9 import { createDb } from '@drama/db'
10 10 import { createLLM, createStaticKeyProvider } from '@drama/llm'
11 11 import { createLogger } from '@drama/observability'
12   -import { createAccessRequests, createAgentAccess, createOwnerSettings, createWebhooks, nextAllowedSendTime } from '@voko/access'
  12 +import { createAccessRequests, createAgentAccess, createOwnerSettings, createWebhooks, nextAllowedSendTime, nextLocalHour } from '@voko/access'
13 13 import { createMemoryStore, createRateLimiter } from '@drama/ratelimit'
14 14 import { createEscrow, createTips, verifyConservation } from '@voko/clearing'
15   -import { createAgents, createEvents, createGuilds, createHelp, createJobs, createJudge, createTasks } from '@voko/exchange'
  15 +import { createAgents, createEvents, createGame, createGuilds, createHelp, createJobs, createJudge, createTasks } from '@voko/exchange'
16 16  
17 17 import { readEnv } from '../env.js'
18 18 import { createEmailPorts } from './email.js'
... ... @@ -69,7 +69,41 @@ export async function createRuntime({ service }) {
69 69 })
70 70 const tips = createTips({ db })
71 71 const webhooks = createWebhooks({ db, secret_key: env.webhookSecretKey })
72   - const jobs = createJobs({ db, tasks, help, conservationProblems: () => verifyConservation(db), logger })
  72 + const game = createGame({ db })
  73 +
  74 + /**
  75 + * 给主人发日报:过去 24 小时没有任何动静(无结算、无在手任务、无待回复求助)就不打扰。
  76 + * @param {object} ctx
  77 + * @param {{ user_id: string }} input
  78 + */
  79 + async function sendDigest(ctx, { user_id }) {
  80 + const digest = await game.digestFor(ctx, { owner_user_id: user_id, since: new Date(Date.now() - 86400_000) })
  81 + const quiet = digest.open_help === 0 && digest.agents.every((agent) => agent.settled === 0 && agent.active_tasks === 0)
  82 + if (!quiet) await ownerNotifier.sendDigest(ctx, { user_id, digest })
  83 + }
  84 + const jobs = createJobs({ db, tasks, help, sendDigest, runDailyGame: (ctx) => game.runDaily(ctx), conservationProblems: () => verifyConservation(db), logger })
  85 +
  86 + /**
  87 + * 给每个有 Agent 且开着日报的主人排下一次日报(当地 09:00)。重复调用无害。
  88 + * @param {object} ctx
  89 + */
  90 + async function scheduleDigests(ctx) {
  91 + const now = new Date()
  92 + const owners = await db('vk_agents').whereNotNull('owner_user_id').distinct('owner_user_id')
  93 + for (const { owner_user_id } of owners) {
  94 + const settings = await ownerSettings.get(ctx, { user_id: owner_user_id })
  95 + if (settings.email_digest) await jobs.enqueue(ctx, { kind: 'daily_digest', ref_id: owner_user_id, run_at: nextLocalHour(now, settings.timezone, 9) })
  96 + }
  97 + }
  98 +
  99 + /**
  100 + * 运营给公会预算调账:记到公司 Agent 的钱包,reason=admin_adjustment,note 必填。
  101 + * @param {{ wallet_user_id: string, amount: number, note: string, idempotency_key: string }} input
  102 + */
  103 + async function grantBudget({ wallet_user_id, amount, note, idempotency_key }) {
  104 + return createLedger(createBillingRepo(db)).grant({ user_id: wallet_user_id, amount, source: 'admin', reason: 'admin_adjustment', note, idempotency_key: `vk:budget:${idempotency_key}` })
  105 + }
  106 +
73 107  
74 108 /**
75 109 * 给已结算任务的赢家打赏;收款事件与动账同事务。
... ... @@ -135,7 +169,7 @@ export async function createRuntime({ service }) {
135 169 return {
136 170 env,
137 171 logger,
138   - services: { db, auth, userRepo, access, agents, guilds, agentAccess, escrow, tasks, events, jobs, help, ownerSettings, webhooks, tipTask, rateLimiter, register, oauthEnabled: env.oauth.googleClientId !== null },
  172 + services: { db, auth, userRepo, access, agents, guilds, agentAccess, escrow, tasks, events, jobs, help, ownerSettings, webhooks, game, sendDigest, scheduleDigests, grantBudget, tipTask, rateLimiter, register, oauthEnabled: env.oauth.googleClientId !== null },
139 173 async close() {
140 174 await db.destroy()
141 175 },
... ...
apps/voko-server/src/db/migrations/202610100004_titles_and_guild_goal.js 0 → 100644
  1 +/** W5:称号 vk_agent_titles;公会周目标与等级(weekly_goal 为本周结算积分目标,0 表示未设)。与 tables/*.cjs 逐字一致。 */
  2 +export async function up(db) {
  3 + await db.schema.createTable('vk_agent_titles', (t) => {
  4 + t.string('id', 64).primary()
  5 + t.string('agent_id', 64).notNullable()
  6 + t.string('title', 32).notNullable()
  7 + t.string('period_key', 16).notNullable()
  8 + t.datetime('awarded_at', { precision: 3 }).notNullable()
  9 + t.unique(['agent_id', 'title', 'period_key'], { indexName: 'uq_vk_agent_titles_once' })
  10 + t.check("title in ('first_relay','streak_7','weekly_mvp')", [], 'ck_vk_agent_titles_title')
  11 + })
  12 +
  13 + await db.schema.alterTable('vk_guilds', (t) => {
  14 + t.bigInteger('weekly_goal').unsigned().notNullable().defaultTo(0)
  15 + t.integer('level').unsigned().notNullable().defaultTo(1)
  16 + t.string('goal_reached_week', 10).nullable()
  17 + })
  18 +}
  19 +
  20 +export async function down(db) {
  21 + await db.schema.alterTable('vk_guilds', (t) => t.dropColumns('weekly_goal', 'level', 'goal_reached_week'))
  22 + await db.schema.dropTable('vk_agent_titles')
  23 +}
... ...
apps/voko-server/src/routes/owner.js
1 1 import * as v from 'valibot'
2 2  
3 3 import { codedError, requireUser } from './auth.js'
4   -import { agentView, IdempotencyKey, ledgerView, PublishBody, REFUSAL_STATUS, taskView } from './v1.js'
  4 +import { agentView, FeedQuery, feedView, IdempotencyKey, LeaderboardQuery, ledgerView, PublishBody, REFUSAL_STATUS, taskView } from './v1.js'
5 5  
6 6 const AgentParams = v.object({ id: v.pipe(v.string(), v.minLength(1), v.maxLength(64)) })
7 7 const LimitBody = v.object({ daily_spend_limit: v.nullable(v.pipe(v.number(), v.safeInteger(), v.minValue(0))) })
... ... @@ -18,6 +18,10 @@ const SettingsBody = v.object({
18 18 email_help: v.boolean(),
19 19 email_digest: v.boolean(),
20 20 })
  21 +const GuildParams = v.object({ id: v.pipe(v.string(), v.minLength(1), v.maxLength(64)) })
  22 +const BudgetBody = v.object({ amount: v.pipe(v.number(), v.safeInteger(), v.minValue(1), v.maxValue(1_000_000_000)), note: v.pipe(v.string(), v.minLength(1), v.maxLength(500)) })
  23 +const GoalBody = v.object({ weekly_goal: v.pipe(v.number(), v.safeInteger(), v.minValue(0), v.maxValue(1_000_000_000)) })
  24 +const UserParams = v.object({ user_id: v.pipe(v.string(), v.minLength(1), v.maxLength(64)) })
21 25 const StatusBody = v.object({ status: v.picklist(['active', 'suspended']) })
22 26  
23 27 /**
... ... @@ -27,7 +31,7 @@ const StatusBody = v.object({ status: v.picklist([&#39;active&#39;, &#39;suspended&#39;]) })
27 31 * @param {Record<string, any>} services
28 32 */
29 33 export function registerOwnerRoutes(app, services) {
30   - const { agents, agentAccess, guilds, escrow, tasks, help, ownerSettings, db } = services
  34 + const { agents, agentAccess, guilds, escrow, tasks, help, ownerSettings, game, db } = services
31 35  
32 36 /**
33 37 * 业务拒绝 → 网页接口的错误形状。
... ... @@ -160,6 +164,82 @@ export function registerOwnerRoutes(app, services) {
160 164 return result.ok ? { task: taskView(result.task) } : refused(reply, result)
161 165 })
162 166  
  167 + // 公会城、动态与排行榜:以自己某个 Agent 的身份看它所在的公会。
  168 + app.get('/api/voko/agents/:id/world/state', async (request) => {
  169 + const { agent } = await ownAgent(request)
  170 + return game.worldState(request.ctx, { guild_id: agent.guild_id })
  171 + })
  172 +
  173 + app.get('/api/voko/agents/:id/world/feed', async (request) => {
  174 + const { agent } = await ownAgent(request)
  175 + const query = v.parse(FeedQuery, request.query)
  176 + return { events: (await game.worldFeed(request.ctx, { guild_id: agent.guild_id, since: query.since })).map(feedView) }
  177 + })
  178 +
  179 + app.get('/api/voko/agents/:id/leaderboard', async (request) => {
  180 + const { agent } = await ownAgent(request)
  181 + const query = v.parse(LeaderboardQuery, request.query)
  182 + return { ...query, leaderboard: await game.leaderboard(request.ctx, { agent, ...query }) }
  183 + })
  184 +
  185 + /** @param {import('fastify').FastifyRequest} request */
  186 + async function requireAdmin(request) {
  187 + const user = await requireUser(request, services)
  188 + if (user.role !== 'admin') throw codedError('admin permission required', 'FORBIDDEN')
  189 + return user
  190 + }
  191 +
  192 + // 运营:公会与预算。预算记在公司 Agent 的钱包里,走人工调账。
  193 + app.get('/api/voko/admin/guilds', async (request) => {
  194 + await requireAdmin(request)
  195 + const rows = await guilds.listForAdmin(request.ctx)
  196 + return {
  197 + guilds: await Promise.all(rows.map(async ({ company_wallet_user_id, ...guild }) => ({
  198 + ...guild,
  199 + require_lead_approval: guild.require_lead_approval === 1,
  200 + budget: company_wallet_user_id === null ? null : await escrow.walletOf(request.ctx, { wallet_user_id: company_wallet_user_id }),
  201 + }))),
  202 + }
  203 + })
  204 +
  205 + app.post('/api/voko/admin/guilds/:id/budget', async (request) => {
  206 + await requireAdmin(request)
  207 + const params = v.parse(GuildParams, request.params)
  208 + const body = v.parse(BudgetBody, request.body)
  209 + const key = v.parse(IdempotencyKey, request.headers['idempotency-key'])
  210 + const guild = (await guilds.listForAdmin(request.ctx)).find((item) => item.id === params.id)
  211 + if (guild === undefined) throw codedError(`Guild not found: ${params.id}`, 'NOT_FOUND')
  212 + if (guild.company_wallet_user_id === null) throw codedError('This guild has no company agent yet; its lead must claim one first', 'CONFLICT')
  213 + await services.grantBudget({ wallet_user_id: guild.company_wallet_user_id, amount: body.amount, note: body.note, idempotency_key: key })
  214 + return { budget: await escrow.walletOf(request.ctx, { wallet_user_id: guild.company_wallet_user_id }) }
  215 + })
  216 +
  217 + app.put('/api/voko/admin/guilds/:id/weekly-goal', async (request) => {
  218 + await requireAdmin(request)
  219 + const params = v.parse(GuildParams, request.params)
  220 + const body = v.parse(GoalBody, request.body)
  221 + if (!(await guilds.setWeeklyGoal(request.ctx, { guild_id: params.id, weekly_goal: body.weekly_goal }))) throw codedError(`Guild not found: ${params.id}`, 'NOT_FOUND')
  222 + return { ok: true }
  223 + })
  224 +
  225 + // 运营:数据看板;手动触发每日称号结算与某位主人的日报(平时由 worker 定时执行)。
  226 + app.get('/api/voko/admin/dashboard', async (request) => {
  227 + await requireAdmin(request)
  228 + return { dashboard: await game.dashboard(request.ctx) }
  229 + })
  230 +
  231 + app.post('/api/voko/admin/game/run-daily', async (request) => {
  232 + await requireAdmin(request)
  233 + return { awarded: await game.runDaily(request.ctx) }
  234 + })
  235 +
  236 + app.post('/api/voko/admin/digests/:user_id/send', async (request) => {
  237 + await requireAdmin(request)
  238 + const params = v.parse(UserParams, request.params)
  239 + await services.sendDigest(request.ctx, { user_id: params.user_id })
  240 + return { ok: true }
  241 + })
  242 +
163 243 // 运营:仲裁队列与裁决。
164 244 app.get('/api/voko/admin/disputes', async (request) => {
165 245 const user = await requireUser(request, services)
... ...
apps/voko-server/src/routes/v1.js
... ... @@ -53,6 +53,8 @@ const ReviewBody = v.object({
53 53 const HelpBody = v.object({ question: v.pipe(v.string(), v.minLength(1), v.maxLength(4000)) })
54 54 const TipBody = v.object({ amount: Reward })
55 55 const LedgerQuery = v.object({ limit: v.optional(v.pipe(v.string(), v.transform(Number), v.integer(), v.minValue(1), v.maxValue(100)), '50') })
  56 +export const LeaderboardQuery = v.object({ scope: v.optional(v.picklist(['guild', 'world']), 'guild'), period: v.optional(v.picklist(['week', 'all']), 'week') })
  57 +export const FeedQuery = v.object({ since: v.optional(v.pipe(v.string(), v.transform(Number), v.integer(), v.minValue(0)), '0') })
56 58 const TransferBody = v.object({ to_agent_id: v.pipe(v.string(), v.minLength(1), v.maxLength(64)) })
57 59 const CommentBody = v.object({ body: v.pipe(v.string(), v.minLength(1), v.maxLength(4000)) })
58 60 const EventsQuery = v.object({ since: v.optional(v.pipe(v.string(), v.transform(Number), v.integer(), v.minValue(0)), '0') })
... ... @@ -70,7 +72,7 @@ const UpdateMeBody = v.object({ description: v.optional(Description), skills: v.
70 72 * @param {Record<string, any>} services
71 73 */
72 74 export function registerV1Routes(app, services) {
73   - const { agentAccess, agents, db, tasks, events, escrow, help, tipTask, webhooks, rateLimiter } = services
  75 + const { agentAccess, agents, db, tasks, events, escrow, help, tipTask, webhooks, game, rateLimiter } = services
74 76  
75 77 app.register(async (v1) => {
76 78 v1.setErrorHandler((/** @type {any} */ err, request, reply) => {
... ... @@ -164,6 +166,25 @@ export function registerV1Routes(app, services) {
164 166  
165 167 v1.get('/wallet', async (request) => ok(await escrow.walletOf(request.ctx, { wallet_user_id: agentOf(request).wallet_user_id })))
166 168  
  169 + v1.get('/leaderboard', async (request) => {
  170 + const query = v.parse(LeaderboardQuery, request.query)
  171 + return ok({ ...query, leaderboard: await game.leaderboard(request.ctx, { agent: agentOf(request), ...query }) })
  172 + })
  173 +
  174 + // 公会城只读接口:只能看自己公会(未认领的 Agent 没有公会)。
  175 + v1.get('/world/state', async (request, reply) => {
  176 + const agent = agentOf(request)
  177 + if (agent.guild_id === null) return fail(reply, 403, 'forbidden', 'Agent is not a member of any guild', 'Ask your human to claim you first.')
  178 + return ok(await game.worldState(request.ctx, { guild_id: agent.guild_id }))
  179 + })
  180 +
  181 + v1.get('/world/feed', async (request, reply) => {
  182 + const agent = agentOf(request)
  183 + if (agent.guild_id === null) return fail(reply, 403, 'forbidden', 'Agent is not a member of any guild', 'Ask your human to claim you first.')
  184 + const query = v.parse(FeedQuery, request.query)
  185 + return ok({ events: (await game.worldFeed(request.ctx, { guild_id: agent.guild_id, since: query.since })).map(feedView) })
  186 + })
  187 +
167 188 v1.get('/wallet/ledger', async (request) => {
168 189 const query = v.parse(LedgerQuery, request.query)
169 190 const rows = await escrow.ledgerOf(request.ctx, { wallet_user_id: agentOf(request).wallet_user_id, limit: query.limit })
... ... @@ -344,6 +365,20 @@ function commentView(comment) {
344 365 }
345 366  
346 367 /**
  368 + * 公会动态的对外形状:事件类型、时间、当事 Agent 与任务概况,供前端翻译成动画。
  369 + * @param {Record<string, any>} event
  370 + */
  371 +export function feedView(event) {
  372 + return {
  373 + id: event.id,
  374 + type: event.type,
  375 + at: event.created_at.toISOString(),
  376 + agent_id: event.target_agent_id,
  377 + task: event.task_id === null ? null : { id: event.task_id, title: event.title, reward: event.reward, rarity: event.rarity, holder_agent_id: event.holder_agent_id, publisher_agent_id: event.publisher_agent_id },
  378 + }
  379 +}
  380 +
  381 +/**
347 382 * 流水的对外形状:任务相关的流水带 task_id。
348 383 * @param {{ id: string, type: string, amount: number, reason: string, ref_type: string|null, ref_id: string|null, created_at: Date }} row
349 384 */
... ...
apps/voko-server/src/voko-worker-main.js
... ... @@ -15,7 +15,10 @@ async function tick() {
15 15 await runtime.services.jobs.runDue({})
16 16 await runtime.services.webhooks.deliverDue({})
17 17 ticks += 1
18   - if (ticks % HEARTBEAT_EVERY_TICKS === 1) await runtime.services.jobs.ensureDaily({})
  18 + if (ticks % HEARTBEAT_EVERY_TICKS === 1) {
  19 + await runtime.services.jobs.ensureDaily({})
  20 + await runtime.services.scheduleDigests({})
  21 + }
19 22 if (ticks % HEARTBEAT_EVERY_TICKS === 0) runtime.logger.info('worker 心跳', { ticks })
20 23 }
21 24  
... ...
apps/voko-server/tools/scenarios/voko-alpha/_lib.mjs
... ... @@ -170,11 +170,13 @@ function createWorld(name, db) {
170 170 if (agentIds.length > 0) {
171 171 await db('billing_ledger').whereIn('user_id', wallets).del()
172 172 await db('vk_api_keys').whereIn('agent_id', agentIds).del()
  173 + await db('vk_agent_titles').whereIn('agent_id', agentIds).del()
173 174 await db('vk_agent_claims').whereIn('agent_id', agentIds).del()
174 175 await db('vk_agents').whereIn('id', agentIds).del()
175 176 await db('users').whereIn('id', wallets).del()
176 177 }
177 178 if (userIds.length > 0) {
  179 + await db('vk_jobs').where({ kind: 'daily_digest' }).whereIn('ref_id', userIds).del()
178 180 await db('vk_access_requests').whereIn('user_id', userIds).del()
179 181 await db('vk_owner_settings').whereIn('user_id', userIds).del()
180 182 await db('auth_sessions').whereIn('user_id', userIds).del()
... ...
apps/voko-server/tools/scenarios/voko-alpha/f4-growth.mjs 0 → 100644
  1 +#!/usr/bin/env node
  2 +/**
  3 + * F4 成长与游戏层的真实链路脚本(Planning 用例 F4-02、03、04、05)。F4-01 公会城画面为网页,手工走查。
  4 + */
  5 +import { randomUUID } from 'node:crypto'
  6 +
  7 +import { runScenario } from './_lib.mjs'
  8 +
  9 +await runScenario('f4', async ({ db, check, call, admin, waitForEmail, createOwner, createGuild, createAgent, publish, wallet, submit, claim, review }) => {
  10 + const alice = await createOwner('alice')
  11 + const bob = await createOwner('bob')
  12 + const carol = await createOwner('carol')
  13 + const guild = await createGuild('main', alice.user_id)
  14 + const other = await createGuild('other', carol.user_id)
  15 + const company = await createAgent('company', alice, guild, 'company')
  16 + const taker = await createAgent('taker', bob, guild)
  17 + const helper = await createAgent('helper', carol, guild)
  18 + const outsider = await createAgent('outsider', carol, other)
  19 + const adminToken = await admin()
  20 + const me = async (agent) => (await call('GET', '/v1/agents/me', { token: agent.key })).json.data
  21 + const eventsOf = async (agent) => (await call('GET', '/v1/events?since=0', { token: agent.key })).json.data.events
  22 + /** 发布 → 接单方认领、提交 → 发布方通过。 */
  23 + const deliver = async (agent, overrides) => {
  24 + const task = (await publish(company, overrides)).json.data
  25 + await claim(agent, task.id)
  26 + await submit(agent, task.id)
  27 + const accepted = await review(company, task.id, 'accept', ['c1'])
  28 + check(`结算 ${task.reward} 分给 ${agent.id.slice(0, 11)}`, accepted.status === 200 && accepted.json.data.status === 'settled', accepted)
  29 + return task
  30 + }
  31 +
  32 + // 运营后台:公会预算走人工调账,周目标可配置。
  33 + const budgetKey = randomUUID()
  34 + const granted = await call('POST', `/api/voko/admin/guilds/${guild}/budget`, { token: adminToken, idempotency_key: budgetKey, body: { amount: 1000, note: 'f4 scenario budget' } })
  35 + const replayed = await call('POST', `/api/voko/admin/guilds/${guild}/budget`, { token: adminToken, idempotency_key: budgetKey, body: { amount: 1000, note: 'f4 scenario budget' } })
  36 + check('运营给公会预算调账 1000,重放不重复', granted.status === 200 && granted.json.budget.available === 1100 && replayed.json.budget.available === 1100, { granted, replayed })
  37 + const noCompany = await call('POST', `/api/voko/admin/guilds/${other}/budget`, { token: adminToken, idempotency_key: randomUUID(), body: { amount: 10, note: 'x' } })
  38 + const notAdmin = await call('GET', '/api/voko/admin/guilds', { token: alice.token })
  39 + check('没有公司 Agent 的公会不能调预算 → 409;非运营看后台 → 403', noCompany.status === 409 && notAdmin.status === 403, { noCompany, notAdmin })
  40 + check('设置公会周目标 150', (await call('PUT', `/api/voko/admin/guilds/${guild}/weekly-goal`, { token: adminToken, body: { weekly_goal: 150 } })).status === 200)
  41 + const listed = (await call('GET', '/api/voko/admin/guilds', { token: adminToken })).json.guilds.find((item) => item.id === guild)
  42 + check('后台公会列表:公司 Agent、预算、周目标', listed.company_agent_id === company.id && listed.budget.available === 1100 && listed.weekly_goal === 150 && listed.level === 1, listed)
  43 +
  44 + // F4-02 结算后 XP 增加、升级事件、解锁更高稀有度。
  45 + const rare = (await publish(company, { reward: 250 })).json.data
  46 + const locked = await claim(taker, rare.id)
  47 + check('1 级接不了稀有度 4 的任务 → 422', rare.rarity === 4 && locked.status === 422, { rare, locked })
  48 + await deliver(taker, { reward: 120 })
  49 + const leveled = await me(taker)
  50 + check('F4-02 结算后 XP = 120,升到 2 级,收到 agent.level_up', leveled.xp === 120 && leveled.level === 2 && (await eventsOf(taker)).some((e) => e.type === 'agent.level_up'), leveled)
  51 + const unlocked = await claim(taker, rare.id)
  52 + check('F4-02 升级后解锁稀有度 4 的任务', unlocked.status === 200, unlocked)
  53 +
  54 + // F4-04 公会周目标:本周结算积分达到目标 → 公会升级。
  55 + const beforeGoal = (await call('GET', '/v1/world/state', { token: taker.key })).json.data
  56 + check('周目标进度 = 本周已结算赏金 120,公会仍为 1 级', beforeGoal.guild.weekly_progress === 120 && beforeGoal.guild.weekly_goal === 150 && beforeGoal.guild.level === 1, beforeGoal.guild)
  57 +
  58 + // F4-03 称号:首次接力成功。helper 留检查点后放弃,taker 接力完成。
  59 + const relay = (await publish(company, { reward: 60 })).json.data
  60 + await claim(helper, relay.id)
  61 + await call('POST', `/v1/tasks/${relay.id}/checkpoints`, { token: helper.key, body: { progress: 50, note: 'Half done' } })
  62 + await call('POST', `/v1/tasks/${relay.id}/release`, { token: helper.key })
  63 + await claim(taker, relay.id)
  64 + await submit(taker, relay.id)
  65 + await review(company, relay.id, 'accept', ['c1'])
  66 + const state = (await call('GET', '/v1/world/state', { token: taker.key })).json.data
  67 + const titled = (id) => state.agents.find((agent) => agent.id === id).titles
  68 + check('F4-03 接力双方都获得 first_relay 称号并收到事件', titled(taker.id).includes('first_relay') && titled(helper.id).includes('first_relay') && titled(company.id).length === 0 && (await eventsOf(helper)).some((e) => e.type === 'agent.title_awarded'), state.agents)
  69 + const titlesBefore = Number((await db('vk_agent_titles').whereIn('agent_id', [taker.id, helper.id]).count({ n: '*' }).first()).n)
  70 + await deliver(taker, { reward: 10 })
  71 + const daily = await call('POST', '/api/voko/admin/game/run-daily', { token: adminToken })
  72 + const titlesAfter = Number((await db('vk_agent_titles').whereIn('agent_id', [taker.id, helper.id]).count({ n: '*' }).first()).n)
  73 + check('称号只发一次;每日称号结算可重复执行', titlesAfter === titlesBefore && daily.status === 200 && typeof daily.json.awarded.streak_7 === 'number', { titlesBefore, titlesAfter, daily })
  74 + check('F4-04 周目标达成(120 + 60 + 10 ≥ 150)→ 公会升到 2 级,发 guild.goal_progress', state.guild.level === 2 && state.guild.weekly_progress === 180 && (await eventsOf(company)).filter((e) => e.type === 'guild.goal_progress').length === 1, state.guild)
  75 +
  76 + // F4-04 排行榜与流水一致。
  77 + const board = (await call('GET', '/v1/leaderboard?scope=guild&period=week', { token: taker.key })).json.data.leaderboard
  78 + const earnedFromLedger = async (agent) => (await call('GET', '/v1/wallet/ledger?limit=100', { token: agent.key })).json.data.ledger.filter((row) => ['vk_task_payout', 'vk_relay_share'].includes(row.reason)).reduce((sum, row) => sum + row.amount, 0)
  79 + check('F4-04 本周排行与流水一致:taker 160 第一,helper 30 第二', board.length === 2 && board[0].agent_id === taker.id && board[0].points === 160 && board[0].points === (await earnedFromLedger(taker)) && board[1].agent_id === helper.id && board[1].points === 30 && board[1].points === (await earnedFromLedger(helper)) && board[0].rank === 1, board)
  80 + const world = (await call('GET', '/v1/leaderboard?scope=world&period=all', { token: outsider.key })).json.data.leaderboard
  81 + check('世界榜包含其他公会的 Agent', world.some((row) => row.agent_id === taker.id), world.slice(0, 3))
  82 + check('钱包与 XP 对得上:taker 100 + 160,XP 160', (await wallet(taker)).available === 260 && (await me(taker)).xp === 160, await wallet(taker))
  83 +
  84 + // F4-04 日报邮件。
  85 + const digest = await call('POST', `/api/voko/admin/digests/${bob.user_id}/send`, { token: adminToken })
  86 + const digestMail = await waitForEmail({ to_email: bob.email, purpose: 'daily_digest', status: 'sent' })
  87 + check('F4-04 日报邮件已投递', digest.status === 200 && digestMail.template === 'voko-notice', digestMail)
  88 +
  89 + // F4-05 公会城只读接口:成员可读,未认领的 Agent 403,只能看到自己公会。
  90 + const feed = await call('GET', '/v1/world/feed?since=0', { token: helper.key })
  91 + const types = feed.json.data.events.map((event) => event.type)
  92 + check('F4-05 成员可读动态:含认领、通过、升级、称号、周目标,同一事件不重复', feed.status === 200 && ['task.published', 'task.claimed', 'task.accepted', 'agent.level_up', 'agent.title_awarded', 'guild.goal_progress'].every((type) => types.includes(type)) && new Set(feed.json.data.events.map((event) => event.id)).size === feed.json.data.events.length && feed.json.data.events.find((event) => event.type === 'task.claimed').task.title.length > 0, types)
  93 + const later = await call('GET', `/v1/world/feed?since=${feed.json.data.events.at(-1).id}`, { token: helper.key })
  94 + check('since 游标之后没有旧事件', later.json.data.events.length === 0, later)
  95 + const unclaimed = (await call('POST', '/v1/agents/register', { body: { name: 'f4-unclaimed', skills: [] } })).json.data
  96 + const strangerState = await call('GET', '/v1/world/state', { token: unclaimed.api_key })
  97 + const strangerFeed = await call('GET', '/v1/world/feed', { token: unclaimed.api_key })
  98 + await db('vk_api_keys').where({ agent_id: unclaimed.agent_id }).del()
  99 + await db('vk_agent_claims').where({ agent_id: unclaimed.agent_id }).del()
  100 + const wallets = await db('vk_agents').where({ id: unclaimed.agent_id }).select('wallet_user_id')
  101 + await db('vk_agents').where({ id: unclaimed.agent_id }).del()
  102 + await db('users').whereIn('id', wallets.map((row) => row.wallet_user_id)).del()
  103 + const otherState = await call('GET', '/v1/world/state', { token: outsider.key })
  104 + check('F4-05 非成员(未认领)→ 403;别的公会的 Agent 只看到自己的公会', strangerState.status === 403 && strangerFeed.status === 403 && otherState.json.data.guild.id === other && otherState.json.data.agents.every((agent) => agent.id !== taker.id), { strangerState, otherState })
  105 +
  106 + // 数据看板。
  107 + const dashboard = (await call('GET', '/api/voko/admin/dashboard', { token: adminToken })).json.dashboard
  108 + check('数据看板有完成任务数、赚取积分、平均交付周期、一次通过率', dashboard.tasks_settled >= 3 && dashboard.points_earned >= 190 && typeof dashboard.avg_delivery_hours === 'number' && dashboard.first_pass_rate > 0 && dashboard.first_pass_rate <= 1, dashboard)
  109 +})
... ...
packages/email/src/templates/voko-notice.js
1 1 import { EmailError } from '../map-error.js'
2 2  
3   -/** VOKO 给主人的通知:Agent 已认领、Agent 求助。每种通知一组文案,`{name}` 等占位符由调用方数据填入。 */
  3 +/** VOKO 给主人的通知:Agent 已认领、Agent 求助、日报。每种通知一组文案,`{name}` 等占位符由调用方数据填入。 */
4 4 const COPY = Object.freeze({
5 5 en: {
6 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 7 help: { subject: '{agent} needs your help', heading: '{agent} asked for help', body: 'Task: {task}\n\n{question}', action: 'Reply in your inbox' },
  8 + digest: { subject: 'Your VOKO daily digest', heading: 'What your agents did in the last 24 hours', body: '{summary}', action: 'Open my agents' },
8 9 },
9 10 zh: {
10 11 claimed: { subject: '你已在 VOKO 认领 {agent}', heading: '{agent} 已激活', body: '你的 Agent 已加入公会并收到 100 启动积分。你可以在「我的 Agent」里管理它、重置 API key 或设置单日支出上限。', action: '打开我的 Agent' },
11 12 help: { subject: '{agent} 需要你的帮助', heading: '{agent} 向你求助', body: '任务:{task}\n\n{question}', action: '去收件箱回复' },
  13 + digest: { subject: '你的 VOKO 日报', heading: '过去 24 小时你的 Agent 做了什么', body: '{summary}', action: '打开我的 Agent' },
12 14 },
13 15 })
14 16  
15 17 /**
16   - * @param {{ locale: 'en'|'zh', kind: 'claimed'|'help', url: string, agent: string, task?: string, question?: string }} data
  18 + * @param {{ locale: 'en'|'zh', kind: 'claimed'|'help'|'digest', url: string, agent?: string, task?: string, question?: string, summary?: string }} data
17 19 */
18 20 export function render(data) {
19 21 if (!data || typeof data !== 'object') fail('data is required')
... ... @@ -22,7 +24,7 @@ export function render(data) {
22 24 const copy = byKind[data.kind]
23 25 if (!copy) fail(`unsupported kind: ${String(data.kind)}`)
24 26 const url = requiredUrl(data.url)
25   - const fill = (/** @type {string} */ text) => text.replace(/\{(agent|task|question)\}/g, (_, key) => requiredText(data[key], key))
  27 + const fill = (/** @type {string} */ text) => text.replace(/\{(agent|task|question|summary)\}/g, (_, key) => requiredText(data[key], key))
26 28 const heading = fill(copy.heading)
27 29 const body = fill(copy.body)
28 30 const text = [heading, '', body, '', `${copy.action}: ${url}`].join('\n')
... ...
packages/email/test/voko-notice.test.js
... ... @@ -13,7 +13,8 @@ describe(&#39;voko-notice 模板&#39;, () =&gt; {
13 13  
14 14 it('缺字段、未知类型、非 https 链接直接报错', () => {
15 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/)
  16 + expect(() => renderTemplate('voko-notice', { locale: 'en', kind: 'weekly', agent: 'Scout', url: 'https://x.test' })).toThrow(/unsupported kind/)
  17 + expect(renderTemplate('voko-notice', { locale: 'zh', kind: 'digest', summary: 'Scout:结算 2 单', url: 'https://x.test' }).text).toContain('结算 2 单')
17 18 expect(() => renderTemplate('voko-notice', { locale: 'en', kind: 'claimed', agent: 'Scout', url: 'http://evil.test' })).toThrow(/must use https/)
18 19 })
19 20 })
... ...
packages/voko-access/src/index.js
... ... @@ -10,6 +10,6 @@
10 10 export { createAccessRequests, accessRequestFromRow, ACCESS_REQUEST_STATUSES } from './access-requests.js'
11 11 export { createAgentAccess, MAX_AGENTS_PER_OWNER, API_KEY_PREFIX } from './agent-access.js'
12 12 export { createOwnerSettings, DEFAULT_OWNER_SETTINGS } from './owner-settings.js'
13   -export { nextAllowedSendTime } from './quiet-hours.js'
  13 +export { nextAllowedSendTime, nextLocalHour } from './quiet-hours.js'
14 14 export { createWebhooks, signWebhook, isPublicAddress } from './webhooks.js'
15 15 export { default as accessDbSchema } from './db/tables/index.cjs'
... ...
packages/voko-access/src/owner-settings.js
1 1 const TABLE = 'vk_owner_settings'
2 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 })
  3 +/** 没有保存过设置的主人使用的产品默认值:Asia/Shanghai、23:00–08:00 安静、两类邮件都开。 */
  4 +export const DEFAULT_OWNER_SETTINGS = Object.freeze({ timezone: 'Asia/Shanghai', quiet_start_hour: 23, quiet_end_hour: 8, email_help: true, email_digest: true })
5 5  
6 6 /**
7 7 * @typedef {{ timezone: string, quiet_start_hour: number, quiet_end_hour: number, email_help: boolean, email_digest: boolean }} OwnerSettings
... ...
packages/voko-access/src/quiet-hours.js
... ... @@ -16,3 +16,17 @@ export function nextAllowedSendTime(now, { timezone, quiet_start_hour, quiet_end
16 16 const secondsLeft = ((quiet_end_hour - hour + 24) % 24) * 3600 - part('minute') * 60 - part('second')
17 17 return new Date(now.getTime() - now.getMilliseconds() + secondsLeft * 1000)
18 18 }
  19 +
  20 +/**
  21 + * 该时区下一个 `hour` 点整(严格晚于 now),用于每日定时通知。夏令时的近似同上。
  22 + * @param {Date} now
  23 + * @param {string} timezone
  24 + * @param {number} hour 0–23
  25 + * @returns {Date}
  26 + */
  27 +export function nextLocalHour(now, timezone, hour) {
  28 + const parts = new Intl.DateTimeFormat('en-US', { timeZone: timezone, hour: 'numeric', minute: 'numeric', second: 'numeric', hourCycle: 'h23' }).formatToParts(now)
  29 + const part = (/** @type {string} */ type) => Number(parts.find((item) => item.type === type).value)
  30 + const secondsLeft = ((hour - part('hour') + 24) % 24) * 3600 - part('minute') * 60 - part('second')
  31 + return new Date(now.getTime() - now.getMilliseconds() + (secondsLeft <= 0 ? secondsLeft + 86400 : secondsLeft) * 1000)
  32 +}
... ...
packages/voko-access/test/quiet-hours.test.js
1 1 import { describe, expect, it } from 'vitest'
2   -import { nextAllowedSendTime } from '../src/quiet-hours.js'
  2 +import { nextAllowedSendTime, nextLocalHour } from '../src/quiet-hours.js'
3 3  
4 4 const night = { timezone: 'Asia/Shanghai', quiet_start_hour: 23, quiet_end_hour: 8 }
5 5  
... ... @@ -26,3 +26,11 @@ describe(&#39;安静时段&#39;, () =&gt; {
26 26 expect(nextAllowedSendTime(noon, { timezone: 'UTC', quiet_start_hour: 9, quiet_end_hour: 9 })).toBe(noon)
27 27 })
28 28 })
  29 +
  30 +describe('下一个当地整点', () => {
  31 + it('当天还没到 → 今天;已过或正好 → 明天', () => {
  32 + expect(nextLocalHour(new Date('2026-10-12T00:30:00.000Z'), 'Asia/Shanghai', 9)).toEqual(new Date('2026-10-12T01:00:00.000Z'))
  33 + expect(nextLocalHour(new Date('2026-10-12T01:00:00.000Z'), 'Asia/Shanghai', 9)).toEqual(new Date('2026-10-13T01:00:00.000Z'))
  34 + expect(nextLocalHour(new Date('2026-10-12T05:20:10.250Z'), 'Asia/Shanghai', 9)).toEqual(new Date('2026-10-13T01:00:00.000Z'))
  35 + })
  36 +})
... ...
packages/voko-exchange/src/db/tables/index.cjs
... ... @@ -16,6 +16,7 @@ const tableModules = [
16 16 require('./vk_comments.cjs'),
17 17 require('./vk_events.cjs'),
18 18 require('./vk_jobs.cjs'),
  19 + require('./vk_agent_titles.cjs'),
19 20 ]
20 21  
21 22 const targetTables = tableModules.map(({ tableName, buildTable }) => [tableName, buildTable])
... ...
packages/voko-exchange/src/db/tables/vk_agent_titles.cjs 0 → 100644
  1 +const tableName = 'vk_agent_titles'
  2 +
  3 +function buildTable(t) {
  4 + t.string('id', 64).primary()
  5 + t.string('agent_id', 64).notNullable()
  6 + t.string('title', 32).notNullable()
  7 + t.string('period_key', 16).notNullable()
  8 + t.datetime('awarded_at', { precision: 3 }).notNullable()
  9 + t.unique(['agent_id', 'title', 'period_key'], { indexName: 'uq_vk_agent_titles_once' })
  10 + t.check("title in ('first_relay','streak_7','weekly_mvp')", [], 'ck_vk_agent_titles_title')
  11 +}
  12 +
  13 +module.exports = { tableName, buildTable }
... ...
packages/voko-exchange/src/db/tables/vk_guilds.cjs
... ... @@ -11,6 +11,9 @@ function buildTable(t) {
11 11 t.integer('piece_review_window_sec').unsigned().notNullable().defaultTo(86400)
12 12 t.integer('contest_review_window_sec').unsigned().notNullable().defaultTo(259200)
13 13 t.integer('transfer_ttl_sec').unsigned().notNullable().defaultTo(86400)
  14 + t.bigInteger('weekly_goal').unsigned().notNullable().defaultTo(0)
  15 + t.integer('level').unsigned().notNullable().defaultTo(1)
  16 + t.string('goal_reached_week', 10).nullable()
14 17 t.datetime('created_at', { precision: 3 }).notNullable()
15 18 t.datetime('updated_at', { precision: 3 }).nullable()
16 19 t.unique(['slug'], { indexName: 'uq_vk_guilds_slug' })
... ...
packages/voko-exchange/src/game.js 0 → 100644
  1 +import { randomUUID } from 'node:crypto'
  2 +
  3 +import { levelForXp, weekStart } from './level.js'
  4 +import { insertEvent } from './tasks.js'
  5 +
  6 +const EARNING_REASONS = Object.freeze(['vk_task_payout', 'vk_relay_share'])
  7 +const DAY_MS = 86400_000
  8 +
  9 +/**
  10 + * 结算后的成长结算,在结算事务里调用:收款人升级、接力称号、公会周目标。
  11 + * @param {import('knex').Knex} trx
  12 + * @param {{ id: string, guild_id: string }} task
  13 + * @param {string[]} recipient_agent_ids 本次结算的全部收款人(多于 1 人即发生了接力)
  14 + * @param {Date} now
  15 + */
  16 +export async function growAfterSettle(trx, task, recipient_agent_ids, now) {
  17 + for (const agent_id of recipient_agent_ids) {
  18 + const agent = await trx('vk_agents').where({ id: agent_id }).first('xp', 'level')
  19 + const level = levelForXp(agent.xp)
  20 + if (level > agent.level) {
  21 + await trx('vk_agents').where({ id: agent_id }).update({ level })
  22 + await insertEvent(trx, task, 'agent.level_up', agent_id, now)
  23 + }
  24 + if (recipient_agent_ids.length > 1) await awardTitle(trx, task, agent_id, 'first_relay', '', now)
  25 + }
  26 + const guild = await trx('vk_guilds').where({ id: task.guild_id }).forUpdate().first('weekly_goal', 'goal_reached_week')
  27 + const week = weekStart(now).toISOString().slice(0, 10)
  28 + if (guild.weekly_goal > 0 && guild.goal_reached_week !== week && (await weeklyProgress(trx, task.guild_id, now)) >= guild.weekly_goal) {
  29 + await trx('vk_guilds').where({ id: task.guild_id }).update({ goal_reached_week: week, updated_at: now }).increment('level', 1)
  30 + await insertEvent(trx, task, 'guild.goal_progress', null, now)
  31 + }
  32 +}
  33 +
  34 +/**
  35 + * 发称号;同一 Agent 同一称号同一周期只发一次(UNIQUE 裁决),重复时不再发事件。
  36 + * @param {import('knex').Knex} trx
  37 + * @param {{ id: string|null, guild_id: string }} task 事件挂靠的任务;与具体任务无关的称号 id 为 null
  38 + * @param {string} agent_id
  39 + * @param {'first_relay'|'streak_7'|'weekly_mvp'} title
  40 + * @param {string} period_key 一次性称号为空串;周称号为该周周一的日期
  41 + * @param {Date} now
  42 + */
  43 +async function awardTitle(trx, task, agent_id, title, period_key, now) {
  44 + if ((await trx('vk_agent_titles').where({ agent_id, title, period_key }).first('id')) !== undefined) return
  45 + await trx('vk_agent_titles').insert({ id: `ti_${randomUUID()}`, agent_id, title, period_key, awarded_at: now })
  46 + await insertEvent(trx, task, 'agent.title_awarded', agent_id, now)
  47 +}
  48 +
  49 +/**
  50 + * 公会本周已结算任务的赏金之和。
  51 + * @param {import('knex').Knex} runner
  52 + * @param {string} guild_id
  53 + * @param {Date} now
  54 + * @returns {Promise<number>}
  55 + */
  56 +async function weeklyProgress(runner, guild_id, now) {
  57 + const row = await runner('vk_tasks').where({ guild_id, status: 'settled' }).where('settled_at', '>=', weekStart(now)).sum({ points: 'reward' }).first()
  58 + return Number(row.points)
  59 +}
  60 +
  61 +/**
  62 + * 游戏层的只读视图与每日结算:排行榜、公会城状态与动态、称号、日报数据。全部来自业务表与事件表,不另存一份。
  63 + * @param {{ db: import('knex').Knex }} deps
  64 + */
  65 +export function createGame({ db }) {
  66 + if (typeof db !== 'function') throw new Error('createGame: db is required')
  67 +
  68 + /**
  69 + * 各 Agent 在时间段内靠任务赚到的积分(任务收入 + 接力分成),与账本流水一致。
  70 + * @param {{ guild_id: string|null, since: Date|null, until: Date|null }} filter
  71 + */
  72 + function earnings({ guild_id, since, until }) {
  73 + const query = db('billing_ledger as l')
  74 + .join('vk_agents as a', 'a.wallet_user_id', 'l.user_id')
  75 + .where('l.source', 'voko')
  76 + .whereIn('l.reason', EARNING_REASONS)
  77 + .groupBy('a.id', 'a.name', 'a.level', 'a.guild_id')
  78 + .select('a.id as agent_id', 'a.name', 'a.level', 'a.guild_id', db.raw('SUM(l.amount) as points'))
  79 + if (guild_id !== null) query.where('a.guild_id', guild_id)
  80 + if (since !== null) query.where('l.created_at', '>=', since)
  81 + if (until !== null) query.where('l.created_at', '<', until)
  82 + return query
  83 + }
  84 +
  85 + /**
  86 + * @param {object} _ctx
  87 + * @param {{ agent: import('./agents.js').Agent, scope: 'guild'|'world', period: 'week'|'all' }} input
  88 + * @returns {Promise<Array<{ rank: number, agent_id: string, name: string, level: number, points: number }>>}
  89 + */
  90 + async function leaderboard(_ctx, { agent, scope, period }) {
  91 + const rows = await earnings({ guild_id: scope === 'guild' ? agent.guild_id : null, since: period === 'week' ? weekStart(new Date()) : null, until: null }).orderBy([{ column: 'points', order: 'desc' }, { column: 'a.id', order: 'asc' }]).limit(50)
  92 + return rows.map((row, index) => ({ rank: index + 1, agent_id: row.agent_id, name: row.name, level: row.level, points: Number(row.points) }))
  93 + }
  94 +
  95 + /**
  96 + * 公会城当前状态:公会等级与本周目标进度,各 Agent 的状态(求助中 / 工作中 / 空闲)与称号。
  97 + * @param {object} _ctx
  98 + * @param {{ guild_id: string }} input
  99 + */
  100 + async function worldState(_ctx, { guild_id }) {
  101 + const now = new Date()
  102 + const [guild, agents, working, asking, titles] = await Promise.all([
  103 + db('vk_guilds').where({ id: guild_id }).first('id', 'name', 'level', 'weekly_goal'),
  104 + db('vk_agents').where({ guild_id, status: 'active' }).orderBy('created_at', 'asc').select('id', 'name', 'role', 'level', 'xp'),
  105 + db('vk_tasks').where({ guild_id }).whereIn('status', ['claimed', 'rework']).select('holder_agent_id'),
  106 + db('vk_help_requests as h').join('vk_tasks as t', 't.id', 'h.task_id').where({ 't.guild_id': guild_id, 'h.status': 'open' }).select('h.agent_id'),
  107 + db('vk_agent_titles as ti').join('vk_agents as a', 'a.id', 'ti.agent_id').where('a.guild_id', guild_id).select('ti.agent_id', 'ti.title'),
  108 + ])
  109 + if (guild === undefined) throw new Error(`vk_guilds row missing, id: ${guild_id}`)
  110 + const busy = new Set(working.map((row) => row.holder_agent_id))
  111 + const stuck = new Set(asking.map((row) => row.agent_id))
  112 + return {
  113 + guild: { id: guild.id, name: guild.name, level: guild.level, weekly_goal: guild.weekly_goal, weekly_progress: await weeklyProgress(db, guild_id, now) },
  114 + agents: agents.map((agent) => ({
  115 + ...agent,
  116 + state: stuck.has(agent.id) ? 'help' : busy.has(agent.id) ? 'working' : 'idle',
  117 + titles: [...new Set(titles.filter((row) => row.agent_id === agent.id).map((row) => row.title))],
  118 + })),
  119 + }
  120 + }
  121 +
  122 + /**
  123 + * 公会动态流:同一时刻发给多方的同一事件只出现一次;带任务标题与当事 Agent,供前端翻译成动画。
  124 + * @param {object} _ctx
  125 + * @param {{ guild_id: string, since: number }} input
  126 + */
  127 + async function worldFeed(_ctx, { guild_id, since }) {
  128 + const events = await db('vk_events as e')
  129 + .leftJoin('vk_tasks as t', 't.id', 'e.task_id')
  130 + .where('e.guild_id', guild_id)
  131 + .where('e.id', '>', since)
  132 + .groupBy('e.type', 'e.task_id', 'e.created_at', 't.title', 't.reward', 't.rarity', 't.holder_agent_id', 't.publisher_agent_id')
  133 + .orderBy('id', 'asc')
  134 + .limit(100)
  135 + .select(db.raw('MIN(e.id) as id'), 'e.type', 'e.task_id', 'e.created_at', db.raw('MIN(e.target_agent_id) as target_agent_id'), 't.title', 't.reward', 't.rarity', 't.holder_agent_id', 't.publisher_agent_id')
  136 + return events.map((event) => ({ ...event, id: Number(event.id) }))
  137 + }
  138 +
  139 + /**
  140 + * 每日称号结算:连续 7 天有任务结算(streak_7);上一周各公会赚得最多的 Agent(weekly_mvp)。重复执行无害。
  141 + * @param {object} _ctx
  142 + * @returns {Promise<{ streak_7: number, weekly_mvp: number }>}
  143 + */
  144 + async function runDaily(_ctx) {
  145 + const now = new Date()
  146 + const today = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate()))
  147 + const streakers = await db('vk_escrow_payouts as p')
  148 + .join('vk_agents as a', 'a.wallet_user_id', 'p.recipient_wallet')
  149 + .whereIn('p.kind', ['winner', 'relay_share'])
  150 + .where('p.created_at', '>=', new Date(today.getTime() - 6 * DAY_MS))
  151 + .groupBy('a.id', 'a.guild_id')
  152 + .havingRaw('COUNT(DISTINCT DATE(p.created_at)) >= 7')
  153 + .select('a.id as agent_id', 'a.guild_id')
  154 + const thisWeek = weekStart(now)
  155 + const lastWeek = new Date(thisWeek.getTime() - 7 * DAY_MS)
  156 + const earned = await earnings({ guild_id: null, since: lastWeek, until: thisWeek }).orderBy([{ column: 'points', order: 'desc' }, { column: 'a.id', order: 'asc' }])
  157 + const mvps = new Map()
  158 + for (const row of earned) if (!mvps.has(row.guild_id)) mvps.set(row.guild_id, row)
  159 + await db.transaction(async (trx) => {
  160 + for (const row of streakers) await awardTitle(trx, { id: null, guild_id: row.guild_id }, row.agent_id, 'streak_7', '', now)
  161 + for (const row of mvps.values()) await awardTitle(trx, { id: null, guild_id: row.guild_id }, row.agent_id, 'weekly_mvp', lastWeek.toISOString().slice(0, 10), now)
  162 + })
  163 + return { streak_7: streakers.length, weekly_mvp: mvps.size }
  164 + }
  165 +
  166 + /**
  167 + * 日报数据:主人名下各 Agent 自 `since` 以来的结算数与赚取积分、当前在手任务数、等你回复的求助数。
  168 + * @param {object} _ctx
  169 + * @param {{ owner_user_id: string, since: Date }} input
  170 + */
  171 + async function digestFor(_ctx, { owner_user_id, since }) {
  172 + const agents = await db('vk_agents').where({ owner_user_id }).whereNot({ status: 'pending_claim' }).select('id', 'name', 'wallet_user_id', 'level')
  173 + const [earned, active, help] = await Promise.all([
  174 + db('billing_ledger').whereIn('user_id', agents.map((agent) => agent.wallet_user_id)).where({ source: 'voko' }).whereIn('reason', EARNING_REASONS).where('created_at', '>=', since).groupBy('user_id').select('user_id', db.raw('SUM(amount) as points'), db.raw('COUNT(*) as settled')),
  175 + db('vk_tasks').whereIn('holder_agent_id', agents.map((agent) => agent.id)).whereIn('status', ['claimed', 'rework', 'submitted']).groupBy('holder_agent_id').select('holder_agent_id', db.raw('COUNT(*) as n')),
  176 + db('vk_help_requests').where({ owner_user_id, status: 'open' }).count({ n: '*' }).first(),
  177 + ])
  178 + return {
  179 + open_help: Number(help.n),
  180 + agents: agents.map((agent) => {
  181 + const income = earned.find((row) => row.user_id === agent.wallet_user_id)
  182 + const holding = active.find((row) => row.holder_agent_id === agent.id)
  183 + return { name: agent.name, level: agent.level, settled: income === undefined ? 0 : Number(income.settled), points: income === undefined ? 0 : Number(income.points), active_tasks: holding === undefined ? 0 : Number(holding.n) }
  184 + }),
  185 + }
  186 + }
  187 +
  188 + /**
  189 + * 数据看板(PRD 指标):完成任务数、赚取积分、平均交付周期、一次通过率,以及当前在途与争议数。
  190 + * @param {object} _ctx
  191 + */
  192 + async function dashboard(_ctx) {
  193 + const [settled, earned, statuses, agents, disputes] = await Promise.all([
  194 + db('vk_tasks').where({ status: 'settled' }).select(db.raw('COUNT(*) as n'), db.raw('SUM(rework_count = 0) as first_pass'), db.raw('AVG(TIMESTAMPDIFF(SECOND, created_at, settled_at)) as avg_cycle_sec')).first(),
  195 + db('billing_ledger').where({ source: 'voko' }).whereIn('reason', EARNING_REASONS).sum({ points: 'amount' }).first(),
  196 + db('vk_tasks').groupBy('status').select('status', db.raw('COUNT(*) as n')),
  197 + db('vk_agents').groupBy('status').select('status', db.raw('COUNT(*) as n')),
  198 + db('vk_disputes').whereNot({ status: 'resolved' }).count({ n: '*' }).first(),
  199 + ])
  200 + const settledCount = Number(settled.n)
  201 + return {
  202 + tasks_settled: settledCount,
  203 + points_earned: Number(earned.points),
  204 + avg_delivery_hours: settledCount === 0 ? null : Math.round(Number(settled.avg_cycle_sec) / 360) / 10,
  205 + first_pass_rate: settledCount === 0 ? null : Math.round((Number(settled.first_pass) / settledCount) * 1000) / 1000,
  206 + open_disputes: Number(disputes.n),
  207 + tasks_by_status: Object.fromEntries(statuses.map((row) => [row.status, Number(row.n)])),
  208 + agents_by_status: Object.fromEntries(agents.map((row) => [row.status, Number(row.n)])),
  209 + }
  210 + }
  211 +
  212 + return { leaderboard, worldState, worldFeed, runDaily, digestFor, dashboard }
  213 +}
... ...
packages/voko-exchange/src/guilds.js
... ... @@ -38,5 +38,26 @@ export function createGuilds({ db }) {
38 38 return db(TABLE).orderBy('name', 'asc').select('id', 'name', 'slug')
39 39 }
40 40  
41   - return { create, list }
  41 + /**
  42 + * 运营视角的公会列表:负责人、公司 Agent 及其钱包、等级与周目标。
  43 + * @param {object} _ctx
  44 + */
  45 + async function listForAdmin(_ctx) {
  46 + return db(`${TABLE} as g`)
  47 + .leftJoin('vk_agents as a', 'a.id', 'g.company_agent_id')
  48 + .leftJoin('users as u', 'u.id', 'g.lead_user_id')
  49 + .orderBy('g.name', 'asc')
  50 + .select('g.id', 'g.name', 'g.slug', 'g.level', 'g.weekly_goal', 'g.require_lead_approval', 'u.email as lead_email', 'a.id as company_agent_id', 'a.name as company_agent_name', 'a.wallet_user_id as company_wallet_user_id')
  51 + }
  52 +
  53 + /**
  54 + * @param {object} _ctx
  55 + * @param {{ guild_id: string, weekly_goal: number }} input 0 表示不设周目标
  56 + * @returns {Promise<boolean>}
  57 + */
  58 + async function setWeeklyGoal(_ctx, { guild_id, weekly_goal }) {
  59 + return (await db(TABLE).where({ id: guild_id }).update({ weekly_goal, updated_at: new Date() })) === 1
  60 + }
  61 +
  62 + return { create, list, listForAdmin, setWeeklyGoal }
42 63 }
... ...
packages/voko-exchange/src/index.js
... ... @@ -12,6 +12,8 @@ export { createTasks } from &#39;./tasks.js&#39;
12 12 export { createEvents } from './events.js'
13 13 export { createJobs } from './jobs.js'
14 14 export { createHelp } from './help.js'
  15 +export { createGame } from './game.js'
  16 +export { levelForXp, weekStart, MAX_LEVEL } from './level.js'
15 17 export { createJudge } from './judge.js'
16 18 export { rarityForReward, maxRarityForLevel } from './rarity.js'
17 19 export { taskFromRow, TASK_STATUSES, TASK_TEMPLATES } from './task.js'
... ...
packages/voko-exchange/src/jobs.js
  1 +import { randomUUID } from 'node:crypto'
  2 +
1 3 const JOBS = 'vk_jobs'
2 4 const BATCH = 20
3 5 const LEASE_MS = 60_000
... ... @@ -22,11 +24,13 @@ const WARN_KINDS = Object.freeze([&#39;checkpoint_warn&#39;, &#39;review_warn&#39;])
22 24 * arbitrate(ctx: object, input: { task_id: string }): Promise<void>,
23 25 * },
24 26 * help: { notify(ctx: object, input: { help_id: string }): Promise<void> },
  27 + * sendDigest(ctx: object, input: { user_id: string }): Promise<void>,
  28 + * runDailyGame(ctx: object): Promise<unknown>,
25 29 * conservationProblems(): Promise<Array<{ rule: string, id: string, detail: string }>>,
26 30 * logger: { error(message: string, fields: object): void },
27 31 * }} deps
28 32 */
29   -export function createJobs({ db, tasks, help, conservationProblems, logger }) {
  33 +export function createJobs({ db, tasks, help, sendDigest, runDailyGame, conservationProblems, logger }) {
30 34 if (typeof db !== 'function') throw new Error('createJobs: db is required')
31 35  
32 36 /**
... ... @@ -79,8 +83,10 @@ export function createJobs({ db, tasks, help, conservationProblems, logger }) {
79 83 if (problems.length > 0) throw new Error(`conservation check failed: ${JSON.stringify(problems.slice(0, 20))}`)
80 84 return
81 85 }
  86 + if (job.kind === 'daily_game') return void await runDailyGame(ctx)
82 87 if (job.ref_id === null) throw new Error(`vk_jobs.ref_id missing for ${job.kind}, id: ${job.id}`)
83 88 const task_id = job.ref_id
  89 + if (job.kind === 'daily_digest') return sendDigest(ctx, { user_id: job.ref_id })
84 90 if (TASK_ACTION_KINDS.includes(job.kind)) return void await tasks.actAsWorker(ctx, { task_id, action: { type: job.kind } })
85 91 if (WARN_KINDS.includes(job.kind)) return tasks.warn(ctx, { task_id, kind: job.kind })
86 92 if (job.kind === 'transfer_expire') return tasks.expireTransfers(ctx, { task_id })
... ... @@ -98,18 +104,29 @@ export function createJobs({ db, tasks, help, conservationProblems, logger }) {
98 104 }
99 105  
100 106 /**
101   - * 确保下一次每日对账(UTC 01:00)已经排上;重复调用无害(dedupe_key 按日期唯一)。
  107 + * 排一条 job;同一类型、同一对象、同一时刻只排一次(dedupe_key UNIQUE),重复调用无害。
102 108 * @param {object} _ctx
  109 + * @param {{ kind: string, ref_id: string|null, run_at: Date }} input
103 110 */
104   - async function ensureDaily(_ctx) {
105   - const now = new Date()
106   - const next = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate(), 1))
107   - if (next <= now) next.setUTCDate(next.getUTCDate() + 1)
  111 + async function enqueue(_ctx, { kind, ref_id, run_at }) {
108 112 await db(JOBS)
109   - .insert({ id: `j_conservation_${next.toISOString().slice(0, 10)}`, kind: 'verify_conservation', ref_id: null, run_at: next, status: 'pending', attempts: 0, dedupe_key: `verify_conservation:${next.toISOString().slice(0, 10)}`, created_at: now })
  113 + .insert({ id: `j_${randomUUID()}`, kind, ref_id, run_at, status: 'pending', attempts: 0, dedupe_key: `${kind}:${ref_id === null ? '' : ref_id}:${run_at.toISOString()}`, created_at: new Date() })
110 114 .onConflict('dedupe_key')
111 115 .ignore()
112 116 }
113 117  
114   - return { runDue, ensureDaily }
  118 + /**
  119 + * 确保下一次每日对账(UTC 01:00)与每日称号结算(UTC 00:10)已经排上。
  120 + * @param {object} ctx
  121 + */
  122 + async function ensureDaily(ctx) {
  123 + const now = new Date()
  124 + for (const [kind, hour, minute] of /** @type {const} */ ([['verify_conservation', 1, 0], ['daily_game', 0, 10]])) {
  125 + const next = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate(), hour, minute))
  126 + if (next <= now) next.setUTCDate(next.getUTCDate() + 1)
  127 + await enqueue(ctx, { kind, ref_id: null, run_at: next })
  128 + }
  129 + }
  130 +
  131 + return { runDue, enqueue, ensureDaily }
115 132 }
... ...
packages/voko-exchange/src/level.js 0 → 100644
  1 +// ponytail: 等级门槛先写成常量(PRD:由运营配置);要在后台改时与稀有度区间一起换成配置表。
  2 +/** 第 n 级(从 1 起)所需的累计 XP。经验 = 结算到手的积分。 */
  3 +const LEVEL_MIN_XP = Object.freeze([0, 100, 300, 600, 1000, 1500, 2100, 2800, 3600, 4500])
  4 +
  5 +export const MAX_LEVEL = LEVEL_MIN_XP.length
  6 +
  7 +/**
  8 + * @param {number} xp 非负整数
  9 + * @returns {number} 1 – MAX_LEVEL
  10 + */
  11 +export function levelForXp(xp) {
  12 + if (!Number.isSafeInteger(xp) || xp < 0) throw new Error(`levelForXp: xp invalid: ${xp}`)
  13 + return LEVEL_MIN_XP.findLastIndex((min) => xp >= min) + 1
  14 +}
  15 +
  16 +/**
  17 + * 排行榜与周目标的周:周一 00:00 UTC 起算(每周一重置)。
  18 + * @param {Date} now
  19 + * @returns {Date}
  20 + */
  21 +export function weekStart(now) {
  22 + const day = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate()))
  23 + day.setUTCDate(day.getUTCDate() - ((day.getUTCDay() + 6) % 7))
  24 + return day
  25 +}
... ...
packages/voko-exchange/src/tasks.js
1 1 import { randomUUID } from 'node:crypto'
2 2  
  3 +import { growAfterSettle } from './game.js'
3 4 import { maxRarityForLevel, rarityForReward } from './rarity.js'
4 5 import { reviewViolation } from './review.js'
5 6 import { splitRelayPayout } from './split-relay.js'
... ... @@ -246,6 +247,7 @@ export function createTasks({ db, agents, escrow, judge }) {
246 247 await trx('vk_agents').where({ id: recipient.id }).increment('xp', share.amount)
247 248 }
248 249 await escrow.settle(ctx, trx, { task_id: task.id, payouts })
  250 + await growAfterSettle(trx, task, shares.map((share) => share.agent_id), new Date())
249 251 }
250 252  
251 253 /**
... ... @@ -686,7 +688,7 @@ async function acceptanceOf(runner, task_id) {
686 688  
687 689 /**
688 690 * @param {import('knex').Knex} trx
689   - * @param {{ id: string, guild_id: string }} task
  691 + * @param {{ id: string|null, guild_id: string }} task 与具体任务无关的事件 id 为 null
690 692 * @param {string} type
691 693 * @param {string|null} target_agent_id null 表示发给整个公会
692 694 * @param {Date} now
... ...
packages/voko-exchange/test/level.test.js 0 → 100644
  1 +import { describe, expect, it } from 'vitest'
  2 +import { levelForXp, MAX_LEVEL, weekStart } from '../src/level.js'
  3 +import { maxRarityForLevel } from '../src/rarity.js'
  4 +
  5 +describe('SM-03 levelForXp 与稀有度上限的边界值', () => {
  6 + it.each([[0, 1], [99, 1], [100, 2], [299, 2], [300, 3], [4499, 9], [4500, 10], [1_000_000, 10]])('xp %i → level %i', (xp, level) => {
  7 + expect(levelForXp(xp)).toBe(level)
  8 + })
  9 +
  10 + it('升级解锁更高稀有度:1 级到 3,2 级到 4,满级封顶 10', () => {
  11 + expect(maxRarityForLevel(levelForXp(99))).toBe(3)
  12 + expect(maxRarityForLevel(levelForXp(100))).toBe(4)
  13 + expect(maxRarityForLevel(levelForXp(4500))).toBe(10)
  14 + expect(MAX_LEVEL).toBe(10)
  15 + })
  16 +
  17 + it('非法 XP 直接 throw', () => {
  18 + expect(() => levelForXp(-1)).toThrow(/xp invalid/)
  19 + expect(() => levelForXp(1.5)).toThrow(/xp invalid/)
  20 + })
  21 +})
  22 +
  23 +describe('周起点:周一 00:00 UTC', () => {
  24 + it.each([
  25 + ['2026-10-12T00:00:00.000Z', '2026-10-12T00:00:00.000Z'],
  26 + ['2026-10-14T15:30:00.000Z', '2026-10-12T00:00:00.000Z'],
  27 + ['2026-10-18T23:59:59.999Z', '2026-10-12T00:00:00.000Z'],
  28 + ['2026-10-19T00:00:00.000Z', '2026-10-19T00:00:00.000Z'],
  29 + ])('%s → %s', (now, start) => {
  30 + expect(weekStart(new Date(now))).toEqual(new Date(start))
  31 + })
  32 +})
... ...