diff --git a/docs/superpowers/plans/2026-04-25-erp-ai-phase1-mvp.md b/docs/superpowers/plans/2026-04-25-erp-ai-phase1-mvp.md index 8070b9e..01ac4c1 100644 --- a/docs/superpowers/plans/2026-04-25-erp-ai-phase1-mvp.md +++ b/docs/superpowers/plans/2026-04-25-erp-ai-phase1-mvp.md @@ -402,3 +402,386 @@ git commit -m "feat(health): 添加 HealthDataProvider stub 实现" ``` --- + +## Chunk 2: 数据库迁移 + SeaORM Entity + +### Task 4: 创建 ai_prompts / ai_analysis_results / ai_usage_logs 迁移 + +**Files:** +- Create: `crates/erp-server/migration/src/m20260425_000050_create_ai_tables.rs` +- Modify: `crates/erp-server/migration/src/lib.rs` — 注册到 Migrator + +> 先查看 `migration/src/lib.rs` 最后一个 migration 编号,确保 `000050` 不冲突。 + +- [ ] **Step 1: 创建迁移文件** + +```rust +// crates/erp-server/migration/src/m20260425_000050_create_ai_tables.rs +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?; + + // ai_prompts 索引 + 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?; + + // ai_analysis 索引 + 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?; + + // ai_usage 索引 + 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, +} +``` + +- [ ] **Step 2: 在 migration/src/lib.rs 注册** + +在 `Migrator::migrations()` 的 `vec![]` 末尾添加: + +```rust +Box::new(m20260425_000050_create_ai_tables::Migration), +``` + +- [ ] **Step 3: 验证迁移编译** + +```bash +cargo check -p erp-server +``` + +- [ ] **Step 4: 提交** + +```bash +git add crates/erp-server/migration/src/ +git commit -m "feat(db): 添加 ai_prompts / ai_analysis_results / ai_usage_logs 迁移" +``` + +--- + +### Task 5: 创建 SeaORM Entity + +**Files:** +- Create: `crates/erp-ai/src/entity/mod.rs` +- Create: `crates/erp-ai/src/entity/ai_prompt.rs` +- Create: `crates/erp-ai/src/entity/ai_analysis.rs` +- Create: `crates/erp-ai/src/entity/ai_usage.rs` + +- [ ] **Step 1: 创建 entity/mod.rs** + +```rust +// crates/erp-ai/src/entity/mod.rs +pub mod ai_analysis; +pub mod ai_prompt; +pub mod ai_usage; +``` + +- [ ] **Step 2: 创建 ai_prompt.rs** + +```rust +// crates/erp-ai/src/entity/ai_prompt.rs +use sea_orm::entity::prelude::*; +use serde::{Deserialize, Serialize}; + +#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Serialize, Deserialize)] +#[sea_orm(table_name = "ai_prompts")] +pub struct Model { + #[sea_orm(primary_key, auto_increment = false)] + pub id: Uuid, + pub tenant_id: Uuid, + pub name: String, + pub description: String, + pub system_prompt: String, + pub user_prompt_template: String, + pub variables_schema: Option, + pub model_config: serde_json::Value, + pub version: i32, + pub is_active: bool, + pub category: String, + pub tags: Option, + pub created_at: DateTimeUtc, + pub updated_at: DateTimeUtc, + pub created_by: Option, + pub updated_by: Option, + pub deleted_at: Option, + pub version_lock: i32, +} + +#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)] +pub enum Relation {} + +impl ActiveModelBehavior for ActiveModel {} +``` + +- [ ] **Step 3: 创建 ai_analysis.rs** + +```rust +// crates/erp-ai/src/entity/ai_analysis.rs +use sea_orm::entity::prelude::*; +use serde::{Deserialize, Serialize}; + +#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Serialize, Deserialize)] +#[sea_orm(table_name = "ai_analysis_results")] +pub struct Model { + #[sea_orm(primary_key, auto_increment = false)] + pub id: Uuid, + pub tenant_id: Uuid, + pub patient_id: Uuid, + pub analysis_type: String, + pub source_ref: String, + pub prompt_id: Uuid, + pub prompt_version: i32, + pub model_used: String, + pub input_data_hash: String, + pub sanitized_input: Option, + pub result_content: Option, + pub result_metadata: Option, + pub status: String, + pub error_message: Option, + pub created_at: DateTimeUtc, + pub updated_at: DateTimeUtc, + pub created_by: Option, + pub updated_by: Option, + pub deleted_at: Option, + pub version_lock: i32, +} + +#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)] +pub enum Relation {} + +impl ActiveModelBehavior for ActiveModel {} +``` + +- [ ] **Step 4: 创建 ai_usage.rs** + +```rust +// crates/erp-ai/src/entity/ai_usage.rs +use sea_orm::entity::prelude::*; +use serde::{Deserialize, Serialize}; + +#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Serialize, Deserialize)] +#[sea_orm(table_name = "ai_usage_logs")] +pub struct Model { + #[sea_orm(primary_key, auto_increment = false)] + pub id: Uuid, + pub tenant_id: Uuid, + pub provider: String, + pub model: String, + pub analysis_type: String, + pub input_tokens: i32, + pub output_tokens: i32, + pub duration_ms: i32, + pub cost_cents: i32, + pub is_cache_hit: bool, + pub created_at: DateTimeUtc, +} + +#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)] +pub enum Relation {} + +impl ActiveModelBehavior for ActiveModel {} +``` + +- [ ] **Step 5: 更新 lib.rs 添加 entity mod** + +在 `crates/erp-ai/src/lib.rs` 中添加: + +```rust +pub mod entity; +``` + +- [ ] **Step 6: 验证编译** + +```bash +cargo check -p erp-ai +``` + +- [ ] **Step 7: 提交** + +```bash +git add crates/erp-ai/src/entity/ crates/erp-ai/src/lib.rs +git commit -m "feat(ai): 添加 SeaORM Entity (ai_prompt/ai_analysis/ai_usage)" +``` + +---