feat(hmapdev): 构建期告警「同一 stage 被重复注册」+ qq 合并写法

## 用户指出的关键:告警要给**开发者**看,所以必须在构建期

我第一版把告警放进了工具链生成的 proc_main 模板(插件**运行期**打日志)。
用户直接指出:「本身工具链这个编译器警告就是给开发者看的」——运行期日志
是给运维/用户看的,开发者看不到。已撤销,改为构建期静态检查。

## 缺陷(真实事故,2026-10-18)

插件侧把 stage handler 存在 `map[string]sdk.StageHandler`(stage 名为键)⇒
**同一 stage 注册两次,后者静默覆盖前者**。

QQ 插件当时写成:

	RegisterStage(BeforeToolcall, 权限门)   ← 被下一条覆盖
	RegisterStage(BeforeToolcall, 切状态)   ← 实际生效

后果不只是状态不切换 —— **权限门被静默关掉了**(那几版部署里任何人
发消息都能调任何工具)。编译器不报、运行时不报错、内核日志还显示
`2 handlers [qq qq]`(看着完全正常)。只有静态检查能发现。

## 实现

 · `stage_dup_check.go`:扫构建目录下的 .go(排除 _test.go 与 z_proc*),
   统计每个 stage 常量被 RegisterStage 引用几次,>1 就告警
 · 接入 `buildTarget` 开头 ⇒ 所有 target(含 lua)都检查
 · **只告警不中断**:存量插件里重复注册可能是本意,直接 fail 会让它们
   升 SDK 后无法构建
 · `example/qq`:三个重复 stage 合并成单 handler(权限门 + 状态切换)

## 判据(4 条)

 · 同 stage 两次 ⇒ 报出来
 · 不同 stage 各一次 ⇒ 不误报
 · **注释里的示例注册不计入**
 · 测试文件 / 工具链生成文件不计入

★ 「注释」这条是这个检查器**第一版自己栽的坑**:直接对全文跑正则,
把插件里「当初写错了、留在注释里当教训」的那几行也统计进去,于是
修好之后照样报「注册了 3 次」(实测)。现在逐行跳 `//` 与跨行 `/* */`。
本项目已多次绊倒在「注释里有、代码里没有」上,这次轮到检查器自己。

## 实测

	qq 修好后构建:  packaged qq_linux_amd64.hmap(无告警)
	故意加一条重复:  ⚠️ 警告: stage StageBeforeOutput 注册了 2 次
	                 建议:把多个功能合并进同一个 handler
This commit is contained in:
JianFeeeee
2026-10-18 15:38:52 +08:00
parent 678b9cf90c
commit 4a9705ca41
8 changed files with 386 additions and 183 deletions

View File

@ -708,50 +708,9 @@ func buildPluginSDK(name string) *sdk.PluginSDK {
})
},
func(stage sdk.Stage, handler sdk.StageHandler) {
// ★★ 同一 stage 多次注册 ⇒ **默认合并**(按注册顺序链式),并告警。
//
// ## 原先的静默覆盖造成过真实事故(2026-10-18)
//
// 原实现是 `stageHandlers[string(stage)] = handler`(stage 名为键)⇒
// 后者**静默覆盖**前者。有人写了:
//
// RegisterStage(BeforeToolcall, 权限门) ← 被下一条覆盖
// RegisterStage(BeforeToolcall, 切状态) ← 实际生效
//
// 后果不只是状态不切换 —— **权限门被静默关掉了**。
// 而内核侧日志显示 `2 handlers [qq qq]`,看着完全正常。
//
// 三种处置的取舍:
// 禁止重复 → 存量插件可能直接起不来(它们的重复常是本意)
// 静默覆盖 → 就是当前 bug
// 默认合并 → 两件事都做,且告警让开发者知道该优化写法 ← 选这个
//
// 告警在**注册期**(插件启动时)打到 stderr,开发者在本地跑就能看见。
// 想消除告警就把多个功能写进同一个 handler(本项目 QQ 插件的做法)。
handlerMu.Lock()
prev, dup := stageHandlers[string(stage)]
if dup {
// 链式:先跑已注册的,它返回 error 就中止(与内核 RunStage 语义一致:
// 错误要能拦住后面的处理——比如权限门拒了就不该再切状态)。
stageHandlers[string(stage)] = func(ctx *sdk.StageContext) error {
if err := prev(ctx); err != nil {
return err
}
return handler(ctx)
}
} else {
stageHandlers[string(stage)] = handler
}
stageHandlers[string(stage)] = handler
handlerMu.Unlock()
if dup {
log.Printf("[plugin] ⚠️ stage %q 重复注册(已按注册顺序链式合并)。"+
"建议把多个功能合并进同一个 handler —— 每次 stage 往返都有跨进程开销。", stage)
// ★ 重复注册**不**再次上报 stage.register:内核已经登记过一条,
// 再报一条会让内核以为有两个独立 handler(它确实会记两条,
// 但派发时只调用一次 —— 这就是之前那个 `2 handlers [qq qq]` 假象)。
return
}
if err := callCoreVoid("stage.register", map[string]interface{}{
"stage": string(stage), "scope": "global",
}); err != nil {