Commit 57d9cf5d5c3f1392fcbd3ec97bdb4888b5b1c697

Authored by yibo
1 parent 17bd7d21

feat(db): 22 张 vk_ 业务表与认领计数列

- 新包 @voko/exchange、@voko/clearing(当前只有表结构),@voko/access 增 3 张表
- migration 202610100001 建表,202610100002 给 vk_access_requests 加 claimed_agent_count(≤3)
- 稀有度为 1–10 的整数等级

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Showing 37 changed files with 898 additions and 1 deletions
apps/voko-server/package.json
... ... @@ -23,6 +23,8 @@
23 23 "@drama/observability": "workspace:*",
24 24 "@drama/server-kit": "workspace:*",
25 25 "@voko/access": "workspace:*",
  26 + "@voko/clearing": "workspace:*",
  27 + "@voko/exchange": "workspace:*",
26 28 "dotenv": "16.6.1",
27 29 "fastify": "^5.1.0",
28 30 "googleapis": "^173.0.0",
... ...
apps/voko-server/src/db/migrations/202610100001_vk_core_tables.js 0 → 100644
  1 +/** Alpha 的 22 张 vk_ 业务表(SDD「数据模型」);与各包 tables/*.cjs 的当前结构逐字一致。 */
  2 +export async function up(db) {
  3 + await db.schema.createTable('vk_guilds', (t) => {
  4 + t.string('id', 64).primary()
  5 + t.string('name', 120).notNullable()
  6 + t.string('slug', 64).notNullable()
  7 + t.string('lead_user_id', 64).notNullable()
  8 + t.string('company_agent_id', 64).nullable()
  9 + t.boolean('require_lead_approval').notNullable()
  10 + t.integer('checkpoint_interval_sec').unsigned().notNullable().defaultTo(21600)
  11 + t.integer('piece_review_window_sec').unsigned().notNullable().defaultTo(86400)
  12 + t.integer('contest_review_window_sec').unsigned().notNullable().defaultTo(259200)
  13 + t.integer('transfer_ttl_sec').unsigned().notNullable().defaultTo(86400)
  14 + t.datetime('created_at', { precision: 3 }).notNullable()
  15 + t.datetime('updated_at', { precision: 3 }).nullable()
  16 + t.unique(['slug'], { indexName: 'uq_vk_guilds_slug' })
  17 + t.check('checkpoint_interval_sec > 0 and piece_review_window_sec > 0 and contest_review_window_sec > 0 and transfer_ttl_sec > 0', [], 'ck_vk_guilds_windows')
  18 + })
  19 +
  20 + await db.schema.createTable('vk_guild_members', (t) => {
  21 + t.string('id', 64).primary()
  22 + t.string('guild_id', 64).notNullable()
  23 + t.string('user_id', 64).notNullable()
  24 + t.string('role', 16).notNullable()
  25 + t.datetime('created_at', { precision: 3 }).notNullable()
  26 + t.unique(['guild_id', 'user_id'], { indexName: 'uq_vk_guild_members_guild_user' })
  27 + t.index(['user_id'], 'idx_vk_guild_members_user')
  28 + t.check("role in ('lead','member')", [], 'ck_vk_guild_members_role')
  29 + })
  30 +
  31 + await db.schema.createTable('vk_agents', (t) => {
  32 + t.string('id', 64).primary()
  33 + t.string('wallet_user_id', 64).notNullable()
  34 + t.string('owner_user_id', 64).nullable()
  35 + t.string('guild_id', 64).nullable()
  36 + t.string('role', 16).notNullable()
  37 + t.string('status', 16).notNullable()
  38 + t.string('name', 120).notNullable()
  39 + t.string('description', 1000).nullable()
  40 + t.json('skills').notNullable()
  41 + t.string('owner_email', 320).nullable()
  42 + t.integer('level').unsigned().notNullable()
  43 + t.bigInteger('xp').unsigned().notNullable()
  44 + t.string('webhook_url', 2048).nullable()
  45 + t.text('webhook_secret_enc').nullable()
  46 + t.bigInteger('daily_spend_limit').nullable()
  47 + t.bigInteger('events_cursor').unsigned().notNullable()
  48 + t.datetime('claimed_at', { precision: 3 }).nullable()
  49 + t.datetime('created_at', { precision: 3 }).notNullable()
  50 + t.datetime('updated_at', { precision: 3 }).nullable()
  51 + t.unique(['wallet_user_id'], { indexName: 'uq_vk_agents_wallet' })
  52 + t.index(['owner_user_id'], 'idx_vk_agents_owner')
  53 + t.index(['guild_id', 'status'], 'idx_vk_agents_guild_status')
  54 + t.check("role in ('company','member')", [], 'ck_vk_agents_role')
  55 + t.check("status in ('pending_claim','active','suspended')", [], 'ck_vk_agents_status')
  56 + t.check("(status = 'pending_claim' and owner_user_id is null and claimed_at is null and guild_id is null) or (status <> 'pending_claim' and owner_user_id is not null and claimed_at is not null and guild_id is not null)", [], 'ck_vk_agents_claim')
  57 + t.check('(webhook_url is null) = (webhook_secret_enc is null)', [], 'ck_vk_agents_webhook_pair')
  58 + t.check('level >= 1', [], 'ck_vk_agents_level')
  59 + t.check('daily_spend_limit is null or daily_spend_limit >= 0', [], 'ck_vk_agents_daily_spend_limit')
  60 + })
  61 +
  62 + await db.schema.createTable('vk_tasks', (t) => {
  63 + t.string('id', 64).primary()
  64 + t.string('guild_id', 64).notNullable()
  65 + t.string('publisher_agent_id', 64).notNullable()
  66 + t.string('template', 16).notNullable()
  67 + t.string('title', 200).notNullable()
  68 + t.text('spec').notNullable()
  69 + t.bigInteger('reward').notNullable()
  70 + t.tinyint('rarity').unsigned().notNullable()
  71 + t.datetime('deadline_at', { precision: 3 }).notNullable()
  72 + t.string('status', 24).notNullable()
  73 + t.string('client_request_key', 191).notNullable()
  74 + t.string('holder_agent_id', 64).nullable()
  75 + t.datetime('next_checkpoint_due_at', { precision: 3 }).nullable()
  76 + t.tinyint('missed_checkpoints').unsigned().notNullable()
  77 + t.tinyint('rework_count').unsigned().notNullable()
  78 + t.datetime('review_due_at', { precision: 3 }).nullable()
  79 + t.datetime('settled_at', { precision: 3 }).nullable()
  80 + t.datetime('created_at', { precision: 3 }).notNullable()
  81 + t.datetime('updated_at', { precision: 3 }).nullable()
  82 + t.unique(['publisher_agent_id', 'client_request_key'], { indexName: 'uq_vk_tasks_publisher_request' })
  83 + t.index(['guild_id', 'status', 'deadline_at'], 'idx_vk_tasks_board')
  84 + t.index(['publisher_agent_id', 'status'], 'idx_vk_tasks_publisher_status')
  85 + t.index(['holder_agent_id', 'status'], 'idx_vk_tasks_holder_status')
  86 + t.check("template in ('piecework','contest')", [], 'ck_vk_tasks_template')
  87 + t.check("status in ('pending_approval','open','claimed','in_contest','submitted','reviewing','rework','disputed','settled','cancelled','refunded')", [], 'ck_vk_tasks_status')
  88 + t.check('reward > 0', [], 'ck_vk_tasks_reward')
  89 + t.check('rarity between 1 and 10', [], 'ck_vk_tasks_rarity')
  90 + t.check('rework_count <= 2', [], 'ck_vk_tasks_rework_count')
  91 + t.check("status not in ('claimed','submitted','rework') or template = 'piecework'", [], 'ck_vk_tasks_piecework_status')
  92 + t.check("status not in ('in_contest','reviewing') or template = 'contest'", [], 'ck_vk_tasks_contest_status')
  93 + t.check("template <> 'contest' or holder_agent_id is null", [], 'ck_vk_tasks_contest_holder')
  94 + t.check("status <> 'claimed' or (holder_agent_id is not null and next_checkpoint_due_at is not null)", [], 'ck_vk_tasks_claimed')
  95 + t.check("status not in ('submitted','reviewing') or review_due_at is not null", [], 'ck_vk_tasks_review_due')
  96 + t.check("status <> 'settled' or settled_at is not null", [], 'ck_vk_tasks_settled')
  97 + })
  98 +
  99 + await db.schema.createTable('vk_task_acceptance_items', (t) => {
  100 + t.string('task_id', 64).notNullable()
  101 + t.string('item_key', 32).notNullable()
  102 + t.tinyint('ordinal').unsigned().notNullable()
  103 + t.string('text', 1000).notNullable()
  104 + t.primary(['task_id', 'item_key'])
  105 + t.unique(['task_id', 'ordinal'], { indexName: 'uq_vk_task_acceptance_items_ordinal' })
  106 + t.check('ordinal between 1 and 5', [], 'ck_vk_task_acceptance_items_ordinal')
  107 + })
  108 +
  109 + await db.schema.createTable('vk_task_holds', (t) => {
  110 + t.string('id', 64).primary()
  111 + t.string('task_id', 64).notNullable()
  112 + t.string('agent_id', 64).notNullable()
  113 + t.datetime('started_at', { precision: 3 }).notNullable()
  114 + t.datetime('ended_at', { precision: 3 }).nullable()
  115 + t.string('end_reason', 16).nullable()
  116 + t.boolean('has_valid_checkpoint').notNullable()
  117 + t.specificType('open_task_id', 'varchar(64) GENERATED ALWAYS AS (CASE WHEN ended_at IS NULL THEN task_id ELSE NULL END) STORED')
  118 + t.unique(['open_task_id'], { indexName: 'uq_vk_task_holds_open_task' })
  119 + t.index(['task_id', 'started_at'], 'idx_vk_task_holds_task')
  120 + t.index(['agent_id', 'ended_at'], 'idx_vk_task_holds_agent')
  121 + t.check("end_reason in ('submitted','released','timeout','transferred')", [], 'ck_vk_task_holds_end_reason')
  122 + t.check('(ended_at is null) = (end_reason is null)', [], 'ck_vk_task_holds_end_pair')
  123 + })
  124 +
  125 + await db.schema.createTable('vk_task_entries', (t) => {
  126 + t.string('id', 64).primary()
  127 + t.string('task_id', 64).notNullable()
  128 + t.string('agent_id', 64).notNullable()
  129 + t.datetime('created_at', { precision: 3 }).notNullable()
  130 + t.unique(['task_id', 'agent_id'], { indexName: 'uq_vk_task_entries_task_agent' })
  131 + t.index(['agent_id'], 'idx_vk_task_entries_agent')
  132 + })
  133 +
  134 + await db.schema.createTable('vk_checkpoints', (t) => {
  135 + t.string('id', 64).primary()
  136 + t.string('task_id', 64).notNullable()
  137 + t.string('hold_id', 64).notNullable()
  138 + t.string('agent_id', 64).notNullable()
  139 + t.tinyint('progress').unsigned().notNullable()
  140 + t.string('note', 2000).nullable()
  141 + t.json('links').notNullable()
  142 + t.datetime('created_at', { precision: 3 }).notNullable()
  143 + t.index(['task_id', 'created_at'], 'idx_vk_checkpoints_task')
  144 + t.index(['hold_id'], 'idx_vk_checkpoints_hold')
  145 + t.check('progress <= 100', [], 'ck_vk_checkpoints_progress')
  146 + })
  147 +
  148 + await db.schema.createTable('vk_submissions', (t) => {
  149 + t.string('id', 64).primary()
  150 + t.string('task_id', 64).notNullable()
  151 + t.string('agent_id', 64).notNullable()
  152 + t.tinyint('round').unsigned().notNullable()
  153 + t.text('summary').notNullable()
  154 + t.json('artifacts').notNullable()
  155 + t.datetime('created_at', { precision: 3 }).notNullable()
  156 + t.unique(['task_id', 'agent_id', 'round'], { indexName: 'uq_vk_submissions_task_agent_round' })
  157 + t.check('round between 1 and 3', [], 'ck_vk_submissions_round')
  158 + })
  159 +
  160 + await db.schema.createTable('vk_reviews', (t) => {
  161 + t.string('id', 64).primary()
  162 + t.string('task_id', 64).notNullable()
  163 + t.string('submission_id', 64).notNullable()
  164 + t.string('reviewer_kind', 16).notNullable()
  165 + t.string('action', 16).notNullable()
  166 + t.text('reason').nullable()
  167 + t.datetime('created_at', { precision: 3 }).notNullable()
  168 + t.index(['task_id', 'created_at'], 'idx_vk_reviews_task')
  169 + t.index(['submission_id'], 'idx_vk_reviews_submission')
  170 + t.check("reviewer_kind in ('publisher','auto','arbitration')", [], 'ck_vk_reviews_reviewer_kind')
  171 + t.check("action in ('accept','reject','select_winner')", [], 'ck_vk_reviews_action')
  172 + })
  173 +
  174 + await db.schema.createTable('vk_review_checks', (t) => {
  175 + t.string('review_id', 64).notNullable()
  176 + t.string('item_key', 32).notNullable()
  177 + t.boolean('pass').notNullable()
  178 + t.string('reason', 1000).notNullable()
  179 + t.primary(['review_id', 'item_key'])
  180 + })
  181 +
  182 + await db.schema.createTable('vk_disputes', (t) => {
  183 + t.string('id', 64).primary()
  184 + t.string('task_id', 64).notNullable()
  185 + t.string('opened_by_agent_id', 64).notNullable()
  186 + t.string('status', 16).notNullable()
  187 + t.json('ai_proposal').nullable()
  188 + t.json('resolution').nullable()
  189 + t.string('resolved_by_user_id', 64).nullable()
  190 + t.datetime('resolved_at', { precision: 3 }).nullable()
  191 + t.datetime('created_at', { precision: 3 }).notNullable()
  192 + t.specificType('open_task_id', "varchar(64) GENERATED ALWAYS AS (CASE WHEN status <> 'resolved' THEN task_id ELSE NULL END) STORED")
  193 + t.unique(['open_task_id'], { indexName: 'uq_vk_disputes_open_task' })
  194 + t.index(['task_id'], 'idx_vk_disputes_task')
  195 + t.index(['status', 'created_at'], 'idx_vk_disputes_queue')
  196 + t.check("status in ('open','ai_proposed','resolved')", [], 'ck_vk_disputes_status')
  197 + t.check("status <> 'ai_proposed' or ai_proposal is not null", [], 'ck_vk_disputes_ai_proposal')
  198 + t.check("(status <> 'resolved' and resolution is null and resolved_by_user_id is null and resolved_at is null) or (status = 'resolved' and resolution is not null and resolved_by_user_id is not null and resolved_at is not null)", [], 'ck_vk_disputes_resolution')
  199 + })
  200 +
  201 + await db.schema.createTable('vk_transfers', (t) => {
  202 + t.string('id', 64).primary()
  203 + t.string('task_id', 64).notNullable()
  204 + t.string('from_agent_id', 64).notNullable()
  205 + t.string('to_agent_id', 64).notNullable()
  206 + t.string('status', 16).notNullable()
  207 + t.datetime('expires_at', { precision: 3 }).notNullable()
  208 + t.datetime('created_at', { precision: 3 }).notNullable()
  209 + t.datetime('resolved_at', { precision: 3 }).nullable()
  210 + t.specificType('pending_task_id', "varchar(64) GENERATED ALWAYS AS (CASE WHEN status = 'pending' THEN task_id ELSE NULL END) STORED")
  211 + t.unique(['pending_task_id'], { indexName: 'uq_vk_transfers_pending_task' })
  212 + t.index(['task_id'], 'idx_vk_transfers_task')
  213 + t.index(['to_agent_id', 'status'], 'idx_vk_transfers_to_agent')
  214 + t.check("status in ('pending','accepted','declined','expired')", [], 'ck_vk_transfers_status')
  215 + t.check("(status = 'pending') = (resolved_at is null)", [], 'ck_vk_transfers_resolved')
  216 + t.check('from_agent_id <> to_agent_id', [], 'ck_vk_transfers_parties')
  217 + })
  218 +
  219 + await db.schema.createTable('vk_help_requests', (t) => {
  220 + t.string('id', 64).primary()
  221 + t.string('task_id', 64).notNullable()
  222 + t.string('agent_id', 64).notNullable()
  223 + t.string('owner_user_id', 64).notNullable()
  224 + t.text('question').notNullable()
  225 + t.string('status', 16).notNullable()
  226 + t.text('answer').nullable()
  227 + t.datetime('answered_at', { precision: 3 }).nullable()
  228 + t.datetime('created_at', { precision: 3 }).notNullable()
  229 + t.index(['owner_user_id', 'status', 'created_at'], 'idx_vk_help_requests_inbox')
  230 + t.index(['task_id'], 'idx_vk_help_requests_task')
  231 + t.index(['agent_id', 'status'], 'idx_vk_help_requests_agent')
  232 + t.check("status in ('open','answered')", [], 'ck_vk_help_requests_status')
  233 + t.check("(status = 'open' and answer is null and answered_at is null) or (status = 'answered' and answer is not null and answered_at is not null)", [], 'ck_vk_help_requests_answer')
  234 + })
  235 +
  236 + await db.schema.createTable('vk_comments', (t) => {
  237 + t.string('id', 64).primary()
  238 + t.string('task_id', 64).notNullable()
  239 + t.string('author_agent_id', 64).notNullable()
  240 + t.text('body').notNullable()
  241 + t.datetime('created_at', { precision: 3 }).notNullable()
  242 + t.index(['task_id', 'created_at', 'id'], 'idx_vk_comments_task')
  243 + })
  244 +
  245 + await db.schema.createTable('vk_events', (t) => {
  246 + t.bigIncrements('id').unsigned().primary()
  247 + t.string('guild_id', 64).notNullable()
  248 + t.string('target_agent_id', 64).nullable()
  249 + t.string('type', 64).notNullable()
  250 + t.string('task_id', 64).nullable()
  251 + t.json('payload').notNullable()
  252 + t.datetime('created_at', { precision: 3 }).notNullable()
  253 + t.index(['target_agent_id', 'id'], 'idx_vk_events_target')
  254 + t.index(['guild_id', 'id'], 'idx_vk_events_guild')
  255 + t.index(['task_id', 'id'], 'idx_vk_events_task')
  256 + })
  257 +
  258 + await db.schema.createTable('vk_jobs', (t) => {
  259 + t.string('id', 64).primary()
  260 + t.string('kind', 32).notNullable()
  261 + t.string('ref_id', 64).nullable()
  262 + t.datetime('run_at', { precision: 3 }).notNullable()
  263 + t.string('status', 16).notNullable()
  264 + t.datetime('lease_until', { precision: 3 }).nullable()
  265 + t.tinyint('attempts').unsigned().notNullable()
  266 + t.string('dedupe_key', 191).notNullable()
  267 + t.text('last_error').nullable()
  268 + t.datetime('created_at', { precision: 3 }).notNullable()
  269 + t.datetime('updated_at', { precision: 3 }).nullable()
  270 + t.unique(['dedupe_key'], { indexName: 'uq_vk_jobs_dedupe' })
  271 + t.index(['status', 'run_at'], 'idx_vk_jobs_due')
  272 + t.check("status in ('pending','running','done','failed')", [], 'ck_vk_jobs_status')
  273 + t.check("status <> 'running' or lease_until is not null", [], 'ck_vk_jobs_lease')
  274 + })
  275 +
  276 + await db.schema.createTable('vk_escrows', (t) => {
  277 + t.string('id', 64).primary()
  278 + t.string('task_id', 64).notNullable()
  279 + t.string('payer_wallet', 64).notNullable()
  280 + t.bigInteger('amount').notNullable()
  281 + t.string('status', 16).notNullable()
  282 + t.string('deduct_ledger_id', 64).notNullable()
  283 + t.datetime('created_at', { precision: 3 }).notNullable()
  284 + t.datetime('closed_at', { precision: 3 }).nullable()
  285 + t.unique(['task_id'], { indexName: 'uq_vk_escrows_task' })
  286 + t.unique(['deduct_ledger_id'], { indexName: 'uq_vk_escrows_deduct_ledger' })
  287 + t.index(['payer_wallet', 'status'], 'idx_vk_escrows_payer_status')
  288 + t.check("status in ('held','released','refunded')", [], 'ck_vk_escrows_status')
  289 + t.check('amount > 0', [], 'ck_vk_escrows_amount')
  290 + t.check("(status = 'held') = (closed_at is null)", [], 'ck_vk_escrows_closed')
  291 + })
  292 +
  293 + await db.schema.createTable('vk_escrow_payouts', (t) => {
  294 + t.string('id', 64).primary()
  295 + t.string('escrow_id', 64).notNullable()
  296 + t.string('task_id', 64).notNullable()
  297 + t.string('recipient_wallet', 64).notNullable()
  298 + t.bigInteger('amount').notNullable()
  299 + t.string('kind', 16).notNullable()
  300 + t.string('ledger_id', 64).notNullable()
  301 + t.datetime('created_at', { precision: 3 }).notNullable()
  302 + t.unique(['escrow_id', 'recipient_wallet', 'kind'], { indexName: 'uq_vk_escrow_payouts_recipient_kind' })
  303 + t.unique(['ledger_id'], { indexName: 'uq_vk_escrow_payouts_ledger' })
  304 + t.index(['task_id'], 'idx_vk_escrow_payouts_task')
  305 + t.check("kind in ('winner','relay_share','refund')", [], 'ck_vk_escrow_payouts_kind')
  306 + t.check('amount > 0', [], 'ck_vk_escrow_payouts_amount')
  307 + })
  308 +
  309 + await db.schema.createTable('vk_agent_claims', (t) => {
  310 + t.string('id', 64).primary()
  311 + t.string('agent_id', 64).notNullable()
  312 + t.string('claim_token_hash', 64).notNullable()
  313 + t.string('verification_code', 16).notNullable()
  314 + t.datetime('expires_at', { precision: 3 }).notNullable()
  315 + t.string('claimed_by_user_id', 64).nullable()
  316 + t.datetime('claimed_at', { precision: 3 }).nullable()
  317 + t.datetime('created_at', { precision: 3 }).notNullable()
  318 + t.unique(['claim_token_hash'], { indexName: 'uq_vk_agent_claims_token' })
  319 + t.unique(['agent_id'], { indexName: 'uq_vk_agent_claims_agent' })
  320 + t.check('(claimed_by_user_id is null) = (claimed_at is null)', [], 'ck_vk_agent_claims_claimed_pair')
  321 + })
  322 +
  323 + await db.schema.createTable('vk_api_keys', (t) => {
  324 + t.string('id', 64).primary()
  325 + t.string('agent_id', 64).notNullable()
  326 + t.string('key_hash', 64).notNullable()
  327 + t.string('key_prefix', 16).notNullable()
  328 + t.datetime('created_at', { precision: 3 }).notNullable()
  329 + t.datetime('revoked_at', { precision: 3 }).nullable()
  330 + t.specificType('active_agent_id', 'varchar(64) GENERATED ALWAYS AS (CASE WHEN revoked_at IS NULL THEN agent_id ELSE NULL END) STORED')
  331 + t.unique(['key_hash'], { indexName: 'uq_vk_api_keys_hash' })
  332 + t.unique(['active_agent_id'], { indexName: 'uq_vk_api_keys_active_agent' })
  333 + t.index(['agent_id'], 'idx_vk_api_keys_agent')
  334 + })
  335 +
  336 + await db.schema.createTable('vk_webhook_deliveries', (t) => {
  337 + t.string('id', 64).primary()
  338 + t.bigInteger('event_id').unsigned().notNullable()
  339 + t.string('agent_id', 64).notNullable()
  340 + t.string('status', 16).notNullable()
  341 + t.tinyint('attempts').unsigned().notNullable()
  342 + t.datetime('next_attempt_at', { precision: 3 }).nullable()
  343 + t.datetime('created_at', { precision: 3 }).notNullable()
  344 + t.datetime('delivered_at', { precision: 3 }).nullable()
  345 + t.unique(['event_id', 'agent_id'], { indexName: 'uq_vk_webhook_deliveries_event_agent' })
  346 + t.index(['status', 'next_attempt_at'], 'idx_vk_webhook_deliveries_due')
  347 + t.check("status in ('pending','delivered','failed')", [], 'ck_vk_webhook_deliveries_status')
  348 + t.check("(status = 'pending') = (next_attempt_at is not null)", [], 'ck_vk_webhook_deliveries_next_attempt')
  349 + t.check("(status = 'delivered') = (delivered_at is not null)", [], 'ck_vk_webhook_deliveries_delivered')
  350 + })
  351 +}
  352 +
  353 +export async function down(db) {
  354 + await db.schema.dropTable('vk_webhook_deliveries')
  355 + await db.schema.dropTable('vk_api_keys')
  356 + await db.schema.dropTable('vk_agent_claims')
  357 + await db.schema.dropTable('vk_escrow_payouts')
  358 + await db.schema.dropTable('vk_escrows')
  359 + await db.schema.dropTable('vk_jobs')
  360 + await db.schema.dropTable('vk_events')
  361 + await db.schema.dropTable('vk_comments')
  362 + await db.schema.dropTable('vk_help_requests')
  363 + await db.schema.dropTable('vk_transfers')
  364 + await db.schema.dropTable('vk_disputes')
  365 + await db.schema.dropTable('vk_review_checks')
  366 + await db.schema.dropTable('vk_reviews')
  367 + await db.schema.dropTable('vk_submissions')
  368 + await db.schema.dropTable('vk_checkpoints')
  369 + await db.schema.dropTable('vk_task_entries')
  370 + await db.schema.dropTable('vk_task_holds')
  371 + await db.schema.dropTable('vk_task_acceptance_items')
  372 + await db.schema.dropTable('vk_tasks')
  373 + await db.schema.dropTable('vk_agents')
  374 + await db.schema.dropTable('vk_guild_members')
  375 + await db.schema.dropTable('vk_guilds')
  376 +}
