From 4ac1fc4f88a2f4f19e700e1b050c70054705cd31 Mon Sep 17 00:00:00 2001 From: hailin Date: Fri, 6 Feb 2026 08:39:10 -0800 Subject: [PATCH] fix(db): add multi-tenancy migration for conversation-service tables - Add tenant_id column to conversations, messages, token_usages tables - Create standalone migration SQL script for production deployment - Add agent_executions table to init-db.sql for new installations - Fix MessageORM created_at nullable mismatch with database schema - Backfill existing data with default tenant ID Co-Authored-By: Claude Opus 4.6 --- .../database/postgres/entities/message.orm.ts | 2 +- .../AddMultiTenancyToConversationService.ts | 141 ++++++++++++++++++ scripts/init-db.sql | 44 ++++++ ...add_multi_tenancy_conversation_service.sql | 79 ++++++++++ 4 files changed, 265 insertions(+), 1 deletion(-) create mode 100644 packages/services/conversation-service/src/migrations/AddMultiTenancyToConversationService.ts create mode 100644 scripts/migrations/20260206_add_multi_tenancy_conversation_service.sql diff --git a/packages/services/conversation-service/src/infrastructure/database/postgres/entities/message.orm.ts b/packages/services/conversation-service/src/infrastructure/database/postgres/entities/message.orm.ts index c2d5edf..d0a7fae 100644 --- a/packages/services/conversation-service/src/infrastructure/database/postgres/entities/message.orm.ts +++ b/packages/services/conversation-service/src/infrastructure/database/postgres/entities/message.orm.ts @@ -46,7 +46,7 @@ export class MessageORM { @Column({ name: 'output_tokens', type: 'int', nullable: true }) outputTokens: number | null; - @CreateDateColumn({ name: 'created_at', type: 'timestamptz', nullable: true }) + @CreateDateColumn({ name: 'created_at' }) createdAt: Date; @ManyToOne(() => ConversationORM, (conversation) => conversation.messages) diff --git a/packages/services/conversation-service/src/migrations/AddMultiTenancyToConversationService.ts b/packages/services/conversation-service/src/migrations/AddMultiTenancyToConversationService.ts new file mode 100644 index 0000000..e5fc158 --- /dev/null +++ b/packages/services/conversation-service/src/migrations/AddMultiTenancyToConversationService.ts @@ -0,0 +1,141 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +/** + * 为 conversation-service 的三张表添加 tenant_id 列 + * + * 影响的表: + * - conversations + * - messages + * - token_usages + * + * 使用默认租户 ID 回填现有数据 + */ +export class AddMultiTenancyToConversationService1738900000000 + implements MigrationInterface +{ + name = 'AddMultiTenancyToConversationService1738900000000'; + + private readonly DEFAULT_TENANT_ID = + '00000000-0000-0000-0000-000000000001'; + + public async up(queryRunner: QueryRunner): Promise { + // ========== 1. conversations 表 ========== + await queryRunner.query( + `ALTER TABLE "conversations" ADD COLUMN IF NOT EXISTS "tenant_id" UUID`, + ); + await queryRunner.query( + `UPDATE "conversations" SET "tenant_id" = '${this.DEFAULT_TENANT_ID}' WHERE "tenant_id" IS NULL`, + ); + await queryRunner.query( + `ALTER TABLE "conversations" ALTER COLUMN "tenant_id" SET NOT NULL`, + ); + await queryRunner.query( + `ALTER TABLE "conversations" ALTER COLUMN "tenant_id" SET DEFAULT '${this.DEFAULT_TENANT_ID}'`, + ); + await queryRunner.query( + `CREATE INDEX IF NOT EXISTS "idx_conversations_tenant" ON "conversations" ("tenant_id")`, + ); + await queryRunner.query( + `CREATE INDEX IF NOT EXISTS "idx_conversations_tenant_user" ON "conversations" ("tenant_id", "user_id")`, + ); + await queryRunner.query( + `CREATE INDEX IF NOT EXISTS "idx_conversations_tenant_status" ON "conversations" ("tenant_id", "status")`, + ); + + // ========== 2. messages 表 ========== + await queryRunner.query( + `ALTER TABLE "messages" ADD COLUMN IF NOT EXISTS "tenant_id" UUID`, + ); + await queryRunner.query( + `UPDATE "messages" SET "tenant_id" = '${this.DEFAULT_TENANT_ID}' WHERE "tenant_id" IS NULL`, + ); + await queryRunner.query( + `ALTER TABLE "messages" ALTER COLUMN "tenant_id" SET NOT NULL`, + ); + await queryRunner.query( + `ALTER TABLE "messages" ALTER COLUMN "tenant_id" SET DEFAULT '${this.DEFAULT_TENANT_ID}'`, + ); + await queryRunner.query( + `CREATE INDEX IF NOT EXISTS "idx_messages_tenant" ON "messages" ("tenant_id")`, + ); + + // ========== 3. token_usages 表 ========== + await queryRunner.query( + `ALTER TABLE "token_usages" ADD COLUMN IF NOT EXISTS "tenant_id" UUID`, + ); + await queryRunner.query( + `UPDATE "token_usages" SET "tenant_id" = '${this.DEFAULT_TENANT_ID}' WHERE "tenant_id" IS NULL`, + ); + await queryRunner.query( + `ALTER TABLE "token_usages" ALTER COLUMN "tenant_id" SET NOT NULL`, + ); + await queryRunner.query( + `ALTER TABLE "token_usages" ALTER COLUMN "tenant_id" SET DEFAULT '${this.DEFAULT_TENANT_ID}'`, + ); + await queryRunner.query( + `CREATE INDEX IF NOT EXISTS "idx_token_usages_tenant" ON "token_usages" ("tenant_id")`, + ); + + // ========== 4. 确保 tenants 表存在并有默认租户 ========== + await queryRunner.query(` + CREATE TABLE IF NOT EXISTS "tenants" ( + "id" UUID PRIMARY KEY DEFAULT gen_random_uuid(), + "name" VARCHAR(100) NOT NULL UNIQUE, + "slug" VARCHAR(50) NOT NULL UNIQUE, + "status" VARCHAR(20) NOT NULL DEFAULT 'ACTIVE', + "plan" VARCHAR(30) NOT NULL DEFAULT 'STANDARD', + "max_users" INT NOT NULL DEFAULT 100, + "max_conversations_per_month" INT NOT NULL DEFAULT 10000, + "max_storage_mb" INT NOT NULL DEFAULT 5120, + "current_user_count" INT NOT NULL DEFAULT 0, + "current_conversation_count" INT NOT NULL DEFAULT 0, + "current_storage_bytes" BIGINT NOT NULL DEFAULT 0, + "config" JSONB NOT NULL DEFAULT '{}', + "billing_email" VARCHAR(255), + "billing_name" VARCHAR(100), + "billing_phone" VARCHAR(20), + "created_at" TIMESTAMPTZ NOT NULL DEFAULT NOW(), + "updated_at" TIMESTAMPTZ NOT NULL DEFAULT NOW(), + "suspended_at" TIMESTAMPTZ + ) + `); + + await queryRunner.query(` + INSERT INTO "tenants" ("id", "name", "slug", "status", "plan") + VALUES ('${this.DEFAULT_TENANT_ID}', 'Default Tenant', 'default', 'ACTIVE', 'ENTERPRISE') + ON CONFLICT ("id") DO NOTHING + `); + } + + public async down(queryRunner: QueryRunner): Promise { + // token_usages + await queryRunner.query( + `DROP INDEX IF EXISTS "idx_token_usages_tenant"`, + ); + await queryRunner.query( + `ALTER TABLE "token_usages" DROP COLUMN IF EXISTS "tenant_id"`, + ); + + // messages + await queryRunner.query( + `DROP INDEX IF EXISTS "idx_messages_tenant"`, + ); + await queryRunner.query( + `ALTER TABLE "messages" DROP COLUMN IF EXISTS "tenant_id"`, + ); + + // conversations + await queryRunner.query( + `DROP INDEX IF EXISTS "idx_conversations_tenant_status"`, + ); + await queryRunner.query( + `DROP INDEX IF EXISTS "idx_conversations_tenant_user"`, + ); + await queryRunner.query( + `DROP INDEX IF EXISTS "idx_conversations_tenant"`, + ); + await queryRunner.query( + `ALTER TABLE "conversations" DROP COLUMN IF EXISTS "tenant_id"`, + ); + } +} diff --git a/scripts/init-db.sql b/scripts/init-db.sql index 984f4dc..ed86086 100644 --- a/scripts/init-db.sql +++ b/scripts/init-db.sql @@ -1710,6 +1710,50 @@ CREATE INDEX idx_token_usages_created_brin ON token_usages USING BRIN(created_at -- 租户索引 CREATE INDEX idx_token_usages_tenant ON token_usages(tenant_id); +-- =========================================== +-- Agent执行记录表 (agent_executions) +-- 记录每次 Agent 调用的执行数据,用于运营分析 +-- =========================================== +CREATE TABLE agent_executions ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + -- 租户ID + tenant_id UUID NOT NULL, + -- 所属对话ID + conversation_id UUID NOT NULL, + -- 关联的消息ID + message_id UUID, + -- 所属用户ID + user_id UUID, + -- Agent类型: coordinator, policy_expert, assessment_expert, strategist, objection_handler, case_analyst, memory_manager + agent_type VARCHAR(30) NOT NULL, + -- Agent名称(中文显示名) + agent_name VARCHAR(50) NOT NULL, + -- 执行耗时(毫秒) + duration_ms INT NOT NULL DEFAULT 0, + -- 是否成功 + success BOOLEAN NOT NULL DEFAULT TRUE, + -- 错误信息(失败时记录) + error_message TEXT, + -- 工具调用次数 + tool_calls_count INT NOT NULL DEFAULT 0, + -- 输入Token数 + input_tokens INT NOT NULL DEFAULT 0, + -- 输出Token数 + output_tokens INT NOT NULL DEFAULT 0, + -- 创建时间 + created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW() +); + +COMMENT ON TABLE agent_executions IS 'Agent执行记录表 - 记录每次Agent调用的执行数据'; +COMMENT ON COLUMN agent_executions.agent_type IS 'Agent类型标识符'; +COMMENT ON COLUMN agent_executions.duration_ms IS '执行耗时,单位毫秒'; + +CREATE INDEX idx_agent_executions_tenant ON agent_executions(tenant_id); +CREATE INDEX idx_agent_executions_conversation ON agent_executions(conversation_id); +CREATE INDEX idx_agent_executions_agent_type ON agent_executions(agent_type); +CREATE INDEX idx_agent_executions_created ON agent_executions(created_at); +CREATE INDEX idx_agent_executions_tenant_date ON agent_executions(tenant_id, created_at); + -- =========================================== -- 结束 -- =========================================== diff --git a/scripts/migrations/20260206_add_multi_tenancy_conversation_service.sql b/scripts/migrations/20260206_add_multi_tenancy_conversation_service.sql new file mode 100644 index 0000000..b17e51d --- /dev/null +++ b/scripts/migrations/20260206_add_multi_tenancy_conversation_service.sql @@ -0,0 +1,79 @@ +-- =========================================== +-- 多租户迁移: conversation-service 表 +-- 为 conversations, messages, token_usages 添加 tenant_id +-- 创建 agent_executions 表 +-- 日期: 2026-02-06 +-- =========================================== + +-- ========== 1. 确保 tenants 表和默认租户存在 ========== +CREATE TABLE IF NOT EXISTS tenants ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + name VARCHAR(100) NOT NULL UNIQUE, + slug VARCHAR(50) NOT NULL UNIQUE, + status VARCHAR(20) NOT NULL DEFAULT 'ACTIVE', + plan VARCHAR(30) NOT NULL DEFAULT 'STANDARD', + max_users INT NOT NULL DEFAULT 100, + max_conversations_per_month INT NOT NULL DEFAULT 10000, + max_storage_mb INT NOT NULL DEFAULT 5120, + current_user_count INT NOT NULL DEFAULT 0, + current_conversation_count INT NOT NULL DEFAULT 0, + current_storage_bytes BIGINT NOT NULL DEFAULT 0, + config JSONB NOT NULL DEFAULT '{}', + billing_email VARCHAR(255), + billing_name VARCHAR(100), + billing_phone VARCHAR(20), + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + suspended_at TIMESTAMPTZ +); + +INSERT INTO tenants (id, name, slug, status, plan) +VALUES ('00000000-0000-0000-0000-000000000001', 'Default Tenant', 'default', 'ACTIVE', 'ENTERPRISE') +ON CONFLICT (id) DO NOTHING; + +-- ========== 2. conversations 表 ========== +ALTER TABLE conversations ADD COLUMN IF NOT EXISTS tenant_id UUID; +UPDATE conversations SET tenant_id = '00000000-0000-0000-0000-000000000001' WHERE tenant_id IS NULL; +ALTER TABLE conversations ALTER COLUMN tenant_id SET NOT NULL; +ALTER TABLE conversations ALTER COLUMN tenant_id SET DEFAULT '00000000-0000-0000-0000-000000000001'; +CREATE INDEX IF NOT EXISTS idx_conversations_tenant ON conversations(tenant_id); +CREATE INDEX IF NOT EXISTS idx_conversations_tenant_user ON conversations(tenant_id, user_id); +CREATE INDEX IF NOT EXISTS idx_conversations_tenant_status ON conversations(tenant_id, status); + +-- ========== 3. messages 表 ========== +ALTER TABLE messages ADD COLUMN IF NOT EXISTS tenant_id UUID; +UPDATE messages SET tenant_id = '00000000-0000-0000-0000-000000000001' WHERE tenant_id IS NULL; +ALTER TABLE messages ALTER COLUMN tenant_id SET NOT NULL; +ALTER TABLE messages ALTER COLUMN tenant_id SET DEFAULT '00000000-0000-0000-0000-000000000001'; +CREATE INDEX IF NOT EXISTS idx_messages_tenant ON messages(tenant_id); + +-- ========== 4. token_usages 表 ========== +ALTER TABLE token_usages ADD COLUMN IF NOT EXISTS tenant_id UUID; +UPDATE token_usages SET tenant_id = '00000000-0000-0000-0000-000000000001' WHERE tenant_id IS NULL; +ALTER TABLE token_usages ALTER COLUMN tenant_id SET NOT NULL; +ALTER TABLE token_usages ALTER COLUMN tenant_id SET DEFAULT '00000000-0000-0000-0000-000000000001'; +CREATE INDEX IF NOT EXISTS idx_token_usages_tenant ON token_usages(tenant_id); + +-- ========== 5. agent_executions 表 ========== +CREATE TABLE IF NOT EXISTS agent_executions ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + tenant_id UUID NOT NULL, + conversation_id UUID NOT NULL, + message_id UUID, + user_id UUID, + agent_type VARCHAR(30) NOT NULL, + agent_name VARCHAR(50) NOT NULL, + duration_ms INT NOT NULL DEFAULT 0, + success BOOLEAN NOT NULL DEFAULT TRUE, + error_message TEXT, + tool_calls_count INT NOT NULL DEFAULT 0, + input_tokens INT NOT NULL DEFAULT 0, + output_tokens INT NOT NULL DEFAULT 0, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE INDEX IF NOT EXISTS idx_agent_executions_tenant ON agent_executions(tenant_id); +CREATE INDEX IF NOT EXISTS idx_agent_executions_conversation ON agent_executions(conversation_id); +CREATE INDEX IF NOT EXISTS idx_agent_executions_agent_type ON agent_executions(agent_type); +CREATE INDEX IF NOT EXISTS idx_agent_executions_created ON agent_executions(created_at); +CREATE INDEX IF NOT EXISTS idx_agent_executions_tenant_date ON agent_executions(tenant_id, created_at);