跳转至

插件开发指南

本文是 BaseDataSource 规范的对外版(对外契约以本文为准);内部母版为仓库 doc/05-插件开发指南.md(含实时流式插件等内部扩展设计),两版需同步维护。新数据源上线四步:继承基类 → 放入 plugins/ → 后台审核 → 上线,全程不改框架代码、不重启服务、不升级设备固件。


1. 目录约定

plugins/
└── my_source/                 # 目录名建议 = source_code(snake_case)
    ├── __init__.py            # 必须:导出 DATASOURCE(类或实例)
    ├── fetcher.py             # 可选:实现拆分
    └── requirements.txt       # 可选:插件私有依赖(部署侧安装,框架不自动装)
  1. __init__.py 必须暴露 DATASOURCE(单版本)或 DATASOURCES = [V1, V2](多版本并存);
  2. 插件只能 import:标准库、平台放行的公共依赖(httpx / pydantic / aiomysql,见文末附录)、core.plugins.base;禁止 import 框架其他模块、其他插件、全局单例;
  3. 删除目录 = 移除插件(DB 注册行自动转“代码缺失”,禁止再上线)。

2. BaseDataSource 契约

声明属性 说明
source_code / version 全局唯一,联合唯一
name / category 展示名 / 分类(lottery、hotlist、weather…)
cache_ttl / stale_ttl 缓存 TTL 与降级窗口(秒,后台可覆盖)
cache_scope global 全设备一份;params 按参数分缓存
cron 5 段表达式(北京时间);仅 global 生效,用于定时预热
need_subscription 是否需用户订阅后可见
source_name 来源标注(合规必填,缺省审核不通过)
params_schema JSON Schema draft-07 子集,网关据此校验

必须实现:

  • meta() -> dict:注册/文档元信息,含 resp_example(返回示例,注册审核机器校验其存在且带 source_name,并作为文档站数据源目录的维护基准);
  • async fetch(params) -> dict:回源取数,返回 payload。失败时抛 DataSourceError 子类(推荐)或任意异常——框架统一按 50401 处理并尝试 stale 降级,插件异常永远不影响进程与其他请求。

可选钩子:health_check()(手动预热/健康检查)、on_enable(ctx) / on_disable()(资源清理)。

3. PluginContext(框架注入的运行时)

self.ctx.get_secret("weather_api_key")   # 上游密钥明文(管理员后台加密配置)
self.ctx.http                            # 框架统一 httpx 客户端(超时/重试/UA 已配置),禁止自建无超时客户端
self.ctx.log("info", "fetched", n=10)    # 结构化日志,自动带 source_code,密钥值自动脱敏
self.ctx.media_dir                       # M5 起提供:框架媒体目录(与 /media 静态挂载同源),
                                         # 音频等二进制产物落盘用(如 news_hourly),禁止 base64 塞 payload

4. 数据返回约定(小屏友好)

项 约束 框架行为
payload 序列化大小 < 4KB 超限判失败(不缓存),返回 50000
列表长度 ≤ 10 条 超限截断并记 warning
展示文本单条 ≤ 30 字符(昵称类 ≤ 15) 不截断,审核人工把关
字段稳定 同版本内字段名只增不改;废弃字段保留一个版本周期 文档站公示

5. 参数与 params_schema

  • 支持 type/enum/minimum/maximum/minLength/maxLength/pattern/required/items/properties/default;
  • 校验失败返回 40001 并指明首个错误字段;未声明的额外参数默认丢弃并告警;
  • 设备传参:平铺 query 优先(&city=上海),复杂结构用 params=<urlencoded JSON>。

6. 合规红线(注册审核项)

  1. 彩票类:仅官方公开开奖结果;不得出现投注/预测/导流链接/二维码;source_name 必须含“仅供信息展示”类免责表述;
  2. 社媒类:仅公开榜单/公开页数据;不请求、不存储个人隐私内容;
  3. meta().resp_example 与文档必须写明数据来源;
  4. 上游 Cookie/Key 一律走 ctx.get_secret(),严禁硬编码或写日志(CI 扫描 + 审核检查)。

7. 完整示例(weather_basic)

from app.core.plugins.base import BaseDataSource
from app.core.plugins.errors import UpstreamBadResponseError

