feat: 建立可靠通知、吊销与跨实例状态
This commit is contained in:
@@ -0,0 +1,216 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"log/slog"
|
||||
"time"
|
||||
|
||||
"silk-server-go/internal/model"
|
||||
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/clause"
|
||||
)
|
||||
|
||||
const (
|
||||
OutboxStatusPending = "pending"
|
||||
OutboxStatusSending = "sending"
|
||||
OutboxStatusSent = "sent"
|
||||
OutboxStatusRetry = "retry"
|
||||
OutboxStatusFailed = "failed"
|
||||
OutboxStatusCancelled = "cancelled"
|
||||
|
||||
OutboxEventWechatSubscribe = "wechat.subscribe"
|
||||
)
|
||||
|
||||
// Event 写入 outbox 的领域事件。
|
||||
type Event struct {
|
||||
ID string
|
||||
Type string
|
||||
AggregateType string
|
||||
AggregateID string
|
||||
Payload json.RawMessage
|
||||
}
|
||||
|
||||
// EventHandler 处理一条已领取的 outbox 事件。
|
||||
type EventHandler func(ctx context.Context, event model.OutboxEvent) error
|
||||
|
||||
// Outbox 基于 PostgreSQL 的可靠事件箱。
|
||||
type Outbox struct {
|
||||
db *gorm.DB
|
||||
handler EventHandler
|
||||
interval time.Duration
|
||||
batchSize int
|
||||
maxAttempts int
|
||||
backoff time.Duration
|
||||
}
|
||||
|
||||
// NewOutbox 创建默认配置的 Outbox。
|
||||
func NewOutbox(db *gorm.DB) *Outbox {
|
||||
return &Outbox{
|
||||
db: db,
|
||||
interval: 5 * time.Second,
|
||||
batchSize: 20,
|
||||
maxAttempts: 5,
|
||||
backoff: 15 * time.Second,
|
||||
}
|
||||
}
|
||||
|
||||
// SetHandler 设置事件处理器。
|
||||
func (o *Outbox) SetHandler(handler EventHandler) {
|
||||
o.handler = handler
|
||||
}
|
||||
|
||||
// PublishTx 在业务事务内写入事件;重复 eventId 通过唯一索引幂等跳过。
|
||||
func (o *Outbox) PublishTx(tx *gorm.DB, event Event) error {
|
||||
if event.Type == "" {
|
||||
return errors.New("outbox event type is required")
|
||||
}
|
||||
if event.ID == "" {
|
||||
event.ID = randomEventID()
|
||||
}
|
||||
payload := event.Payload
|
||||
if len(payload) == 0 {
|
||||
payload = json.RawMessage(`{}`)
|
||||
}
|
||||
record := model.OutboxEvent{
|
||||
EventID: event.ID,
|
||||
EventType: event.Type,
|
||||
AggregateType: strPtrOrNil(event.AggregateType),
|
||||
AggregateID: strPtrOrNil(event.AggregateID),
|
||||
Payload: payload,
|
||||
Status: OutboxStatusPending,
|
||||
MaxAttempts: o.maxAttempts,
|
||||
NextAttemptAt: time.Now(),
|
||||
}
|
||||
// PublishTx 必须复用调用方事务,避免 GORM 对单条 Create 再开嵌套事务。
|
||||
return tx.Session(&gorm.Session{SkipDefaultTransaction: true}).Clauses(clause.OnConflict{
|
||||
Columns: []clause.Column{{Name: "event_id"}},
|
||||
DoNothing: true,
|
||||
}).Create(&record).Error
|
||||
}
|
||||
|
||||
// ClaimNext 领取一批到期待处理事件。
|
||||
func (o *Outbox) ClaimNext(ctx context.Context, limit int) ([]model.OutboxEvent, error) {
|
||||
if limit <= 0 {
|
||||
limit = o.batchSize
|
||||
}
|
||||
var events []model.OutboxEvent
|
||||
err := o.db.WithContext(ctx).
|
||||
Where("status IN ? AND next_attempt_at <= ?", []string{OutboxStatusPending, OutboxStatusRetry}, time.Now()).
|
||||
Order("created_at ASC").
|
||||
Limit(limit).
|
||||
Find(&events).Error
|
||||
return events, err
|
||||
}
|
||||
|
||||
// MarkSending 原子标记领取状态,避免多 worker 重复处理。
|
||||
func (o *Outbox) MarkSending(ctx context.Context, id string) (bool, error) {
|
||||
result := o.db.WithContext(ctx).
|
||||
Model(&model.OutboxEvent{}).
|
||||
Where("id = ? AND status IN ?", id, []string{OutboxStatusPending, OutboxStatusRetry}).
|
||||
Update("status", OutboxStatusSending)
|
||||
return result.RowsAffected > 0, result.Error
|
||||
}
|
||||
|
||||
// MarkResult 写入成功/失败并计算下一次重试时间。
|
||||
func (o *Outbox) MarkResult(ctx context.Context, id string, processErr error) error {
|
||||
var event model.OutboxEvent
|
||||
if err := o.db.WithContext(ctx).First(&event, "id = ?", id).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if event.MaxAttempts <= 0 {
|
||||
event.MaxAttempts = o.maxAttempts
|
||||
}
|
||||
event.Attempts++
|
||||
now := time.Now()
|
||||
updates := map[string]interface{}{
|
||||
"attempts": event.Attempts,
|
||||
"updated_at": now,
|
||||
"next_attempt_at": now,
|
||||
}
|
||||
if processErr == nil {
|
||||
updates["status"] = OutboxStatusSent
|
||||
updates["last_error"] = nil
|
||||
} else {
|
||||
message := processErr.Error()
|
||||
updates["last_error"] = message
|
||||
if event.Attempts >= event.MaxAttempts {
|
||||
updates["status"] = OutboxStatusFailed
|
||||
} else {
|
||||
updates["status"] = OutboxStatusRetry
|
||||
updates["next_attempt_at"] = now.Add(o.backoff * time.Duration(event.Attempts))
|
||||
}
|
||||
}
|
||||
return o.db.WithContext(ctx).
|
||||
Model(&model.OutboxEvent{}).
|
||||
Where("id = ?", id).
|
||||
Updates(updates).Error
|
||||
}
|
||||
|
||||
// ProcessPending 领取并处理到期事件,返回本轮处理数量。
|
||||
func (o *Outbox) ProcessPending(ctx context.Context) (int, error) {
|
||||
if o.handler == nil {
|
||||
return 0, errors.New("outbox handler is not set")
|
||||
}
|
||||
processed := 0
|
||||
for {
|
||||
events, err := o.ClaimNext(ctx, o.batchSize)
|
||||
if err != nil {
|
||||
return processed, err
|
||||
}
|
||||
if len(events) == 0 {
|
||||
return processed, nil
|
||||
}
|
||||
for _, event := range events {
|
||||
claimed, err := o.MarkSending(ctx, event.ID)
|
||||
if err != nil {
|
||||
slog.Warn("outbox mark sending failed", "eventId", event.EventID, "err", err)
|
||||
continue
|
||||
}
|
||||
if !claimed {
|
||||
continue
|
||||
}
|
||||
processErr := o.handler(ctx, event)
|
||||
if err := o.MarkResult(ctx, event.ID, processErr); err != nil {
|
||||
slog.Warn("outbox mark result failed", "eventId", event.EventID, "err", err)
|
||||
continue
|
||||
}
|
||||
processed++
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Start 启动后台 worker。
|
||||
func (o *Outbox) Start(ctx context.Context) {
|
||||
ticker := time.NewTicker(o.interval)
|
||||
defer ticker.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-ticker.C:
|
||||
if _, err := o.ProcessPending(ctx); err != nil {
|
||||
slog.Warn("outbox worker failed", "err", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func strPtrOrNil(value string) *string {
|
||||
if value == "" {
|
||||
return nil
|
||||
}
|
||||
return &value
|
||||
}
|
||||
|
||||
func randomEventID() string {
|
||||
b := make([]byte, 16)
|
||||
if _, err := rand.Read(b); err != nil {
|
||||
return hex.EncodeToString([]byte(time.Now().Format(time.RFC3339Nano)))
|
||||
}
|
||||
return hex.EncodeToString(b)
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/DATA-DOG/go-sqlmock"
|
||||
"gorm.io/driver/postgres"
|
||||
"gorm.io/gorm"
|
||||
|
||||
"silk-server-go/internal/model"
|
||||
)
|
||||
|
||||
func newOutboxMock(t *testing.T) (*Outbox, sqlmock.Sqlmock) {
|
||||
t.Helper()
|
||||
sqlDB, mock, err := sqlmock.New()
|
||||
if err != nil {
|
||||
t.Fatalf("create sqlmock: %v", err)
|
||||
}
|
||||
gdb, err := gorm.Open(postgres.New(postgres.Config{Conn: sqlDB}), &gorm.Config{})
|
||||
if err != nil {
|
||||
t.Fatalf("open gorm: %v", err)
|
||||
}
|
||||
o := NewOutbox(gdb)
|
||||
return o, mock
|
||||
}
|
||||
|
||||
func TestPublishTxUsesSameEventIDIdempotently(t *testing.T) {
|
||||
o, mock := newOutboxMock(t)
|
||||
event := Event{
|
||||
ID: "event-1",
|
||||
Type: OutboxEventWechatSubscribe,
|
||||
Payload: json.RawMessage(`{"userId":"u1"}`),
|
||||
}
|
||||
mock.ExpectQuery(`INSERT INTO "outbox_events".*ON CONFLICT \("event_id"\) DO NOTHING`).
|
||||
WithArgs(
|
||||
sqlmock.AnyArg(), sqlmock.AnyArg(), sqlmock.AnyArg(), sqlmock.AnyArg(),
|
||||
sqlmock.AnyArg(), sqlmock.AnyArg(), sqlmock.AnyArg(), sqlmock.AnyArg(),
|
||||
sqlmock.AnyArg(), sqlmock.AnyArg(), sqlmock.AnyArg(), sqlmock.AnyArg(),
|
||||
).
|
||||
WillReturnRows(sqlmock.NewRows([]string{"id", "payload", "next_attempt_at"}).
|
||||
AddRow("00000000-0000-0000-0000-000000000001", json.RawMessage(`{"userId":"u1"}`), time.Now()))
|
||||
if err := o.PublishTx(o.db, event); err != nil {
|
||||
t.Fatalf("first publish failed: %v", err)
|
||||
}
|
||||
// 第二次相同 eventId 仍走 ON CONFLICT DO NOTHING,不产生重复发送。
|
||||
mock.ExpectQuery(`INSERT INTO "outbox_events".*ON CONFLICT \("event_id"\) DO NOTHING`).
|
||||
WithArgs(
|
||||
sqlmock.AnyArg(), sqlmock.AnyArg(), sqlmock.AnyArg(), sqlmock.AnyArg(),
|
||||
sqlmock.AnyArg(), sqlmock.AnyArg(), sqlmock.AnyArg(), sqlmock.AnyArg(),
|
||||
sqlmock.AnyArg(), sqlmock.AnyArg(), sqlmock.AnyArg(), sqlmock.AnyArg(),
|
||||
).
|
||||
WillReturnRows(sqlmock.NewRows([]string{"id", "payload", "next_attempt_at"}))
|
||||
if err := o.PublishTx(o.db, event); err != nil {
|
||||
t.Fatalf("second publish failed: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProcessPendingWithNoEventsIsNoop(t *testing.T) {
|
||||
o, mock := newOutboxMock(t)
|
||||
o.handler = func(ctx context.Context, event model.OutboxEvent) error { return nil }
|
||||
mock.ExpectQuery(`SELECT .* FROM "outbox_events" WHERE status IN \(.*\)`).
|
||||
WillReturnRows(sqlmock.NewRows([]string{
|
||||
"id", "event_id", "event_type", "aggregate_type", "aggregate_id",
|
||||
"payload", "status", "attempts", "max_attempts", "next_attempt_at",
|
||||
"last_error", "created_at", "updated_at",
|
||||
}))
|
||||
processed, err := o.ProcessPending(context.Background())
|
||||
if err != nil {
|
||||
t.Fatalf("ProcessPending failed: %v", err)
|
||||
}
|
||||
if processed != 0 {
|
||||
t.Fatalf("processed = %d, want 0", processed)
|
||||
}
|
||||
}
|
||||
|
||||
func TestClaimNextReturnsPendingAfterRestart(t *testing.T) {
|
||||
o, mock := newOutboxMock(t)
|
||||
now := time.Now()
|
||||
mock.ExpectQuery(`SELECT .* FROM "outbox_events" WHERE status IN \(.*\)`).
|
||||
WithArgs(OutboxStatusPending, OutboxStatusRetry, sqlmock.AnyArg(), sqlmock.AnyArg()).
|
||||
WillReturnRows(sqlmock.NewRows([]string{
|
||||
"id", "event_id", "event_type", "aggregate_type", "aggregate_id",
|
||||
"payload", "status", "attempts", "max_attempts", "next_attempt_at",
|
||||
"last_error", "created_at", "updated_at",
|
||||
}).AddRow(
|
||||
"id-1", "event-1", OutboxEventWechatSubscribe, nil, nil,
|
||||
json.RawMessage(`{}`), OutboxStatusPending, 0, 5, now,
|
||||
nil, now, now,
|
||||
))
|
||||
events, err := o.ClaimNext(context.Background(), 1)
|
||||
if err != nil {
|
||||
t.Fatalf("ClaimNext failed: %v", err)
|
||||
}
|
||||
if len(events) != 1 || events[0].EventID != "event-1" {
|
||||
t.Fatalf("events = %+v", events)
|
||||
}
|
||||
}
|
||||
@@ -10,6 +10,11 @@ import (
|
||||
"net/url"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"silk-server-go/internal/model"
|
||||
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/clause"
|
||||
)
|
||||
|
||||
const wechatAPIBase = "https://api.weixin.qq.com"
|
||||
@@ -168,9 +173,21 @@ func (s *WechatService) getAccessToken(ctx context.Context) (string, error) {
|
||||
|
||||
// SendSubscribe 发送订阅消息
|
||||
func (s *WechatService) SendSubscribe(ctx context.Context, openid, templateID string, data map[string]map[string]string, page string) error {
|
||||
_, err := s.SendSubscribeResult(ctx, openid, templateID, data, page)
|
||||
return err
|
||||
}
|
||||
|
||||
// WechatSendResult 微信订阅消息响应。
|
||||
type WechatSendResult struct {
|
||||
ErrCode int `json:"errcode"`
|
||||
ErrMsg string `json:"errmsg"`
|
||||
}
|
||||
|
||||
// SendSubscribeResult 发送订阅消息并返回微信响应码。
|
||||
func (s *WechatService) SendSubscribeResult(ctx context.Context, openid, templateID string, data map[string]map[string]string, page string) (WechatSendResult, error) {
|
||||
token, err := s.getAccessToken(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
return WechatSendResult{ErrCode: -1, ErrMsg: err.Error()}, err
|
||||
}
|
||||
payload := map[string]any{
|
||||
"touser": openid,
|
||||
@@ -182,32 +199,142 @@ func (s *WechatService) SendSubscribe(ctx context.Context, openid, templateID st
|
||||
}
|
||||
raw, err := json.Marshal(payload)
|
||||
if err != nil {
|
||||
return err
|
||||
return WechatSendResult{ErrCode: -1, ErrMsg: err.Error()}, err
|
||||
}
|
||||
u := s.baseURL + "/cgi-bin/message/subscribe/send?access_token=" + url.QueryEscape(token)
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, u, bytes.NewReader(raw))
|
||||
if err != nil {
|
||||
return err
|
||||
return WechatSendResult{ErrCode: -1, ErrMsg: err.Error()}, err
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
resp, err := s.httpClient.Do(req)
|
||||
if err != nil {
|
||||
return err
|
||||
return WechatSendResult{ErrCode: -1, ErrMsg: err.Error()}, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
var out struct {
|
||||
ErrCode int `json:"errcode"`
|
||||
ErrMsg string `json:"errmsg"`
|
||||
return WechatSendResult{ErrCode: -1, ErrMsg: err.Error()}, err
|
||||
}
|
||||
var out WechatSendResult
|
||||
if err := json.Unmarshal(body, &out); err != nil {
|
||||
return err
|
||||
return WechatSendResult{ErrCode: -1, ErrMsg: err.Error()}, err
|
||||
}
|
||||
if out.ErrCode != 0 {
|
||||
return fmt.Errorf("微信订阅消息发送失败 (%d): %s", out.ErrCode, out.ErrMsg)
|
||||
return out, fmt.Errorf("微信订阅消息发送失败 (%d): %s", out.ErrCode, out.ErrMsg)
|
||||
}
|
||||
return nil
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// WechatSubscribePayload outbox 微信订阅事件载荷。
|
||||
type WechatSubscribePayload struct {
|
||||
UserID string `json:"userId"`
|
||||
OpenID string `json:"openId"`
|
||||
TemplateKey string `json:"templateKey"`
|
||||
TemplateID string `json:"templateId"`
|
||||
Page string `json:"page"`
|
||||
Title string `json:"title"`
|
||||
Body string `json:"body"`
|
||||
Data map[string]map[string]string `json:"data"`
|
||||
BusinessType string `json:"businessType"`
|
||||
BusinessID string `json:"businessId"`
|
||||
}
|
||||
|
||||
// NewWechatOutboxHandler 创建微信订阅 outbox 处理器。
|
||||
func NewWechatOutboxHandler(db *gorm.DB, wechat *WechatService) EventHandler {
|
||||
return func(ctx context.Context, event model.OutboxEvent) error {
|
||||
if event.EventType != OutboxEventWechatSubscribe {
|
||||
return nil
|
||||
}
|
||||
var payload WechatSubscribePayload
|
||||
if err := json.Unmarshal(event.Payload, &payload); err != nil {
|
||||
return err
|
||||
}
|
||||
return handleWechatSubscribe(ctx, db, wechat, event, payload)
|
||||
}
|
||||
}
|
||||
|
||||
func handleWechatSubscribe(ctx context.Context, db *gorm.DB, wechat *WechatService, event model.OutboxEvent, payload WechatSubscribePayload) error {
|
||||
var binding model.WechatBinding
|
||||
if err := db.Where("user_id = ?", payload.UserID).First(&binding).Error; err != nil {
|
||||
message := "微信未绑定"
|
||||
return saveWechatNotification(ctx, db, event, payload, "", false, WechatSendResult{ErrCode: 40001, ErrMsg: message}, "cancelled", &message)
|
||||
}
|
||||
|
||||
var authorized []string
|
||||
if len(binding.AuthorizedTemplates) > 0 {
|
||||
_ = json.Unmarshal(binding.AuthorizedTemplates, &authorized)
|
||||
}
|
||||
if !IsAuthorized(authorized, payload.TemplateKey) {
|
||||
message := "用户未授权订阅模板"
|
||||
return saveWechatNotification(ctx, db, event, payload, binding.OpenID, false, WechatSendResult{ErrCode: 43101, ErrMsg: message}, "cancelled", &message)
|
||||
}
|
||||
|
||||
if payload.TemplateID == "" {
|
||||
message := "订阅消息模板 ID 未配置"
|
||||
if err := saveWechatNotification(ctx, db, event, payload, binding.OpenID, true, WechatSendResult{ErrCode: -1, ErrMsg: message}, "retry", &message); err != nil {
|
||||
return err
|
||||
}
|
||||
return fmt.Errorf("订阅消息模板 ID 未配置")
|
||||
}
|
||||
|
||||
result, sendErr := wechat.SendSubscribeResult(ctx, binding.OpenID, payload.TemplateID, payload.Data, payload.Page)
|
||||
status := "sent"
|
||||
var lastErr *string
|
||||
maxAttempts := event.MaxAttempts
|
||||
if maxAttempts <= 0 {
|
||||
maxAttempts = 5
|
||||
}
|
||||
if sendErr != nil {
|
||||
message := sendErr.Error()
|
||||
lastErr = &message
|
||||
status = "retry"
|
||||
if event.Attempts+1 >= maxAttempts {
|
||||
status = "failed"
|
||||
}
|
||||
}
|
||||
if err := saveWechatNotification(ctx, db, event, payload, binding.OpenID, true, result, status, lastErr); err != nil {
|
||||
return err
|
||||
}
|
||||
return sendErr
|
||||
}
|
||||
|
||||
func saveWechatNotification(ctx context.Context, db *gorm.DB, event model.OutboxEvent, payload WechatSubscribePayload, openID string, authorized bool, response WechatSendResult, status string, lastErr *string) error {
|
||||
data, _ := json.Marshal(payload.Data)
|
||||
providerResponse, _ := json.Marshal(response)
|
||||
userID := payload.UserID
|
||||
eventID := event.EventID
|
||||
notification := model.Notification{
|
||||
UserID: &userID,
|
||||
Channel: "wechat",
|
||||
Target: openID,
|
||||
Title: payload.Title,
|
||||
Body: payload.Body,
|
||||
Status: status,
|
||||
Attempts: event.Attempts + 1,
|
||||
NextAttemptAt: time.Now(),
|
||||
LastError: lastErr,
|
||||
TemplateID: strPtrOrNil(payload.TemplateID),
|
||||
OpenID: strPtrOrNil(openID),
|
||||
Authorized: authorized,
|
||||
ProviderResponse: providerResponse,
|
||||
Data: data,
|
||||
BusinessType: strPtrOrNil(payload.BusinessType),
|
||||
BusinessID: strPtrOrNil(payload.BusinessID),
|
||||
EventID: &eventID,
|
||||
}
|
||||
return db.WithContext(ctx).
|
||||
Clauses(clause.OnConflict{
|
||||
Columns: []clause.Column{{Name: "event_id"}},
|
||||
DoUpdates: clause.Assignments(map[string]interface{}{
|
||||
"status": status,
|
||||
"attempts": event.Attempts + 1,
|
||||
"next_attempt_at": time.Now(),
|
||||
"last_error": lastErr,
|
||||
"authorized": authorized,
|
||||
"provider_response": providerResponse,
|
||||
"updated_at": time.Now(),
|
||||
}),
|
||||
}).
|
||||
Create(¬ification).Error
|
||||
}
|
||||
|
||||
@@ -106,7 +106,11 @@ func TestWechatSendSubscribeErrorCode(t *testing.T) {
|
||||
s.baseURL = srv.URL
|
||||
s.accessToken = "fake-token"
|
||||
s.tokenExpire = time.Now().Add(time.Hour)
|
||||
if err := s.SendSubscribe(context.Background(), "bad", "tmpl1", nil, ""); err == nil {
|
||||
result, err := s.SendSubscribeResult(context.Background(), "bad", "tmpl1", nil, "")
|
||||
if err == nil {
|
||||
t.Error("errcode!=0 应返回错误")
|
||||
}
|
||||
if result.ErrCode != 40003 || result.ErrMsg != "invalid openid" {
|
||||
t.Errorf("response code = %+v", result)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user