5759c1862e
M0-M7 已完成:核心网关(身份/RBAC/TOTP/OIDC/SAML/Provider/配额/路由/内容策略/审计/定价)+ 资源市场(MCP/Skills/数字员工)。 含 22 个 PostgreSQL 迁移、管理端/门户端前端源码、OpenAPI 契约、部署 compose。 Co-Authored-By: Claude <noreply@anthropic.com>
99 lines
3.0 KiB
Go
99 lines
3.0 KiB
Go
package outbox
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"strconv"
|
|
"time"
|
|
|
|
"github.com/redis/go-redis/v9"
|
|
)
|
|
|
|
var ErrPublisherUnavailable = errors.New("outbox publisher unavailable")
|
|
|
|
type PublishResult struct {
|
|
Published bool
|
|
StreamID string
|
|
}
|
|
|
|
type RedisPublisher struct {
|
|
client *redis.Client
|
|
stream string
|
|
maxLength int64
|
|
markerTTL time.Duration
|
|
script *redis.Script
|
|
}
|
|
|
|
func NewRedisPublisher(client *redis.Client, stream string, maxLength int64, markerTTL time.Duration) *RedisPublisher {
|
|
return &RedisPublisher{client: client, stream: stream, maxLength: maxLength, markerTTL: markerTTL, script: redis.NewScript(publishScript)}
|
|
}
|
|
|
|
// Ping reports whether the downstream Redis stream is reachable. The outbox
|
|
// worker calls it before claiming events so a Redis outage never consumes the
|
|
// events' delivery budget (claiming increments attempts; dead-lettering then
|
|
// burns the whole queue for a fault that was never the events').
|
|
func (p *RedisPublisher) Ping(ctx context.Context) error {
|
|
if p == nil || p.client == nil {
|
|
return ErrPublisherUnavailable
|
|
}
|
|
if err := p.client.Ping(ctx).Err(); err != nil {
|
|
return fmt.Errorf("%w: %v", ErrPublisherUnavailable, err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (p *RedisPublisher) Publish(ctx context.Context, event Event) (PublishResult, error) {
|
|
if p == nil || p.client == nil {
|
|
return PublishResult{}, ErrPublisherUnavailable
|
|
}
|
|
marker := "gateway:{outbox}:published:" + event.EventID
|
|
result, err := p.script.Run(ctx, p.client, []string{marker, p.stream},
|
|
int64(p.markerTTL.Seconds()), p.maxLength, event.EventID, event.EventType, event.EventVersion,
|
|
valueOrEmpty(event.TenantID), event.AggregateType, event.AggregateID, string(event.Payload), string(event.TraceContext), event.OccurredAt.UTC().Format(time.RFC3339Nano),
|
|
).Slice()
|
|
if err != nil || len(result) != 2 {
|
|
return PublishResult{}, fmt.Errorf("%w: %v", ErrPublisherUnavailable, err)
|
|
}
|
|
published, err := redisInt(result[0])
|
|
if err != nil {
|
|
return PublishResult{}, fmt.Errorf("%w: %v", ErrPublisherUnavailable, err)
|
|
}
|
|
streamID := "duplicate"
|
|
if result[1] != nil && fmt.Sprint(result[1]) != "" {
|
|
streamID = fmt.Sprint(result[1])
|
|
}
|
|
return PublishResult{Published: published == 1, StreamID: streamID}, nil
|
|
}
|
|
|
|
func valueOrEmpty(value *string) string {
|
|
if value == nil {
|
|
return ""
|
|
}
|
|
return *value
|
|
}
|
|
|
|
func redisInt(value any) (int64, error) {
|
|
switch typed := value.(type) {
|
|
case int64:
|
|
return typed, nil
|
|
case string:
|
|
return strconv.ParseInt(typed, 10, 64)
|
|
case []byte:
|
|
return strconv.ParseInt(string(typed), 10, 64)
|
|
default:
|
|
return 0, fmt.Errorf("unexpected redis integer %T", value)
|
|
}
|
|
}
|
|
|
|
const publishScript = `
|
|
if redis.call('SET', KEYS[1], '1', 'NX', 'EX', tonumber(ARGV[1])) then
|
|
local id = redis.call('XADD', KEYS[2], 'MAXLEN', '~', tonumber(ARGV[2]), '*',
|
|
'event_id', ARGV[3], 'event_type', ARGV[4], 'event_version', ARGV[5],
|
|
'tenant_id', ARGV[6], 'aggregate_type', ARGV[7], 'aggregate_id', ARGV[8],
|
|
'payload', ARGV[9], 'trace_context', ARGV[10], 'occurred_at', ARGV[11])
|
|
return {1, id}
|
|
end
|
|
return {0, ''}
|
|
`
|