feat: 插件自动挂载测试机制 — capabilities + self_test + 3阶段启动

- Plugin trait 增加 capabilities() 和 self_test() 方法
- PluginVTable 增加 get_capabilities 和 self_test FFI
- ServiceManager 三阶段启动: init → self_test → start
- SendCallback 改为 ctx 参数传递,消除 thread_local
- export_plugin! 宏所有 FFI 函数包裹 catch_unwind
- PluginManifest 增加 capabilities/required_capabilities/auto_test
- 新增 3 个自测相关测试用例 (共 59 测试)
This commit is contained in:
showen
2026-03-13 04:31:39 +08:00
parent 1863efb0f5
commit 99ee78984c
9 changed files with 694 additions and 123 deletions

View File

@@ -18,6 +18,14 @@ pub struct PluginInfo {
pub platform: String, pub platform: String,
} }
/// 单项能力测试结果
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CapabilityTestResult {
pub capability: String,
pub passed: bool,
pub message: String,
}
/// 消息信封 /// 消息信封
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Envelope { pub struct Envelope {
@@ -40,17 +48,29 @@ pub enum Destination {
pub enum Message { pub enum Message {
PlayerCommand(serde_json::Value), PlayerCommand(serde_json::Value),
PlayerStatus(serde_json::Value), PlayerStatus(serde_json::Value),
Trigger { name: String, value: String }, Trigger {
StateChanged { old_state: String, new_state: String }, name: String,
value: String,
},
StateChanged {
old_state: String,
new_state: String,
},
ScreenLockRequest(bool), ScreenLockRequest(bool),
CursorVisibility(bool), CursorVisibility(bool),
WifiCommand(serde_json::Value), WifiCommand(serde_json::Value),
WifiResult(String), WifiResult(String),
WifiProvisioned { ssid: String, ip: String }, WifiProvisioned {
ssid: String,
ip: String,
},
ConfigReloadRequest, ConfigReloadRequest,
Shutdown, Shutdown,
PluginReady(String), PluginReady(String),
Custom { kind: String, payload: String }, Custom {
kind: String,
payload: String,
},
} }
// ── FFI 类型(与主程序 plugin_abi.rs 完全对应) ── // ── FFI 类型(与主程序 plugin_abi.rs 完全对应) ──
@@ -108,36 +128,45 @@ impl FfiResult {
} }
} }
pub type SendCallback = unsafe extern "C" fn(envelope_json: FfiStr); pub type SendCallback = unsafe extern "C" fn(ctx: *mut c_void, envelope_json: FfiStr);
#[repr(C)] #[repr(C)]
pub struct PluginVTable { pub struct PluginVTable {
pub create: unsafe extern "C" fn() -> PluginHandle, pub create: unsafe extern "C" fn() -> PluginHandle,
pub get_info: unsafe extern "C" fn(handle: PluginHandle) -> FfiString, pub get_info: unsafe extern "C" fn(handle: PluginHandle) -> FfiString,
pub init: unsafe extern "C" fn(handle: PluginHandle, config_json: FfiStr, send_cb: SendCallback) -> FfiResult, pub init: unsafe extern "C" fn(
handle: PluginHandle,
config_json: FfiStr,
send_ctx: *mut c_void,
send_cb: SendCallback,
) -> FfiResult,
pub start: unsafe extern "C" fn(handle: PluginHandle) -> FfiResult, pub start: unsafe extern "C" fn(handle: PluginHandle) -> FfiResult,
pub handle_message: unsafe extern "C" fn(handle: PluginHandle, message_json: FfiStr) -> FfiResult, pub handle_message:
unsafe extern "C" fn(handle: PluginHandle, message_json: FfiStr) -> FfiResult,
pub stop: unsafe extern "C" fn(handle: PluginHandle) -> FfiResult, pub stop: unsafe extern "C" fn(handle: PluginHandle) -> FfiResult,
pub destroy: unsafe extern "C" fn(handle: PluginHandle), pub destroy: unsafe extern "C" fn(handle: PluginHandle),
pub get_capabilities: unsafe extern "C" fn(handle: PluginHandle) -> FfiString,
pub self_test: unsafe extern "C" fn(handle: PluginHandle) -> FfiString,
} }
// ── 高级接口:插件作者实现此 trait ── // ── 高级接口:插件作者实现此 trait ──
/// 消息发送器 — 封装 SendCallback提供安全的 Rust API /// 消息发送器 — 封装 SendCallback提供安全的 Rust API
pub struct MessageSender { pub struct MessageSender {
ctx: *mut c_void,
cb: SendCallback, cb: SendCallback,
} }
impl MessageSender { impl MessageSender {
pub fn new(cb: SendCallback) -> Self { pub fn new(ctx: *mut c_void, cb: SendCallback) -> Self {
Self { cb } Self { ctx, cb }
} }
/// 发送消息信封到主程序 /// 发送消息信封到主程序
pub fn send(&self, envelope: &Envelope) { pub fn send(&self, envelope: &Envelope) {
if let Ok(json) = serde_json::to_string(envelope) { if let Ok(json) = serde_json::to_string(envelope) {
if let Ok(cstr) = CString::new(json) { if let Ok(cstr) = CString::new(json) {
unsafe { (self.cb)(cstr.as_ptr()) }; unsafe { (self.cb)(self.ctx, cstr.as_ptr()) };
} }
} }
} }
@@ -179,6 +208,24 @@ pub trait ShowenPlugin: Send {
/// 插件信息 /// 插件信息
fn info(&self) -> PluginInfo; fn info(&self) -> PluginInfo;
/// 声明插件支持的功能列表(默认空)
fn capabilities(&self) -> Vec<String> {
vec![]
}
/// 运行自测,返回每项功能的测试结果
/// 默认实现:所有声明的 capability 均标记为通过
fn self_test(&mut self) -> Vec<CapabilityTestResult> {
self.capabilities()
.into_iter()
.map(|c| CapabilityTestResult {
capability: c,
passed: true,
message: "no test defined".into(),
})
.collect()
}
/// 初始化,收到配置 JSON 和消息发送器 /// 初始化,收到配置 JSON 和消息发送器
fn init(&mut self, config_json: &str, sender: MessageSender) -> Result<(), String>; fn init(&mut self, config_json: &str, sender: MessageSender) -> Result<(), String>;
@@ -206,16 +253,37 @@ pub trait ShowenPlugin: Send {
#[macro_export] #[macro_export]
macro_rules! export_plugin { macro_rules! export_plugin {
($plugin_type:ty, $constructor:expr) => { ($plugin_type:ty, $constructor:expr) => {
fn __showen_panic_error(payload: Box<dyn std::any::Any + Send>) -> String {
let msg = if let Some(s) = payload.downcast_ref::<&'static str>() {
(*s).to_string()
} else if let Some(s) = payload.downcast_ref::<String>() {
s.clone()
} else {
"unknown panic".to_string()
};
format!("plugin panicked: {}", msg)
}
unsafe extern "C" fn __showen_create() -> $crate::PluginHandle { unsafe extern "C" fn __showen_create() -> $crate::PluginHandle {
let plugin: Box<$plugin_type> = Box::new($constructor); match std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
Box::into_raw(plugin) as $crate::PluginHandle let plugin: Box<$plugin_type> = Box::new($constructor);
Box::into_raw(plugin) as $crate::PluginHandle
})) {
Ok(handle) => handle,
Err(_) => std::ptr::null_mut(),
}
} }
unsafe extern "C" fn __showen_get_info(handle: $crate::PluginHandle) -> $crate::FfiString { unsafe extern "C" fn __showen_get_info(handle: $crate::PluginHandle) -> $crate::FfiString {
let plugin = unsafe { &*(handle as *const $plugin_type) }; match std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
let info = <$plugin_type as $crate::ShowenPlugin>::info(plugin); let plugin = unsafe { &*(handle as *const $plugin_type) };
match serde_json::to_string(&info) { let info = <$plugin_type as $crate::ShowenPlugin>::info(plugin);
Ok(json) => $crate::FfiString::from_string(json), match serde_json::to_string(&info) {
Ok(json) => $crate::FfiString::from_string(json),
Err(_) => $crate::FfiString::null(),
}
})) {
Ok(info) => info,
Err(_) => $crate::FfiString::null(), Err(_) => $crate::FfiString::null(),
} }
} }
@@ -223,25 +291,36 @@ macro_rules! export_plugin {
unsafe extern "C" fn __showen_init( unsafe extern "C" fn __showen_init(
handle: $crate::PluginHandle, handle: $crate::PluginHandle,
config_json: $crate::FfiStr, config_json: $crate::FfiStr,
send_ctx: *mut std::ffi::c_void,
send_cb: $crate::SendCallback, send_cb: $crate::SendCallback,
) -> $crate::FfiResult { ) -> $crate::FfiResult {
let plugin = unsafe { &mut *(handle as *mut $plugin_type) }; match std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
let config = match unsafe { std::ffi::CStr::from_ptr(config_json) }.to_str() { let plugin = unsafe { &mut *(handle as *mut $plugin_type) };
Ok(s) => s, let config = match unsafe { std::ffi::CStr::from_ptr(config_json) }.to_str() {
Err(e) => return $crate::FfiResult::err(format!("invalid config UTF-8: {e}")), Ok(s) => s,
}; Err(e) => return $crate::FfiResult::err(format!("invalid config UTF-8: {e}")),
let sender = $crate::MessageSender::new(send_cb); };
match <$plugin_type as $crate::ShowenPlugin>::init(plugin, config, sender) { let sender = $crate::MessageSender::new(send_ctx, send_cb);
Ok(()) => $crate::FfiResult::ok(), match <$plugin_type as $crate::ShowenPlugin>::init(plugin, config, sender) {
Err(e) => $crate::FfiResult::err(e), Ok(()) => $crate::FfiResult::ok(),
Err(e) => $crate::FfiResult::err(e),
}
})) {
Ok(result) => result,
Err(payload) => $crate::FfiResult::err(__showen_panic_error(payload)),
} }
} }
unsafe extern "C" fn __showen_start(handle: $crate::PluginHandle) -> $crate::FfiResult { unsafe extern "C" fn __showen_start(handle: $crate::PluginHandle) -> $crate::FfiResult {
let plugin = unsafe { &mut *(handle as *mut $plugin_type) }; match std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
match <$plugin_type as $crate::ShowenPlugin>::start(plugin) { let plugin = unsafe { &mut *(handle as *mut $plugin_type) };
Ok(()) => $crate::FfiResult::ok(), match <$plugin_type as $crate::ShowenPlugin>::start(plugin) {
Err(e) => $crate::FfiResult::err(e), Ok(()) => $crate::FfiResult::ok(),
Err(e) => $crate::FfiResult::err(e),
}
})) {
Ok(result) => result,
Err(payload) => $crate::FfiResult::err(__showen_panic_error(payload)),
} }
} }
@@ -249,32 +328,76 @@ macro_rules! export_plugin {
handle: $crate::PluginHandle, handle: $crate::PluginHandle,
message_json: $crate::FfiStr, message_json: $crate::FfiStr,
) -> $crate::FfiResult { ) -> $crate::FfiResult {
let plugin = unsafe { &mut *(handle as *mut $plugin_type) }; match std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
let json_str = match unsafe { std::ffi::CStr::from_ptr(message_json) }.to_str() { let plugin = unsafe { &mut *(handle as *mut $plugin_type) };
Ok(s) => s, let json_str = match unsafe { std::ffi::CStr::from_ptr(message_json) }.to_str() {
Err(e) => return $crate::FfiResult::err(format!("invalid message UTF-8: {e}")), Ok(s) => s,
}; Err(e) => return $crate::FfiResult::err(format!("invalid message UTF-8: {e}")),
let message: $crate::Message = match serde_json::from_str(json_str) { };
Ok(m) => m, let message: $crate::Message = match serde_json::from_str(json_str) {
Err(e) => return $crate::FfiResult::err(format!("invalid message JSON: {e}")), Ok(m) => m,
}; Err(e) => return $crate::FfiResult::err(format!("invalid message JSON: {e}")),
match <$plugin_type as $crate::ShowenPlugin>::handle_message(plugin, message) { };
Ok(()) => $crate::FfiResult::ok(), match <$plugin_type as $crate::ShowenPlugin>::handle_message(plugin, message) {
Err(e) => $crate::FfiResult::err(e), Ok(()) => $crate::FfiResult::ok(),
Err(e) => $crate::FfiResult::err(e),
}
})) {
Ok(result) => result,
Err(payload) => $crate::FfiResult::err(__showen_panic_error(payload)),
} }
} }
unsafe extern "C" fn __showen_stop(handle: $crate::PluginHandle) -> $crate::FfiResult { unsafe extern "C" fn __showen_stop(handle: $crate::PluginHandle) -> $crate::FfiResult {
let plugin = unsafe { &mut *(handle as *mut $plugin_type) }; match std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
match <$plugin_type as $crate::ShowenPlugin>::stop(plugin) { let plugin = unsafe { &mut *(handle as *mut $plugin_type) };
Ok(()) => $crate::FfiResult::ok(), match <$plugin_type as $crate::ShowenPlugin>::stop(plugin) {
Err(e) => $crate::FfiResult::err(e), Ok(()) => $crate::FfiResult::ok(),
Err(e) => $crate::FfiResult::err(e),
}
})) {
Ok(result) => result,
Err(payload) => $crate::FfiResult::err(__showen_panic_error(payload)),
} }
} }
unsafe extern "C" fn __showen_destroy(handle: $crate::PluginHandle) { unsafe extern "C" fn __showen_destroy(handle: $crate::PluginHandle) {
if !handle.is_null() { if let Ok(()) = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
drop(unsafe { Box::from_raw(handle as *mut $plugin_type) }); if !handle.is_null() {
drop(unsafe { Box::from_raw(handle as *mut $plugin_type) });
}
})) {
let _ = ();
}
}
unsafe extern "C" fn __showen_get_capabilities(
handle: $crate::PluginHandle,
) -> $crate::FfiString {
match std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
let plugin = unsafe { &*(handle as *const $plugin_type) };
let caps = <$plugin_type as $crate::ShowenPlugin>::capabilities(plugin);
match serde_json::to_string(&caps) {
Ok(json) => $crate::FfiString::from_string(json),
Err(_) => $crate::FfiString::from_string("[]".to_string()),
}
})) {
Ok(caps) => caps,
Err(_) => $crate::FfiString::null(),
}
}
unsafe extern "C" fn __showen_self_test(handle: $crate::PluginHandle) -> $crate::FfiString {
match std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
let plugin = unsafe { &mut *(handle as *mut $plugin_type) };
let results = <$plugin_type as $crate::ShowenPlugin>::self_test(plugin);
match serde_json::to_string(&results) {
Ok(json) => $crate::FfiString::from_string(json),
Err(_) => $crate::FfiString::from_string("[]".to_string()),
}
})) {
Ok(results) => results,
Err(_) => $crate::FfiString::null(),
} }
} }
@@ -287,6 +410,8 @@ macro_rules! export_plugin {
handle_message: __showen_handle_message, handle_message: __showen_handle_message,
stop: __showen_stop, stop: __showen_stop,
destroy: __showen_destroy, destroy: __showen_destroy,
get_capabilities: __showen_get_capabilities,
self_test: __showen_self_test,
}; };
}; };
} }

