feat(backthink/admin): ③b — rag/delete 真实现 + D1 推送出口钩子(自建优先→个推兜底) + api-reference 补 D2/D3/D5 补丁
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled

- B5: POST /admin/rag/delete 真做(DeleteDocument→DeleteProject 级联删章节+索引清理, owner+审计, 200/404/500, 500降级兜底); users/iot scenarios+rules 诚实501(无用户表/IoT场景规则模型, 消息写明缺什么, 不伪造), 锁住 501+not_implemented
- D1: backthink/push_outlet.go PushExporter{Export}; pushOutlet 自建HTTP(2xx=送达, token仅Authorization头+error脱敏)优先→Getui兜底(未接入, 诚实日志'getui not integrated'+(false,nil)); backthink.Deps.Exporter → pusher.withExporter, Push 在WS/IM后 exportAsync(detached+5s超时, 失败仅日志, 不破坏现有投递, 限流不上报); config PushOutletConfig(PUSH_EXPORTER_* + GETUI_*占位)
- 前端 Rag.tsx/api.ts 删除后 reload + 类型; Docs/api-client v0.31 + api-reference(#23 owner/200-404, §8.4, 端点表PATCH/DELETE messages + pinned + messages?q=)
- 测试(push_outlet/admin_rag_delete/admin_honest501/knowledge store), 后端+前端build全绿+3平台交叉编译
- 刻意: Getui SDK 未接入(无SDK不硬造), 主人自建服务填 PUSH_EXPORTER_URL/TOKEN 即可
This commit is contained in:
AskaEth committed 2026-09-09 00:08:29 +08:00
1 parent 844b2a74c1
commit 2dee372fb2
23 files changed
+927 -95

No files matched your search

+51 -5
View File
@@ -128,20 +128,34 @@ type Pusher interface {
// pusher is the concrete Pusher: it consults the gate, then routes the message to
// exactly one target — the active client for a "web" hint, otherwise the IM bridge.
// After the local delivery it mirrors the message to the optional push outlet
// (self-hosted service → Getui fallback) without blocking that delivery.
type pusher struct {
active ActiveClient
im IMAdapter
gate PushGate
log *slog.Logger
active ActiveClient
im IMAdapter
gate PushGate
exporter PushExporter
log *slog.Logger
// exportDone, when non-nil, receives one value after each asynchronous export
// completes. It exists so tests can deterministically observe the hook; the
// send is non-blocking, so a full channel never stalls the exporter.
exportDone chan struct{}
}
// newPusher builds a Pusher from the optional active-client sink, IM sink and
// rate gate. Any of them may be nil; a nil target simply makes that route
// unavailable.
// unavailable. The push outlet is attached separately with withExporter.
func newPusher(active ActiveClient, im IMAdapter, gate PushGate, log *slog.Logger) *pusher {
return &pusher{active: active, im: im, gate: gate, log: log}
}
// withExporter attaches the push outlet hook and returns the pusher for fluent
// use. A nil exporter leaves the hook disabled.
func (p *pusher) withExporter(exporter PushExporter) *pusher {
p.exporter = exporter
return p
}
// Push implements Pusher: it applies the rate gate, then routes the message to a
// single target. A "web" hint reaches the active web/native client; every other
// hint ("im", "auto", or a concrete platform) goes over the IM bridge once, where
@@ -192,6 +206,11 @@ func (p *pusher) Push(ctx context.Context, req PushRequest) PushResult {
if res.Pushed && p.gate != nil && !eventDriven {
p.gate.RecordPush(kind)
}
// D1: mirror the proactive message to the external push outlet (self-hosted
// unified push service → Getui fallback). Fire-and-forget: a slow or failing
// outlet never delays or breaks the local delivery above, and a rate-limited
// push returned before reaching here.
p.exportAsync(msg)
res = annotateHint(res, req.ChannelHint)
if eventDriven {
res.Bypassed = true
@@ -200,6 +219,33 @@ func (p *pusher) Push(ctx context.Context, req PushRequest) PushResult {
return res
}
// exportAsync reports msg to the push outlet on a detached, bounded goroutine so
// the proactive path never blocks on the outlet. A nil exporter (hook disabled)
// or a nil outlet result is a no-op; a delivery miss or error is only logged.
func (p *pusher) exportAsync(msg adapter.UnifiedMessage) {
if p.exporter == nil {
return
}
timeout := defaultExportTimeout
go func() {
ctx, cancel := context.WithTimeout(context.Background(), timeout)
defer cancel()
delivered, err := p.exporter.Export(ctx, msg)
switch {
case err != nil:
p.log.Warn("push outlet export failed", "error", err)
case !delivered:
p.log.Info("push outlet did not deliver", "hint", "getui fallback not integrated")
}
if p.exportDone != nil {
select {
case p.exportDone <- struct{}{}:
default:
}
}
}()
}
// pushActive delivers only to the active web/native client.
func (p *pusher) pushActive(ctx context.Context, msg adapter.UnifiedMessage) PushResult {
if p.active != nil {