... ...
apps/voko-server/src/db/migrations/202610100002_access_request_agent_count.js 0 → 100644
  1 +/** 一人最多认领 3 个 Agent:计数列 + CHECK,认领事务里自增,超出由 DB 拒绝。 */
  2 +const TABLE = 'vk_access_requests'
  3 +
  4 +export async function up(db) {
  5 + await db.schema.alterTable(TABLE, (t) => {
  6 + t.tinyint('claimed_agent_count').unsigned().notNullable().defaultTo(0)
  7 + t.check("claimed_agent_count <= 3 and (status = 'approved' or claimed_agent_count = 0)", [], 'ck_vk_access_requests_agent_count')
  8 + })
  9 +}
  10 +
  11 +export async function down(db) {
  12 + await db.raw('ALTER TABLE ?? DROP CHECK ??', [TABLE, 'ck_vk_access_requests_agent_count'])
  13 + await db.schema.alterTable(TABLE, (t) => t.dropColumn('claimed_agent_count'))
  14 +}
... ...
apps/voko-server/src/db/schema.js
... ... @@ -3,6 +3,8 @@ import { billingDbSchema } from &#39;@drama/billing&#39;
3 3 import { dbSchema } from '@drama/db'
4 4 import { emailDbSchema } from '@drama/email'
5 5 import { accessDbSchema } from '@voko/access'
  6 +import { clearingDbSchema } from '@voko/clearing'
  7 +import { exchangeDbSchema } from '@voko/exchange'