View File

@@ -1,18 +1,23 @@
//! 示例动态插件 — 展示如何使用 showen-plugin-sdk 编写插件 //! 示例动态插件 — 展示如何使用 showen-plugin-sdk 编写插件
//! //!
//! 此插件仅打印日志,用于验证动态加载流程。 //! 此插件演示动态加载流程及自测机制
use showen_plugin_sdk::{ use showen_plugin_sdk::{
export_plugin, Message, MessageSender, PluginInfo, ShowenPlugin, export_plugin, CapabilityTestResult, Message, MessageSender, PluginInfo, ShowenPlugin,
}; };
pub struct ExamplePlugin { pub struct ExamplePlugin {
sender: Option<MessageSender>, sender: Option<MessageSender>,
/// 用于演示可配置的自测失败
fail_optional_test: bool,
} }
impl ExamplePlugin { impl ExamplePlugin {
pub fn new() -> Self { pub fn new() -> Self {
Self { sender: None } Self {
sender: None,
fail_optional_test: false,
}
} }
} }
@@ -26,6 +31,29 @@ impl ShowenPlugin for ExamplePlugin {
} }
} }
fn capabilities(&self) -> Vec<String> {
vec!["logging".to_string(), "metrics".to_string()]
}
fn self_test(&mut self) -> Vec<CapabilityTestResult> {
vec![
CapabilityTestResult {
capability: "logging".to_string(),
passed: true,
message: "log output verified".to_string(),
},
CapabilityTestResult {
capability: "metrics".to_string(),
passed: !self.fail_optional_test,
message: if self.fail_optional_test {
"metrics backend unreachable".to_string()
} else {
"metrics endpoint ok".to_string()
},
},
]
}
fn init(&mut self, config_json: &str, sender: MessageSender) -> Result<(), String> { fn init(&mut self, config_json: &str, sender: MessageSender) -> Result<(), String> {
eprintln!("[ExamplePlugin] init called, config length: {}", config_json.len()); eprintln!("[ExamplePlugin] init called, config length: {}", config_json.len());
self.sender = Some(sender); self.sender = Some(sender);

View File

@@ -4,10 +4,9 @@
//! 对 ServiceManager 而言DynamicPlugin 与静态插件无区别。 //! 对 ServiceManager 而言DynamicPlugin 与静态插件无区别。
use crate::core::message::{Envelope, Message}; use crate::core::message::{Envelope, Message};
use crate::core::plugin::{Plugin, PluginContext, PluginInfo}; use crate::core::plugin::{CapabilityTestResult, Plugin, PluginContext, PluginInfo};
use crate::core::plugin_abi::{ use crate::core::plugin_abi::{
ffi_str_to_str, FfiResult, FfiString, PluginHandle, PluginVTable, ffi_str_to_str, FfiResult, FfiString, PluginHandle, PluginVTable, PLUGIN_VTABLE_SYMBOL,
PLUGIN_VTABLE_SYMBOL,
}; };
use anyhow::{anyhow, Context, Result}; use anyhow::{anyhow, Context, Result};
use libloading::Library; use libloading::Library;
@@ -30,6 +29,8 @@ pub struct DynamicPlugin {
dependencies: Vec<String>, dependencies: Vec<String>,
/// .so 文件路径(用于调试/日志) /// .so 文件路径(用于调试/日志)
so_path: String, so_path: String,
/// Sender 上下文指针(堆分配的 mpsc::Sender
sender_ctx: *mut std::ffi::c_void,
} }
// PluginHandle 是 *mut c_void需要手动声明 Send // PluginHandle 是 *mut c_void需要手动声明 Send
@@ -41,10 +42,7 @@ impl DynamicPlugin {
/// ///
/// # Safety /// # Safety
/// .so 文件必须是由 showen-plugin-sdk 编译的合法插件 /// .so 文件必须是由 showen-plugin-sdk 编译的合法插件
pub unsafe fn load( pub unsafe fn load(so_path: &str, dependencies: Vec<String>) -> Result<Self> {
so_path: &str,
dependencies: Vec<String>,
) -> Result<Self> {
let library = unsafe { let library = unsafe {
Library::new(so_path) Library::new(so_path)
.with_context(|| format!("failed to load plugin .so: {so_path}"))? .with_context(|| format!("failed to load plugin .so: {so_path}"))?
@@ -54,9 +52,7 @@ impl DynamicPlugin {
let vtable: &'static PluginVTable = unsafe { let vtable: &'static PluginVTable = unsafe {
let symbol = library let symbol = library
.get::<*const PluginVTable>(PLUGIN_VTABLE_SYMBOL) .get::<*const PluginVTable>(PLUGIN_VTABLE_SYMBOL)
.with_context(|| { .with_context(|| format!("symbol 'showen_plugin_vtable' not found in {so_path}"))?;
format!("symbol 'showen_plugin_vtable' not found in {so_path}")
})?;
&**symbol &**symbol
}; };
@@ -83,6 +79,7 @@ impl DynamicPlugin {
id, id,
dependencies, dependencies,
so_path: so_path.to_string(), so_path: so_path.to_string(),
sender_ctx: std::ptr::null_mut(),
}) })
} }
@@ -98,14 +95,8 @@ impl DynamicPlugin {
/// 将 FfiResult 转为 anyhow::Result /// 将 FfiResult 转为 anyhow::Result
unsafe fn check_result(&self, result: FfiResult, operation: &str) -> Result<()> { unsafe fn check_result(&self, result: FfiResult, operation: &str) -> Result<()> {
unsafe { result.into_result() }.map_err(|e| { unsafe { result.into_result() }
anyhow!( .map_err(|e| anyhow!("plugin '{}' {} failed: {}", self.id, operation, e))
"plugin '{}' {} failed: {}",
self.id,
operation,
e
)
})
} }
} }
@@ -122,21 +113,40 @@ impl Plugin for DynamicPlugin {
self.dependencies.clone() self.dependencies.clone()
} }
fn capabilities(&self) -> Vec<String> {
let ffi_str: FfiString = unsafe { (self.vtable.get_capabilities)(self.handle) };
let json = match unsafe { ffi_str.into_string() } {
Some(s) => s,
None => return vec![],
};
serde_json::from_str(&json).unwrap_or_default()
}
fn self_test(&mut self) -> Vec<CapabilityTestResult> {
let ffi_str: FfiString = unsafe { (self.vtable.self_test)(self.handle) };
let json = match unsafe { ffi_str.into_string() } {
Some(s) => s,
None => return vec![],
};
serde_json::from_str(&json).unwrap_or_default()
}
fn init(&mut self, ctx: PluginContext) -> Result<()> { fn init(&mut self, ctx: PluginContext) -> Result<()> {
// 序列化配置为 JSON
let config_json = serde_json::to_string(ctx.config.as_ref()) let config_json = serde_json::to_string(ctx.config.as_ref())
.context("failed to serialize config for dynamic plugin")?; .context("failed to serialize config for dynamic plugin")?;
let config_cstr = CString::new(config_json) let config_cstr = CString::new(config_json).context("config JSON contains null byte")?;
.context("config JSON contains null byte")?;
// 创建 SendCallback — 将 mpsc::Sender 转为 C 函数指针 // Sender 分配到堆上,生命周期由 DynamicPlugin 管理
// 使用 thread_local 存储 sender每次 init 更新) let sender_box = Box::new(ctx.tx);
PLUGIN_SENDER.with(|cell| { self.sender_ctx = Box::into_raw(sender_box) as *mut std::ffi::c_void;
*cell.borrow_mut() = Some(ctx.tx);
});
let result = unsafe { let result = unsafe {
(self.vtable.init)(self.handle, config_cstr.as_ptr(), ffi_send_callback) (self.vtable.init)(
self.handle,
config_cstr.as_ptr(),
self.sender_ctx,
ffi_send_callback,
)
}; };
unsafe { self.check_result(result, "init") } unsafe { self.check_result(result, "init") }
} }
@@ -149,12 +159,9 @@ impl Plugin for DynamicPlugin {
fn handle_message(&mut self, msg: Message) -> Result<()> { fn handle_message(&mut self, msg: Message) -> Result<()> {
let msg_json = serde_json::to_string(&msg) let msg_json = serde_json::to_string(&msg)
.context("failed to serialize message for dynamic plugin")?; .context("failed to serialize message for dynamic plugin")?;
let msg_cstr = CString::new(msg_json) let msg_cstr = CString::new(msg_json).context("message JSON contains null byte")?;
.context("message JSON contains null byte")?;
let result = unsafe { let result = unsafe { (self.vtable.handle_message)(self.handle, msg_cstr.as_ptr()) };
(self.vtable.handle_message)(self.handle, msg_cstr.as_ptr())
};
unsafe { self.check_result(result, "handle_message") } unsafe { self.check_result(result, "handle_message") }
} }
@@ -169,18 +176,29 @@ impl Drop for DynamicPlugin {
if !self.handle.is_null() { if !self.handle.is_null() {
unsafe { (self.vtable.destroy)(self.handle) }; unsafe { (self.vtable.destroy)(self.handle) };
} }
if !self.sender_ctx.is_null() {
unsafe {
drop(Box::from_raw(
self.sender_ctx as *mut mpsc::Sender<Envelope>,
));
}
self.sender_ctx = std::ptr::null_mut();
}
} }
} }
// ── SendCallback 实现 ── // ── SendCallback 实现 ──
thread_local! {
static PLUGIN_SENDER: std::cell::RefCell<Option<mpsc::Sender<Envelope>>> =
std::cell::RefCell::new(None);
}
/// C FFI 回调:插件调用此函数向主程序发消息 /// C FFI 回调:插件调用此函数向主程序发消息
unsafe extern "C" fn ffi_send_callback(envelope_json: crate::core::plugin_abi::FfiStr) { unsafe extern "C" fn ffi_send_callback(
ctx: *mut std::ffi::c_void,
envelope_json: crate::core::plugin_abi::FfiStr,
) {
if ctx.is_null() {
eprintln!("[DynamicPlugin] send callback received null sender ctx");
return;
}
let json_str = match unsafe { ffi_str_to_str(envelope_json) } { let json_str = match unsafe { ffi_str_to_str(envelope_json) } {
Some(s) => s, Some(s) => s,
None => { None => {
@@ -197,11 +215,8 @@ unsafe extern "C" fn ffi_send_callback(envelope_json: crate::core::plugin_abi::F
} }
}; };
PLUGIN_SENDER.with(|cell| { let tx = unsafe { &*(ctx as *const mpsc::Sender<Envelope>) };
if let Some(tx) = cell.borrow().as_ref() { if let Err(e) = tx.send(envelope) {
if let Err(e) = tx.send(envelope) { eprintln!("[DynamicPlugin] failed to send envelope: {e}");
eprintln!("[DynamicPlugin] failed to send envelope: {e}"); }
}
}
});
} }

View File

@@ -4,6 +4,14 @@ use anyhow::Result;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use std::sync::{mpsc, Arc}; use std::sync::{mpsc, Arc};
/// 单项能力测试结果
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CapabilityTestResult {
pub capability: String,
pub passed: bool,
pub message: String,
}
/// 所有功能都通过实现此 trait 接入系统 /// 所有功能都通过实现此 trait 接入系统
pub trait Plugin: Send { pub trait Plugin: Send {
/// 唯一标识 (如 "video", "http", "ble") /// 唯一标识 (如 "video", "http", "ble")
@@ -17,6 +25,24 @@ pub trait Plugin: Send {
vec![] vec![]
} }
/// 声明插件支持的功能列表(默认空)
fn capabilities(&self) -> Vec<String> {
vec![]
}
/// 运行自测,返回每项功能的测试结果
/// 默认实现:所有声明的 capability 均标记为通过
fn self_test(&mut self) -> Vec<CapabilityTestResult> {
self.capabilities()
.into_iter()
.map(|c| CapabilityTestResult {
capability: c,
passed: true,
message: "no test defined".into(),
})
.collect()
}
/// 初始化:获取发送通道,声明订阅的消息类型 /// 初始化:获取发送通道,声明订阅的消息类型
fn init(&mut self, ctx: PluginContext) -> Result<()>; fn init(&mut self, ctx: PluginContext) -> Result<()>;

View File

@@ -97,7 +97,7 @@ impl FfiResult {
/// 插件向主程序发消息的回调函数类型 /// 插件向主程序发消息的回调函数类型
/// envelope_json: JSON 序列化的 Envelope /// envelope_json: JSON 序列化的 Envelope
pub type SendCallback = unsafe extern "C" fn(envelope_json: FfiStr); pub type SendCallback = unsafe extern "C" fn(ctx: *mut c_void, envelope_json: FfiStr);
/// 插件虚函数表 — 每个动态插件导出一个此结构体 /// 插件虚函数表 — 每个动态插件导出一个此结构体
#[repr(C)] #[repr(C)]
@@ -111,20 +111,32 @@ pub struct PluginVTable {
/// 初始化插件 /// 初始化插件
/// config_json: 完整的 AppConfig JSON /// config_json: 完整的 AppConfig JSON
/// send_cb: 发送消息的回调 /// send_cb: 发送消息的回调
pub init: unsafe extern "C" fn(handle: PluginHandle, config_json: FfiStr, send_cb: SendCallback) -> FfiResult, pub init: unsafe extern "C" fn(
handle: PluginHandle,
config_json: FfiStr,
send_ctx: *mut c_void,
send_cb: SendCallback,
) -> FfiResult,
/// 启动插件 /// 启动插件
pub start: unsafe extern "C" fn(handle: PluginHandle) -> FfiResult, pub start: unsafe extern "C" fn(handle: PluginHandle) -> FfiResult,
/// 处理消息 /// 处理消息
/// message_json: JSON 序列化的 Message /// message_json: JSON 序列化的 Message
pub handle_message: unsafe extern "C" fn(handle: PluginHandle, message_json: FfiStr) -> FfiResult, pub handle_message:
unsafe extern "C" fn(handle: PluginHandle, message_json: FfiStr) -> FfiResult,
/// 停止插件 /// 停止插件
pub stop: unsafe extern "C" fn(handle: PluginHandle) -> FfiResult, pub stop: unsafe extern "C" fn(handle: PluginHandle) -> FfiResult,
/// 销毁插件实例,释放资源 /// 销毁插件实例,释放资源
pub destroy: unsafe extern "C" fn(handle: PluginHandle), pub destroy: unsafe extern "C" fn(handle: PluginHandle),
/// 获取功能列表 (返回 JSON: Vec<String>)
pub get_capabilities: unsafe extern "C" fn(handle: PluginHandle) -> FfiString,
/// 运行自测 (返回 JSON: Vec<CapabilityTestResult>)
pub self_test: unsafe extern "C" fn(handle: PluginHandle) -> FfiString,
} }
/// 动态插件 .so 中导出的符号名称 /// 动态插件 .so 中导出的符号名称

View File

@@ -29,12 +29,28 @@ pub struct PluginManifest {
#[serde(default = "default_error_policy")] #[serde(default = "default_error_policy")]
pub error_policy: ErrorPolicy, pub error_policy: ErrorPolicy,
pub so_filename: String, pub so_filename: String,
/// 插件声明支持的功能列表
#[serde(default)]
pub capabilities: Vec<String>,
/// 挂载时必须通过测试的功能capabilities 的子集)
#[serde(default)]
pub required_capabilities: Vec<String>,
/// 自测超时(毫秒),默认 5000
#[serde(default = "default_test_timeout")]
pub test_timeout_ms: u64,
/// 是否在挂载时自动运行自测,默认 true
#[serde(default = "default_true")]
pub auto_test: bool,
} }
fn default_error_policy() -> ErrorPolicy { fn default_error_policy() -> ErrorPolicy {
ErrorPolicy::AutoRollback ErrorPolicy::AutoRollback
} }
fn default_test_timeout() -> u64 {
5000
}
/// 插件错误处理策略 /// 插件错误处理策略
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "snake_case")] #[serde(rename_all = "snake_case")]
@@ -250,6 +266,10 @@ mod tests {
dependencies: vec![], dependencies: vec![],
error_policy: ErrorPolicy::AutoRollback, error_policy: ErrorPolicy::AutoRollback,
so_filename: "libtest_plugin.so".to_string(), so_filename: "libtest_plugin.so".to_string(),
capabilities: vec![],
required_capabilities: vec![],
test_timeout_ms: 5000,
auto_test: true,
}; };
fs::write( fs::write(
@@ -333,4 +353,38 @@ mod tests {
let _ = fs::remove_dir_all(&tmp); let _ = fs::remove_dir_all(&tmp);
} }
#[test]
fn manifest_parses_capabilities() {
let json = r#"{
"id": "sensor",
"version": "1.0.0",
"sdk_version": "0.2.0",
"so_filename": "libsensor.so",
"capabilities": ["temperature", "humidity"],
"required_capabilities": ["temperature"],
"test_timeout_ms": 3000,
"auto_test": true
}"#;
let manifest: PluginManifest = serde_json::from_str(json).unwrap();
assert_eq!(manifest.capabilities, vec!["temperature", "humidity"]);
assert_eq!(manifest.required_capabilities, vec!["temperature"]);
assert_eq!(manifest.test_timeout_ms, 3000);
assert!(manifest.auto_test);
}
#[test]
fn manifest_capabilities_default_empty() {
let json = r#"{
"id": "basic",
"version": "1.0.0",
"sdk_version": "0.2.0",
"so_filename": "libbasic.so"
}"#;
let manifest: PluginManifest = serde_json::from_str(json).unwrap();
assert!(manifest.capabilities.is_empty());
assert!(manifest.required_capabilities.is_empty());
assert_eq!(manifest.test_timeout_ms, 5000);
assert!(manifest.auto_test);
}
} }

