feat: Q4 测试覆盖 + 插件生态 — 集成测试/E2E/进销存插件/热更新
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:
@@ -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)))
|
||||
}
|
||||
|
||||
@@ -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 路由
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
|
||||
// ---- 内部辅助 ----
|
||||
|
||||
Reference in New Issue
Block a user