modify: 统一插件风格

This commit is contained in:
2026-08-14 16:04:39 +08:00
parent 56dd4f0ebb
commit 4fa066432d
10 changed files with 516 additions and 436 deletions
+37 -12
View File
@@ -30,7 +30,7 @@
| **example** | 最简示例,Plugin 基类 + Database + 生命周期 | 入门学习 | | **example** | 最简示例,Plugin 基类 + Database + 生命周期 | 入门学习 |
| **auth** | SQLModel ORM + 管理员初始化 + 多进程幂等 | 数据库操作 | | **auth** | SQLModel ORM + 管理员初始化 + 多进程幂等 | 数据库操作 |
| **doubao** | 外部 API 客户端(Ark/OpenAI)+ AICC 安全通道 | 外部集成 | | **doubao** | 外部 API 客户端(Ark/OpenAI)+ AICC 安全通道 | 外部集成 |
| **aibox_sophon_adapter** | 自定义 `APIRouter` + MQTT 转发 + 后台任务 | 高级模式 | | **aibox_sophon_adapter** | 自定义 `APIRouter` + MQTT 客户端组件(`mqtt_clients.py`)+ 后台任务 | 高级模式 |
--- ---
@@ -56,21 +56,24 @@ touch src/plugins/<name>/router/__init__.py
### 第二步:注册插件 ### 第二步:注册插件
在 `config/workers/config.toml` 中添加: 在 `config/workers/<name>/config.toml` 中通过 `[workers]` 节注册(网关启动时自动扫描发现):
```toml ```toml
[[workers]] # config/workers/<name>/config.toml
type = "<name>" [workers]
module = "plugins.<name>" type = "<name>" # 插件类型(worker type)
factory = "create_workers" module = "plugins.<name>" # Python 模块路径
cleanup = "cleanup" factory = "create_workers" # 工厂函数名(Plugin 基类绑定的方法)
count = 3 cleanup = "cleanup" # 清理函数名
count = 3 # Worker 副本数
# 运行时配置(展平后传给 create_workers 的 config 参数)
[workers.config] [workers.config]
type = "<name>"
data_dir = "./data" data_dir = "./data"
``` ```
> 旧式全局注册文件 `config/workers/config.toml`(`[[workers]]` 数组)仅作兜底兼容,新插件请使用各插件自己的 `config.toml`。
### 第三步:编写端点 TOML 配置 ### 第三步:编写端点 TOML 配置
创建 `config/workers/<name>/config.toml`(分组配置): 创建 `config/workers/<name>/config.toml`(分组配置):
@@ -167,9 +170,9 @@ create_workers = _plugin.create_workers
cleanup = _plugin.cleanup cleanup = _plugin.cleanup
``` ```
基类自动处理:幂等、统一日志、数据库建库/释放、后台任务取消、清理钩子、模型自动绑定。详见 `utils/worker/README.md` 的「Plugin 基类」章节。 基类自动处理:幂等(初始化锁)、统一日志、数据库建库/释放、后台任务取消、清理钩子、模型自动绑定、cleanup 时重置已发布的 global_var。详见 `utils/worker/README.md` 的「Plugin 基类」章节。
> **兼容旧写法**:不依赖基类的传统 `create_workers` / `cleanup` 模块级函数写法仍受支持(见 `utils/worker/README.md`)。 > **旧写法已废弃**:不依赖基类的传统 `create_workers` / `cleanup` 模块级函数写法仅框架层面保留兼容,现有插件(auth / doubao / aibox_sophon_adapter / example)均已迁移到 Plugin 基类,新插件请勿采用旧写法。
--- ---
@@ -311,16 +314,38 @@ async def handler(request: RequestCls) -> ResponseCls:
``` ```
| method | 参数来源 | 请求绑定 | | method | 参数来源 | 请求绑定 |
| ------ | ----------------- | ---------------------------- | | ------ | ------------------ | ---------------------------- |
| `GET` | URL query string | TOML `params` 定义 | | `GET` | URL query string | TOML `params` 定义 |
| `POST` | JSON body | 自动绑定到 Pydantic 请求模型 | | `POST` | JSON body | 自动绑定到 Pydantic 请求模型 |
| `MQTT` | MQTT payload JSON | 同 POST | | `MQTT` | MQTT payload JSON | 同 POST |
**请求信封协议**:网关统一把请求组装为 `{"meta": {"method", "user_info"}, "data": {...}}`
发给 Worker;运行时按 `meta.method` 分派到对应 `@rpc_method`,`data` 经请求模型校验后
传给处理器。MQTT 客户端发布消息时需按信封格式(见主 README「请求负载格式」),
MQTT 专用端点要显式 `"meta": {"method": "MQTT"}`。
- 设置 `internal = true` 后,端点不注册 HTTP 路由,仅可通过 Zenoh RPC 或 MQTT 内部调用 - 设置 `internal = true` 后,端点不注册 HTTP 路由,仅可通过 Zenoh RPC 或 MQTT 内部调用
- MQTT 方法返回 `None` 时,Bridge 自动跳过响应发布 - MQTT 方法返回 `None` 时,Bridge 自动跳过响应发布
--- ---
## 启动契约校验
服务启动时(`load_workers` 之后、注册路由之前),网关自动核对三方一致性:
| 检查项 | 策略 |
| ---------------------------- | --------------------------- |
| TOML HTTP 路由缺 `@rpc_method` | **硬失败**,启动直接终止 |
| `[MQTT]` / `internal` 路由缺注册 | 警告日志 |
| 已注册方法无 TOML 路由 | 警告日志(反向漂移检测) |
| params/response_model 缺生成模型 | 警告日志(gen_models 过期) |
| 生成模型未被任何路由引用 | 警告日志(孤儿模型) |
新增端点后如果启动报错「缺少对应的 @rpc_method 注册」,检查 TOML 的 `subject`/方法节
与 `router/<op>.py` 中的 `@rpc_method(subject=..., method=...)` 是否一致。
---
## 命名约定 ## 命名约定
| 概念 | 约定 | 示例 | | 概念 | 约定 | 示例 |
+7 -5
View File
@@ -87,10 +87,12 @@ sequenceDiagram
```text ```text
src/plugins/aibox_sophon_adapter/ src/plugins/aibox_sophon_adapter/
├── __init__.py # 插件入口:create_workers() 工厂 + cleanup() ├── __init__.py # 插件入口:AiboxSophonAdapterPlugin(Plugin 基类)
│ # - 初始化 DB / StreamManager / 各 MQTT 客户端 │ # - on_init: DB / StreamManager / MQTT 客户端 / 全局变量 / 后台任务
│ # - _build_subscribe_routes() 构建订阅路由 │ # - on_cleanup: 流控制器与 MQTT 客户端关闭(db/后台任务由基类处理)
│ # - 启动离线检查后台任务 ├── mqtt_clients.py # MQTT 客户端能力组件(从 __init__ 抽取,统一"启动失败降级为 None"模式)
│ # - start_mqtt_forward / start_mqtt_subscribe / start_internal_mqtt
│ # - build_subscribe_routes() 构建订阅路由
├── global_var.py # 模块级全局变量(db / stream_manager / mqtt_* / 各配置) ├── global_var.py # 模块级全局变量(db / stream_manager / mqtt_* / 各配置)
├── routes.py # 自定义 FastAPI 路由(video_upload / video / alarm_report) ├── routes.py # 自定义 FastAPI 路由(video_upload / video / alarm_report)
├── edge_app_controller.py # 外部 MQTT 请求-响应处理器(command 注册表分发) ├── edge_app_controller.py # 外部 MQTT 请求-响应处理器(command 注册表分发)
@@ -356,7 +358,7 @@ tags = ["aibox_sophon_adapter", "sophon", "adapter"]
### 修改配置 ### 修改配置
编辑 `config/workers/aibox_sophon_adapter/config.toml` 后重启服务即可生效,无需重新生成模型 编辑 `config/workers/aibox_sophon_adapter/config.toml` 后重启服务即可生效,无需重新生成模型
(运行时配置均在 `create_workers` 中读取)。 (运行时配置均在 `AiboxSophonAdapterPlugin.on_init` 中读取;MQTT 客户端启动逻辑见 `mqtt_clients.py`)。
--- ---
+39 -232
View File
@@ -2,7 +2,7 @@
AI Box Sophon Adapter 插件 AI Box Sophon Adapter 插件
基于 MQTT 的 AI Box 设备心跳适配器,实现: 基于 MQTT 的 AI Box 设备心跳适配器,实现:
1. 通过 MQTT 主题 /board_ping 接收设备心跳上报 1. 通过 MQTT 主题接收设备心跳上报
2. 解析设备系统参数(温度、硬盘、内存、通道、任务、TPU 等) 2. 解析设备系统参数(温度、硬盘、内存、通道、任务、TPU 等)
3. 持久化心跳数据到 SQLite 数据库 3. 持久化心跳数据到 SQLite 数据库
4. 提供 HTTP API 查询设备在线状态和详情 4. 提供 HTTP API 查询设备在线状态和详情
@@ -15,122 +15,25 @@ AI Box Sophon Adapter 插件
- 设备按配置间隔(默认 5 秒)上报心跳 - 设备按配置间隔(默认 5 秒)上报心跳
- 服务端每 check_interval 秒检查一次 - 服务端每 check_interval 秒检查一次
- 超过 heartbeat_timeout 秒未收到心跳的设备标记为离线 - 超过 heartbeat_timeout 秒未收到心跳的设备标记为离线
生命周期: 基于 Plugin 基类统一管理(数据库/后台任务/清理钩子由基类处理),
MQTT 客户端启动块已抽取到 mqtt_clients 能力组件。
""" """
import asyncio import asyncio
import logging import logging
from datetime import datetime from datetime import datetime
from pathlib import Path from pathlib import Path
from typing import Any
from chongming_worker import auto_bind_models from chongming_worker.plugin import Plugin
from chongming_worker.runtime import create_workers as _create_workers
from .router import * from .router import * # noqa: F401,F403 触发 @rpc_method 注册
from .routes import router # 自定义 FastAPI 路由(网关自动发现) from .routes import router # noqa: F401 自定义 FastAPI 路由(网关通过包属性自动发现)
# 外部 MQTT 订阅请求处理函数(订阅 /edge_app_controller 等主题的 handler)
from .edge_app_controller import edge_app_controller # noqa: F401 from .edge_app_controller import edge_app_controller # noqa: F401
from .mqtt_clients import start_mqtt_forward, start_mqtt_subscribe, start_internal_mqtt
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
def _build_subscribe_routes(topics_cfg: list) -> list:
"""
根据配置的订阅主题列表构建 MQTTSubscribeClient 路由。
topics_cfg: [{"topic": "...", "handler": "board_ping", "reply_topic": "..."}, ...]
- topic: 订阅主题(支持 + / # 通配符)
- handler: 处理函数名,按序从插件 router 模块、插件包自身查找
- reply_topic: 可选,回复主题;处理后返回值将发布到该主题(支持 {1}{2} 占位符)
适配约定(按首个位置参数类型):
- Pydantic 模型: 用 payload 构造模型实例后调用(如 board_ping -> BoardPingRequest(**payload))
- dict: 直接传整个 payload 和 topic(如 edge_app_controller(payload, topic))
- 其他: 将 payload 作为关键字参数展开调用
返回: [{"topic": ..., "handler": async callable(payload, topic), "reply_topic": ...}]
"""
import importlib
from typing import get_origin
from pydantic import BaseModel
# 注意: 不能用 "from . import router",因为 __init__.py 中
# "from .routes import router" 已将 router 设为包级属性(FastAPI APIRouter 对象),
# 会遮蔽子模块包 aibox_sophon_adapter.router,故用 importlib 显式按名导入。
router_mod = importlib.import_module(f"{__name__}.router")
pkg_mod = importlib.import_module(__name__)
routes = []
for t in topics_cfg or []:
topic = t.get("topic", "")
handler_name = t.get("handler", "")
reply_topic = t.get("reply_topic")
if not topic or not handler_name:
logger.warning(f"[MQTTSubscribe] 跳过无效订阅项: {t}")
continue
fn = getattr(router_mod, handler_name, None)
if fn is None:
fn = getattr(pkg_mod, handler_name, None)
if fn is None:
logger.warning(
f"[MQTTSubscribe] 未知 handler '{handler_name}',跳过 {topic}"
)
continue
# 探测首个位置参数的类型注解
hints = getattr(fn, "__annotations__", {})
first_type: Any = None
for pname, ptype in hints.items():
if pname in ("worker_id", "return"):
continue
first_type = ptype
break
if (
isinstance(first_type, type)
and issubclass(first_type, BaseModel)
):
model_cls = first_type
async def _cb_model(data, topic, _fn=fn, _model=model_cls):
return await _fn(_model(**data))
routes.append({
"topic": topic,
"handler": _cb_model,
"reply_topic": reply_topic,
})
elif first_type is dict or get_origin(first_type) is dict:
# 处理函数期望整个 dict 作为第一个参数(如 edge_app_controller(payload, topic))
async def _cb_payload(data, topic, _fn=fn):
return await _fn(data, topic)
routes.append({
"topic": topic,
"handler": _cb_payload,
"reply_topic": reply_topic,
})
else:
# 其他签名:将 payload 按关键字参数展开调用
async def _cb_kwargs(data, topic, _fn=fn):
if isinstance(data, dict):
return await _fn(**data)
return await _fn()
routes.append({
"topic": topic,
"handler": _cb_kwargs,
"reply_topic": reply_topic,
})
return routes
# 自动绑定 Pydantic 模型(用于 gen_models 生成的模型)
auto_bind_models("aibox_sophon_adapter")
# 离线检查任务引用
_offline_check_task: asyncio.Task | None = None
async def _check_offline_devices(interval: float): async def _check_offline_devices(interval: float):
""" """
后台任务:定期检查离线设备 后台任务:定期检查离线设备
@@ -150,7 +53,7 @@ async def _check_offline_devices(interval: float):
from .models.orm import BoardHeartbeat from .models.orm import BoardHeartbeat
from sqlalchemy import select from sqlalchemy import select
stmt = select(BoardHeartbeat).where(BoardHeartbeat.is_online == True) # type: ignore stmt = select(BoardHeartbeat).where(BoardHeartbeat.is_online)
result = await session.execute(stmt) result = await session.execute(stmt)
records = result.scalars().all() records = result.scalars().all()
@@ -175,14 +78,17 @@ async def _check_offline_devices(interval: float):
logger.error(f"[OfflineChecker] 检查异常: {e}") logger.error(f"[OfflineChecker] 检查异常: {e}")
async def create_workers(session, config, count=1, worker_manager=None): class AiboxSophonAdapterPlugin(Plugin):
""" """AI Box 适配插件 — 统一生命周期。"""
插件入口 — 初始化数据库和全局资源,创建 Worker 副本
由 FastAPI lifespan 在启动时调用。 type = "aibox_sophon_adapter"
""" db_name = "aibox_sophon_adapter.db" # 自动在 data_dir 下建 SQLite 库
auto_bind = True # 首次初始化时自动 auto_bind_models
async def on_init(self):
"""首次 create_workers 时调用一次:初始化数据库客户端、流控制器、后台任务。"""
config = self.config
from .global_var import set_global_var, heartbeat_timeout as _default_timeout from .global_var import set_global_var, heartbeat_timeout as _default_timeout
from chongming_database import Database
from chongming_stream_manager import StreamManager from chongming_stream_manager import StreamManager
# 所有运行时配置均从 [workers.config] 及其子节读取 # 所有运行时配置均从 [workers.config] 及其子节读取
@@ -211,12 +117,8 @@ async def create_workers(session, config, count=1, worker_manager=None):
alarm_fwd_video_url = alarm_fwd_cfg.get("video_upload_url", "http://dg.datagoo.cn:60014/open-api/event-alarm/receiveVideoUpload") alarm_fwd_video_url = alarm_fwd_cfg.get("video_upload_url", "http://dg.datagoo.cn:60014/open-api/event-alarm/receiveVideoUpload")
alarm_fwd_timeout = alarm_fwd_cfg.get("timeout", 10.0) alarm_fwd_timeout = alarm_fwd_cfg.get("timeout", 10.0)
# 初始化数据库 # 数据库已由基类初始化(data_dir/aibox_sophon_adapter.db)
db_path = data_dir / "aibox_sophon_adapter.db" db = self.db
db_path.parent.mkdir(parents=True, exist_ok=True)
db = Database(db_path)
await db.initialize()
# 初始化视频流控制器 # 初始化视频流控制器
stream_manager = StreamManager(mediamtx_addr) stream_manager = StreamManager(mediamtx_addr)
@@ -224,93 +126,12 @@ async def create_workers(session, config, count=1, worker_manager=None):
# upload 根目录(用于清理告警关联视频) # upload 根目录(用于清理告警关联视频)
upload_root_path = str(data_dir / "uploads") upload_root_path = str(data_dir / "uploads")
# 设定模块级全局变量(供 _check_offline_devices 使用) # MQTT 客户端能力组件(启动失败降级为 None,仅警告)
global heartbeat_timeout mqtt_fwd_client = await start_mqtt_forward(config.get("mqtt_forward", {}))
heartbeat_timeout = hb_timeout mqtt_sub_client = await start_mqtt_subscribe(config.get("mqtt_subscribe", {}))
int_mqtt_client, int_req_topic, int_resp_topic = await start_internal_mqtt(
# 初始化外部 MQTT 转发客户端 config.get("internal_mqtt", {})
mqtt_fwd = config.get("mqtt_forward", {})
mqtt_fwd_enabled = mqtt_fwd.get("enabled", False)
mqtt_fwd_client = None
if mqtt_fwd_enabled:
from chongming_mqtt.mqtt_forward import MQTTForwardClient
mqtt_fwd_uri = mqtt_fwd.get("uri", "")
mqtt_fwd_topic = mqtt_fwd.get("topic", "/board_ping")
logger.warning(f"[MQTTForward] 配置已加载: enabled={mqtt_fwd_enabled}, uri={mqtt_fwd_uri}, topic={mqtt_fwd_topic}")
if mqtt_fwd_uri:
mqtt_fwd_client = MQTTForwardClient(uri=mqtt_fwd_uri, topic=mqtt_fwd_topic)
try:
await mqtt_fwd_client.start()
logger.warning(f"[MQTTForward] 外部转发已启用: {mqtt_fwd_uri} → {mqtt_fwd_topic}")
except Exception as e:
logger.warning(f"[MQTTForward] 启动失败,将跳过转发: {e}")
mqtt_fwd_client = None
else:
logger.warning(f"[MQTTForward] mqtt_forward 未启用,配置内容: {mqtt_fwd}")
# 初始化外部 MQTT 订阅客户端(本插件直接订阅外部 Broker,不走网关 Bridge)
mqtt_sub = config.get("mqtt_subscribe", {})
mqtt_sub_enabled = mqtt_sub.get("enabled", False)
mqtt_sub_client = None
if mqtt_sub_enabled:
from chongming_mqtt.mqtt_subscribe import MQTTSubscribeClient
mqtt_sub_uri = mqtt_sub.get("uri", "")
mqtt_sub_topics = mqtt_sub.get("topics", []) or []
logger.warning(
f"[MQTTSubscribe] 配置已加载: enabled={mqtt_sub_enabled}, "
f"uri={mqtt_sub_uri}, topics={mqtt_sub_topics}"
) )
if mqtt_sub_uri and mqtt_sub_topics:
routes = _build_subscribe_routes(mqtt_sub_topics)
mqtt_sub_client = MQTTSubscribeClient(uri=mqtt_sub_uri, routes=routes)
try:
await mqtt_sub_client.start()
logger.warning(f"[MQTTSubscribe] 外部订阅已启用: {mqtt_sub_uri}")
except Exception as e:
logger.warning(f"[MQTTSubscribe] 启动失败,将跳过订阅: {e}")
mqtt_sub_client = None
else:
logger.warning(
"[MQTTSubscribe] 未配置 uri 或 topics,跳过外部订阅"
)
else:
logger.warning(f"[MQTTSubscribe] mqtt_subscribe 未启用,配置内容: {mqtt_sub}")
# 初始化内部 MQTT 请求-响应客户端
# (handler 内对任意应用进程发起请求-响应,主题显式传入,默认连内部 127.0.0.1:1883)
int_mqtt_cfg = config.get("internal_mqtt", {})
int_mqtt_enabled = int_mqtt_cfg.get("enabled", False)
int_mqtt_client = None
# 内部请求-响应主题(edge_app_controller 的 alg_media_fetch 命令使用)。
# 与盒子侧约定一致(盒子只设根主题 box,子路径固定 aibox_sophon/...);
# 勿用 chongming/{插件}/{操作} 保留命名空间——网关 MQTT Bridge 订阅了
# chongming/+/+,会拦截 3 级主题并按 Worker RPC 路由,而 edge_app_controller
# 不是 @rpc_method(Worker 无对应 Queryable),导致 worker_manager 报
# "Worker ... 无回复,尝试重试"。
int_req_topic = int_mqtt_cfg.get(
"request_topic", "box/aibox_sophon/edge_app_controller"
)
int_resp_topic = int_mqtt_cfg.get(
"response_topic", "box/aibox_sophon/edge_app_controller_reply"
)
if int_mqtt_enabled:
from chongming_mqtt.internal_mqtt import InternalMQTTClient
int_mqtt_uri = int_mqtt_cfg.get("uri", "mqtt://127.0.0.1:1883")
logger.warning(
f"[InternalMQTT] 配置已加载: enabled={int_mqtt_enabled}, uri={int_mqtt_uri}, "
f"request_topic={int_req_topic}, response_topic={int_resp_topic}"
)
int_mqtt_client = InternalMQTTClient(uri=int_mqtt_uri)
try:
await int_mqtt_client.start()
logger.warning(f"[InternalMQTT] 内部请求-响应客户端已启用: {int_mqtt_uri}")
except Exception as e:
logger.warning(f"[InternalMQTT] 启动失败,将跳过: {e}")
int_mqtt_client = None
else:
logger.warning(f"[InternalMQTT] internal_mqtt 未启用,配置内容: {int_mqtt_cfg}")
set_global_var("db", db) set_global_var("db", db)
set_global_var("heartbeat_timeout", hb_timeout) set_global_var("heartbeat_timeout", hb_timeout)
@@ -331,40 +152,19 @@ async def create_workers(session, config, count=1, worker_manager=None):
set_global_var("alarm_forward_timeout", alarm_fwd_timeout) set_global_var("alarm_forward_timeout", alarm_fwd_timeout)
logger.info( logger.info(
f"[aibox_sophon_adapter] 已初始化: " "[aibox_sophon_adapter] 已初始化: "
f"心跳间隔={hb_interval}s, 超时={hb_timeout}s, 检查间隔={check_interval}s" f"心跳间隔={hb_interval}s, 超时={hb_timeout}s, 检查间隔={check_interval}s"
) )
# 启动离线设备检查后台任务 # 后台任务由基类跟踪,cleanup 时自动取消
global _offline_check_task self.spawn_task(_check_offline_devices(check_interval))
_offline_check_task = asyncio.create_task(_check_offline_devices(check_interval))
# 创建 Worker 副本 async def on_cleanup(self):
return _create_workers(session, config, count, worker_manager) """cleanup 时调用:关闭流控制器与外部 MQTT 客户端(db 与后台任务由基类处理)。"""
async def cleanup(worker_item: str):
"""
清理资源
在 Worker 关闭时调用,关闭数据库连接和后台任务。
"""
from .global_var import ( from .global_var import (
db, stream_manager, mqtt_forward, mqtt_subscribe, internal_mqtt, stream_manager, mqtt_forward, mqtt_subscribe, internal_mqtt,
) )
global _offline_check_task
if _offline_check_task is not None and not _offline_check_task.done():
_offline_check_task.cancel()
try:
await _offline_check_task
except asyncio.CancelledError:
pass
logger.info("[aibox_sophon_adapter] 离线检查任务已停止")
if db is not None:
await db.dispose()
if stream_manager is not None: if stream_manager is not None:
await stream_manager.stop_all() await stream_manager.stop_all()
@@ -377,4 +177,11 @@ async def cleanup(worker_item: str):
if internal_mqtt is not None: if internal_mqtt is not None:
await internal_mqtt.stop() await internal_mqtt.stop()
logger.info(f"[aibox_sophon_adapter] {worker_item} 清理完毕") logger.info("[aibox_sophon_adapter] 清理完成")
# 暴露给 app lifespan 的模块级入口(bound method,签名与旧写法完全一致)
# 注意: 实例名必须为 plugin(router 已被 routes.router APIRouter 占用)
plugin = AiboxSophonAdapterPlugin()
create_workers = plugin.create_workers
cleanup = plugin.cleanup
+219
View File
@@ -0,0 +1,219 @@
"""
AI Box Sophon Adapter — MQTT 客户端能力组件
从插件 __init__.py 抽取的三个外部 MQTT 客户端启动块,统一模式:
- enabled=False → 仅警告"未启用"并返回 None
- enabled=True → 懒加载客户端类,启动失败降级为 None(仅警告,不阻断插件)
内部 MQTT 主题约定(勿改):
与盒子侧约定一致(盒子只设根主题 box,子路径固定 aibox_sophon/...);
勿用 chongming/{插件}/{操作} 保留命名空间——网关 MQTT Bridge 订阅了
chongming/+/+,会拦截 3 级主题并按 Worker RPC 路由,而 edge_app_controller
不是 @rpc_method(Worker 无对应 Queryable),导致 worker_manager 报
"Worker ... 无回复,尝试重试"。
"""
import logging
from typing import Any
logger = logging.getLogger(__name__)
# 插件包名(作为 importlib 探测的基准,兼容 plugins.xxx 与 xxx 两种导入路径)
_PKG = __package__ or "plugins.aibox_sophon_adapter"
def _import_first(candidates: list) -> Any:
"""按序尝试导入模块,全部失败返回 None。"""
import importlib
for name in candidates:
try:
return importlib.import_module(name)
except ImportError:
continue
return None
def build_subscribe_routes(topics_cfg: list) -> list:
"""
根据配置的订阅主题列表构建 MQTTSubscribeClient 路由。
topics_cfg: [{"topic": "...", "handler": "board_ping", "reply_topic": "..."}, ...]
- topic: 订阅主题(支持 + / # 通配符)
- handler: 处理函数名,按序从插件 router 模块、插件包自身查找
- reply_topic: 可选,回复主题;处理后返回值将发布到该主题(支持 {1}{2} 占位符)
适配约定(按首个位置参数类型):
- Pydantic 模型: 用 payload 构造模型实例后调用(如 board_ping -> BoardPingRequest(**payload))
- dict: 直接传整个 payload 和 topic(如 edge_app_controller(payload, topic))
- 其他: 将 payload 作为关键字参数展开调用
返回: [{"topic": ..., "handler": async callable(payload, topic), "reply_topic": ...}]
"""
from typing import get_origin
from pydantic import BaseModel
# 注意: 不能用 "from .. import router" 取子模块,因为插件包属性 router
# 已被 routes.router(FastAPI APIRouter)遮蔽,故用 importlib 按名导入。
pkg_base = _PKG
pkg_leaf = _PKG.rsplit(".", 1)[-1]
router_mod = _import_first([f"{pkg_base}.router", f"{pkg_leaf}.router"])
pkg_mod = _import_first([pkg_base, pkg_leaf])
routes = []
for t in topics_cfg or []:
topic = t.get("topic", "")
handler_name = t.get("handler", "")
reply_topic = t.get("reply_topic")
if not topic or not handler_name:
logger.warning("[MQTTSubscribe] 跳过无效订阅项: %s", t)
continue
fn = None
if router_mod is not None:
fn = getattr(router_mod, handler_name, None)
if fn is None and pkg_mod is not None:
fn = getattr(pkg_mod, handler_name, None)
if fn is None:
logger.warning(
"[MQTTSubscribe] 未知 handler '%s',跳过 %s", handler_name, topic
)
continue
# 探测首个位置参数的类型注解
hints = getattr(fn, "__annotations__", {})
first_type: Any = None
for pname, ptype in hints.items():
if pname in ("worker_id", "return"):
continue
first_type = ptype
break
if (
isinstance(first_type, type)
and issubclass(first_type, BaseModel)
):
model_cls = first_type
async def _cb_model(data, topic, _fn=fn, _model=model_cls):
return await _fn(_model(**data))
routes.append({
"topic": topic,
"handler": _cb_model,
"reply_topic": reply_topic,
})
elif first_type is dict or get_origin(first_type) is dict:
# 处理函数期望整个 dict 作为第一个参数(如 edge_app_controller(payload, topic))
async def _cb_payload(data, topic, _fn=fn):
return await _fn(data, topic)
routes.append({
"topic": topic,
"handler": _cb_payload,
"reply_topic": reply_topic,
})
else:
# 其他签名:将 payload 按关键字参数展开调用
async def _cb_kwargs(data, topic, _fn=fn):
if isinstance(data, dict):
return await _fn(**data)
return await _fn()
routes.append({
"topic": topic,
"handler": _cb_kwargs,
"reply_topic": reply_topic,
})
return routes
async def start_mqtt_forward(cfg: dict) -> Any:
"""启动外部 MQTT 转发客户端,返回客户端或 None。"""
enabled = cfg.get("enabled", False)
if not enabled:
logger.warning("[MQTTForward] mqtt_forward 未启用,配置内容: %s", cfg)
return None
from chongming_mqtt.mqtt_forward import MQTTForwardClient
uri = cfg.get("uri", "")
topic = cfg.get("topic", "/board_ping")
logger.warning(
"[MQTTForward] 配置已加载: enabled=%s, uri=%s, topic=%s", enabled, uri, topic
)
if not uri:
return None
client = MQTTForwardClient(uri=uri, topic=topic)
try:
await client.start()
logger.warning("[MQTTForward] 外部转发已启用: %s → %s", uri, topic)
return client
except Exception as e:
logger.warning("[MQTTForward] 启动失败,将跳过转发: %s", e)
return None
async def start_mqtt_subscribe(cfg: dict) -> Any:
"""启动外部 MQTT 订阅客户端(本插件直接订阅外部 Broker,不走网关 Bridge)。"""
enabled = cfg.get("enabled", False)
if not enabled:
logger.warning("[MQTTSubscribe] mqtt_subscribe 未启用,配置内容: %s", cfg)
return None
from chongming_mqtt.mqtt_subscribe import MQTTSubscribeClient
uri = cfg.get("uri", "")
topics = cfg.get("topics", []) or []
logger.warning(
"[MQTTSubscribe] 配置已加载: enabled=%s, uri=%s, topics=%s",
enabled, uri, topics,
)
if not (uri and topics):
logger.warning("[MQTTSubscribe] 未配置 uri 或 topics,跳过外部订阅")
return None
routes = build_subscribe_routes(topics)
client = MQTTSubscribeClient(uri=uri, routes=routes)
try:
await client.start()
logger.warning("[MQTTSubscribe] 外部订阅已启用: %s", uri)
return client
except Exception as e:
logger.warning("[MQTTSubscribe] 启动失败,将跳过订阅: %s", e)
return None
async def start_internal_mqtt(cfg: dict) -> tuple[Any, str, str]:
"""
启动内部 MQTT 请求-响应客户端
(handler 内对任意应用进程发起请求-响应,主题显式传入,默认连内部 127.0.0.1:1883)。
返回 (client | None, request_topic, response_topic):
主题无论是否启用都按配置解析并返回(由调用方写入 global_var)。
"""
req_topic = cfg.get(
"request_topic", "box/aibox_sophon/edge_app_controller"
)
resp_topic = cfg.get(
"response_topic", "box/aibox_sophon/edge_app_controller_reply"
)
enabled = cfg.get("enabled", False)
if not enabled:
logger.warning("[InternalMQTT] internal_mqtt 未启用,配置内容: %s", cfg)
return None, req_topic, resp_topic
from chongming_mqtt.internal_mqtt import InternalMQTTClient
uri = cfg.get("uri", "mqtt://127.0.0.1:1883")
logger.warning(
"[InternalMQTT] 配置已加载: enabled=%s, uri=%s, request_topic=%s, response_topic=%s",
enabled, uri, req_topic, resp_topic,
)
client = InternalMQTTClient(uri=uri)
try:
await client.start()
logger.warning("[InternalMQTT] 内部请求-响应客户端已启用: %s", uri)
return client, req_topic, resp_topic
except Exception as e:
logger.warning("[InternalMQTT] 启动失败,将跳过: %s", e)
return None, req_topic, resp_topic
+4
View File
@@ -159,6 +159,10 @@ JWT Token 有效期 3 小时,载荷包含:`sub`(用户ID)、`username`
## 默认管理员账户 ## 默认管理员账户
插件基于 `Plugin` 基类统一生命周期:`on_init` 中执行管理员种子逻辑
(先按用户名、再按邮箱兜底查询;多进程并发启动时靠唯一约束 + `IntegrityError` 回滚保证幂等),
数据库 `auth.db` 由基类统一初始化并在 cleanup 时释放。
首次启动时自动创建: 首次启动时自动创建:
| 字段 | 值 | | 字段 | 值 |
+30 -21
View File
@@ -1,29 +1,36 @@
from pathlib import Path """
Auth Worker 插件 — 鉴权管理(登录 / 注册 / 资料 / 改密)
基于 Plugin 基类统一生命周期:数据库由基类初始化(auth.db),
on_init 中执行 admin 幂等种子逻辑,on_cleanup 无需重复 dispose。
"""
import logging
from datetime import datetime from datetime import datetime
from chongming_worker import auto_bind_models from chongming_worker.plugin import Plugin
from chongming_worker.runtime import create_workers as _create_workers
from chongming_password import hash_password from chongming_password import hash_password
from .router import * from .router import * # noqa: F401,F403 触发 @rpc_method 注册
logger = logging.getLogger(__name__)
auto_bind_models("auth") class AuthPlugin(Plugin):
"""Auth 插件 — 统一生命周期。"""
type = "auth"
db_name = "auth.db" # 自动在 data_dir 下建 SQLite 库
auto_bind = True # 首次初始化时自动 auto_bind_models("auth")
async def create_workers(session, config, count=1, worker_manager=None): async def on_init(self):
from chongming_database import Database """首次 create_workers 时调用一次:初始化 admin 账号(幂等)。"""
from .global_var import set_global_var
from .models.auth import Users from .models.auth import Users
from sqlalchemy import select from sqlalchemy import select
db_path = Path(config.get("data_dir", "./data")) / "auth.db"
db = Database(db_path)
await db.initialize()
# 初始化管理员(多进程并发启动时必须幂等,避免 UNIQUE 约束冲突)
from sqlalchemy.exc import IntegrityError from sqlalchemy.exc import IntegrityError
db = self.db
# 初始化管理员(多进程并发启动时必须幂等,避免 UNIQUE 约束冲突)
async with db.get_session() as sess: async with db.get_session() as sess:
select_stmt = select(Users) \ select_stmt = select(Users) \
.where(Users.username == "admin") # type: ignore .where(Users.username == "admin") # type: ignore
@@ -53,13 +60,15 @@ async def create_workers(session, config, count=1, worker_manager=None):
# 其他并发进程已抢先创建 admin,当前进程忽略即可 # 其他并发进程已抢先创建 admin,当前进程忽略即可
await sess.rollback() await sess.rollback()
set_global_var("db", db) # 写入全局变量供 router 使用(数据库由基类在 cleanup 时统一 dispose)
self.set_var("db", db)
return _create_workers(session, config, count, worker_manager) async def on_cleanup(self):
"""cleanup 时调用(数据库由基类处理)。"""
logger.info("[auth] 清理完成")
async def cleanup(worker_item: str):
from .global_var import db
if db is not None: # 暴露给 app lifespan 的模块级入口(bound method,签名与旧写法完全一致)
await db.dispose() plugin = AuthPlugin()
print(f"{worker_item} 清理完毕") create_workers = plugin.create_workers
cleanup = plugin.cleanup
+6 -4
View File
@@ -15,7 +15,7 @@
## 配置 ## 配置
在 `config/workers/doubao/config.toml` 和 `config/workers/config.toml` 中设置: 在 `config/workers/doubao/config.toml` 中设置:
```toml ```toml
[workers.config] [workers.config]
@@ -64,9 +64,11 @@ model = "doubao-seed-evolving" # 模型名称
## 实现细节 ## 实现细节
- 使用 `httpx` 异步 HTTP 客户端调用火山引擎 API - 生命周期基于 `Plugin` 基类:`on_init` 按 `is_aicc` 分支创建 Ark(`AsyncArk`)或
- 支持流式响应(streaming)中的思考模式 token AICC(`AsyncOpenAI` + `AsyncSecureHttpClient`)客户端并写入 `global_var`;
- API Key 优先从环境变量 `VOLC_ACCESSKEY` 读取 `on_cleanup` 释放客户端引用(无数据库)
- API Key 优先取配置 `api_key`,其次环境变量 `VOLC_ACCESSKEY`(缺失时插件初始化报错)
- 支持 AICC 安全通信模式(懒加载 SDK,避免非 AICC 环境导入失败)
- 超时时间默认 30 秒,在 `config.toml` 的 `defaults.timeout` 中配置 - 超时时间默认 30 秒,在 `config.toml` 的 `defaults.timeout` 中配置
--- ---
+29 -18
View File
@@ -1,28 +1,36 @@
""" """
豆包视觉理解 Worker 插件 豆包视觉理解 Worker 插件
基于 Plugin 基类统一生命周期:on_init 中按配置创建 Ark / AICC 客户端,
on_cleanup 释放客户端引用(无数据库)。
""" """
import os import os
import logging import logging
from openai import AsyncOpenAI from openai import AsyncOpenAI
from volcenginesdkarkruntime import AsyncArk from volcenginesdkarkruntime import AsyncArk
from chongming_worker import create_workers as _create_workers
from chongming_worker import auto_bind_models from chongming_worker.plugin import Plugin
from .global_var import ( from .global_var import (
set_ark_client, set_aicc_client, set_is_aicc, set_ark_client, set_aicc_client, set_is_aicc,
cleanup_all, cleanup_all,
) )
from .router import * from .router import * # noqa: F401,F403 触发 @rpc_method 注册
logger = logging.getLogger("plugins.doubao") logger = logging.getLogger(__name__)
# 自动绑定 gen_models 生成的 Pydantic 模型
auto_bind_models("doubao")
async def create_workers(session, config, count=1, worker_manager=None): class DoubaoPlugin(Plugin):
""" """豆包插件 — 统一生命周期。"""
工厂函数:由 app 生命周期调用,创建 Worker 协程。
""" type = "doubao"
logger.info("豆包插件初始化开始 (count=%d, aicc=%s)", count, config.get("is_aicc", False)) db_name = None # 无数据库
auto_bind = True # 首次初始化时自动 auto_bind_models("doubao")
async def on_init(self):
"""首次 create_workers 时调用一次:初始化豆包客户端。"""
config = self.config
logger.info("豆包插件初始化开始 (count=%s, aicc=%s)",
config.get("count", 1), config.get("is_aicc", False))
is_aicc = config.get("is_aicc", False) is_aicc = config.get("is_aicc", False)
set_is_aicc(is_aicc) set_is_aicc(is_aicc)
@@ -84,12 +92,15 @@ async def create_workers(session, config, count=1, worker_manager=None):
set_ark_client(client) set_ark_client(client)
logger.info("豆包 Ark 客户端已创建 (base_url=%s)", base_url) logger.info("豆包 Ark 客户端已创建 (base_url=%s)", base_url)
# 委托给通用运行时创建 Worker 任务 logger.info("豆包插件初始化完成")
logger.info("豆包插件初始化完成,创建 %d 个 Worker 副本", count)
return _create_workers(session, config, count, worker_manager)
async def cleanup(worker_item: str): async def on_cleanup(self):
"""清理资源""" """cleanup 时调用:释放客户端引用。"""
logger.info("豆包插件清理: %s", worker_item)
cleanup_all() cleanup_all()
logger.info("豆包插件清理完毕: %s", worker_item) logger.info("[doubao] 清理完成")
# 暴露给 app lifespan 的模块级入口(bound method,签名与旧写法完全一致)
plugin = DoubaoPlugin()
create_workers = plugin.create_workers
cleanup = plugin.cleanup
+2 -1
View File
@@ -54,7 +54,8 @@ curl "http://localhost:8000/api/v1/calc/count"
1. **配置**:`config/workers/example/*.toml` — 定义每个端点的参数和响应结构 1. **配置**:`config/workers/example/*.toml` — 定义每个端点的参数和响应结构
2. **模型**:`models/pydantic.py` — 由 `gen_models` 根据 TOML 自动生成 2. **模型**:`models/pydantic.py` — 由 `gen_models` 根据 TOML 自动生成
3. **路由**:`router/` 下每个文件实现一个 RPC 端点,使用 `@rpc_method` 装饰器 3. **路由**:`router/` 下每个文件实现一个 RPC 端点,使用 `@rpc_method` 装饰器
4. **工厂**:`__init__.py` 中的 `create_workers` 初始化数据库并启动 Worker 4. **入口**:`__init__.py` 的 `ExamplePlugin`(Plugin 基类)统一生命周期——`on_init` 初始化
数据库并发布到 global_var,`create_workers` / `cleanup` 为其 bound method
--- ---
+1 -1
View File
@@ -2,7 +2,7 @@
MQTT 端点处理器 — 通过 MQTT 主题 chongming/example/add 调用 MQTT 端点处理器 — 通过 MQTT 主题 chongming/example/add 调用
与 GET/POST 的 add 共用同一个 subject (example.add), 与 GET/POST 的 add 共用同一个 subject (example.add),
通过 _method="MQTT" 区分。 通过信封 meta.method="MQTT" 区分。
""" """
import time import time