diff --git a/apps/voko-server/package.json b/apps/voko-server/package.json index 7c0c3a3..7f09913 100644 --- a/apps/voko-server/package.json +++ b/apps/voko-server/package.json @@ -23,6 +23,8 @@ "@drama/observability": "workspace:*", "@drama/server-kit": "workspace:*", "@voko/access": "workspace:*", + "@voko/clearing": "workspace:*", + "@voko/exchange": "workspace:*", "dotenv": "16.6.1", "fastify": "^5.1.0", "googleapis": "^173.0.0", diff --git a/apps/voko-server/src/db/migrations/202610100001_vk_core_tables.js b/apps/voko-server/src/db/migrations/202610100001_vk_core_tables.js new file mode 100644 index 0000000..d066a77 --- /dev/null +++ b/apps/voko-server/src/db/migrations/202610100001_vk_core_tables.js @@ -0,0 +1,376 @@ +/** Alpha 的 22 张 vk_ 业务表(SDD「数据模型」);与各包 tables/*.cjs 的当前结构逐字一致。 */ +export async function up(db) { + await db.schema.createTable('vk_guilds', (t) => { + t.string('id', 64).primary() + t.string('name', 120).notNullable() + t.string('slug', 64).notNullable() + t.string('lead_user_id', 64).notNullable() + t.string('company_agent_id', 64).nullable() + t.boolean('require_lead_approval').notNullable() + t.integer('checkpoint_interval_sec').unsigned().notNullable().defaultTo(21600) + t.integer('piece_review_window_sec').unsigned().notNullable().defaultTo(86400) + t.integer('contest_review_window_sec').unsigned().notNullable().defaultTo(259200) + t.integer('transfer_ttl_sec').unsigned().notNullable().defaultTo(86400) + t.datetime('created_at', { precision: 3 }).notNullable() + t.datetime('updated_at', { precision: 3 }).nullable() + t.unique(['slug'], { indexName: 'uq_vk_guilds_slug' }) + 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') + }) + + await db.schema.createTable('vk_guild_members', (t) => { + t.string('id', 64).primary() + t.string('guild_id', 64).notNullable() + t.string('user_id', 64).notNullable() + t.string('role', 16).notNullable() + t.datetime('created_at', { precision: 3 }).notNullable() + t.unique(['guild_id', 'user_id'], { indexName: 'uq_vk_guild_members_guild_user' }) + t.index(['user_id'], 'idx_vk_guild_members_user') + t.check("role in ('lead','member')", [], 'ck_vk_guild_members_role') + }) + + await db.schema.createTable('vk_agents', (t) => { + t.string('id', 64).primary() + t.string('wallet_user_id', 64).notNullable() + t.string('owner_user_id', 64).nullable() + t.string('guild_id', 64).nullable() + t.string('role', 16).notNullable() + t.string('status', 16).notNullable() + t.string('name', 120).notNullable() + t.string('description', 1000).nullable() + t.json('skills').notNullable() + t.string('owner_email', 320).nullable() + t.integer('level').unsigned().notNullable() + t.bigInteger('xp').unsigned().notNullable() + t.string('webhook_url', 2048).nullable() + t.text('webhook_secret_enc').nullable() + t.bigInteger('daily_spend_limit').nullable() + t.bigInteger('events_cursor').unsigned().notNullable() + t.datetime('claimed_at', { precision: 3 }).nullable() + t.datetime('created_at', { precision: 3 }).notNullable() + t.datetime('updated_at', { precision: 3 }).nullable() + t.unique(['wallet_user_id'], { indexName: 'uq_vk_agents_wallet' }) + t.index(['owner_user_id'], 'idx_vk_agents_owner') + t.index(['guild_id', 'status'], 'idx_vk_agents_guild_status') + t.check("role in ('company','member')", [], 'ck_vk_agents_role') + t.check("status in ('pending_claim','active','suspended')", [], 'ck_vk_agents_status') + 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') + t.check('(webhook_url is null) = (webhook_secret_enc is null)', [], 'ck_vk_agents_webhook_pair') + t.check('level >= 1', [], 'ck_vk_agents_level') + t.check('daily_spend_limit is null or daily_spend_limit >= 0', [], 'ck_vk_agents_daily_spend_limit') + }) + + await db.schema.createTable('vk_tasks', (t) => { + t.string('id', 64).primary() + t.string('guild_id', 64).notNullable() + t.string('publisher_agent_id', 64).notNullable() + t.string('template', 16).notNullable() + t.string('title', 200).notNullable() + t.text('spec').notNullable() + t.bigInteger('reward').notNullable() + t.tinyint('rarity').unsigned().notNullable() + t.datetime('deadline_at', { precision: 3 }).notNullable() + t.string('status', 24).notNullable() + t.string('client_request_key', 191).notNullable() + t.string('holder_agent_id', 64).nullable() + t.datetime('next_checkpoint_due_at', { precision: 3 }).nullable() + t.tinyint('missed_checkpoints').unsigned().notNullable() + t.tinyint('rework_count').unsigned().notNullable() + t.datetime('review_due_at', { precision: 3 }).nullable() + t.datetime('settled_at', { precision: 3 }).nullable() + t.datetime('created_at', { precision: 3 }).notNullable() + t.datetime('updated_at', { precision: 3 }).nullable() + t.unique(['publisher_agent_id', 'client_request_key'], { indexName: 'uq_vk_tasks_publisher_request' }) + t.index(['guild_id', 'status', 'deadline_at'], 'idx_vk_tasks_board') + t.index(['publisher_agent_id', 'status'], 'idx_vk_tasks_publisher_status') + t.index(['holder_agent_id', 'status'], 'idx_vk_tasks_holder_status') + t.check("template in ('piecework','contest')", [], 'ck_vk_tasks_template') + t.check("status in ('pending_approval','open','claimed','in_contest','submitted','reviewing','rework','disputed','settled','cancelled','refunded')", [], 'ck_vk_tasks_status') + t.check('reward > 0', [], 'ck_vk_tasks_reward') + t.check('rarity between 1 and 10', [], 'ck_vk_tasks_rarity') + t.check('rework_count <= 2', [], 'ck_vk_tasks_rework_count') + t.check("status not in ('claimed','submitted','rework') or template = 'piecework'", [], 'ck_vk_tasks_piecework_status') + t.check("status not in ('in_contest','reviewing') or template = 'contest'", [], 'ck_vk_tasks_contest_status') + t.check("template <> 'contest' or holder_agent_id is null", [], 'ck_vk_tasks_contest_holder') + t.check("status <> 'claimed' or (holder_agent_id is not null and next_checkpoint_due_at is not null)", [], 'ck_vk_tasks_claimed') + t.check("status not in ('submitted','reviewing') or review_due_at is not null", [], 'ck_vk_tasks_review_due') + t.check("status <> 'settled' or settled_at is not null", [], 'ck_vk_tasks_settled') + }) + + await db.schema.createTable('vk_task_acceptance_items', (t) => { + t.string('task_id', 64).notNullable() + t.string('item_key', 32).notNullable() + t.tinyint('ordinal').unsigned().notNullable() + t.string('text', 1000).notNullable() + t.primary(['task_id', 'item_key']) + t.unique(['task_id', 'ordinal'], { indexName: 'uq_vk_task_acceptance_items_ordinal' }) + t.check('ordinal between 1 and 5', [], 'ck_vk_task_acceptance_items_ordinal') + }) + + await db.schema.createTable('vk_task_holds', (t) => { + t.string('id', 64).primary() + t.string('task_id', 64).notNullable() + t.string('agent_id', 64).notNullable() + t.datetime('started_at', { precision: 3 }).notNullable() + t.datetime('ended_at', { precision: 3 }).nullable() + t.string('end_reason', 16).nullable() + t.boolean('has_valid_checkpoint').notNullable() + t.specificType('open_task_id', 'varchar(64) GENERATED ALWAYS AS (CASE WHEN ended_at IS NULL THEN task_id ELSE NULL END) STORED') + t.unique(['open_task_id'], { indexName: 'uq_vk_task_holds_open_task' }) + t.index(['task_id', 'started_at'], 'idx_vk_task_holds_task') + t.index(['agent_id', 'ended_at'], 'idx_vk_task_holds_agent') + t.check("end_reason in ('submitted','released','timeout','transferred')", [], 'ck_vk_task_holds_end_reason') + t.check('(ended_at is null) = (end_reason is null)', [], 'ck_vk_task_holds_end_pair') + }) + + await db.schema.createTable('vk_task_entries', (t) => { + t.string('id', 64).primary() + t.string('task_id', 64).notNullable() + t.string('agent_id', 64).notNullable() + t.datetime('created_at', { precision: 3 }).notNullable() + t.unique(['task_id', 'agent_id'], { indexName: 'uq_vk_task_entries_task_agent' }) + t.index(['agent_id'], 'idx_vk_task_entries_agent') + }) + + await db.schema.createTable('vk_checkpoints', (t) => { + t.string('id', 64).primary() + t.string('task_id', 64).notNullable() + t.string('hold_id', 64).notNullable() + t.string('agent_id', 64).notNullable() + t.tinyint('progress').unsigned().notNullable() + t.string('note', 2000).nullable() + t.json('links').notNullable() + t.datetime('created_at', { precision: 3 }).notNullable() + t.index(['task_id', 'created_at'], 'idx_vk_checkpoints_task') + t.index(['hold_id'], 'idx_vk_checkpoints_hold') + t.check('progress <= 100', [], 'ck_vk_checkpoints_progress') + }) + + await db.schema.createTable('vk_submissions', (t) => { + t.string('id', 64).primary() + t.string('task_id', 64).notNullable() + t.string('agent_id', 64).notNullable() + t.tinyint('round').unsigned().notNullable() + t.text('summary').notNullable() + t.json('artifacts').notNullable() + t.datetime('created_at', { precision: 3 }).notNullable() + t.unique(['task_id', 'agent_id', 'round'], { indexName: 'uq_vk_submissions_task_agent_round' }) + t.check('round between 1 and 3', [], 'ck_vk_submissions_round') + }) + + await db.schema.createTable('vk_reviews', (t) => { + t.string('id', 64).primary() + t.string('task_id', 64).notNullable() + t.string('submission_id', 64).notNullable() + t.string('reviewer_kind', 16).notNullable() + t.string('action', 16).notNullable() + t.text('reason').nullable() + t.datetime('created_at', { precision: 3 }).notNullable() + t.index(['task_id', 'created_at'], 'idx_vk_reviews_task') + t.index(['submission_id'], 'idx_vk_reviews_submission') + t.check("reviewer_kind in ('publisher','auto','arbitration')", [], 'ck_vk_reviews_reviewer_kind') + t.check("action in ('accept','reject','select_winner')", [], 'ck_vk_reviews_action') + }) + + await db.schema.createTable('vk_review_checks', (t) => { + t.string('review_id', 64).notNullable() + t.string('item_key', 32).notNullable() + t.boolean('pass').notNullable() + t.string('reason', 1000).notNullable() + t.primary(['review_id', 'item_key']) + }) + + await db.schema.createTable('vk_disputes', (t) => { + t.string('id', 64).primary() + t.string('task_id', 64).notNullable() + t.string('opened_by_agent_id', 64).notNullable() + t.string('status', 16).notNullable() + t.json('ai_proposal').nullable() + t.json('resolution').nullable() + t.string('resolved_by_user_id', 64).nullable() + t.datetime('resolved_at', { precision: 3 }).nullable() + t.datetime('created_at', { precision: 3 }).notNullable() + t.specificType('open_task_id', "varchar(64) GENERATED ALWAYS AS (CASE WHEN status <> 'resolved' THEN task_id ELSE NULL END) STORED") + t.unique(['open_task_id'], { indexName: 'uq_vk_disputes_open_task' }) + t.index(['task_id'], 'idx_vk_disputes_task') + t.index(['status', 'created_at'], 'idx_vk_disputes_queue') + t.check("status in ('open','ai_proposed','resolved')", [], 'ck_vk_disputes_status') + t.check("status <> 'ai_proposed' or ai_proposal is not null", [], 'ck_vk_disputes_ai_proposal') + 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') + }) + + await db.schema.createTable('vk_transfers', (t) => { + t.string('id', 64).primary() + t.string('task_id', 64).notNullable() + t.string('from_agent_id', 64).notNullable() + t.string('to_agent_id', 64).notNullable() + t.string('status', 16).notNullable() + t.datetime('expires_at', { precision: 3 }).notNullable() + t.datetime('created_at', { precision: 3 }).notNullable() + t.datetime('resolved_at', { precision: 3 }).nullable() + t.specificType('pending_task_id', "varchar(64) GENERATED ALWAYS AS (CASE WHEN status = 'pending' THEN task_id ELSE NULL END) STORED") + t.unique(['pending_task_id'], { indexName: 'uq_vk_transfers_pending_task' }) + t.index(['task_id'], 'idx_vk_transfers_task') + t.index(['to_agent_id', 'status'], 'idx_vk_transfers_to_agent') + t.check("status in ('pending','accepted','declined','expired')", [], 'ck_vk_transfers_status') + t.check("(status = 'pending') = (resolved_at is null)", [], 'ck_vk_transfers_resolved') + t.check('from_agent_id <> to_agent_id', [], 'ck_vk_transfers_parties') + }) + + await db.schema.createTable('vk_help_requests', (t) => { + t.string('id', 64).primary() + t.string('task_id', 64).notNullable() + t.string('agent_id', 64).notNullable() + t.string('owner_user_id', 64).notNullable() + t.text('question').notNullable() + t.string('status', 16).notNullable() + t.text('answer').nullable() + t.datetime('answered_at', { precision: 3 }).nullable() + t.datetime('created_at', { precision: 3 }).notNullable() + t.index(['owner_user_id', 'status', 'created_at'], 'idx_vk_help_requests_inbox') + t.index(['task_id'], 'idx_vk_help_requests_task') + t.index(['agent_id', 'status'], 'idx_vk_help_requests_agent') + t.check("status in ('open','answered')", [], 'ck_vk_help_requests_status') + 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') + }) + + await db.schema.createTable('vk_comments', (t) => { + t.string('id', 64).primary() + t.string('task_id', 64).notNullable() + t.string('author_agent_id', 64).notNullable() + t.text('body').notNullable() + t.datetime('created_at', { precision: 3 }).notNullable() + t.index(['task_id', 'created_at', 'id'], 'idx_vk_comments_task') + }) + + await db.schema.createTable('vk_events', (t) => { + t.bigIncrements('id').unsigned().primary() + t.string('guild_id', 64).notNullable() + t.string('target_agent_id', 64).nullable() + t.string('type', 64).notNullable() + t.string('task_id', 64).nullable() + t.json('payload').notNullable() + t.datetime('created_at', { precision: 3 }).notNullable() + t.index(['target_agent_id', 'id'], 'idx_vk_events_target') + t.index(['guild_id', 'id'], 'idx_vk_events_guild') + t.index(['task_id', 'id'], 'idx_vk_events_task') + }) + + await db.schema.createTable('vk_jobs', (t) => { + t.string('id', 64).primary() + t.string('kind', 32).notNullable() + t.string('ref_id', 64).nullable() + t.datetime('run_at', { precision: 3 }).notNullable() + t.string('status', 16).notNullable() + t.datetime('lease_until', { precision: 3 }).nullable() + t.tinyint('attempts').unsigned().notNullable() + t.string('dedupe_key', 191).notNullable() + t.text('last_error').nullable() + t.datetime('created_at', { precision: 3 }).notNullable() + t.datetime('updated_at', { precision: 3 }).nullable() + t.unique(['dedupe_key'], { indexName: 'uq_vk_jobs_dedupe' }) + t.index(['status', 'run_at'], 'idx_vk_jobs_due') + t.check("status in ('pending','running','done','failed')", [], 'ck_vk_jobs_status') + t.check("status <> 'running' or lease_until is not null", [], 'ck_vk_jobs_lease') + }) + + await db.schema.createTable('vk_escrows', (t) => { + t.string('id', 64).primary() + t.string('task_id', 64).notNullable() + t.string('payer_wallet', 64).notNullable() + t.bigInteger('amount').notNullable() + t.string('status', 16).notNullable() + t.string('deduct_ledger_id', 64).notNullable() + t.datetime('created_at', { precision: 3 }).notNullable() + t.datetime('closed_at', { precision: 3 }).nullable() + t.unique(['task_id'], { indexName: 'uq_vk_escrows_task' }) + t.unique(['deduct_ledger_id'], { indexName: 'uq_vk_escrows_deduct_ledger' }) + t.index(['payer_wallet', 'status'], 'idx_vk_escrows_payer_status') + t.check("status in ('held','released','refunded')", [], 'ck_vk_escrows_status') + t.check('amount > 0', [], 'ck_vk_escrows_amount') + t.check("(status = 'held') = (closed_at is null)", [], 'ck_vk_escrows_closed') + }) + + await db.schema.createTable('vk_escrow_payouts', (t) => { + t.string('id', 64).primary() + t.string('escrow_id', 64).notNullable() + t.string('task_id', 64).notNullable() + t.string('recipient_wallet', 64).notNullable() + t.bigInteger('amount').notNullable() + t.string('kind', 16).notNullable() + t.string('ledger_id', 64).notNullable() + t.datetime('created_at', { precision: 3 }).notNullable() + t.unique(['escrow_id', 'recipient_wallet', 'kind'], { indexName: 'uq_vk_escrow_payouts_recipient_kind' }) + t.unique(['ledger_id'], { indexName: 'uq_vk_escrow_payouts_ledger' }) + t.index(['task_id'], 'idx_vk_escrow_payouts_task') + t.check("kind in ('winner','relay_share','refund')", [], 'ck_vk_escrow_payouts_kind') + t.check('amount > 0', [], 'ck_vk_escrow_payouts_amount') + }) + + await db.schema.createTable('vk_agent_claims', (t) => { + t.string('id', 64).primary() + t.string('agent_id', 64).notNullable() + t.string('claim_token_hash', 64).notNullable() + t.string('verification_code', 16).notNullable() + t.datetime('expires_at', { precision: 3 }).notNullable() + t.string('claimed_by_user_id', 64).nullable() + t.datetime('claimed_at', { precision: 3 }).nullable() + t.datetime('created_at', { precision: 3 }).notNullable() + t.unique(['claim_token_hash'], { indexName: 'uq_vk_agent_claims_token' }) + t.unique(['agent_id'], { indexName: 'uq_vk_agent_claims_agent' }) + t.check('(claimed_by_user_id is null) = (claimed_at is null)', [], 'ck_vk_agent_claims_claimed_pair') + }) + + await db.schema.createTable('vk_api_keys', (t) => { + t.string('id', 64).primary() + t.string('agent_id', 64).notNullable() + t.string('key_hash', 64).notNullable() + t.string('key_prefix', 16).notNullable() + t.datetime('created_at', { precision: 3 }).notNullable() + t.datetime('revoked_at', { precision: 3 }).nullable() + t.specificType('active_agent_id', 'varchar(64) GENERATED ALWAYS AS (CASE WHEN revoked_at IS NULL THEN agent_id ELSE NULL END) STORED') + t.unique(['key_hash'], { indexName: 'uq_vk_api_keys_hash' }) + t.unique(['active_agent_id'], { indexName: 'uq_vk_api_keys_active_agent' }) + t.index(['agent_id'], 'idx_vk_api_keys_agent') + }) + + await db.schema.createTable('vk_webhook_deliveries', (t) => { + t.string('id', 64).primary() + t.bigInteger('event_id').unsigned().notNullable() + t.string('agent_id', 64).notNullable() + t.string('status', 16).notNullable() + t.tinyint('attempts').unsigned().notNullable() + t.datetime('next_attempt_at', { precision: 3 }).nullable() + t.datetime('created_at', { precision: 3 }).notNullable() + t.datetime('delivered_at', { precision: 3 }).nullable() + t.unique(['event_id', 'agent_id'], { indexName: 'uq_vk_webhook_deliveries_event_agent' }) + t.index(['status', 'next_attempt_at'], 'idx_vk_webhook_deliveries_due') + t.check("status in ('pending','delivered','failed')", [], 'ck_vk_webhook_deliveries_status') + t.check("(status = 'pending') = (next_attempt_at is not null)", [], 'ck_vk_webhook_deliveries_next_attempt') + t.check("(status = 'delivered') = (delivered_at is not null)", [], 'ck_vk_webhook_deliveries_delivered') + }) +} + +export async function down(db) { + await db.schema.dropTable('vk_webhook_deliveries') + await db.schema.dropTable('vk_api_keys') + await db.schema.dropTable('vk_agent_claims') + await db.schema.dropTable('vk_escrow_payouts') + await db.schema.dropTable('vk_escrows') + await db.schema.dropTable('vk_jobs') + await db.schema.dropTable('vk_events') + await db.schema.dropTable('vk_comments') + await db.schema.dropTable('vk_help_requests') + await db.schema.dropTable('vk_transfers') + await db.schema.dropTable('vk_disputes') + await db.schema.dropTable('vk_review_checks') + await db.schema.dropTable('vk_reviews') + await db.schema.dropTable('vk_submissions') + await db.schema.dropTable('vk_checkpoints') + await db.schema.dropTable('vk_task_entries') + await db.schema.dropTable('vk_task_holds') + await db.schema.dropTable('vk_task_acceptance_items') + await db.schema.dropTable('vk_tasks') + await db.schema.dropTable('vk_agents') + await db.schema.dropTable('vk_guild_members') + await db.schema.dropTable('vk_guilds') +} diff --git a/apps/voko-server/src/db/migrations/202610100002_access_request_agent_count.js b/apps/voko-server/src/db/migrations/202610100002_access_request_agent_count.js new file mode 100644 index 0000000..db5a40e --- /dev/null +++ b/apps/voko-server/src/db/migrations/202610100002_access_request_agent_count.js @@ -0,0 +1,14 @@ +/** 一人最多认领 3 个 Agent:计数列 + CHECK,认领事务里自增,超出由 DB 拒绝。 */ +const TABLE = 'vk_access_requests' + +export async function up(db) { + await db.schema.alterTable(TABLE, (t) => { + t.tinyint('claimed_agent_count').unsigned().notNullable().defaultTo(0) + t.check("claimed_agent_count <= 3 and (status = 'approved' or claimed_agent_count = 0)", [], 'ck_vk_access_requests_agent_count') + }) +} + +export async function down(db) { + await db.raw('ALTER TABLE ?? DROP CHECK ??', [TABLE, 'ck_vk_access_requests_agent_count']) + await db.schema.alterTable(TABLE, (t) => t.dropColumn('claimed_agent_count')) +} diff --git a/apps/voko-server/src/db/schema.js b/apps/voko-server/src/db/schema.js index 4a8359a..0bfd4c4 100644 --- a/apps/voko-server/src/db/schema.js +++ b/apps/voko-server/src/db/schema.js @@ -3,6 +3,8 @@ import { billingDbSchema } from '@drama/billing' import { dbSchema } from '@drama/db' import { emailDbSchema } from '@drama/email' import { accessDbSchema } from '@voko/access' +import { clearingDbSchema } from '@voko/clearing' +import { exchangeDbSchema } from '@voko/exchange' /** voko 库的当前完整结构(新空库 init 时按此建表)。 */ export const vokoTargetTables = [ @@ -11,4 +13,6 @@ export const vokoTargetTables = [ ...billingDbSchema.targetTables, ...emailDbSchema.targetTables, ...accessDbSchema.targetTables, + ...exchangeDbSchema.targetTables, + ...clearingDbSchema.targetTables, ] diff --git a/packages/voko-access/src/access-requests.js b/packages/voko-access/src/access-requests.js index 9bf59aa..3ba5b4a 100644 --- a/packages/voko-access/src/access-requests.js +++ b/packages/voko-access/src/access-requests.js @@ -15,6 +15,7 @@ export const ACCESS_REQUEST_STATUSES = Object.freeze(['pending', 'approved', 're * created_at: Date, * reviewed_by_user_id: string|null, * reviewed_at: Date|null, + * claimed_agent_count: number, * }} AccessRequest */ @@ -41,6 +42,7 @@ export function accessRequestFromRow(row) { throw new Error(`Unhandled status: ${status}, id: ${id}`) } if (!(row.created_at instanceof Date)) throw new Error(`vk_access_requests.created_at missing, id: ${id}`) + if (!Number.isInteger(row.claimed_agent_count)) throw new Error(`vk_access_requests.claimed_agent_count invalid, id: ${id}`) return { id, user_id: requiredString(row.user_id, 'user_id', id), @@ -50,6 +52,7 @@ export function accessRequestFromRow(row) { created_at: row.created_at, reviewed_by_user_id: row.reviewed_by_user_id, reviewed_at: row.reviewed_at, + claimed_agent_count: row.claimed_agent_count, } } @@ -71,7 +74,7 @@ export function createAccessRequests({ db, notifier }) { */ async function submit(ctx, { user_id, email, note }) { await db(TABLE) - .insert({ id: `ar_${randomUUID()}`, user_id, email, note, status: 'pending', created_at: new Date(), reviewed_by_user_id: null, reviewed_at: null }) + .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 }) .onConflict('user_id') .ignore() return getForUser(ctx, { user_id }) diff --git a/packages/voko-access/src/db/tables/index.cjs b/packages/voko-access/src/db/tables/index.cjs index 5a3159b..d376981 100644 --- a/packages/voko-access/src/db/tables/index.cjs +++ b/packages/voko-access/src/db/tables/index.cjs @@ -1,5 +1,8 @@ const tableModules = [ require('./vk_access_requests.cjs'), + require('./vk_agent_claims.cjs'), + require('./vk_api_keys.cjs'), + require('./vk_webhook_deliveries.cjs'), ] const targetTables = tableModules.map(({ tableName, buildTable }) => [tableName, buildTable]) diff --git a/packages/voko-access/src/db/tables/vk_access_requests.cjs b/packages/voko-access/src/db/tables/vk_access_requests.cjs index 0cbcba3..030a8a6 100644 --- a/packages/voko-access/src/db/tables/vk_access_requests.cjs +++ b/packages/voko-access/src/db/tables/vk_access_requests.cjs @@ -9,10 +9,12 @@ function buildTable(t) { t.datetime('created_at', { precision: 3 }).notNullable() t.string('reviewed_by_user_id', 64).nullable() t.datetime('reviewed_at', { precision: 3 }).nullable() + t.tinyint('claimed_agent_count').unsigned().notNullable().defaultTo(0) t.unique(['user_id'], { indexName: 'uq_vk_access_requests_user' }) t.index(['status', 'created_at', 'id'], 'idx_vk_access_requests_queue') t.check("status in ('pending','approved','rejected')", [], 'ck_vk_access_requests_status') 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') + t.check("claimed_agent_count <= 3 and (status = 'approved' or claimed_agent_count = 0)", [], 'ck_vk_access_requests_agent_count') } module.exports = { tableName, buildTable } diff --git a/packages/voko-access/src/db/tables/vk_agent_claims.cjs b/packages/voko-access/src/db/tables/vk_agent_claims.cjs new file mode 100644 index 0000000..d4cab3c --- /dev/null +++ b/packages/voko-access/src/db/tables/vk_agent_claims.cjs @@ -0,0 +1,17 @@ +const tableName = 'vk_agent_claims' + +function buildTable(t) { + t.string('id', 64).primary() + t.string('agent_id', 64).notNullable() + t.string('claim_token_hash', 64).notNullable() + t.string('verification_code', 16).notNullable() + t.datetime('expires_at', { precision: 3 }).notNullable() + t.string('claimed_by_user_id', 64).nullable() + t.datetime('claimed_at', { precision: 3 }).nullable() + t.datetime('created_at', { precision: 3 }).notNullable() + t.unique(['claim_token_hash'], { indexName: 'uq_vk_agent_claims_token' }) + t.unique(['agent_id'], { indexName: 'uq_vk_agent_claims_agent' }) + t.check('(claimed_by_user_id is null) = (claimed_at is null)', [], 'ck_vk_agent_claims_claimed_pair') +} + +module.exports = { tableName, buildTable } diff --git a/packages/voko-access/src/db/tables/vk_api_keys.cjs b/packages/voko-access/src/db/tables/vk_api_keys.cjs new file mode 100644 index 0000000..efad3a5 --- /dev/null +++ b/packages/voko-access/src/db/tables/vk_api_keys.cjs @@ -0,0 +1,16 @@ +const tableName = 'vk_api_keys' + +function buildTable(t) { + t.string('id', 64).primary() + t.string('agent_id', 64).notNullable() + t.string('key_hash', 64).notNullable() + t.string('key_prefix', 16).notNullable() + t.datetime('created_at', { precision: 3 }).notNullable() + t.datetime('revoked_at', { precision: 3 }).nullable() + t.specificType('active_agent_id', 'varchar(64) GENERATED ALWAYS AS (CASE WHEN revoked_at IS NULL THEN agent_id ELSE NULL END) STORED') + t.unique(['key_hash'], { indexName: 'uq_vk_api_keys_hash' }) + t.unique(['active_agent_id'], { indexName: 'uq_vk_api_keys_active_agent' }) + t.index(['agent_id'], 'idx_vk_api_keys_agent') +} + +module.exports = { tableName, buildTable } diff --git a/packages/voko-access/src/db/tables/vk_webhook_deliveries.cjs b/packages/voko-access/src/db/tables/vk_webhook_deliveries.cjs new file mode 100644 index 0000000..9795388 --- /dev/null +++ b/packages/voko-access/src/db/tables/vk_webhook_deliveries.cjs @@ -0,0 +1,19 @@ +const tableName = 'vk_webhook_deliveries' + +function buildTable(t) { + t.string('id', 64).primary() + t.bigInteger('event_id').unsigned().notNullable() + t.string('agent_id', 64).notNullable() + t.string('status', 16).notNullable() + t.tinyint('attempts').unsigned().notNullable() + t.datetime('next_attempt_at', { precision: 3 }).nullable() + t.datetime('created_at', { precision: 3 }).notNullable() + t.datetime('delivered_at', { precision: 3 }).nullable() + t.unique(['event_id', 'agent_id'], { indexName: 'uq_vk_webhook_deliveries_event_agent' }) + t.index(['status', 'next_attempt_at'], 'idx_vk_webhook_deliveries_due') + t.check("status in ('pending','delivered','failed')", [], 'ck_vk_webhook_deliveries_status') + t.check("(status = 'pending') = (next_attempt_at is not null)", [], 'ck_vk_webhook_deliveries_next_attempt') + t.check("(status = 'delivered') = (delivered_at is not null)", [], 'ck_vk_webhook_deliveries_delivered') +} + +module.exports = { tableName, buildTable } diff --git a/packages/voko-access/test/access-request-from-row.test.js b/packages/voko-access/test/access-request-from-row.test.js index 95aebed..ce892f2 100644 --- a/packages/voko-access/test/access-request-from-row.test.js +++ b/packages/voko-access/test/access-request-from-row.test.js @@ -7,6 +7,7 @@ const base = { email: 'a@example.com', note: null, created_at: new Date('2026-10-10T00:00:00Z'), + claimed_agent_count: 0, } describe('accessRequestFromRow 覆盖全部状态', () => { diff --git a/packages/voko-clearing/package.json b/packages/voko-clearing/package.json new file mode 100644 index 0000000..64dceb4 --- /dev/null +++ b/packages/voko-clearing/package.json @@ -0,0 +1,9 @@ +{ + "name": "@voko/clearing", + "version": "0.0.0", + "private": true, + "type": "module", + "exports": { + ".": "./src/index.js" + } +} diff --git a/packages/voko-clearing/src/db/tables/index.cjs b/packages/voko-clearing/src/db/tables/index.cjs new file mode 100644 index 0000000..6ac3eae --- /dev/null +++ b/packages/voko-clearing/src/db/tables/index.cjs @@ -0,0 +1,8 @@ +const tableModules = [ + require('./vk_escrows.cjs'), + require('./vk_escrow_payouts.cjs'), +] + +const targetTables = tableModules.map(({ tableName, buildTable }) => [tableName, buildTable]) + +module.exports = { targetTables } diff --git a/packages/voko-clearing/src/db/tables/vk_escrow_payouts.cjs b/packages/voko-clearing/src/db/tables/vk_escrow_payouts.cjs new file mode 100644 index 0000000..0a412b0 --- /dev/null +++ b/packages/voko-clearing/src/db/tables/vk_escrow_payouts.cjs @@ -0,0 +1,19 @@ +const tableName = 'vk_escrow_payouts' + +function buildTable(t) { + t.string('id', 64).primary() + t.string('escrow_id', 64).notNullable() + t.string('task_id', 64).notNullable() + t.string('recipient_wallet', 64).notNullable() + t.bigInteger('amount').notNullable() + t.string('kind', 16).notNullable() + t.string('ledger_id', 64).notNullable() + t.datetime('created_at', { precision: 3 }).notNullable() + t.unique(['escrow_id', 'recipient_wallet', 'kind'], { indexName: 'uq_vk_escrow_payouts_recipient_kind' }) + t.unique(['ledger_id'], { indexName: 'uq_vk_escrow_payouts_ledger' }) + t.index(['task_id'], 'idx_vk_escrow_payouts_task') + t.check("kind in ('winner','relay_share','refund')", [], 'ck_vk_escrow_payouts_kind') + t.check('amount > 0', [], 'ck_vk_escrow_payouts_amount') +} + +module.exports = { tableName, buildTable } diff --git a/packages/voko-clearing/src/db/tables/vk_escrows.cjs b/packages/voko-clearing/src/db/tables/vk_escrows.cjs new file mode 100644 index 0000000..d3837a5 --- /dev/null +++ b/packages/voko-clearing/src/db/tables/vk_escrows.cjs @@ -0,0 +1,20 @@ +const tableName = 'vk_escrows' + +function buildTable(t) { + t.string('id', 64).primary() + t.string('task_id', 64).notNullable() + t.string('payer_wallet', 64).notNullable() + t.bigInteger('amount').notNullable() + t.string('status', 16).notNullable() + t.string('deduct_ledger_id', 64).notNullable() + t.datetime('created_at', { precision: 3 }).notNullable() + t.datetime('closed_at', { precision: 3 }).nullable() + t.unique(['task_id'], { indexName: 'uq_vk_escrows_task' }) + t.unique(['deduct_ledger_id'], { indexName: 'uq_vk_escrows_deduct_ledger' }) + t.index(['payer_wallet', 'status'], 'idx_vk_escrows_payer_status') + t.check("status in ('held','released','refunded')", [], 'ck_vk_escrows_status') + t.check('amount > 0', [], 'ck_vk_escrows_amount') + t.check("(status = 'held') = (closed_at is null)", [], 'ck_vk_escrows_closed') +} + +module.exports = { tableName, buildTable } diff --git a/packages/voko-clearing/src/index.js b/packages/voko-clearing/src/index.js new file mode 100644 index 0000000..85a9a31 --- /dev/null +++ b/packages/voko-clearing/src/index.js @@ -0,0 +1,9 @@ +/** + * `@voko/clearing` —— VOKO 的积分清算:托管与发放明细;动账一律经 `@drama/billing` 的账本。 + * + * 当前只有表结构;托管、结算、退款 service 在 W2–W4 加入。 + * + * 边界:不读 `process.env`、不建连接;db 由 composition root 注入。 + */ + +export { default as clearingDbSchema } from './db/tables/index.cjs' diff --git a/packages/voko-exchange/package.json b/packages/voko-exchange/package.json new file mode 100644 index 0000000..6198a09 --- /dev/null +++ b/packages/voko-exchange/package.json @@ -0,0 +1,9 @@ +{ + "name": "@voko/exchange", + "version": "0.0.0", + "private": true, + "type": "module", + "exports": { + ".": "./src/index.js" + } +} diff --git a/packages/voko-exchange/src/db/tables/index.cjs b/packages/voko-exchange/src/db/tables/index.cjs new file mode 100644 index 0000000..fec7cf4 --- /dev/null +++ b/packages/voko-exchange/src/db/tables/index.cjs @@ -0,0 +1,23 @@ +const tableModules = [ + require('./vk_guilds.cjs'), + require('./vk_guild_members.cjs'), + require('./vk_agents.cjs'), + require('./vk_tasks.cjs'), + require('./vk_task_acceptance_items.cjs'), + require('./vk_task_holds.cjs'), + require('./vk_task_entries.cjs'), + require('./vk_checkpoints.cjs'), + require('./vk_submissions.cjs'), + require('./vk_reviews.cjs'), + require('./vk_review_checks.cjs'), + require('./vk_disputes.cjs'), + require('./vk_transfers.cjs'), + require('./vk_help_requests.cjs'), + require('./vk_comments.cjs'), + require('./vk_events.cjs'), + require('./vk_jobs.cjs'), +] + +const targetTables = tableModules.map(({ tableName, buildTable }) => [tableName, buildTable]) + +module.exports = { targetTables } diff --git a/packages/voko-exchange/src/db/tables/vk_agents.cjs b/packages/voko-exchange/src/db/tables/vk_agents.cjs new file mode 100644 index 0000000..68b6603 --- /dev/null +++ b/packages/voko-exchange/src/db/tables/vk_agents.cjs @@ -0,0 +1,34 @@ +const tableName = 'vk_agents' + +function buildTable(t) { + t.string('id', 64).primary() + t.string('wallet_user_id', 64).notNullable() + t.string('owner_user_id', 64).nullable() + t.string('guild_id', 64).nullable() + t.string('role', 16).notNullable() + t.string('status', 16).notNullable() + t.string('name', 120).notNullable() + t.string('description', 1000).nullable() + t.json('skills').notNullable() + t.string('owner_email', 320).nullable() + t.integer('level').unsigned().notNullable() + t.bigInteger('xp').unsigned().notNullable() + t.string('webhook_url', 2048).nullable() + t.text('webhook_secret_enc').nullable() + t.bigInteger('daily_spend_limit').nullable() + t.bigInteger('events_cursor').unsigned().notNullable() + t.datetime('claimed_at', { precision: 3 }).nullable() + t.datetime('created_at', { precision: 3 }).notNullable() + t.datetime('updated_at', { precision: 3 }).nullable() + t.unique(['wallet_user_id'], { indexName: 'uq_vk_agents_wallet' }) + t.index(['owner_user_id'], 'idx_vk_agents_owner') + t.index(['guild_id', 'status'], 'idx_vk_agents_guild_status') + t.check("role in ('company','member')", [], 'ck_vk_agents_role') + t.check("status in ('pending_claim','active','suspended')", [], 'ck_vk_agents_status') + 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') + t.check('(webhook_url is null) = (webhook_secret_enc is null)', [], 'ck_vk_agents_webhook_pair') + t.check('level >= 1', [], 'ck_vk_agents_level') + t.check('daily_spend_limit is null or daily_spend_limit >= 0', [], 'ck_vk_agents_daily_spend_limit') +} + +module.exports = { tableName, buildTable } diff --git a/packages/voko-exchange/src/db/tables/vk_checkpoints.cjs b/packages/voko-exchange/src/db/tables/vk_checkpoints.cjs new file mode 100644 index 0000000..3713a2f --- /dev/null +++ b/packages/voko-exchange/src/db/tables/vk_checkpoints.cjs @@ -0,0 +1,17 @@ +const tableName = 'vk_checkpoints' + +function buildTable(t) { + t.string('id', 64).primary() + t.string('task_id', 64).notNullable() + t.string('hold_id', 64).notNullable() + t.string('agent_id', 64).notNullable() + t.tinyint('progress').unsigned().notNullable() + t.string('note', 2000).nullable() + t.json('links').notNullable() + t.datetime('created_at', { precision: 3 }).notNullable() + t.index(['task_id', 'created_at'], 'idx_vk_checkpoints_task') + t.index(['hold_id'], 'idx_vk_checkpoints_hold') + t.check('progress <= 100', [], 'ck_vk_checkpoints_progress') +} + +module.exports = { tableName, buildTable } diff --git a/packages/voko-exchange/src/db/tables/vk_comments.cjs b/packages/voko-exchange/src/db/tables/vk_comments.cjs new file mode 100644 index 0000000..0b62f94 --- /dev/null +++ b/packages/voko-exchange/src/db/tables/vk_comments.cjs @@ -0,0 +1,12 @@ +const tableName = 'vk_comments' + +function buildTable(t) { + t.string('id', 64).primary() + t.string('task_id', 64).notNullable() + t.string('author_agent_id', 64).notNullable() + t.text('body').notNullable() + t.datetime('created_at', { precision: 3 }).notNullable() + t.index(['task_id', 'created_at', 'id'], 'idx_vk_comments_task') +} + +module.exports = { tableName, buildTable } diff --git a/packages/voko-exchange/src/db/tables/vk_disputes.cjs b/packages/voko-exchange/src/db/tables/vk_disputes.cjs new file mode 100644 index 0000000..4cdad65 --- /dev/null +++ b/packages/voko-exchange/src/db/tables/vk_disputes.cjs @@ -0,0 +1,22 @@ +const tableName = 'vk_disputes' + +function buildTable(t) { + t.string('id', 64).primary() + t.string('task_id', 64).notNullable() + t.string('opened_by_agent_id', 64).notNullable() + t.string('status', 16).notNullable() + t.json('ai_proposal').nullable() + t.json('resolution').nullable() + t.string('resolved_by_user_id', 64).nullable() + t.datetime('resolved_at', { precision: 3 }).nullable() + t.datetime('created_at', { precision: 3 }).notNullable() + t.specificType('open_task_id', "varchar(64) GENERATED ALWAYS AS (CASE WHEN status <> 'resolved' THEN task_id ELSE NULL END) STORED") + t.unique(['open_task_id'], { indexName: 'uq_vk_disputes_open_task' }) + t.index(['task_id'], 'idx_vk_disputes_task') + t.index(['status', 'created_at'], 'idx_vk_disputes_queue') + t.check("status in ('open','ai_proposed','resolved')", [], 'ck_vk_disputes_status') + t.check("status <> 'ai_proposed' or ai_proposal is not null", [], 'ck_vk_disputes_ai_proposal') + 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') +} + +module.exports = { tableName, buildTable } diff --git a/packages/voko-exchange/src/db/tables/vk_events.cjs b/packages/voko-exchange/src/db/tables/vk_events.cjs new file mode 100644 index 0000000..fcb0ce1 --- /dev/null +++ b/packages/voko-exchange/src/db/tables/vk_events.cjs @@ -0,0 +1,16 @@ +const tableName = 'vk_events' + +function buildTable(t) { + t.bigIncrements('id').unsigned().primary() + t.string('guild_id', 64).notNullable() + t.string('target_agent_id', 64).nullable() + t.string('type', 64).notNullable() + t.string('task_id', 64).nullable() + t.json('payload').notNullable() + t.datetime('created_at', { precision: 3 }).notNullable() + t.index(['target_agent_id', 'id'], 'idx_vk_events_target') + t.index(['guild_id', 'id'], 'idx_vk_events_guild') + t.index(['task_id', 'id'], 'idx_vk_events_task') +} + +module.exports = { tableName, buildTable } diff --git a/packages/voko-exchange/src/db/tables/vk_guild_members.cjs b/packages/voko-exchange/src/db/tables/vk_guild_members.cjs new file mode 100644 index 0000000..243ca92 --- /dev/null +++ b/packages/voko-exchange/src/db/tables/vk_guild_members.cjs @@ -0,0 +1,14 @@ +const tableName = 'vk_guild_members' + +function buildTable(t) { + t.string('id', 64).primary() + t.string('guild_id', 64).notNullable() + t.string('user_id', 64).notNullable() + t.string('role', 16).notNullable() + t.datetime('created_at', { precision: 3 }).notNullable() + t.unique(['guild_id', 'user_id'], { indexName: 'uq_vk_guild_members_guild_user' }) + t.index(['user_id'], 'idx_vk_guild_members_user') + t.check("role in ('lead','member')", [], 'ck_vk_guild_members_role') +} + +module.exports = { tableName, buildTable } diff --git a/packages/voko-exchange/src/db/tables/vk_guilds.cjs b/packages/voko-exchange/src/db/tables/vk_guilds.cjs new file mode 100644 index 0000000..6ad5af5 --- /dev/null +++ b/packages/voko-exchange/src/db/tables/vk_guilds.cjs @@ -0,0 +1,20 @@ +const tableName = 'vk_guilds' + +function buildTable(t) { + t.string('id', 64).primary() + t.string('name', 120).notNullable() + t.string('slug', 64).notNullable() + t.string('lead_user_id', 64).notNullable() + t.string('company_agent_id', 64).nullable() + t.boolean('require_lead_approval').notNullable() + t.integer('checkpoint_interval_sec').unsigned().notNullable().defaultTo(21600) + t.integer('piece_review_window_sec').unsigned().notNullable().defaultTo(86400) + t.integer('contest_review_window_sec').unsigned().notNullable().defaultTo(259200) + t.integer('transfer_ttl_sec').unsigned().notNullable().defaultTo(86400) + t.datetime('created_at', { precision: 3 }).notNullable() + t.datetime('updated_at', { precision: 3 }).nullable() + t.unique(['slug'], { indexName: 'uq_vk_guilds_slug' }) + 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') +} + +module.exports = { tableName, buildTable } diff --git a/packages/voko-exchange/src/db/tables/vk_help_requests.cjs b/packages/voko-exchange/src/db/tables/vk_help_requests.cjs new file mode 100644 index 0000000..ac4b5ca --- /dev/null +++ b/packages/voko-exchange/src/db/tables/vk_help_requests.cjs @@ -0,0 +1,20 @@ +const tableName = 'vk_help_requests' + +function buildTable(t) { + t.string('id', 64).primary() + t.string('task_id', 64).notNullable() + t.string('agent_id', 64).notNullable() + t.string('owner_user_id', 64).notNullable() + t.text('question').notNullable() + t.string('status', 16).notNullable() + t.text('answer').nullable() + t.datetime('answered_at', { precision: 3 }).nullable() + t.datetime('created_at', { precision: 3 }).notNullable() + t.index(['owner_user_id', 'status', 'created_at'], 'idx_vk_help_requests_inbox') + t.index(['task_id'], 'idx_vk_help_requests_task') + t.index(['agent_id', 'status'], 'idx_vk_help_requests_agent') + t.check("status in ('open','answered')", [], 'ck_vk_help_requests_status') + 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') +} + +module.exports = { tableName, buildTable } diff --git a/packages/voko-exchange/src/db/tables/vk_jobs.cjs b/packages/voko-exchange/src/db/tables/vk_jobs.cjs new file mode 100644 index 0000000..9317adc --- /dev/null +++ b/packages/voko-exchange/src/db/tables/vk_jobs.cjs @@ -0,0 +1,21 @@ +const tableName = 'vk_jobs' + +function buildTable(t) { + t.string('id', 64).primary() + t.string('kind', 32).notNullable() + t.string('ref_id', 64).nullable() + t.datetime('run_at', { precision: 3 }).notNullable() + t.string('status', 16).notNullable() + t.datetime('lease_until', { precision: 3 }).nullable() + t.tinyint('attempts').unsigned().notNullable() + t.string('dedupe_key', 191).notNullable() + t.text('last_error').nullable() + t.datetime('created_at', { precision: 3 }).notNullable() + t.datetime('updated_at', { precision: 3 }).nullable() + t.unique(['dedupe_key'], { indexName: 'uq_vk_jobs_dedupe' }) + t.index(['status', 'run_at'], 'idx_vk_jobs_due') + t.check("status in ('pending','running','done','failed')", [], 'ck_vk_jobs_status') + t.check("status <> 'running' or lease_until is not null", [], 'ck_vk_jobs_lease') +} + +module.exports = { tableName, buildTable } diff --git a/packages/voko-exchange/src/db/tables/vk_review_checks.cjs b/packages/voko-exchange/src/db/tables/vk_review_checks.cjs new file mode 100644 index 0000000..17d6480 --- /dev/null +++ b/packages/voko-exchange/src/db/tables/vk_review_checks.cjs @@ -0,0 +1,11 @@ +const tableName = 'vk_review_checks' + +function buildTable(t) { + t.string('review_id', 64).notNullable() + t.string('item_key', 32).notNullable() + t.boolean('pass').notNullable() + t.string('reason', 1000).notNullable() + t.primary(['review_id', 'item_key']) +} + +module.exports = { tableName, buildTable } diff --git a/packages/voko-exchange/src/db/tables/vk_reviews.cjs b/packages/voko-exchange/src/db/tables/vk_reviews.cjs new file mode 100644 index 0000000..0c58dda --- /dev/null +++ b/packages/voko-exchange/src/db/tables/vk_reviews.cjs @@ -0,0 +1,17 @@ +const tableName = 'vk_reviews' + +function buildTable(t) { + t.string('id', 64).primary() + t.string('task_id', 64).notNullable() + t.string('submission_id', 64).notNullable() + t.string('reviewer_kind', 16).notNullable() + t.string('action', 16).notNullable() + t.text('reason').nullable() + t.datetime('created_at', { precision: 3 }).notNullable() + t.index(['task_id', 'created_at'], 'idx_vk_reviews_task') + t.index(['submission_id'], 'idx_vk_reviews_submission') + t.check("reviewer_kind in ('publisher','auto','arbitration')", [], 'ck_vk_reviews_reviewer_kind') + t.check("action in ('accept','reject','select_winner')", [], 'ck_vk_reviews_action') +} + +module.exports = { tableName, buildTable } diff --git a/packages/voko-exchange/src/db/tables/vk_submissions.cjs b/packages/voko-exchange/src/db/tables/vk_submissions.cjs new file mode 100644 index 0000000..1927bc7 --- /dev/null +++ b/packages/voko-exchange/src/db/tables/vk_submissions.cjs @@ -0,0 +1,15 @@ +const tableName = 'vk_submissions' + +function buildTable(t) { + t.string('id', 64).primary() + t.string('task_id', 64).notNullable() + t.string('agent_id', 64).notNullable() + t.tinyint('round').unsigned().notNullable() + t.text('summary').notNullable() + t.json('artifacts').notNullable() + t.datetime('created_at', { precision: 3 }).notNullable() + t.unique(['task_id', 'agent_id', 'round'], { indexName: 'uq_vk_submissions_task_agent_round' }) + t.check('round between 1 and 3', [], 'ck_vk_submissions_round') +} + +module.exports = { tableName, buildTable } diff --git a/packages/voko-exchange/src/db/tables/vk_task_acceptance_items.cjs b/packages/voko-exchange/src/db/tables/vk_task_acceptance_items.cjs new file mode 100644 index 0000000..c9abb65 --- /dev/null +++ b/packages/voko-exchange/src/db/tables/vk_task_acceptance_items.cjs @@ -0,0 +1,13 @@ +const tableName = 'vk_task_acceptance_items' + +function buildTable(t) { + t.string('task_id', 64).notNullable() + t.string('item_key', 32).notNullable() + t.tinyint('ordinal').unsigned().notNullable() + t.string('text', 1000).notNullable() + t.primary(['task_id', 'item_key']) + t.unique(['task_id', 'ordinal'], { indexName: 'uq_vk_task_acceptance_items_ordinal' }) + t.check('ordinal between 1 and 5', [], 'ck_vk_task_acceptance_items_ordinal') +} + +module.exports = { tableName, buildTable } diff --git a/packages/voko-exchange/src/db/tables/vk_task_entries.cjs b/packages/voko-exchange/src/db/tables/vk_task_entries.cjs new file mode 100644 index 0000000..5ab092b --- /dev/null +++ b/packages/voko-exchange/src/db/tables/vk_task_entries.cjs @@ -0,0 +1,12 @@ +const tableName = 'vk_task_entries' + +function buildTable(t) { + t.string('id', 64).primary() + t.string('task_id', 64).notNullable() + t.string('agent_id', 64).notNullable() + t.datetime('created_at', { precision: 3 }).notNullable() + t.unique(['task_id', 'agent_id'], { indexName: 'uq_vk_task_entries_task_agent' }) + t.index(['agent_id'], 'idx_vk_task_entries_agent') +} + +module.exports = { tableName, buildTable } diff --git a/packages/voko-exchange/src/db/tables/vk_task_holds.cjs b/packages/voko-exchange/src/db/tables/vk_task_holds.cjs new file mode 100644 index 0000000..c1d5e5d --- /dev/null +++ b/packages/voko-exchange/src/db/tables/vk_task_holds.cjs @@ -0,0 +1,19 @@ +const tableName = 'vk_task_holds' + +function buildTable(t) { + t.string('id', 64).primary() + t.string('task_id', 64).notNullable() + t.string('agent_id', 64).notNullable() + t.datetime('started_at', { precision: 3 }).notNullable() + t.datetime('ended_at', { precision: 3 }).nullable() + t.string('end_reason', 16).nullable() + t.boolean('has_valid_checkpoint').notNullable() + t.specificType('open_task_id', 'varchar(64) GENERATED ALWAYS AS (CASE WHEN ended_at IS NULL THEN task_id ELSE NULL END) STORED') + t.unique(['open_task_id'], { indexName: 'uq_vk_task_holds_open_task' }) + t.index(['task_id', 'started_at'], 'idx_vk_task_holds_task') + t.index(['agent_id', 'ended_at'], 'idx_vk_task_holds_agent') + t.check("end_reason in ('submitted','released','timeout','transferred')", [], 'ck_vk_task_holds_end_reason') + t.check('(ended_at is null) = (end_reason is null)', [], 'ck_vk_task_holds_end_pair') +} + +module.exports = { tableName, buildTable } diff --git a/packages/voko-exchange/src/db/tables/vk_tasks.cjs b/packages/voko-exchange/src/db/tables/vk_tasks.cjs new file mode 100644 index 0000000..ba80bc6 --- /dev/null +++ b/packages/voko-exchange/src/db/tables/vk_tasks.cjs @@ -0,0 +1,40 @@ +const tableName = 'vk_tasks' + +function buildTable(t) { + t.string('id', 64).primary() + t.string('guild_id', 64).notNullable() + t.string('publisher_agent_id', 64).notNullable() + t.string('template', 16).notNullable() + t.string('title', 200).notNullable() + t.text('spec').notNullable() + t.bigInteger('reward').notNullable() + t.tinyint('rarity').unsigned().notNullable() + t.datetime('deadline_at', { precision: 3 }).notNullable() + t.string('status', 24).notNullable() + t.string('client_request_key', 191).notNullable() + t.string('holder_agent_id', 64).nullable() + t.datetime('next_checkpoint_due_at', { precision: 3 }).nullable() + t.tinyint('missed_checkpoints').unsigned().notNullable() + t.tinyint('rework_count').unsigned().notNullable() + t.datetime('review_due_at', { precision: 3 }).nullable() + t.datetime('settled_at', { precision: 3 }).nullable() + t.datetime('created_at', { precision: 3 }).notNullable() + t.datetime('updated_at', { precision: 3 }).nullable() + t.unique(['publisher_agent_id', 'client_request_key'], { indexName: 'uq_vk_tasks_publisher_request' }) + t.index(['guild_id', 'status', 'deadline_at'], 'idx_vk_tasks_board') + t.index(['publisher_agent_id', 'status'], 'idx_vk_tasks_publisher_status') + t.index(['holder_agent_id', 'status'], 'idx_vk_tasks_holder_status') + t.check("template in ('piecework','contest')", [], 'ck_vk_tasks_template') + t.check("status in ('pending_approval','open','claimed','in_contest','submitted','reviewing','rework','disputed','settled','cancelled','refunded')", [], 'ck_vk_tasks_status') + t.check('reward > 0', [], 'ck_vk_tasks_reward') + t.check('rarity between 1 and 10', [], 'ck_vk_tasks_rarity') + t.check('rework_count <= 2', [], 'ck_vk_tasks_rework_count') + t.check("status not in ('claimed','submitted','rework') or template = 'piecework'", [], 'ck_vk_tasks_piecework_status') + t.check("status not in ('in_contest','reviewing') or template = 'contest'", [], 'ck_vk_tasks_contest_status') + t.check("template <> 'contest' or holder_agent_id is null", [], 'ck_vk_tasks_contest_holder') + t.check("status <> 'claimed' or (holder_agent_id is not null and next_checkpoint_due_at is not null)", [], 'ck_vk_tasks_claimed') + t.check("status not in ('submitted','reviewing') or review_due_at is not null", [], 'ck_vk_tasks_review_due') + t.check("status <> 'settled' or settled_at is not null", [], 'ck_vk_tasks_settled') +} + +module.exports = { tableName, buildTable } diff --git a/packages/voko-exchange/src/db/tables/vk_transfers.cjs b/packages/voko-exchange/src/db/tables/vk_transfers.cjs new file mode 100644 index 0000000..447fa46 --- /dev/null +++ b/packages/voko-exchange/src/db/tables/vk_transfers.cjs @@ -0,0 +1,21 @@ +const tableName = 'vk_transfers' + +function buildTable(t) { + t.string('id', 64).primary() + t.string('task_id', 64).notNullable() + t.string('from_agent_id', 64).notNullable() + t.string('to_agent_id', 64).notNullable() + t.string('status', 16).notNullable() + t.datetime('expires_at', { precision: 3 }).notNullable() + t.datetime('created_at', { precision: 3 }).notNullable() + t.datetime('resolved_at', { precision: 3 }).nullable() + t.specificType('pending_task_id', "varchar(64) GENERATED ALWAYS AS (CASE WHEN status = 'pending' THEN task_id ELSE NULL END) STORED") + t.unique(['pending_task_id'], { indexName: 'uq_vk_transfers_pending_task' }) + t.index(['task_id'], 'idx_vk_transfers_task') + t.index(['to_agent_id', 'status'], 'idx_vk_transfers_to_agent') + t.check("status in ('pending','accepted','declined','expired')", [], 'ck_vk_transfers_status') + t.check("(status = 'pending') = (resolved_at is null)", [], 'ck_vk_transfers_resolved') + t.check('from_agent_id <> to_agent_id', [], 'ck_vk_transfers_parties') +} + +module.exports = { tableName, buildTable } diff --git a/packages/voko-exchange/src/index.js b/packages/voko-exchange/src/index.js new file mode 100644 index 0000000..99e9309 --- /dev/null +++ b/packages/voko-exchange/src/index.js @@ -0,0 +1,9 @@ +/** + * `@voko/exchange` —— VOKO 的任务交换:公会、Agent、任务、检查点、提交、验收、评论、求助、事件与定时任务。 + * + * 当前只有表结构;状态机与 service 在 W1–W3 加入。 + * + * 边界:不读 `process.env`、不建连接;db 由 composition root 注入。 + */ + +export { default as exchangeDbSchema } from './db/tables/index.cjs' diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 4db91cc..b51f63a 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -69,6 +69,12 @@ importers: '@voko/access': specifier: workspace:* version: link:../../packages/voko-access + '@voko/clearing': + specifier: workspace:* + version: link:../../packages/voko-clearing + '@voko/exchange': + specifier: workspace:* + version: link:../../packages/voko-exchange dotenv: specifier: 16.6.1 version: 16.6.1 @@ -286,6 +292,10 @@ importers: specifier: ^2.1.4 version: 2.1.9(@types/node@24.13.2)(jsdom@25.0.1)(sass@1.101.0) + packages/voko-clearing: {} + + packages/voko-exchange: {} + packages: '@asamuzakjp/css-color@3.2.0':