class WeatherBasicSource(BaseDataSource):
    source_code = "weather_basic"
    version = "v1"
    name = "基础天气"
    category = "weather"
    cache_ttl = 3600
    stale_ttl = 7200
    cache_scope = "params"            # 按城市分缓存
    need_subscription = True
    source_name = "数据来自公开天气服务,仅供参考"
    params_schema = {
        "type": "object",
        "properties": {"city": {"type": "string", "minLength": 2, "maxLength": 12}},
        "required": ["city"],
    }

    def meta(self) -> dict:
        return {
            "name": self.name, "category": self.category,
            "cache": {"ttl": self.cache_ttl, "scope": self.cache_scope},
            "params_schema": self.params_schema,
            "resp_example": {"city": "上海", "temp": 31, "text": "多云",
                             "wind": "东南风3级", "source_name": self.source_name},
        }

    async def fetch(self, params: dict) -> dict:
        api_key = self.ctx.get_secret("weather_api_key")
        if not api_key:
            raise UpstreamBadResponseError("secret weather_api_key missing")
        resp = await self.ctx.http.get("https://api.example.com/v1/now",
                                       params={"city": params["city"], "key": api_key})
        if resp.status_code != 200:
            raise UpstreamBadResponseError(f"upstream http {resp.status_code}")
        raw = resp.json()["data"]
        return {"city": params["city"][:12], "temp": int(raw["temp"]),
                "text": raw["text"][:6], "wind": raw["wind"][:8],
                "source_name": self.source_name}

DATASOURCE = WeatherBasicSource

8. 注册 → 审核 → 版本管理

  1. 目录放入 plugins/ → 启动扫描或管理端“重新扫描” → 新行(待审核);
  2. 管理员核对 meta、缓存/限流策略、schema、source_name 合规、依赖声明 → approve 上线(≤5s 生效);
  3. 新版本:同目录暴露 DATASOURCES = [V1, V2] → v2 审核上线后 v1 建议立即 deprecate(默认日落 +90 天)→ 到期自动转已下线(50302);
  4. 停用 disable(临时维护,可恢复)/ 下线 offline(永久)均由管理端操作并记审计。

9. 开发自检清单(提交审核前)

  • [ ] 目录内可独立 import,无框架内部依赖;
  • [ ] 所有外部请求走 ctx.http 且有超时;无阻塞调用(同步 IO 用 asyncio.to_thread);
  • [ ] 密钥零硬编码、零日志输出;
  • [ ] payload < 4KB、列表 ≤ 10、文本 ≤ 30 字;
  • [ ] 同参数重复调用幂等;上游异常路径本地验证过(mock 或断网);
  • [ ] meta() 与实际声明一致,resp_example 真实可用。

附录 A:插件可用公共依赖

镜像内预装(插件 requirements.txt 只需声明额外依赖):httpx、pydantic>=2。其余第三方库由插件自声明,部署方评估后安装。

M3 起放行 aiomysql(随平台 [mysql] extra 预装):限数据库直连类插件使用,约束如下——

  • 仅只读 SELECT,禁止插件内写库/建表/建连接池;短连接 + connect_timeout 兜底(回源频率由 global 缓存与 cron 预热压低);
  • 连接参数一律经 ctx.get_secret 注入(如 lottery_ssq 的 db_host/db_port/db_user/db_password/db_name,由 scripts/seed_lottery_secrets.py 加密写入),严禁硬编码;
  • 查询函数写成独立方法(如 _query_latest)作为测试注入点,单测 monkeypatch 不真连库。

M5 起放行 edge-tts(news_hourly 插件私有依赖,见其 requirements.txt,部署侧安装):限文字转语音类插件使用,约束如下——

  • 走微软 Edge 朗读在线接口(websockets),非官方接口、免 Key,存在失效风险:TTS 封装须独立成文件并保持 synthesize(text, voice) -> bytes 签名稳定,便于切换云厂商 REST TTS;
  • 仅在插件 fetch 内延迟 import;依赖未安装时插件注册/扫描不受影响,合成时抛 DataSourceError 家族异常;
  • 合成产物落盘 {ctx.media_dir}/<子目录>/(框架媒体目录,与 /media 静态挂载同源),禁止 base64 塞进 payload(<4KB 硬约束);
  • 落盘必须临时文件 + os.replace 原子替换(下载端并发保护),归档文件由插件自行清理限量。