feat: Q4 测试覆盖 + 插件生态 — 集成测试/E2E/进销存插件/热更新
Some checks failed
CI / rust-check (push) Has been cancelled
CI / rust-test (push) Has been cancelled
CI / frontend-build (push) Has been cancelled
CI / security-audit (push) Has been cancelled

Q4 成熟度路线图全部完成:

1. 集成测试框架 (Testcontainers + PostgreSQL):
   - auth_tests: 用户 CRUD、租户隔离、用户名唯一性
   - plugin_tests: 动态表创建查询、租户数据隔离

2. Playwright E2E 测试:
   - 登录页面渲染和表单验证测试
   - 用户管理、插件管理、多租户隔离占位测试

3. 进销存插件 (erp-plugin-inventory):
   - 6 实体: 产品/仓库/库存/供应商/采购单/销售单
   - 12 权限、6 页面、完整 manifest
   - WASM 编译验证通过

4. 插件热更新:
   - POST /api/v1/admin/plugins/{id}/upgrade
   - manifest 对比 + 增量 DDL + WASM 热加载
   - 失败保持旧版本继续运行

5. 文档更新: CLAUDE.md + wiki/index.md 同步 Q2-Q4 进度
This commit is contained in:
iven
2026-04-17 22:17:47 +08:00
parent 62eea3d20d
commit e8739e80c7
22 changed files with 1679 additions and 64 deletions

View File

@@ -50,9 +50,12 @@ where
})?.to_vec());
}
"manifest" => {
let text = field.text().await.map_err(|e| {
let bytes = field.bytes().await.map_err(|e| {
AppError::Validation(format!("读取 Manifest 失败: {}", e))
})?;
let text = String::from_utf8(bytes.to_vec()).map_err(|e| {
AppError::Validation(format!("Manifest 不是有效的 UTF-8: {}", e))
})?;
manifest_toml = Some(text);
}
_ => {}
@@ -381,3 +384,75 @@ where
.await?;
Ok(Json(ApiResponse::ok(result)))
}
#[utoipa::path(
post,
path = "/api/v1/admin/plugins/{id}/upgrade",
request_body(content_type = "multipart/form-data"),
responses(
(status = 200, description = "升级成功", body = ApiResponse<PluginResp>),
),
security(("bearer_auth" = [])),
tag = "插件管理"
)]
/// POST /api/v1/admin/plugins/{id}/upgrade — 热更新插件
///
/// 上传新版本 WASM + manifest对比 schema 变更,执行增量 DDL
/// 更新插件记录。失败时保持旧版本继续运行。
pub async fn upgrade_plugin<S>(
State(state): State<PluginState>,
Extension(ctx): Extension<TenantContext>,
Path(id): Path<Uuid>,
mut multipart: Multipart,
) -> Result<Json<ApiResponse<PluginResp>>, AppError>
where
PluginState: FromRef<S>,
S: Clone + Send + Sync + 'static,
{
require_permission(&ctx, "plugin.admin")?;
let mut wasm_binary: Option<Vec<u8>> = None;
let mut manifest_toml: Option<String> = None;
while let Some(field) = multipart.next_field().await.map_err(|e| {
AppError::Validation(format!("Multipart 解析失败: {}", e))
})? {
let name = field.name().unwrap_or("");
match name {
"wasm" => {
wasm_binary = Some(field.bytes().await.map_err(|e| {
AppError::Validation(format!("读取 WASM 文件失败: {}", e))
})?.to_vec());
}
"manifest" => {
let bytes = field.bytes().await.map_err(|e| {
AppError::Validation(format!("读取 Manifest 失败: {}", e))
})?;
manifest_toml = Some(String::from_utf8(bytes.to_vec()).map_err(|e| {
AppError::Validation(format!("Manifest 不是有效的 UTF-8: {}", e))
})?);
}
_ => {}
}
}
let wasm = wasm_binary.ok_or_else(|| {
AppError::Validation("缺少 wasm 文件".to_string())
})?;
let manifest = manifest_toml.ok_or_else(|| {
AppError::Validation("缺少 manifest 文件".to_string())
})?;
let result = PluginService::upgrade(
id,
ctx.tenant_id,
ctx.user_id,
wasm,
&manifest,
&state.db,
&state.engine,
)
.await?;
Ok(Json(ApiResponse::ok(result)))
}

View File