View File

@@ -1,6 +1,6 @@
use crate::core::config::AppConfig; use crate::core::config::AppConfig;
use crate::core::message::{Destination, Envelope, Message}; use crate::core::message::{Destination, Envelope, Message};
use crate::core::plugin::{Plugin, PluginContext}; use crate::core::plugin::{CapabilityTestResult, Plugin, PluginContext};
use crate::core::plugin_loader::ErrorPolicy; use crate::core::plugin_loader::ErrorPolicy;
use anyhow::{anyhow, Result}; use anyhow::{anyhow, Result};
use std::collections::{HashMap, HashSet}; use std::collections::{HashMap, HashSet};
@@ -19,6 +19,14 @@ struct PluginState {
max_errors: u32, max_errors: u32,
/// 是否启用 /// 是否启用
enabled: bool, enabled: bool,
/// 挂载时的自测结果
test_results: Vec<CapabilityTestResult>,
/// 声明的功能列表
capabilities: Vec<String>,
/// manifest 中声明的必须通过的功能
required_capabilities: Vec<String>,
/// 是否自动测试
auto_test: bool,
} }
impl PluginState { impl PluginState {
@@ -30,14 +38,14 @@ impl PluginState {
error_count: 0, error_count: 0,
max_errors: u32::MAX, // 静态插件不自动禁用 max_errors: u32::MAX, // 静态插件不自动禁用
enabled: true, enabled: true,
test_results: vec![],
capabilities: vec![],
required_capabilities: vec![],
auto_test: false, // 静态插件默认不自测
} }
} }
fn new_dynamic( fn new_dynamic(plugin: Box<dyn Plugin>, error_policy: ErrorPolicy, max_errors: u32) -> Self {
plugin: Box<dyn Plugin>,
error_policy: ErrorPolicy,
max_errors: u32,
) -> Self {
Self { Self {
plugin, plugin,
is_dynamic: true, is_dynamic: true,
@@ -45,6 +53,10 @@ impl PluginState {
error_count: 0, error_count: 0,
max_errors, max_errors,
enabled: true, enabled: true,
test_results: vec![],
capabilities: vec![],
required_capabilities: vec![],
auto_test: true,
} }
} }
@@ -97,6 +109,19 @@ impl ServiceManager {
plugin: Box<dyn Plugin>, plugin: Box<dyn Plugin>,
error_policy: ErrorPolicy, error_policy: ErrorPolicy,
max_errors: u32, max_errors: u32,
) {
self.register_dynamic_with_manifest(plugin, error_policy, max_errors, vec![], vec![], true);
}
/// 注册动态插件(带 manifest 自测信息)
pub fn register_dynamic_with_manifest(
&mut self,
plugin: Box<dyn Plugin>,
error_policy: ErrorPolicy,
max_errors: u32,
required_capabilities: Vec<String>,
capabilities: Vec<String>,
auto_test: bool,
) { ) {
println!( println!(
"[ServiceManager] 注册动态插件: {} (策略: {:?}, 最大错误: {})", "[ServiceManager] 注册动态插件: {} (策略: {:?}, 最大错误: {})",
@@ -104,16 +129,19 @@ impl ServiceManager {
error_policy, error_policy,
max_errors max_errors
); );
self.plugins let mut state = PluginState::new_dynamic(plugin, error_policy, max_errors);
.push(PluginState::new_dynamic(plugin, error_policy, max_errors)); state.required_capabilities = required_capabilities;
state.capabilities = capabilities;
state.auto_test = auto_test;
self.plugins.push(state);
} }
/// 按注册顺序 init() + start() 所有插件 /// 按注册顺序 init() → self_test() → start() 所有插件
/// 动态插件 init/start 失败时按策略处理,不中断其他插件 /// 动态插件 init/start/test 失败时按策略处理,不中断其他插件
pub fn start_all(&mut self) -> Result<()> { pub fn start_all(&mut self) -> Result<()> {
self.validate_and_sort_plugins()?; self.validate_and_sort_plugins()?;
// init // Phase 1: init
for state in &mut self.plugins { for state in &mut self.plugins {
let ctx = PluginContext { let ctx = PluginContext {
tx: self.tx.clone(), tx: self.tx.clone(),
@@ -135,7 +163,91 @@ impl ServiceManager {
} }
} }
// start // Phase 2: self_test (init 之后, start 之前)
for state in &mut self.plugins {
if !state.enabled || !state.auto_test {
continue;
}
// 获取插件的功能列表并运行自测
let caps = state.plugin.capabilities();
if caps.is_empty() && state.required_capabilities.is_empty() {
// 无功能声明 → 跳过自测
continue;
}
println!(
"[ServiceManager] 自测插件: {} (功能: {:?})",
state.id(),
caps
);
state.capabilities = caps;
let results = state.plugin.self_test();
// 检查 required_capabilities 中的项是否全部通过
let mut has_required_failure = false;
let passed_caps: std::collections::HashSet<&str> = results
.iter()
.filter(|r| r.passed)
.map(|r| r.capability.as_str())
.collect();
// 检查每个 required capability 是否出现在通过列表中
for req in &state.required_capabilities {
if !passed_caps.contains(req.as_str()) {
eprintln!(
"[ServiceManager] ✗ [必须] {}{}",
req,
results
.iter()
.find(|r| r.capability == *req)
.map(|r| r.message.as_str())
.unwrap_or("未在测试结果中出现")
);
has_required_failure = true;
}
}
for result in &results {
if result.passed {
println!(
"[ServiceManager] ✓ {}{}",
result.capability, result.message
);
} else if !state.required_capabilities.contains(&result.capability) {
eprintln!(
"[ServiceManager] ✗ [可选] {}{}",
result.capability, result.message
);
}
}
state.test_results = results;
if has_required_failure {
if state.is_dynamic {
match &state.error_policy {
ErrorPolicy::AutoRollback => {
eprintln!(
"[ServiceManager] 动态插件 '{}' 必须能力自测失败,禁用 (待回退)",
state.id()
);
}
ErrorPolicy::DisableAndLog => {
eprintln!(
"[ServiceManager] 动态插件 '{}' 必须能力自测失败,禁用",
state.id()
);
}
}
state.enabled = false;
} else {
return Err(anyhow!("静态插件 '{}' 必须能力自测失败", state.id()));
}
}
}
// Phase 3: start
for state in &mut self.plugins { for state in &mut self.plugins {
if !state.enabled { if !state.enabled {
continue; continue;
@@ -198,11 +310,7 @@ impl ServiceManager {
} }
println!("[ServiceManager] 停止插件: {}", state.id()); println!("[ServiceManager] 停止插件: {}", state.id());
if let Err(e) = state.plugin.stop() { if let Err(e) = state.plugin.stop() {
eprintln!( eprintln!("[ServiceManager] 停止插件 '{}' 失败: {}", state.id(), e);
"[ServiceManager] 停止插件 '{}' 失败: {}",
state.id(),
e
);
} }
} }
Ok(()) Ok(())
@@ -241,6 +349,8 @@ impl ServiceManager {
error_count: s.error_count, error_count: s.error_count,
max_errors: s.max_errors, max_errors: s.max_errors,
enabled: s.enabled, enabled: s.enabled,
test_results: s.test_results.clone(),
capabilities: s.capabilities.clone(),
}) })
.collect() .collect()
} }
@@ -259,15 +369,13 @@ impl ServiceManager {
.position(|s| s.id() == plugin_id) .position(|s| s.id() == plugin_id)
.ok_or_else(|| anyhow!("plugin '{plugin_id}' not found for replacement"))?; .ok_or_else(|| anyhow!("plugin '{plugin_id}' not found for replacement"))?;
// Stop old plugin if !self.plugins[idx].is_dynamic {
if self.plugins[idx].enabled { return Err(anyhow!(
let _ = self.plugins[idx].plugin.stop(); "plugin '{plugin_id}' is not dynamic and cannot be replaced"
));
} }
// Replace
let mut new_state = PluginState::new_dynamic(new_plugin, error_policy, max_errors); let mut new_state = PluginState::new_dynamic(new_plugin, error_policy, max_errors);
// Init new plugin
let ctx = PluginContext { let ctx = PluginContext {
tx: self.tx.clone(), tx: self.tx.clone(),
config: Arc::clone(&self.config), config: Arc::clone(&self.config),
@@ -275,6 +383,10 @@ impl ServiceManager {
new_state.plugin.init(ctx)?; new_state.plugin.init(ctx)?;
new_state.plugin.start()?; new_state.plugin.start()?;
if self.plugins[idx].enabled {
let _ = self.plugins[idx].plugin.stop();
}
self.plugins[idx] = new_state; self.plugins[idx] = new_state;
println!("[ServiceManager] 插件 '{plugin_id}' 热替换成功"); println!("[ServiceManager] 插件 '{plugin_id}' 热替换成功");
Ok(()) Ok(())
@@ -487,6 +599,7 @@ impl ServiceManager {
"[ServiceManager] 插件 '{}' 错误次数达到阈值,已禁用", "[ServiceManager] 插件 '{}' 错误次数达到阈值,已禁用",
plugin_id plugin_id
); );
let _ = state.plugin.stop();
state.enabled = false; state.enabled = false;
} }
ErrorPolicy::AutoRollback => { ErrorPolicy::AutoRollback => {
@@ -495,6 +608,7 @@ impl ServiceManager {
plugin_id plugin_id
); );
// 先禁用,等待外部 (main.rs / HTTP API) 调用 VersionManager 执行回退 // 先禁用,等待外部 (main.rs / HTTP API) 调用 VersionManager 执行回退
let _ = state.plugin.stop();
state.enabled = false; state.enabled = false;
} }
} }
@@ -516,4 +630,6 @@ pub struct PluginStateInfo {
pub error_count: u32, pub error_count: u32,
pub max_errors: u32, pub max_errors: u32,
pub enabled: bool, pub enabled: bool,
pub test_results: Vec<CapabilityTestResult>,
pub capabilities: Vec<String>,
} }

