topfans/backend/pkg/push/uni_push_client.go

130 lines
3.9 KiB
Go

// Package push 提供统一推送客户端,用于调用 uniCloud sendMessage 云函数触发手机通知栏消息。
//
// 当前实现:阿里云 BSPAPP(uniCloud)云函数 sendMessage(uniPush)。
// 后续可扩展为多个 Pusher(clientID 分配 + 灰度),保持 Send(ctx, payload) 接口不变。
package push
import (
"bytes"
"context"
"crypto/rand"
"encoding/hex"
"encoding/json"
"fmt"
"net/http"
"time"
"go.uber.org/zap"
)
// Pusher 推送客户端统一接口(便于后续扩展多通道)。
type Pusher interface {
Send(ctx context.Context, p Payload) error
}
// Payload 推送给云函数 sendMessage 的载荷。
//
// 字段名严格对齐云函数约定(cids / title / content / request_id / data);
// data 用于透传到 App 端,App 在 onPushMessage 中读取 data.payload 决定跳转。
type Payload struct {
CIDs []string `json:"cids"`
Title string `json:"title"`
Content string `json:"content"`
RequestID string `json:"request_id"`
Data map[string]interface{} `json:"data"`
}
// UniPushClient 调用 uniCloud sendMessage 云函数做推送。
//
// 设计要点:
// - 一次发送 = 一次 HTTP POST;HTTP 客户端短超时(默认 4s)避免 goroutine 泄漏。
// - 不重试:推送失败只 warn 日志;客户端下次启动会重新注册 cid,可视为自愈。
// - 不做并发控制:调用方应在 goroutine 内 fire-and-forget;Send 本身不阻塞业务。
type UniPushClient struct {
url string
http *http.Client
logger *zap.Logger
}
// NewUniPushClient 创建推送客户端。
//
// - url:uniCloud 云函数 URL(如 https://fc-mp-xxx.next.bspapp.com/sendMessage)。
// - timeout:HTTP 调用超时,推送调用必须异步,推荐 3~5s。
// - logger:可选,nil 时使用 zap.NewNop()。
func NewUniPushClient(url string, timeout time.Duration, logger *zap.Logger) *UniPushClient {
if timeout <= 0 {
timeout = 4 * time.Second
}
if logger == nil {
logger = zap.NewNop()
}
return &UniPushClient{
url: url,
http: &http.Client{Timeout: timeout},
logger: logger,
}
}
// Send 同步发送推送(返回云函数调用结果)。调用方应自行在 goroutine 中调用以避免阻塞。
//
// 行为:
// - cids 为空时 debug log 后直接返回 nil(视为跳过)。
// - request_id 为空时自动生成(timestamp + 随机 hex),便于云函数侧排重。
// - 非 2xx 响应返回 error 并附状态码与 body 片段。
func (c *UniPushClient) Send(ctx context.Context, p Payload) error {
if c == nil || c.url == "" {
return fmt.Errorf("uniPush client not initialized")
}
if len(p.CIDs) == 0 {
c.logger.Debug("uniPush skip: empty cids",
zap.String("request_id", p.RequestID))
return nil
}
if p.RequestID == "" {
p.RequestID = genRequestID()
}
body, err := json.Marshal(p)
if err != nil {
return fmt.Errorf("marshal payload: %w", err)
}
req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.url, bytes.NewReader(body))
if err != nil {
return fmt.Errorf("build request: %w", err)
}
req.Header.Set("Content-Type", "application/json")
resp, err := c.http.Do(req)
if err != nil {
return fmt.Errorf("http do: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode/100 != 2 {
buf := make([]byte, 512)
n, _ := resp.Body.Read(buf)
return fmt.Errorf("uniPush status=%d body=%s", resp.StatusCode, string(buf[:n]))
}
c.logger.Info("uniPush sent",
zap.String("request_id", p.RequestID),
zap.Int("cid_count", len(p.CIDs)),
zap.String("title", p.Title),
)
return nil
}
// genRequestID 生成请求幂等 id(时间戳 + 随机 hex)。
func genRequestID() string {
now := time.Now().UnixMilli()
b := make([]byte, 4)
_, _ = rand.Read(b)
return fmt.Sprintf("%d-%s", now, hex.EncodeToString(b))
}
// NoopPusher 空实现,用于 push 关闭或测试桩。
type NoopPusher struct{}
// Send 直接返回 nil,不打日志。
func (NoopPusher) Send(_ context.Context, _ Payload) error { return nil }