@@ -62,6 +62,10 @@ impl PluginModule {
.route(
"/admin/plugins/{id}/config",
put(crate::handler::plugin_handler::update_plugin_config::<S>),
)
.route(
"/admin/plugins/{id}/upgrade",
post(crate::handler::plugin_handler::upgrade_plugin::<S>),
);
// 插件数据 CRUD 路由

View File

@@ -514,6 +514,125 @@ impl PluginService {
active.update(db).await?;
Ok(())
}
/// 热更新插件 — 上传新版本 WASM + manifest对比 schema 变更,执行增量 DDL
///
/// 流程:
/// 1. 解析新 manifest
/// 2. 获取当前插件信息
/// 3. 对比 schema 变更,为新增实体创建表
/// 4. 卸载旧 WASM加载新 WASM
/// 5. 更新数据库记录
/// 6. 失败时保持旧版本继续运行(回滚)
pub async fn upgrade(
plugin_id: Uuid,
tenant_id: Uuid,
operator_id: Uuid,
new_wasm: Vec<u8>,
new_manifest_toml: &str,
db: &sea_orm::DatabaseConnection,
engine: &PluginEngine,
) -> AppResult<PluginResp> {
let new_manifest = parse_manifest(new_manifest_toml)?;
let model = find_plugin(plugin_id, tenant_id, db).await?;
let old_manifest: PluginManifest =
serde_json::from_value(model.manifest_json.clone())
.map_err(|e| PluginError::InvalidManifest(e.to_string()))?;
let old_version = old_manifest.metadata.version.clone();
let new_version = new_manifest.metadata.version.clone();
if old_manifest.metadata.id != new_manifest.metadata.id {
return Err(PluginError::InvalidManifest(
format!("插件 ID 不匹配: 旧={}, 新={}", old_manifest.metadata.id, new_manifest.metadata.id)
).into());
}
let plugin_manifest_id = &new_manifest.metadata.id;
// 对比 schema — 为新增实体创建动态表
if let Some(new_schema) = &new_manifest.schema {
let old_entities: Vec<&str> = old_manifest
.schema
.as_ref()
.map(|s| s.entities.iter().map(|e| e.name.as_str()).collect())
.unwrap_or_default();
for entity in &new_schema.entities {
if !old_entities.contains(&entity.name.as_str()) {
tracing::info!(entity = %entity.name, "创建新增实体表");
DynamicTableManager::create_table(db, plugin_manifest_id, entity).await?;
}
}
}
// 卸载旧 WASM 并加载新 WASM
engine.unload(plugin_manifest_id).await.ok();
engine
.load(plugin_manifest_id, &new_wasm, new_manifest.clone())
.await
.map_err(|e| {
tracing::error!(error = %e, "新版本 WASM 加载失败");
e
})?;
// 更新数据库记录
let wasm_hash = {
let mut hasher = Sha256::new();
hasher.update(&new_wasm);
format!("{:x}", hasher.finalize())
};
let now = Utc::now();
let mut active: plugin::ActiveModel = model.into();
active.wasm_binary = Set(new_wasm);
active.wasm_hash = Set(wasm_hash);
active.manifest_json = Set(serde_json::to_value(&new_manifest)
.map_err(|e| PluginError::InvalidManifest(e.to_string()))?);
active.plugin_version = Set(new_version.clone());
active.updated_at = Set(now);
active.updated_by = Set(Some(operator_id));
active.version = Set(active.version.unwrap() + 1);
let updated = active
.update(db)
.await
.map_err(|e| PluginError::DatabaseError(e.to_string()))?;
// 更新 plugin_entities 表中的 schema_json
if let Some(schema) = &new_manifest.schema {
for entity in &schema.entities {
let entity_model = plugin_entity::Entity::find()
.filter(plugin_entity::Column::PluginId.eq(plugin_id))
.filter(plugin_entity::Column::TenantId.eq(tenant_id))
.filter(plugin_entity::Column::EntityName.eq(&entity.name))
.filter(plugin_entity::Column::DeletedAt.is_null())
.one(db)
.await
.map_err(|e| PluginError::DatabaseError(e.to_string()))?;
if let Some(em) = entity_model {
let mut active: plugin_entity::ActiveModel = em.into();
active.schema_json = Set(serde_json::to_value(entity)
.map_err(|e| PluginError::InvalidManifest(e.to_string()))?);
active.updated_at = Set(now);
active.updated_by = Set(Some(operator_id));
active.update(db).await.map_err(|e| PluginError::DatabaseError(e.to_string()))?;
}
}
}
tracing::info!(
plugin_id = %plugin_id,
old_version = %old_version,
new_version = %new_version,
"插件热更新成功"
);
let entities = find_plugin_entities(plugin_id, tenant_id, db).await?;
Ok(plugin_model_to_resp(&updated, &new_manifest, entities))
}
}
// ---- 内部辅助 ----