diff --git a/crates/erp-server/migration/src/lib.rs b/crates/erp-server/migration/src/lib.rs index 5de48bf..63f6d52 100644 --- a/crates/erp-server/migration/src/lib.rs +++ b/crates/erp-server/migration/src/lib.rs @@ -51,6 +51,7 @@ mod m20260425_000048_add_patient_id_number_hash; mod m20260425_000049_widen_patient_id_number; mod m20260425_00050_add_doctor_name_column; mod m20260425_000051_dialysis_and_lab_enhance; +mod m20260425_000052_create_ai_tables; pub struct Migrator; @@ -109,6 +110,7 @@ impl MigratorTrait for Migrator { Box::new(m20260425_000049_widen_patient_id_number::Migration), Box::new(m20260425_00050_add_doctor_name_column::Migration), Box::new(m20260425_000051_dialysis_and_lab_enhance::Migration), + Box::new(m20260425_000052_create_ai_tables::Migration), ] } } diff --git a/crates/erp-server/migration/src/m20260425_000052_create_ai_tables.rs b/crates/erp-server/migration/src/m20260425_000052_create_ai_tables.rs new file mode 100644 index 0000000..2b1f5a0 --- /dev/null +++ b/crates/erp-server/migration/src/m20260425_000052_create_ai_tables.rs @@ -0,0 +1,328 @@ +use sea_orm_migration::prelude::*; + +#[derive(DeriveMigrationName)] +pub struct Migration; + +#[async_trait::async_trait] +impl MigrationTrait for Migration { + async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> { + // 1. ai_prompts — Prompt 模板存储 + manager + .create_table( + Table::create() + .table(AiPrompt::Table) + .if_not_exists() + .col(ColumnDef::new(AiPrompt::Id).uuid().not_null().primary_key()) + .col(ColumnDef::new(AiPrompt::TenantId).uuid().not_null()) + .col(ColumnDef::new(AiPrompt::Name).string_len(100).not_null()) + .col( + ColumnDef::new(AiPrompt::Description) + .text() + .not_null() + .default(""), + ) + .col(ColumnDef::new(AiPrompt::SystemPrompt).text().not_null()) + .col( + ColumnDef::new(AiPrompt::UserPromptTemplate) + .text() + .not_null(), + ) + .col(ColumnDef::new(AiPrompt::VariablesSchema).json().null()) + .col(ColumnDef::new(AiPrompt::ModelConfig).json().not_null()) + .col( + ColumnDef::new(AiPrompt::Version) + .integer() + .not_null() + .default(1), + ) + .col( + ColumnDef::new(AiPrompt::IsActive) + .boolean() + .not_null() + .default(true), + ) + .col( + ColumnDef::new(AiPrompt::Category) + .string_len(50) + .not_null() + .default("analysis"), + ) + .col(ColumnDef::new(AiPrompt::Tags).json().null()) + .col( + ColumnDef::new(AiPrompt::CreatedAt) + .timestamp_with_time_zone() + .not_null() + .default(Expr::current_timestamp()), + ) + .col( + ColumnDef::new(AiPrompt::UpdatedAt) + .timestamp_with_time_zone() + .not_null() + .default(Expr::current_timestamp()), + ) + .col(ColumnDef::new(AiPrompt::CreatedBy).uuid().null()) + .col(ColumnDef::new(AiPrompt::UpdatedBy).uuid().null()) + .col(ColumnDef::new(AiPrompt::DeletedAt).timestamp_with_time_zone().null()) + .col( + ColumnDef::new(AiPrompt::VersionLock) + .integer() + .not_null() + .default(1), + ) + .to_owned(), + ) + .await?; + + manager + .create_index( + Index::create() + .if_not_exists() + .name("idx_ai_prompts_tenant_name") + .table(AiPrompt::Table) + .col(AiPrompt::TenantId) + .col(AiPrompt::Name) + .col(AiPrompt::IsActive) + .to_owned(), + ) + .await?; + + // 2. ai_analysis_results — 分析结果存储 + manager + .create_table( + Table::create() + .table(AiAnalysis::Table) + .if_not_exists() + .col(ColumnDef::new(AiAnalysis::Id).uuid().not_null().primary_key()) + .col(ColumnDef::new(AiAnalysis::TenantId).uuid().not_null()) + .col(ColumnDef::new(AiAnalysis::PatientId).uuid().not_null()) + .col( + ColumnDef::new(AiAnalysis::AnalysisType) + .string_len(50) + .not_null(), + ) + .col( + ColumnDef::new(AiAnalysis::SourceRef) + .string_len(200) + .not_null(), + ) + .col(ColumnDef::new(AiAnalysis::PromptId).uuid().not_null()) + .col(ColumnDef::new(AiAnalysis::PromptVersion).integer().not_null()) + .col( + ColumnDef::new(AiAnalysis::ModelUsed) + .string_len(100) + .not_null(), + ) + .col( + ColumnDef::new(AiAnalysis::InputDataHash) + .string_len(64) + .not_null(), + ) + .col(ColumnDef::new(AiAnalysis::SanitizedInput).json().null()) + .col(ColumnDef::new(AiAnalysis::ResultContent).text().null()) + .col(ColumnDef::new(AiAnalysis::ResultMetadata).json().null()) + .col( + ColumnDef::new(AiAnalysis::Status) + .string_len(20) + .not_null() + .default("pending"), + ) + .col(ColumnDef::new(AiAnalysis::ErrorMessage).text().null()) + .col( + ColumnDef::new(AiAnalysis::CreatedAt) + .timestamp_with_time_zone() + .not_null() + .default(Expr::current_timestamp()), + ) + .col( + ColumnDef::new(AiAnalysis::UpdatedAt) + .timestamp_with_time_zone() + .not_null() + .default(Expr::current_timestamp()), + ) + .col(ColumnDef::new(AiAnalysis::CreatedBy).uuid().null()) + .col(ColumnDef::new(AiAnalysis::UpdatedBy).uuid().null()) + .col( + ColumnDef::new(AiAnalysis::DeletedAt) + .timestamp_with_time_zone() + .null(), + ) + .col( + ColumnDef::new(AiAnalysis::VersionLock) + .integer() + .not_null() + .default(1), + ) + .to_owned(), + ) + .await?; + + manager + .create_index( + Index::create() + .if_not_exists() + .name("idx_ai_analysis_tenant_patient") + .table(AiAnalysis::Table) + .col(AiAnalysis::TenantId) + .col(AiAnalysis::PatientId) + .to_owned(), + ) + .await?; + + manager + .create_index( + Index::create() + .if_not_exists() + .name("idx_ai_analysis_cache_hash") + .table(AiAnalysis::Table) + .col(AiAnalysis::InputDataHash) + .to_owned(), + ) + .await?; + + // 3. ai_usage_logs — AI 调用计量 + manager + .create_table( + Table::create() + .table(AiUsage::Table) + .if_not_exists() + .col(ColumnDef::new(AiUsage::Id).uuid().not_null().primary_key()) + .col(ColumnDef::new(AiUsage::TenantId).uuid().not_null()) + .col(ColumnDef::new(AiUsage::Provider).string_len(50).not_null()) + .col(ColumnDef::new(AiUsage::Model).string_len(100).not_null()) + .col( + ColumnDef::new(AiUsage::AnalysisType) + .string_len(50) + .not_null(), + ) + .col( + ColumnDef::new(AiUsage::InputTokens) + .integer() + .not_null() + .default(0), + ) + .col( + ColumnDef::new(AiUsage::OutputTokens) + .integer() + .not_null() + .default(0), + ) + .col( + ColumnDef::new(AiUsage::DurationMs) + .integer() + .not_null() + .default(0), + ) + .col( + ColumnDef::new(AiUsage::CostCents) + .integer() + .not_null() + .default(0), + ) + .col( + ColumnDef::new(AiUsage::IsCacheHit) + .boolean() + .not_null() + .default(false), + ) + .col( + ColumnDef::new(AiUsage::CreatedAt) + .timestamp_with_time_zone() + .not_null() + .default(Expr::current_timestamp()), + ) + .to_owned(), + ) + .await?; + + manager + .create_index( + Index::create() + .if_not_exists() + .name("idx_ai_usage_tenant_created") + .table(AiUsage::Table) + .col(AiUsage::TenantId) + .col(AiUsage::CreatedAt) + .to_owned(), + ) + .await?; + + Ok(()) + } + + async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> { + manager + .drop_table(Table::drop().table(AiUsage::Table).to_owned()) + .await?; + manager + .drop_table(Table::drop().table(AiAnalysis::Table).to_owned()) + .await?; + manager + .drop_table(Table::drop().table(AiPrompt::Table).to_owned()) + .await?; + Ok(()) + } +} + +#[derive(DeriveIden)] +enum AiPrompt { + Table, + Id, + TenantId, + Name, + Description, + SystemPrompt, + UserPromptTemplate, + VariablesSchema, + ModelConfig, + Version, + IsActive, + Category, + Tags, + CreatedAt, + UpdatedAt, + CreatedBy, + UpdatedBy, + DeletedAt, + VersionLock, +} + +#[derive(DeriveIden)] +enum AiAnalysis { + Table, + Id, + TenantId, + PatientId, + AnalysisType, + SourceRef, + PromptId, + PromptVersion, + ModelUsed, + InputDataHash, + SanitizedInput, + ResultContent, + ResultMetadata, + Status, + ErrorMessage, + CreatedAt, + UpdatedAt, + CreatedBy, + UpdatedBy, + DeletedAt, + VersionLock, +} + +#[derive(DeriveIden)] +enum AiUsage { + Table, + Id, + TenantId, + Provider, + Model, + AnalysisType, + InputTokens, + OutputTokens, + DurationMs, + CostCents, + IsCacheHit, + CreatedAt, +}