feat(config): 运行时配置体系②——渠道配置落 channels.json + 面板落盘 + 热更新
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled

- runtimeconfig/channels.go: channels.json 承载 map[platform][]ChannelAccount(token/baseURL/黑白名单/群聊限流), LoadChannels 优先文件/回退env, SaveChannels(0600+tmp原子写), Upsert/Delete/Masked脱敏
- channelbridge/reload.go: adapterManager 热替换——工厂重建新adapter先连接, 再原子断旧adapter(独立readCtx/进行中不中断); Bridge.Reload(platform) 逐账号增删
- 面板 PUT/DELETE /admin/channels/{platform}/config(owner+审计) 落盘channels.json+Reload热更新; GET /admin/channels 附账号掩码不泄裸token
- 前端 Channels.tsx 配置表单(id/token/baseURL/黑白名单/限流+config JSON)+保存热更新提示+掩码展示/删除
- .gitignore 加 channels.json; .env 退兜底(.env.example注明); token不打印/Config()脱敏
- channelbridge.go 拆 logic→reload.go(742→731); 全仓 go build/vet/test绿+前端build绿
- 未做真token端到端(需渠道凭据,不读.env)——如实标注待凭据
This commit is contained in:
Aska committed 2026-09-07 13:29:41 +08:00
1 parent 2d7151f979
commit aedae2190b
18 files changed
+1743 -126

No files matched your search

@@ -114,6 +114,10 @@ type Options struct {
// Now is the wall-clock source for quiet-hour judgement. Nil uses time.Now,
// which makes time-dependent behaviour injectable in tests.
Now func() time.Time
// Factory rebuilds a platform's adapters from its effective account config
// (channels.json → env fallback). When non-nil the bridge supports hot reload;
// nil keeps the bridge boot-only (Reload returns ErrNilFactory).
Factory PlatformFactory
}
// Bridge owns the channel adapter lifecycle and the inbound message pipeline. It
@@ -133,10 +137,13 @@ type Bridge struct {
quietEndHour int
now func() time.Time
mu sync.RWMutex
adapters map[string]adapter.PlatformAdapter
connected map[string]bool
closed bool
mu sync.RWMutex
adapters map[string]adapter.PlatformAdapter
connected map[string]bool
readCancels map[string]context.CancelFunc
started bool
closed bool
factory PlatformFactory
// gateMu guards the per-adapter flow gates (design/12 §6 策略三/四). They are
// built lazily on the first inbound message per account and are independent of
@@ -189,7 +196,9 @@ func New(opts Options) (*Bridge, error) {
now: now,
adapters: make(map[string]adapter.PlatformAdapter),
connected: make(map[string]bool),
readCancels: make(map[string]context.CancelFunc),
gates: make(map[string]*adapter.FlowGate),
factory: opts.Factory,
cancel: func() {},
sem: make(chan struct{}, maxConcurrent),
}, nil
@@ -236,42 +245,20 @@ func (b *Bridge) WebhookHandlers() map[string]http.Handler {
// Start connects every configured adapter, installs the inbound handler, and
// launches the read loops. Adapters without credentials (or whose Connect fails)
// are logged and left disconnected; they never fail startup. Start blocks only
// long enough to connect (bounded by connectTimeout per adapter) and returns.
// long enough to connect (bounded by connectTimeout per adapter) and returns. It
// is idempotent: a second call is a no-op.
func (b *Bridge) Start() {
b.mu.Lock()
if b.closed {
if b.closed || b.started {
b.mu.Unlock()
return
}
b.ctx, b.cancel = context.WithCancel(context.Background())
b.started = true
b.mu.Unlock()
for _, a := range b.snapshot() {
if !b.configured(a) {
b.log.Warn("channel skipped: no credentials", "channel", a.Name())
continue
}
connCtx, cancel := context.WithTimeout(context.Background(), connectTimeout)
err := a.Connect(connCtx)
cancel()
if err != nil {
b.log.Warn("channel connect failed, leaving disconnected",
"channel", a.Name(), "error", err)
continue
}
b.mu.Lock()
b.connected[a.Name()] = true
b.mu.Unlock()
// Close over the adapter so the inbound callback knows which account
// received the message — required for multi-account reply routing and for
// reading that account's allow/deny lists (design/16 §4.6 §5).
a.SetMessageHandler(func(ctx context.Context, msg adapter.UnifiedMessage) {
b.onMessage(ctx, msg, a)
})
b.wg.Add(1)
go b.readLoop(a)
b.log.Info("channel connected", "channel", a.Name())
b.connectAndRun(a)
}
b.log.Info("channel bridge started", "connected", b.ConnectedCount(),
"registered", len(b.snapshot()))
@@ -363,14 +350,16 @@ func basePlatform(name string) string {
return name
}
// readLoop runs an adapter's blocking read path until the bridge stops. The emit
// sink is wired to onMessage so poll-style adapters (Telegram getUpdates, weixin
// long poll) deliver through the same pipeline as push-style adapters.
func (b *Bridge) readLoop(a adapter.PlatformAdapter) {
// readLoop runs an adapter's blocking read path until the bridge stops or the
// adapter's own context is cancelled (a hot reload). The emit sink is wired to
// onMessage so poll-style adapters (Telegram getUpdates, weixin long poll)
// deliver through the same pipeline as push-style adapters. Message processing
// uses the bridge-lifetime context (not readCtx) so a reload leaves in-flight
// messages alone.
func (b *Bridge) readLoop(a adapter.PlatformAdapter, readCtx context.Context) {
defer b.wg.Done()
ctx := b.ctx
a.ReadMessages(ctx, func(msg adapter.UnifiedMessage) {
b.onMessage(ctx, msg, a)
a.ReadMessages(readCtx, func(msg adapter.UnifiedMessage) {
b.onMessage(b.ctx, msg, a)
})
}