// 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 }