130 lines
3.9 KiB
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 } |