feat(push): 可选、配置式、多厂商的推送通道(HMS 为首个实现)
用户要求:推送密钥必须是可选项(自部署后端不能写死推送方式),且要支持 多厂商配置式接入 —— 每个用户各自部署服务器、自己选厂商、自己配凭证。 所以落地成: · internal/push:通道抽象 + 工厂表(RegisterType),加厂商不改配置层与端点形状; HMS 只是第一个实现(internal/push/hms.go) · 配置在 PUSH_CONFIG(默认 <AGENTMAIL_DATA_DIR>/push.json),一项一个厂商, 凭证走文件(app_secret_file / files.*,建议 600);环境变量只是可选覆盖 · 没配 = 整条推送路径连一次查库都不发生(shouldDispatch 早退); 单项配错(未知类型/密钥读不到/enabled:false)只跳过那一条,不影响启动 · push_tokens 表带 provider 维度 + 三个 /me/devices/push-token 端点; 没配推送时端点照存并回 enabled:false(登记成功 != 服务端开了推送) · notify.Recipients 末尾异步挂钩:收件人名单直接用 SSE 那份 seen(两条通道 共用同一份"谁该收到"的判据);失败只记日志,绝不拖住收信 HMS 的形状是拿真凭证打线上接口问出来的(v1 + message.token[] + testMessage; payload/target 形状 v1 不认、v2 要服务账号 JWT)。未上架应用必须 test_message=true, 单批 ≤10 token(MaxTokensPerRequest 声明)、每日 1000 条兜底(项目级额度)。 实测:App ID + App Secret 能换到 access_token(3600s);形状被线上服务接受。 判据:repo 6 条 + push 12 条 + handler 3 组,全部做过**变异验证** —— 过程中抓出两条假判据(异步分发与 t.Cleanup 赛跑而假绿;密钥文件优先级没被覆盖) 并补掉。Go 全量测试与 go vet 干净。 ★ 未验:端到端真机送达(需要真机 token + 客户端按 com.jianf.agentmail 重编并签名, 签名指纹还要在 AGC 登记)—— 从未真正发出过一条能到达设备的推送。 详见 docs/HMS-PUSH-PLAN.md 的「实现状态」一节。
This commit is contained in:
179
server/internal/push/push.go
Normal file
179
server/internal/push/push.go
Normal file
@ -0,0 +1,179 @@
|
||||
/*
|
||||
Package push 是「新邮件」的第二条送达通道(第一条是 SSE)。
|
||||
|
||||
# 它是可选的,且默认关闭
|
||||
|
||||
自部署实例通常**一个推送渠道都没配** —— 那是正常状态,不是配置错误:客户端
|
||||
在线时 SSE 已经够用,推送只解决「App 不在前台 / 被系统杀掉」这一种情形。
|
||||
因此本包所有入口在没注册任何 provider 时都是**立即返回**:不查库、不建连接、
|
||||
不刷日志(用户 2026-09-15 的明确要求:「不能写死推送方式,因为我们是自部署后端」
|
||||
「即推送密钥应当是可选项」)。
|
||||
|
||||
# 为什么不写死华为
|
||||
|
||||
表与端点都带 `provider` 维度,Notifier 是接口:加一个通道(web push、别的厂商)
|
||||
只加一个实现 + 一行注册,不动 schema、不动端点形状、不动调用方。
|
||||
华为 HMS 只是第一个实现(internal/push/hms.go)。
|
||||
|
||||
# 为什么发送是异步且会丢
|
||||
|
||||
推送发生在**收信路径**上(notify.Recipients),而它已经在库事务之外的下发阶段:
|
||||
一个慢的推送 HTTP 请求不能拖住邮件送达 —— 收信是主功能,推送是锦上添花。
|
||||
因此:有界并发 + 超时 + 失败只记日志。**宁可丢一条通知,不可慢一封邮件**。
|
||||
*/
|
||||
package push
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/agentmail/gateway/internal/repo"
|
||||
)
|
||||
|
||||
// NewMail 是要推送的一封新邮件。
|
||||
//
|
||||
// 字段刻意少:推送只负责「通知栏那一行 + 点进去看哪封信」。正文不进通知,
|
||||
// 否则锁屏上就会露出邮件内容(SSE 是给已解锁的在线客户端用的,两者隐私模型不同)。
|
||||
type NewMail struct {
|
||||
MailID string
|
||||
SessionID string
|
||||
// From 是发件方名字,用于通知标题。
|
||||
From string
|
||||
// Subject 是邮件主题。
|
||||
Subject string
|
||||
// Recipients 是**该收到这封信的人名**(= SSE 的收件判据:主收件人 + 抄送方)。
|
||||
// 两条通道共用同一份名单,不各算一套。
|
||||
Recipients []string
|
||||
}
|
||||
|
||||
// Notifier 是一个推送通道。
|
||||
type Notifier interface {
|
||||
// Name 是 provider 标识,与 push_tokens.provider 的值一致(如 "hms")。
|
||||
Name() string
|
||||
// MaxTokensPerRequest 是单次请求能带的最大 token 数(厂商限额,如华为测试消息 ≤10)。
|
||||
// 由通道自己声明,而不是调用方写死一个「10」—— 限额是通道的属性。
|
||||
MaxTokensPerRequest() int
|
||||
// Send 向一批 token 投递同一条通知。返回错误只用于**记日志**。
|
||||
Send(ctx context.Context, tokens []string, n NewMail) error
|
||||
}
|
||||
|
||||
const (
|
||||
// maxInFlight 是在途推送任务上限。超了就丢掉这一轮(记日志),不排队:
|
||||
// 排队的后果是通知在几十秒后集中弹出来,那比丢掉更糟。
|
||||
maxInFlight = 4
|
||||
// sendTimeout 是单个 provider 单批的超时。
|
||||
sendTimeout = 10 * time.Second
|
||||
)
|
||||
|
||||
var (
|
||||
mu sync.RWMutex
|
||||
providers []Notifier
|
||||
slots = make(chan struct{}, maxInFlight)
|
||||
)
|
||||
|
||||
// Register 注册一个推送通道。由 main 按配置调用 —— 没配就不注册。
|
||||
func Register(n Notifier) {
|
||||
if n == nil {
|
||||
return
|
||||
}
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
providers = append(providers, n)
|
||||
log.Printf("[push] 通道已启用: %s(单批最多 %d 个 token)", n.Name(), n.MaxTokensPerRequest())
|
||||
}
|
||||
|
||||
// Enabled 报告是否配了任何推送通道。
|
||||
//
|
||||
// 端点据此回 `enabled`,客户端据此知道自己「登记了也可能收不到」——
|
||||
// 而不是以为登记失败。
|
||||
func Enabled() bool {
|
||||
mu.RLock()
|
||||
defer mu.RUnlock()
|
||||
return len(providers) > 0
|
||||
}
|
||||
|
||||
// Names 返回已启用的通道名(端点回给客户端看)。
|
||||
func Names() []string {
|
||||
mu.RLock()
|
||||
defer mu.RUnlock()
|
||||
out := make([]string, 0, len(providers))
|
||||
for _, p := range providers {
|
||||
out = append(out, p.Name())
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// shouldDispatch 报告这封邮件值不值得进推送管线:没配通道、或没有收件人 → 不值。
|
||||
//
|
||||
// 为什么单独成一个**纯函数**(不碰库、不起 goroutine):因为“没配通道时零开销”
|
||||
// 这条判据必须能**同步**验证。2026-09-15 变异验证实测过:把判据写成“调 NotifyNewMail
|
||||
// 后用 nil 库不 panic”,去掉本函数里的 Enabled() 后测试**仍然绿** —— 分发在
|
||||
// goroutine 里跑,而 t.Cleanup 已经把真库装回去了,于是判据在错误的理由上通过。
|
||||
// 纯函数没有这个<E8BF99>赛跑面。
|
||||
func shouldDispatch(n NewMail) bool {
|
||||
return Enabled() && len(n.Recipients) > 0
|
||||
}
|
||||
|
||||
// NotifyNewMail 异步把一封新邮件推给收件方登记的设备。
|
||||
//
|
||||
// 调用方(notify.Recipients)**永远不因此拿到错误**:推送失败不该影响收信,
|
||||
// 也不该让调用方写一半成功一半失败的处理逻辑。
|
||||
func NotifyNewMail(ctx context.Context, n NewMail) {
|
||||
if !shouldDispatch(n) {
|
||||
return
|
||||
}
|
||||
select {
|
||||
case slots <- struct{}{}:
|
||||
default:
|
||||
log.Printf("[push] 在途任务已达上限 %d,跳过本轮推送(可选通道,丢一条通知不影响收信)", maxInFlight)
|
||||
return
|
||||
}
|
||||
go func() {
|
||||
defer func() { <-slots }()
|
||||
// 用 Background 而不是请求的 ctx:发信请求一旦返回,ctx 就被取消,
|
||||
// 挂在它上面的推送会被立刻掐断(而收信方恰恰是那个已经离线的人)。
|
||||
sendCtx, cancel := context.WithTimeout(context.Background(), sendTimeout)
|
||||
defer cancel()
|
||||
dispatch(sendCtx, n)
|
||||
}()
|
||||
}
|
||||
|
||||
// dispatch 按 provider 分组投递,每批不超过该通道声明的上限。
|
||||
func dispatch(ctx context.Context, n NewMail) {
|
||||
tokens, err := repo.ListPushTokensOfOwners(ctx, n.Recipients)
|
||||
if err != nil {
|
||||
log.Printf("[push] 读推送登记失败(不影响收信): %v", err)
|
||||
return
|
||||
}
|
||||
if len(tokens) == 0 {
|
||||
return
|
||||
}
|
||||
grouped := map[string][]string{}
|
||||
for _, t := range tokens {
|
||||
grouped[t.Provider] = append(grouped[t.Provider], t.Token)
|
||||
}
|
||||
mu.RLock()
|
||||
list := append([]Notifier(nil), providers...)
|
||||
mu.RUnlock()
|
||||
for _, p := range list {
|
||||
ts := grouped[p.Name()]
|
||||
if len(ts) == 0 {
|
||||
continue
|
||||
}
|
||||
batch := p.MaxTokensPerRequest()
|
||||
if batch <= 0 {
|
||||
batch = 1
|
||||
}
|
||||
for i := 0; i < len(ts); i += batch {
|
||||
end := i + batch
|
||||
if end > len(ts) {
|
||||
end = len(ts)
|
||||
}
|
||||
if err := p.Send(ctx, ts[i:end], n); err != nil {
|
||||
log.Printf("[push] %s 投递失败(不影响收信): %v", p.Name(), err)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user