feat(push-gateway): config 扩展 + kafka consumer + ws handler + nextstep 文档
This commit is contained in:
@@ -1,6 +1,10 @@
|
||||
// Package config loads runtime configuration for push-gateway from environment
|
||||
// variables. Required variables (JWT_SECRET in production, INTERNAL_API_TOKEN
|
||||
// when not in DevMode) cause a fatal exit when missing.
|
||||
// variables. Required variables (JWT_SECRET in production, PUSH_INTERNAL_TOKEN
|
||||
// or INTERNAL_API_TOKEN when not in DevMode) cause a fatal exit when missing.
|
||||
//
|
||||
// v2 alignment (ARB-013 / msg contract): Kafka topic default changed to
|
||||
// edu.notify.notification.sent; PUSH_INTERNAL_TOKEN is the canonical env var
|
||||
// for /internal/* auth (INTERNAL_API_TOKEN kept as a backward-compat alias).
|
||||
package config
|
||||
|
||||
import (
|
||||
@@ -54,9 +58,15 @@ func Load() *Config {
|
||||
}
|
||||
}
|
||||
|
||||
internalToken := env.Get("INTERNAL_API_TOKEN", "")
|
||||
// PUSH_INTERNAL_TOKEN is the canonical env var for /internal/* auth
|
||||
// (ARB-013 alignment with msg). INTERNAL_API_TOKEN is kept as a
|
||||
// backward-compat alias for existing deployments.
|
||||
internalToken := env.Get("PUSH_INTERNAL_TOKEN", "")
|
||||
if internalToken == "" {
|
||||
internalToken = env.Get("INTERNAL_API_TOKEN", "")
|
||||
}
|
||||
if internalToken == "" && !devMode {
|
||||
log.Fatal("INTERNAL_API_TOKEN must be set in non-dev mode")
|
||||
log.Fatal("PUSH_INTERNAL_TOKEN (or INTERNAL_API_TOKEN) must be set in non-dev mode")
|
||||
}
|
||||
|
||||
return &Config{
|
||||
@@ -71,7 +81,9 @@ func Load() *Config {
|
||||
HeartbeatInterval: env.GetInt("HEARTBEAT_INTERVAL_SECONDS", 30),
|
||||
JWKSURL: env.Get("JWKS_URL", "http://localhost:50052/.well-known/jwks.json"),
|
||||
KafkaBrokers: parseBrokers(env.Get("KAFKA_BROKERS", "localhost:9092")),
|
||||
KafkaNotificationTopic: env.Get("KAFKA_NOTIFICATION_TOPIC", "edu.notification.requested"),
|
||||
// ARB-013 canonical topic name: edu.<domain>.<aggregate>.<action>.
|
||||
// msg's Outbox publisher emits to this topic.
|
||||
KafkaNotificationTopic: env.Get("KAFKA_NOTIFICATION_TOPIC", "edu.notify.notification.sent"),
|
||||
KafkaConsumerGroup: env.Get("KAFKA_CONSUMER_GROUP", "push-gateway"),
|
||||
InstanceID: env.Get("INSTANCE_ID", generateInstanceID()),
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@ func TestLoadDevMode(t *testing.T) {
|
||||
t.Setenv("DEV_MODE", "true")
|
||||
t.Setenv("JWT_SECRET", "")
|
||||
t.Setenv("INTERNAL_API_TOKEN", "")
|
||||
t.Setenv("PUSH_INTERNAL_TOKEN", "")
|
||||
|
||||
cfg := Load()
|
||||
|
||||
@@ -31,8 +32,9 @@ func TestLoadDevMode(t *testing.T) {
|
||||
if cfg.HeartbeatInterval != 30 {
|
||||
t.Errorf("HeartbeatInterval = %d, want 30", cfg.HeartbeatInterval)
|
||||
}
|
||||
if cfg.KafkaNotificationTopic != "edu.notification.requested" {
|
||||
t.Errorf("KafkaNotificationTopic = %q, want edu.notification.requested", cfg.KafkaNotificationTopic)
|
||||
// ARB-013: canonical topic name is edu.notify.notification.sent.
|
||||
if cfg.KafkaNotificationTopic != "edu.notify.notification.sent" {
|
||||
t.Errorf("KafkaNotificationTopic = %q, want edu.notify.notification.sent", cfg.KafkaNotificationTopic)
|
||||
}
|
||||
if cfg.KafkaConsumerGroup != "push-gateway" {
|
||||
t.Errorf("KafkaConsumerGroup = %q, want push-gateway", cfg.KafkaConsumerGroup)
|
||||
@@ -42,7 +44,8 @@ func TestLoadDevMode(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestLoadProdMode verifies production requires JWT_SECRET + INTERNAL_API_TOKEN.
|
||||
// TestLoadProdMode verifies production requires JWT_SECRET + PUSH_INTERNAL_TOKEN
|
||||
// (or the legacy INTERNAL_API_TOKEN alias).
|
||||
func TestLoadProdMode(t *testing.T) {
|
||||
// Ensure the env vars are unset so Load fails. We can't call os.Exit in
|
||||
// a test, so we verify the required behavior by setting DEV_MODE=false
|
||||
@@ -50,7 +53,8 @@ func TestLoadProdMode(t *testing.T) {
|
||||
// os.Exit, we instead test the happy path: all required vars set.
|
||||
t.Setenv("DEV_MODE", "false")
|
||||
t.Setenv("JWT_SECRET", "prod-secret")
|
||||
t.Setenv("INTERNAL_API_TOKEN", "prod-token")
|
||||
t.Setenv("PUSH_INTERNAL_TOKEN", "prod-token")
|
||||
t.Setenv("INTERNAL_API_TOKEN", "")
|
||||
|
||||
cfg := Load()
|
||||
|
||||
@@ -61,7 +65,22 @@ func TestLoadProdMode(t *testing.T) {
|
||||
t.Errorf("JWTSecret = %q, want prod-secret", cfg.JWTSecret)
|
||||
}
|
||||
if cfg.InternalAPIToken != "prod-token" {
|
||||
t.Errorf("InternalAPIToken = %q, want prod-token", cfg.InternalAPIToken)
|
||||
t.Errorf("InternalAPIToken = %q, want prod-token (from PUSH_INTERNAL_TOKEN)", cfg.InternalAPIToken)
|
||||
}
|
||||
}
|
||||
|
||||
// TestLoadProdModeLegacyToken verifies INTERNAL_API_TOKEN is accepted as a
|
||||
// backward-compat alias when PUSH_INTERNAL_TOKEN is not set.
|
||||
func TestLoadProdModeLegacyToken(t *testing.T) {
|
||||
t.Setenv("DEV_MODE", "false")
|
||||
t.Setenv("JWT_SECRET", "prod-secret")
|
||||
t.Setenv("PUSH_INTERNAL_TOKEN", "")
|
||||
t.Setenv("INTERNAL_API_TOKEN", "legacy-token")
|
||||
|
||||
cfg := Load()
|
||||
|
||||
if cfg.InternalAPIToken != "legacy-token" {
|
||||
t.Errorf("InternalAPIToken = %q, want legacy-token (from INTERNAL_API_TOKEN fallback)", cfg.InternalAPIToken)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,12 +1,11 @@
|
||||
// Package kafkaconsumer subscribes to the edu.notification.requested topic
|
||||
// (ISSUE-053 final topic name) on behalf of push-gateway. Each consumed
|
||||
// Package kafkaconsumer subscribes to the edu.notify.notification.sent topic
|
||||
// (ARB-013 canonical topic name) on behalf of push-gateway. Each consumed
|
||||
// NotificationRequested event is dispatched to the Hub for delivery to online
|
||||
// clients. At-least-once delivery is enforced via manual commit; idempotency
|
||||
// is provided by Redis SETNX on event_id (TTL 24h).
|
||||
//
|
||||
// Topic naming follows the G16 rule (edu.<domain>.<aggregate>.<action>); the
|
||||
// previous abstract name edu.notification.events / edu.msg.notification.events
|
||||
// is deprecated (see president-final-rulings §1.5).
|
||||
// Topic naming follows ARB-013 (edu.<domain>.<aggregate>.<action>); the
|
||||
// previous name edu.notification.requested is deprecated (v2 alignment).
|
||||
package kafkaconsumer
|
||||
|
||||
import (
|
||||
@@ -159,12 +158,19 @@ func (c *Consumer) processMessage(ctx context.Context, msg kafka.Message) {
|
||||
if err := json.Unmarshal(msg.Value, &event); err != nil {
|
||||
observability.Logger().Warn("kafka: invalid message payload",
|
||||
"topic", c.topic, "partition", msg.Partition, "offset", msg.Offset, "err", err)
|
||||
c.metrics.IncPushed("notification.requested", "invalid")
|
||||
c.metrics.IncPushed("notification.invalid", "invalid")
|
||||
// Commit to skip the poison message.
|
||||
_ = c.reader.CommitMessages(ctx, msg)
|
||||
return
|
||||
}
|
||||
|
||||
// eventLabel is the metrics label for IncPushed. Prefer event_type (the
|
||||
// actual event name like "ExamExtended"); fall back to a generic label.
|
||||
eventLabel := event.EventType
|
||||
if eventLabel == "" {
|
||||
eventLabel = "notification.sent"
|
||||
}
|
||||
|
||||
// Idempotency: skip already-processed events by event_id.
|
||||
fresh, err := c.redis.DedupEventId(ctx, event.EventID)
|
||||
if err != nil {
|
||||
@@ -174,7 +180,7 @@ func (c *Consumer) processMessage(ctx context.Context, msg kafka.Message) {
|
||||
if !fresh {
|
||||
observability.Logger().Debug("kafka: duplicate event skipped",
|
||||
"event_id", event.EventID)
|
||||
c.metrics.IncPushed("notification.requested", "duplicate")
|
||||
c.metrics.IncPushed(eventLabel, "duplicate")
|
||||
_ = c.reader.CommitMessages(ctx, msg)
|
||||
return
|
||||
}
|
||||
@@ -182,7 +188,7 @@ func (c *Consumer) processMessage(ctx context.Context, msg kafka.Message) {
|
||||
// Retry up to MaxRetries on dispatch failure.
|
||||
for attempt := 1; attempt <= MaxRetries; attempt++ {
|
||||
if err := c.dispatch(ctx, event); err == nil {
|
||||
c.metrics.IncPushed("notification.requested", "delivered")
|
||||
c.metrics.IncPushed(eventLabel, "delivered")
|
||||
_ = c.reader.CommitMessages(ctx, msg)
|
||||
return
|
||||
} else if attempt < MaxRetries {
|
||||
@@ -195,7 +201,7 @@ func (c *Consumer) processMessage(ctx context.Context, msg kafka.Message) {
|
||||
observability.Logger().Error("kafka: dispatch failed after retries, sending to DLQ",
|
||||
"event_id", event.EventID, "topic", c.topic)
|
||||
c.sendToDLQ(ctx, msg.Value)
|
||||
c.metrics.IncPushed("notification.requested", "dlq")
|
||||
c.metrics.IncPushed(eventLabel, "dlq")
|
||||
_ = c.reader.CommitMessages(ctx, msg)
|
||||
}
|
||||
|
||||
|
||||
@@ -6,8 +6,12 @@
|
||||
// - WebSocket /ws: JWT RS256 validated via shared-go/jwks (caching JWKS from
|
||||
// iam /.well-known/jwks.json, refreshed every 5 minutes). DevMode accepts
|
||||
// the literal "dev-token" returning a synthetic dev-user subject.
|
||||
// - /internal/*: X-Internal-Token header matched against INTERNAL_API_TOKEN
|
||||
// (ARB-015 §17.3 / president §7.2). DevMode skips the check.
|
||||
// - /internal/*: X-Internal-Key header matched against PUSH_INTERNAL_TOKEN
|
||||
// (ARB-013 alignment with msg). X-Internal-Token is accepted as a
|
||||
// backward-compat alias. DevMode skips the check.
|
||||
//
|
||||
// Request body field naming: msg sends userId (camelCase); legacy callers may
|
||||
// still send user_id (snake_case). Both are accepted by /internal/push.
|
||||
//
|
||||
// Heartbeat (RFC 6455 control frames, not text messages):
|
||||
// - Client sends Ping every 30s; gorilla/websocket auto-replies with Pong.
|
||||
@@ -40,8 +44,12 @@ import (
|
||||
)
|
||||
|
||||
// internalTokenHeader is the canonical header for /internal/* authentication
|
||||
// (ARB-015 §17.3 / president §7.2). Was X-Internal-Key in pre-P5 builds.
|
||||
const internalTokenHeader = "X-Internal-Token"
|
||||
// (ARB-013 alignment with msg). legacyInternalTokenHeader is accepted as a
|
||||
// backward-compat alias for existing callers.
|
||||
const (
|
||||
internalTokenHeader = "X-Internal-Key"
|
||||
legacyInternalTokenHeader = "X-Internal-Token"
|
||||
)
|
||||
|
||||
// readBufferSize / writeBufferSize tune gorilla/websocket's internal buffers.
|
||||
const (
|
||||
@@ -244,16 +252,20 @@ func (h *Handler) readerLoop(c *hub.Connection) {
|
||||
|
||||
// PushHandler implements POST /internal/push: directed push to a single user.
|
||||
// Response carries delivered + online so msg (ai10) can decide offline fallback.
|
||||
//
|
||||
// Request body accepts both camelCase (userId, msg's preferred format) and
|
||||
// snake_case (user_id, legacy format) field names for the user identifier.
|
||||
func (h *Handler) PushHandler(c *gin.Context) {
|
||||
if !h.checkInternalToken(c) {
|
||||
return
|
||||
}
|
||||
|
||||
var req struct {
|
||||
UserID string `json:"user_id" binding:"required"`
|
||||
Event string `json:"event" binding:"required"`
|
||||
Data map[string]any `json:"data"`
|
||||
TTL *int `json:"ttl,omitempty"`
|
||||
UserID string `json:"userId"` // camelCase (msg canonical, ARB-013)
|
||||
UserIDLeg string `json:"user_id"` // snake_case (legacy)
|
||||
Event string `json:"event" binding:"required"`
|
||||
Data map[string]any `json:"data"`
|
||||
TTL *int `json:"ttl,omitempty"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.AbortWithStatusJSON(http.StatusBadRequest, pushResponse{
|
||||
@@ -262,6 +274,17 @@ func (h *Handler) PushHandler(c *gin.Context) {
|
||||
})
|
||||
return
|
||||
}
|
||||
// Prefer userId (camelCase); fall back to user_id (snake_case, legacy).
|
||||
if req.UserID == "" {
|
||||
req.UserID = req.UserIDLeg
|
||||
}
|
||||
if req.UserID == "" {
|
||||
c.AbortWithStatusJSON(http.StatusBadRequest, pushResponse{
|
||||
Success: false,
|
||||
Error: &errBody{Code: "PUSH_INVALID_REQUEST", Message: "userId (or user_id) is required"},
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
message, err := buildMessage(req.Event, req.Data)
|
||||
if err != nil {
|
||||
@@ -399,8 +422,10 @@ func (h *Handler) OnlineHandler(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, gin.H{"online": false, "instances": []string{}})
|
||||
}
|
||||
|
||||
// checkInternalToken validates the X-Internal-Token header. Returns false when
|
||||
// the request has been aborted (caller should return immediately).
|
||||
// checkInternalToken validates the internal token header. The canonical header
|
||||
// is X-Internal-Key (ARB-013 alignment with msg); X-Internal-Token is accepted
|
||||
// as a backward-compat alias. Returns false when the request has been aborted
|
||||
// (caller should return immediately).
|
||||
func (h *Handler) checkInternalToken(c *gin.Context) bool {
|
||||
if h.cfg.DevMode {
|
||||
return true
|
||||
@@ -412,7 +437,11 @@ func (h *Handler) checkInternalToken(c *gin.Context) bool {
|
||||
})
|
||||
return false
|
||||
}
|
||||
if c.GetHeader(internalTokenHeader) != h.cfg.InternalAPIToken {
|
||||
token := c.GetHeader(internalTokenHeader)
|
||||
if token == "" {
|
||||
token = c.GetHeader(legacyInternalTokenHeader)
|
||||
}
|
||||
if token != h.cfg.InternalAPIToken {
|
||||
c.AbortWithStatusJSON(http.StatusUnauthorized, pushResponse{
|
||||
Success: false,
|
||||
Error: &errBody{Code: "PUSH_UNAUTHORIZED", Message: "invalid or missing internal token"},
|
||||
|
||||
Reference in New Issue
Block a user