View File

@@ -1,7 +1,8 @@
use super::config::{parse_str, AppConfig}; use super::config::{parse_str, AppConfig};
use super::message::{Destination, Envelope, Message}; use super::message::{Destination, Envelope, Message};
use super::plugin::{Platform, Plugin, PluginContext, PluginInfo}; use super::plugin::{CapabilityTestResult, Platform, Plugin, PluginContext, PluginInfo};
use super::service_manager::ServiceManager; use super::service_manager::ServiceManager;
use super::plugin_loader::ErrorPolicy;
use anyhow::Result; use anyhow::Result;
use std::sync::{Arc, Mutex}; use std::sync::{Arc, Mutex};
@@ -421,3 +422,194 @@ fn topological_sort_places_http_after_video() {
http_init_pos http_init_pos
); );
} }
// ── 自测相关测试 ──
/// 支持自测的 TestPlugin 变体
struct TestPluginWithSelfTest {
id: String,
events: Arc<Mutex<Vec<String>>>,
caps: Vec<String>,
test_results: Vec<CapabilityTestResult>,
}
impl TestPluginWithSelfTest {
fn new(
id: &str,
events: Arc<Mutex<Vec<String>>>,
caps: Vec<String>,
test_results: Vec<CapabilityTestResult>,
) -> Self {
Self {
id: id.to_string(),
events,
caps,
test_results,
}
}
fn record(&self, entry: impl Into<String>) {
lock_events(&self.events).push(entry.into());
}
}
impl Plugin for TestPluginWithSelfTest {
fn id(&self) -> &str {
&self.id
}
fn info(&self) -> PluginInfo {
PluginInfo {
name: self.id.clone(),
version: "test".to_string(),
description: "test plugin with self_test".to_string(),
platform: Platform::Any,
}
}
fn capabilities(&self) -> Vec<String> {
self.caps.clone()
}
fn self_test(&mut self) -> Vec<CapabilityTestResult> {
self.record(format!("self_test:{}", self.id));
self.test_results.clone()
}
fn init(&mut self, _ctx: PluginContext) -> Result<()> {
self.record(format!("init:{}", self.id));
Ok(())
}
fn start(&mut self) -> Result<()> {
self.record(format!("start:{}", self.id));
Ok(())
}
fn handle_message(&mut self, _msg: Message) -> Result<()> {
Ok(())
}
fn stop(&mut self) -> Result<()> {
self.record(format!("stop:{}", self.id));
Ok(())
}
}
#[test]
fn self_test_all_pass_allows_normal_start() {
let events = Arc::new(Mutex::new(Vec::new()));
let mut manager = ServiceManager::new(test_config());
let plugin = TestPluginWithSelfTest::new(
"sensor",
events.clone(),
vec!["temperature".into()],
vec![CapabilityTestResult {
capability: "temperature".into(),
passed: true,
message: "ok".into(),
}],
);
manager.register_dynamic_with_manifest(
Box::new(plugin),
ErrorPolicy::DisableAndLog,
5,
vec!["temperature".into()],
vec!["temperature".into()],
true,
);
manager.start_all().expect("start_all should succeed when all tests pass");
let log = lock_events(&events);
assert!(log.contains(&"init:sensor".to_string()));
assert!(log.contains(&"self_test:sensor".to_string()));
assert!(log.contains(&"start:sensor".to_string()));
}
#[test]
fn self_test_required_capability_fails_disables_dynamic_plugin() {
let events = Arc::new(Mutex::new(Vec::new()));
let mut manager = ServiceManager::new(test_config());
let plugin = TestPluginWithSelfTest::new(
"sensor",
events.clone(),
vec!["temperature".into()],
vec![CapabilityTestResult {
capability: "temperature".into(),
passed: false,
message: "sensor not connected".into(),
}],
);
manager.register_dynamic_with_manifest(
Box::new(plugin),
ErrorPolicy::DisableAndLog,
5,
vec!["temperature".into()],
vec!["temperature".into()],
true,
);
// Should succeed (dynamic plugin failure doesn't abort)
manager.start_all().expect("start_all should succeed");
let log = lock_events(&events);
assert!(log.contains(&"init:sensor".to_string()));
assert!(log.contains(&"self_test:sensor".to_string()));
// start should NOT have been called
assert!(!log.contains(&"start:sensor".to_string()));
// Plugin should be disabled
let states = manager.plugin_states();
assert!(!states[0].enabled, "plugin should be disabled after required capability failure");
}
#[test]
fn self_test_optional_capability_fails_still_starts() {
let events = Arc::new(Mutex::new(Vec::new()));
let mut manager = ServiceManager::new(test_config());
let plugin = TestPluginWithSelfTest::new(
"sensor",
events.clone(),
vec!["temperature".into(), "humidity".into()],
vec![
CapabilityTestResult {
capability: "temperature".into(),
passed: true,
message: "ok".into(),
},
CapabilityTestResult {
capability: "humidity".into(),
passed: false,
message: "sensor not calibrated".into(),
},
],
);
// Only temperature is required; humidity is optional
manager.register_dynamic_with_manifest(
Box::new(plugin),
ErrorPolicy::DisableAndLog,
5,
vec!["temperature".into()],
vec!["temperature".into(), "humidity".into()],
true,
);
manager.start_all().expect("start_all should succeed");
let log = lock_events(&events);
assert!(log.contains(&"self_test:sensor".to_string()));
assert!(log.contains(&"start:sensor".to_string()), "plugin should start despite optional failure");
// Test results should be recorded
let states = manager.plugin_states();
assert_eq!(states[0].test_results.len(), 2);
assert!(states[0].test_results[0].passed);
assert!(!states[0].test_results[1].passed);
}

View File

@@ -80,10 +80,13 @@ fn main() -> Result<()> {
match loader.load_plugin(plugin_id, Some(&entry.active_version)) { match loader.load_plugin(plugin_id, Some(&entry.active_version)) {
Ok((plugin, manifest)) => { Ok((plugin, manifest)) => {
manager.register_dynamic( manager.register_dynamic_with_manifest(
Box::new(plugin), Box::new(plugin),
manifest.error_policy, manifest.error_policy,
entry.max_errors, entry.max_errors,
manifest.required_capabilities,
manifest.capabilities,
manifest.auto_test,
); );
println!( println!(
"{} v{} (动态)", "{} v{} (动态)",