6 8  
7 9 /** voko 库的当前完整结构(新空库 init 时按此建表)。 */
8 10 export const vokoTargetTables = [
... ... @@ -11,4 +13,6 @@ export const vokoTargetTables = [
11 13 ...billingDbSchema.targetTables,
12 14 ...emailDbSchema.targetTables,
13 15 ...accessDbSchema.targetTables,
  16 + ...exchangeDbSchema.targetTables,
  17 + ...clearingDbSchema.targetTables,
14 18 ]
... ...
packages/voko-access/src/access-requests.js
... ... @@ -15,6 +15,7 @@ export const ACCESS_REQUEST_STATUSES = Object.freeze([&#39;pending&#39;, &#39;approved&#39;, &#39;re
15 15 * created_at: Date,
16 16 * reviewed_by_user_id: string|null,
17 17 * reviewed_at: Date|null,
  18 + * claimed_agent_count: number,
18 19 * }} AccessRequest
19 20 */
20 21  
... ... @@ -41,6 +42,7 @@ export function accessRequestFromRow(row) {
41 42 throw new Error(`Unhandled status: ${status}, id: ${id}`)
42 43 }
43 44 if (!(row.created_at instanceof Date)) throw new Error(`vk_access_requests.created_at missing, id: ${id}`)
  45 + if (!Number.isInteger(row.claimed_agent_count)) throw new Error(`vk_access_requests.claimed_agent_count invalid, id: ${id}`)
44 46 return {
45 47 id,
46 48 user_id: requiredString(row.user_id, 'user_id', id),
... ... @@ -50,6 +52,7 @@ export function accessRequestFromRow(row) {
50 52 created_at: row.created_at,
51 53 reviewed_by_user_id: row.reviewed_by_user_id,
52 54 reviewed_at: row.reviewed_at,
  55 + claimed_agent_count: row.claimed_agent_count,
53 56 }
54 57 }
55 58  
... ... @@ -71,7 +74,7 @@ export function createAccessRequests({ db, notifier }) {
71 74 */
72 75 async function submit(ctx, { user_id, email, note }) {
73 76 await db(TABLE)
74   - .insert({ id: `ar_${randomUUID()}`, user_id, email, note, status: 'pending', created_at: new Date(), reviewed_by_user_id: null, reviewed_at: null })
  77 + .insert({ id: `ar_${randomUUID()}`, user_id, email, note, status: 'pending', created_at: new Date(), reviewed_by_user_id: null, reviewed_at: null, claimed_agent_count: 0 })
75 78 .onConflict('user_id')
76 79 .ignore()
77 80 return getForUser(ctx, { user_id })
... ...
packages/voko-access/src/db/tables/index.cjs
1 1 const tableModules = [
2 2 require('./vk_access_requests.cjs'),
  3 + require('./vk_agent_claims.cjs'),
  4 + require('./vk_api_keys.cjs'),
  5 + require('./vk_webhook_deliveries.cjs'),
3 6 ]
4 7  
5 8 const targetTables = tableModules.map(({ tableName, buildTable }) => [tableName, buildTable])
... ...
packages/voko-access/src/db/tables/vk_access_requests.cjs
... ... @@ -9,10 +9,12 @@ function buildTable(t) {
9 9 t.datetime('created_at', { precision: 3 }).notNullable()
10 10 t.string('reviewed_by_user_id', 64).nullable()
11 11 t.datetime('reviewed_at', { precision: 3 }).nullable()
  12 + t.tinyint('claimed_agent_count').unsigned().notNullable().defaultTo(0)
12 13 t.unique(['user_id'], { indexName: 'uq_vk_access_requests_user' })
13 14 t.index(['status', 'created_at', 'id'], 'idx_vk_access_requests_queue')
14 15 t.check("status in ('pending','approved','rejected')", [], 'ck_vk_access_requests_status')
15 16 t.check("(status = 'pending' and reviewed_at is null and reviewed_by_user_id is null) or (status <> 'pending' and reviewed_at is not null and reviewed_by_user_id is not null)", [], 'ck_vk_access_requests_review')
  17 + t.check("claimed_agent_count <= 3 and (status = 'approved' or claimed_agent_count = 0)", [], 'ck_vk_access_requests_agent_count')
16 18 }
17 19  
18 20 module.exports = { tableName, buildTable }
... ...
packages/voko-access/src/db/tables/vk_agent_claims.cjs 0 → 100644
  1 +const tableName = 'vk_agent_claims'
  2 +
  3 +function buildTable(t) {
  4 + t.string('id', 64).primary()
  5 + t.string('agent_id', 64).notNullable()
  6 + t.string('claim_token_hash', 64).notNullable()
  7 + t.string('verification_code', 16).notNullable()
  8 + t.datetime('expires_at', { precision: 3 }).notNullable()
  9 + t.string('claimed_by_user_id', 64).nullable()
  10 + t.datetime('claimed_at', { precision: 3 }).nullable()
  11 + t.datetime('created_at', { precision: 3 }).notNullable()
  12 + t.unique(['claim_token_hash'], { indexName: 'uq_vk_agent_claims_token' })
  13 + t.unique(['agent_id'], { indexName: 'uq_vk_agent_claims_agent' })
  14 + t.check('(claimed_by_user_id is null) = (claimed_at is null)', [], 'ck_vk_agent_claims_claimed_pair')
  15 +}
  16 +
  17 +module.exports = { tableName, buildTable }
... ...
packages/voko-access/src/db/tables/vk_api_keys.cjs 0 → 100644
  1 +const tableName = 'vk_api_keys'
  2 +
  3 +function buildTable(t) {
  4 + t.string('id', 64).primary()
  5 + t.string('agent_id', 64).notNullable()
  6 + t.string('key_hash', 64).notNullable()
  7 + t.string('key_prefix', 16).notNullable()
  8 + t.datetime('created_at', { precision: 3 }).notNullable()
  9 + t.datetime('revoked_at', { precision: 3 }).nullable()
  10 + t.specificType('active_agent_id', 'varchar(64) GENERATED ALWAYS AS (CASE WHEN revoked_at IS NULL THEN agent_id ELSE NULL END) STORED')
  11 + t.unique(['key_hash'], { indexName: 'uq_vk_api_keys_hash' })
  12 + t.unique(['active_agent_id'], { indexName: 'uq_vk_api_keys_active_agent' })
  13 + t.index(['agent_id'], 'idx_vk_api_keys_agent')
  14 +}
  15 +
  16 +module.exports = { tableName, buildTable }
... ...
packages/voko-access/src/db/tables/vk_webhook_deliveries.cjs 0 → 100644
  1 +const tableName = 'vk_webhook_deliveries'
  2 +
  3 +function buildTable(t) {
  4 + t.string('id', 64).primary()
  5 + t.bigInteger('event_id').unsigned().notNullable()
  6 + t.string('agent_id', 64).notNullable()
  7 + t.string('status', 16).notNullable()
  8 + t.tinyint('attempts').unsigned().notNullable()
  9 + t.datetime('next_attempt_at', { precision: 3 }).nullable()
  10 + t.datetime('created_at', { precision: 3 }).notNullable()
  11 + t.datetime('delivered_at', { precision: 3 }).nullable()
  12 + t.unique(['event_id', 'agent_id'], { indexName: 'uq_vk_webhook_deliveries_event_agent' })
  13 + t.index(['status', 'next_attempt_at'], 'idx_vk_webhook_deliveries_due')
  14 + t.check("status in ('pending','delivered','failed')", [], 'ck_vk_webhook_deliveries_status')
  15 + t.check("(status = 'pending') = (next_attempt_at is not null)", [], 'ck_vk_webhook_deliveries_next_attempt')
  16 + t.check("(status = 'delivered') = (delivered_at is not null)", [], 'ck_vk_webhook_deliveries_delivered')
  17 +}
  18 +
  19 +module.exports = { tableName, buildTable }
... ...
packages/voko-access/test/access-request-from-row.test.js
... ... @@ -7,6 +7,7 @@ const base = {
7 7 email: 'a@example.com',
8 8 note: null,
9 9 created_at: new Date('2026-10-10T00:00:00Z'),
  10 + claimed_agent_count: 0,
10 11 }
11 12  
12 13 describe('accessRequestFromRow 覆盖全部状态', () => {
... ...
packages/voko-clearing/package.json 0 → 100644
  1 +{
  2 + "name": "@voko/clearing",
  3 + "version": "0.0.0",
  4 + "private": true,
  5 + "type": "module",
  6 + "exports": {
  7 + ".": "./src/index.js"
  8 + }
  9 +}
... ...
packages/voko-clearing/src/db/tables/index.cjs 0 → 100644
  1 +const tableModules = [
  2 + require('./vk_escrows.cjs'),
  3 + require('./vk_escrow_payouts.cjs'),
  4 +]
  5 +
  6 +const targetTables = tableModules.map(({ tableName, buildTable }) => [tableName, buildTable])
  7 +
  8 +module.exports = { targetTables }
... ...
packages/voko-clearing/src/db/tables/vk_escrow_payouts.cjs 0 → 100644
  1 +const tableName = 'vk_escrow_payouts'
  2 +
  3 +function buildTable(t) {
  4 + t.string('id', 64).primary()
  5 + t.string('escrow_id', 64).notNullable()
  6 + t.string('task_id', 64).notNullable()
  7 + t.string('recipient_wallet', 64).notNullable()
  8 + t.bigInteger('amount').notNullable()
  9 + t.string('kind', 16).notNullable()
  10 + t.string('ledger_id', 64).notNullable()
  11 + t.datetime('created_at', { precision: 3 }).notNullable()
  12 + t.unique(['escrow_id', 'recipient_wallet', 'kind'], { indexName: 'uq_vk_escrow_payouts_recipient_kind' })
  13 + t.unique(['ledger_id'], { indexName: 'uq_vk_escrow_payouts_ledger' })
  14 + t.index(['task_id'], 'idx_vk_escrow_payouts_task')
  15 + t.check("kind in ('winner','relay_share','refund')", [], 'ck_vk_escrow_payouts_kind')
  16 + t.check('amount > 0', [], 'ck_vk_escrow_payouts_amount')
  17 +}
  18 +
  19 +module.exports = { tableName, buildTable }
... ...
packages/voko-clearing/src/db/tables/vk_escrows.cjs 0 → 100644
  1 +const tableName = 'vk_escrows'
  2 +
  3 +function buildTable(t) {
  4 + t.string('id', 64).primary()
  5 + t.string('task_id', 64).notNullable()
  6 + t.string('payer_wallet', 64).notNullable()
  7 + t.bigInteger('amount').notNullable()
  8 + t.string('status', 16).notNullable()
  9 + t.string('deduct_ledger_id', 64).notNullable()
  10 + t.datetime('created_at', { precision: 3 }).notNullable()
  11 + t.datetime('closed_at', { precision: 3 }).nullable()
  12 + t.unique(['task_id'], { indexName: 'uq_vk_escrows_task' })
  13 + t.unique(['deduct_ledger_id'], { indexName: 'uq_vk_escrows_deduct_ledger' })
  14 + t.index(['payer_wallet', 'status'], 'idx_vk_escrows_payer_status')
  15 + t.check("status in ('held','released','refunded')", [], 'ck_vk_escrows_status')
  16 + t.check('amount > 0', [], 'ck_vk_escrows_amount')
  17 + t.check("(status = 'held') = (closed_at is null)", [], 'ck_vk_escrows_closed')
  18 +}
  19 +
  20 +module.exports = { tableName, buildTable }
... ...
packages/voko-clearing/src/index.js 0 → 100644
  1 +/**
  2 + * `@voko/clearing` —— VOKO 的积分清算:托管与发放明细;动账一律经 `@drama/billing` 的账本。
  3 + *
  4 + * 当前只有表结构;托管、结算、退款 service 在 W2–W4 加入。
  5 + *
  6 + * 边界:不读 `process.env`、不建连接;db 由 composition root 注入。
  7 + */
  8 +
  9 +export { default as clearingDbSchema } from './db/tables/index.cjs'
... ...
packages/voko-exchange/package.json 0 → 100644
  1 +{
  2 + "name": "@voko/exchange",
  3 + "version": "0.0.0",
  4 + "private": true,
  5 + "type": "module",
  6 + "exports": {
  7 + ".": "./src/index.js"
  8 + }
  9 +}
... ...
packages/voko-exchange/src/db/tables/index.cjs 0 → 100644
  1 +const tableModules = [
  2 + require('./vk_guilds.cjs'),
  3 + require('./vk_guild_members.cjs'),
  4 + require('./vk_agents.cjs'),
  5 + require('./vk_tasks.cjs'),
  6 + require('./vk_task_acceptance_items.cjs'),
  7 + require('./vk_task_holds.cjs'),
  8 + require('./vk_task_entries.cjs'),
  9 + require('./vk_checkpoints.cjs'),
  10 + require('./vk_submissions.cjs'),
  11 + require('./vk_reviews.cjs'),
  12 + require('./vk_review_checks.cjs'),
  13 + require('./vk_disputes.cjs'),
  14 + require('./vk_transfers.cjs'),
  15 + require('./vk_help_requests.cjs'),
  16 + require('./vk_comments.cjs'),
  17 + require('./vk_events.cjs'),
  18 + require('./vk_jobs.cjs'),
  19 +]
  20 +
  21 +const targetTables = tableModules.map(({ tableName, buildTable }) => [tableName, buildTable])
  22 +
  23 +module.exports = { targetTables }
... ...
packages/voko-exchange/src/db/tables/vk_agents.cjs 0 → 100644
  1 +const tableName = 'vk_agents'
  2 +
  3 +function buildTable(t) {
  4 + t.string('id', 64).primary()
  5 + t.string('wallet_user_id', 64).notNullable()
  6 + t.string('owner_user_id', 64).nullable()
  7 + t.string('guild_id', 64).nullable()
  8 + t.string('role', 16).notNullable()
  9 + t.string('status', 16).notNullable()
  10 + t.string('name', 120).notNullable()
  11 + t.string('description', 1000).nullable()
  12 + t.json('skills').notNullable()
  13 + t.string('owner_email', 320).nullable()
  14 + t.integer('level').unsigned().notNullable()
  15 + t.bigInteger('xp').unsigned().notNullable()
  16 + t.string('webhook_url', 2048).nullable()
  17 + t.text('webhook_secret_enc').nullable()
  18 + t.bigInteger('daily_spend_limit').nullable()
  19 + t.bigInteger('events_cursor').unsigned().notNullable()
  20 + t.datetime('claimed_at', { precision: 3 }).nullable()
  21 + t.datetime('created_at', { precision: 3 }).notNullable()
  22 + t.datetime('updated_at', { precision: 3 }).nullable()
  23 + t.unique(['wallet_user_id'], { indexName: 'uq_vk_agents_wallet' })
  24 + t.index(['owner_user_id'], 'idx_vk_agents_owner')
  25 + t.index(['guild_id', 'status'], 'idx_vk_agents_guild_status')
  26 + t.check("role in ('company','member')", [], 'ck_vk_agents_role')
  27 + t.check("status in ('pending_claim','active','suspended')", [], 'ck_vk_agents_status')
  28 + t.check("(status = 'pending_claim' and owner_user_id is null and claimed_at is null and guild_id is null) or (status <> 'pending_claim' and owner_user_id is not null and claimed_at is not null and guild_id is not null)", [], 'ck_vk_agents_claim')
  29 + t.check('(webhook_url is null) = (webhook_secret_enc is null)', [], 'ck_vk_agents_webhook_pair')
  30 + t.check('level >= 1', [], 'ck_vk_agents_level')
  31 + t.check('daily_spend_limit is null or daily_spend_limit >= 0', [], 'ck_vk_agents_daily_spend_limit')
  32 +}
  33 +
  34 +module.exports = { tableName, buildTable }
... ...
packages/voko-exchange/src/db/tables/vk_checkpoints.cjs 0 → 100644
  1 +const tableName = 'vk_checkpoints'
  2 +
  3 +function buildTable(t) {
  4 + t.string('id', 64).primary()
  5 + t.string('task_id', 64).notNullable()
  6 + t.string('hold_id', 64).notNullable()
  7 + t.string('agent_id', 64).notNullable()
  8 + t.tinyint('progress').unsigned().notNullable()
  9 + t.string('note', 2000).nullable()
  10 + t.json('links').notNullable()
  11 + t.datetime('created_at', { precision: 3 }).notNullable()
  12 + t.index(['task_id', 'created_at'], 'idx_vk_checkpoints_task')
  13 + t.index(['hold_id'], 'idx_vk_checkpoints_hold')
  14 + t.check('progress <= 100', [], 'ck_vk_checkpoints_progress')
  15 +}
  16 +
  17 +module.exports = { tableName, buildTable }
... ...
packages/voko-exchange/src/db/tables/vk_comments.cjs 0 → 100644
  1 +const tableName = 'vk_comments'
  2 +
  3 +function buildTable(t) {
  4 + t.string('id', 64).primary()
  5 + t.string('task_id', 64).notNullable()
  6 + t.string('author_agent_id', 64).notNullable()
  7 + t.text('body').notNullable()
  8 + t.datetime('created_at', { precision: 3 }).notNullable()
  9 + t.index(['task_id', 'created_at', 'id'], 'idx_vk_comments_task')
  10 +}
  11 +
  12 +module.exports = { tableName, buildTable }
... ...
packages/voko-exchange/src/db/tables/vk_disputes.cjs 0 → 100644
  1 +const tableName = 'vk_disputes'
  2 +
  3 +function buildTable(t) {
  4 + t.string('id', 64).primary()
  5 + t.string('task_id', 64).notNullable()
  6 + t.string('opened_by_agent_id', 64).notNullable()
  7 + t.string('status', 16).notNullable()
  8 + t.json('ai_proposal').nullable()
  9 + t.json('resolution').nullable()
  10 + t.string('resolved_by_user_id', 64).nullable()
  11 + t.datetime('resolved_at', { precision: 3 }).nullable()
  12 + t.datetime('created_at', { precision: 3 }).notNullable()
  13 + t.specificType('open_task_id', "varchar(64) GENERATED ALWAYS AS (CASE WHEN status <> 'resolved' THEN task_id ELSE NULL END) STORED")
  14 + t.unique(['open_task_id'], { indexName: 'uq_vk_disputes_open_task' })
  15 + t.index(['task_id'], 'idx_vk_disputes_task')
  16 + t.index(['status', 'created_at'], 'idx_vk_disputes_queue')
  17 + t.check("status in ('open','ai_proposed','resolved')", [], 'ck_vk_disputes_status')
  18 + t.check("status <> 'ai_proposed' or ai_proposal is not null", [], 'ck_vk_disputes_ai_proposal')
  19 + t.check("(status <> 'resolved' and resolution is null and resolved_by_user_id is null and resolved_at is null) or (status = 'resolved' and resolution is not null and resolved_by_user_id is not null and resolved_at is not null)", [], 'ck_vk_disputes_resolution')
  20 +}
  21 +
  22 +module.exports = { tableName, buildTable }
... ...
packages/voko-exchange/src/db/tables/vk_events.cjs 0 → 100644
  1 +const tableName = 'vk_events'
  2 +
  3 +function buildTable(t) {
  4 + t.bigIncrements('id').unsigned().primary()
  5 + t.string('guild_id', 64).notNullable()
  6 + t.string('target_agent_id', 64).nullable()
  7 + t.string('type', 64).notNullable()
  8 + t.string('task_id', 64).nullable()
  9 + t.json('payload').notNullable()
  10 + t.datetime('created_at', { precision: 3 }).notNullable()
  11 + t.index(['target_agent_id', 'id'], 'idx_vk_events_target')
  12 + t.index(['guild_id', 'id'], 'idx_vk_events_guild')
  13 + t.index(['task_id', 'id'], 'idx_vk_events_task')
  14 +}
  15 +
  16 +module.exports = { tableName, buildTable }
... ...
packages/voko-exchange/src/db/tables/vk_guild_members.cjs 0 → 100644
  1 +const tableName = 'vk_guild_members'
  2 +
  3 +function buildTable(t) {
  4 + t.string('id', 64).primary()
  5 + t.string('guild_id', 64).notNullable()
  6 + t.string('user_id', 64).notNullable()
  7 + t.string('role', 16).notNullable()
  8 + t.datetime('created_at', { precision: 3 }).notNullable()
  9 + t.unique(['guild_id', 'user_id'], { indexName: 'uq_vk_guild_members_guild_user' })
  10 + t.index(['user_id'], 'idx_vk_guild_members_user')
  11 + t.check("role in ('lead','member')", [], 'ck_vk_guild_members_role')
  12 +}
  13 +
  14 +module.exports = { tableName, buildTable }
... ...
packages/voko-exchange/src/db/tables/vk_guilds.cjs 0 → 100644
  1 +const tableName = 'vk_guilds'
  2 +
  3 +function buildTable(t) {
  4 + t.string('id', 64).primary()
  5 + t.string('name', 120).notNullable()
  6 + t.string('slug', 64).notNullable()
  7 + t.string('lead_user_id', 64).notNullable()
  8 + t.string('company_agent_id', 64).nullable()
  9 + t.boolean('require_lead_approval').notNullable()
  10 + t.integer('checkpoint_interval_sec').unsigned().notNullable().defaultTo(21600)
  11 + t.integer('piece_review_window_sec').unsigned().notNullable().defaultTo(86400)
  12 + t.integer('contest_review_window_sec').unsigned().notNullable().defaultTo(259200)
  13 + t.integer('transfer_ttl_sec').unsigned().notNullable().defaultTo(86400)
  14 + t.datetime('created_at', { precision: 3 }).notNullable()
  15 + t.datetime('updated_at', { precision: 3 }).nullable()
  16 + t.unique(['slug'], { indexName: 'uq_vk_guilds_slug' })
  17 + t.check('checkpoint_interval_sec > 0 and piece_review_window_sec > 0 and contest_review_window_sec > 0 and transfer_ttl_sec > 0', [], 'ck_vk_guilds_windows')
  18 +}
  19 +
  20 +module.exports = { tableName, buildTable }
... ...
packages/voko-exchange/src/db/tables/vk_help_requests.cjs 0 → 100644
  1 +const tableName = 'vk_help_requests'
  2 +
  3 +function buildTable(t) {
  4 + t.string('id', 64).primary()
  5 + t.string('task_id', 64).notNullable()
  6 + t.string('agent_id', 64).notNullable()
  7 + t.string('owner_user_id', 64).notNullable()
  8 + t.text('question').notNullable()
  9 + t.string('status', 16).notNullable()
  10 + t.text('answer').nullable()
  11 + t.datetime('answered_at', { precision: 3 }).nullable()
  12 + t.datetime('created_at', { precision: 3 }).notNullable()
  13 + t.index(['owner_user_id', 'status', 'created_at'], 'idx_vk_help_requests_inbox')
  14 + t.index(['task_id'], 'idx_vk_help_requests_task')
  15 + t.index(['agent_id', 'status'], 'idx_vk_help_requests_agent')
  16 + t.check("status in ('open','answered')", [], 'ck_vk_help_requests_status')
  17 + t.check("(status = 'open' and answer is null and answered_at is null) or (status = 'answered' and answer is not null and answered_at is not null)", [], 'ck_vk_help_requests_answer')
  18 +}
  19 +
  20 +module.exports = { tableName, buildTable }
... ...
packages/voko-exchange/src/db/tables/vk_jobs.cjs 0 → 100644
  1 +const tableName = 'vk_jobs'
  2 +
  3 +function buildTable(t) {
  4 + t.string('id', 64).primary()
  5 + t.string('kind', 32).notNullable()
  6 + t.string('ref_id', 64).nullable()
  7 + t.datetime('run_at', { precision: 3 }).notNullable()
  8 + t.string('status', 16).notNullable()
  9 + t.datetime('lease_until', { precision: 3 }).nullable()
  10 + t.tinyint('attempts').unsigned().notNullable()
  11 + t.string('dedupe_key', 191).notNullable()
  12 + t.text('last_error').nullable()
  13 + t.datetime('created_at', { precision: 3 }).notNullable()
  14 + t.datetime('updated_at', { precision: 3 }).nullable()
  15 + t.unique(['dedupe_key'], { indexName: 'uq_vk_jobs_dedupe' })
  16 + t.index(['status', 'run_at'], 'idx_vk_jobs_due')
  17 + t.check("status in ('pending','running','done','failed')", [], 'ck_vk_jobs_status')
  18 + t.check("status <> 'running' or lease_until is not null", [], 'ck_vk_jobs_lease')
  19 +}
  20 +
  21 +module.exports = { tableName, buildTable }
... ...
packages/voko-exchange/src/db/tables/vk_review_checks.cjs 0 → 100644
  1 +const tableName = 'vk_review_checks'
  2 +
  3 +function buildTable(t) {
  4 + t.string('review_id', 64).notNullable()
  5 + t.string('item_key', 32).notNullable()
  6 + t.boolean('pass').notNullable()
  7 + t.string('reason', 1000).notNullable()
  8 + t.primary(['review_id', 'item_key'])
  9 +}
  10 +
  11 +module.exports = { tableName, buildTable }
... ...
packages/voko-exchange/src/db/tables/vk_reviews.cjs 0 → 100644
  1 +const tableName = 'vk_reviews'
  2 +
  3 +function buildTable(t) {
  4 + t.string('id', 64).primary()
  5 + t.string('task_id', 64).notNullable()
  6 + t.string('submission_id', 64).notNullable()
  7 + t.string('reviewer_kind', 16).notNullable()
  8 + t.string('action', 16).notNullable()
  9 + t.text('reason').nullable()
  10 + t.datetime('created_at', { precision: 3 }).notNullable()
  11 + t.index(['task_id', 'created_at'], 'idx_vk_reviews_task')
  12 + t.index(['submission_id'], 'idx_vk_reviews_submission')
  13 + t.check("reviewer_kind in ('publisher','auto','arbitration')", [], 'ck_vk_reviews_reviewer_kind')
  14 + t.check("action in ('accept','reject','select_winner')", [], 'ck_vk_reviews_action')
  15 +}
  16 +
  17 +module.exports = { tableName, buildTable }
... ...
packages/voko-exchange/src/db/tables/vk_submissions.cjs 0 → 100644
  1 +const tableName = 'vk_submissions'
  2 +
  3 +function buildTable(t) {
  4 + t.string('id', 64).primary()
  5 + t.string('task_id', 64).notNullable()
  6 + t.string('agent_id', 64).notNullable()
  7 + t.tinyint('round').unsigned().notNullable()
  8 + t.text('summary').notNullable()
  9 + t.json('artifacts').notNullable()
  10 + t.datetime('created_at', { precision: 3 }).notNullable()
  11 + t.unique(['task_id', 'agent_id', 'round'], { indexName: 'uq_vk_submissions_task_agent_round' })
  12 + t.check('round between 1 and 3', [], 'ck_vk_submissions_round')
  13 +}
  14 +
  15 +module.exports = { tableName, buildTable }
... ...
packages/voko-exchange/src/db/tables/vk_task_acceptance_items.cjs 0 → 100644
  1 +const tableName = 'vk_task_acceptance_items'
  2 +
  3 +function buildTable(t) {
  4 + t.string('task_id', 64).notNullable()
  5 + t.string('item_key', 32).notNullable()
  6 + t.tinyint('ordinal').unsigned().notNullable()
  7 + t.string('text', 1000).notNullable()
  8 + t.primary(['task_id', 'item_key'])
  9 + t.unique(['task_id', 'ordinal'], { indexName: 'uq_vk_task_acceptance_items_ordinal' })
  10 + t.check('ordinal between 1 and 5', [], 'ck_vk_task_acceptance_items_ordinal')
  11 +}
  12 +
  13 +module.exports = { tableName, buildTable }
... ...
packages/voko-exchange/src/db/tables/vk_task_entries.cjs 0 → 100644
  1 +const tableName = 'vk_task_entries'
  2 +
  3 +function buildTable(t) {
  4 + t.string('id', 64).primary()
  5 + t.string('task_id', 64).notNullable()
  6 + t.string('agent_id', 64).notNullable()
  7 + t.datetime('created_at', { precision: 3 }).notNullable()
  8 + t.unique(['task_id', 'agent_id'], { indexName: 'uq_vk_task_entries_task_agent' })
  9 + t.index(['agent_id'], 'idx_vk_task_entries_agent')
  10 +}
  11 +
  12 +module.exports = { tableName, buildTable }
... ...
packages/voko-exchange/src/db/tables/vk_task_holds.cjs 0 → 100644
  1 +const tableName = 'vk_task_holds'
  2 +
  3 +function buildTable(t) {
  4 + t.string('id', 64).primary()
  5 + t.string('task_id', 64).notNullable()
  6 + t.string('agent_id', 64).notNullable()
  7 + t.datetime('started_at', { precision: 3 }).notNullable()
  8 + t.datetime('ended_at', { precision: 3 }).nullable()
  9 + t.string('end_reason', 16).nullable()
  10 + t.boolean('has_valid_checkpoint').notNullable()
  11 + t.specificType('open_task_id', 'varchar(64) GENERATED ALWAYS AS (CASE WHEN ended_at IS NULL THEN task_id ELSE NULL END) STORED')
  12 + t.unique(['open_task_id'], { indexName: 'uq_vk_task_holds_open_task' })
  13 + t.index(['task_id', 'started_at'], 'idx_vk_task_holds_task')
  14 + t.index(['agent_id', 'ended_at'], 'idx_vk_task_holds_agent')
  15 + t.check("end_reason in ('submitted','released','timeout','transferred')", [], 'ck_vk_task_holds_end_reason')
  16 + t.check('(ended_at is null) = (end_reason is null)', [], 'ck_vk_task_holds_end_pair')
  17 +}
  18 +
  19 +module.exports = { tableName, buildTable }
... ...
packages/voko-exchange/src/db/tables/vk_tasks.cjs 0 → 100644
  1 +const tableName = 'vk_tasks'
  2 +
  3 +function buildTable(t) {
  4 + t.string('id', 64).primary()
  5 + t.string('guild_id', 64).notNullable()
  6 + t.string('publisher_agent_id', 64).notNullable()
  7 + t.string('template', 16).notNullable()
  8 + t.string('title', 200).notNullable()
  9 + t.text('spec').notNullable()
  10 + t.bigInteger('reward').notNullable()
  11 + t.tinyint('rarity').unsigned().notNullable()
  12 + t.datetime('deadline_at', { precision: 3 }).notNullable()
  13 + t.string('status', 24).notNullable()
  14 + t.string('client_request_key', 191).notNullable()
  15 + t.string('holder_agent_id', 64).nullable()
  16 + t.datetime('next_checkpoint_due_at', { precision: 3 }).nullable()
  17 + t.tinyint('missed_checkpoints').unsigned().notNullable()
  18 + t.tinyint('rework_count').unsigned().notNullable()
  19 + t.datetime('review_due_at', { precision: 3 }).nullable()
  20 + t.datetime('settled_at', { precision: 3 }).nullable()
  21 + t.datetime('created_at', { precision: 3 }).notNullable()
  22 + t.datetime('updated_at', { precision: 3 }).nullable()
  23 + t.unique(['publisher_agent_id', 'client_request_key'], { indexName: 'uq_vk_tasks_publisher_request' })
  24 + t.index(['guild_id', 'status', 'deadline_at'], 'idx_vk_tasks_board')
  25 + t.index(['publisher_agent_id', 'status'], 'idx_vk_tasks_publisher_status')
  26 + t.index(['holder_agent_id', 'status'], 'idx_vk_tasks_holder_status')
  27 + t.check("template in ('piecework','contest')", [], 'ck_vk_tasks_template')
  28 + t.check("status in ('pending_approval','open','claimed','in_contest','submitted','reviewing','rework','disputed','settled','cancelled','refunded')", [], 'ck_vk_tasks_status')
  29 + t.check('reward > 0', [], 'ck_vk_tasks_reward')
  30 + t.check('rarity between 1 and 10', [], 'ck_vk_tasks_rarity')
  31 + t.check('rework_count <= 2', [], 'ck_vk_tasks_rework_count')
  32 + t.check("status not in ('claimed','submitted','rework') or template = 'piecework'", [], 'ck_vk_tasks_piecework_status')
  33 + t.check("status not in ('in_contest','reviewing') or template = 'contest'", [], 'ck_vk_tasks_contest_status')
  34 + t.check("template <> 'contest' or holder_agent_id is null", [], 'ck_vk_tasks_contest_holder')
  35 + t.check("status <> 'claimed' or (holder_agent_id is not null and next_checkpoint_due_at is not null)", [], 'ck_vk_tasks_claimed')
  36 + t.check("status not in ('submitted','reviewing') or review_due_at is not null", [], 'ck_vk_tasks_review_due')
  37 + t.check("status <> 'settled' or settled_at is not null", [], 'ck_vk_tasks_settled')
  38 +}
  39 +
  40 +module.exports = { tableName, buildTable }
... ...
packages/voko-exchange/src/db/tables/vk_transfers.cjs 0 → 100644
  1 +const tableName = 'vk_transfers'
  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('to_agent_id', 64).notNullable()
  8 + t.string('status', 16).notNullable()
  9 + t.datetime('expires_at', { precision: 3 }).notNullable()
  10 + t.datetime('created_at', { precision: 3 }).notNullable()
  11 + t.datetime('resolved_at', { precision: 3 }).nullable()
  12 + t.specificType('pending_task_id', "varchar(64) GENERATED ALWAYS AS (CASE WHEN status = 'pending' THEN task_id ELSE NULL END) STORED")
  13 + t.unique(['pending_task_id'], { indexName: 'uq_vk_transfers_pending_task' })
  14 + t.index(['task_id'], 'idx_vk_transfers_task')
  15 + t.index(['to_agent_id', 'status'], 'idx_vk_transfers_to_agent')
  16 + t.check("status in ('pending','accepted','declined','expired')", [], 'ck_vk_transfers_status')
  17 + t.check("(status = 'pending') = (resolved_at is null)", [], 'ck_vk_transfers_resolved')
  18 + t.check('from_agent_id <> to_agent_id', [], 'ck_vk_transfers_parties')
  19 +}
  20 +
  21 +module.exports = { tableName, buildTable }
... ...
packages/voko-exchange/src/index.js 0 → 100644
  1 +/**
  2 + * `@voko/exchange` —— VOKO 的任务交换:公会、Agent、任务、检查点、提交、验收、评论、求助、事件与定时任务。
  3 + *
  4 + * 当前只有表结构;状态机与 service 在 W1–W3 加入。
  5 + *
  6 + * 边界:不读 `process.env`、不建连接;db 由 composition root 注入。
  7 + */
  8 +
  9 +export { default as exchangeDbSchema } from './db/tables/index.cjs'
... ...
pnpm-lock.yaml
... ... @@ -69,6 +69,12 @@ importers:
69 69 '@voko/access':
70 70 specifier: workspace:*
71 71 version: link:../../packages/voko-access
  72 + '@voko/clearing':
  73 + specifier: workspace:*
  74 + version: link:../../packages/voko-clearing
  75 + '@voko/exchange':
  76 + specifier: workspace:*
  77 + version: link:../../packages/voko-exchange
72 78 dotenv:
73 79 specifier: 16.6.1
74 80 version: 16.6.1
... ... @@ -286,6 +292,10 @@ importers:
286 292 specifier: ^2.1.4
287 293 version: 2.1.9(@types/node@24.13.2)(jsdom@25.0.1)(sass@1.101.0)
288 294  
  295 + packages/voko-clearing: {}
  296 +
  297 + packages/voko-exchange: {}
  298 +
289 299 packages:
290 300  
291 301 '@asamuzakjp/css-color@3.2.0':
... ...