Version: 0.9.77.dev.260505
后端:
1.阶段 6 CP4/CP5 目录收口与共享边界纯化
- 将 backend 根目录收口为 services、client、gateway、cmd、shared 五个一级目录
- 收拢 bootstrap、inits、infra/kafka、infra/outbox、conv、respond、pkg、middleware,移除根目录旧实现与空目录
- 将 utils 下沉到 services/userauth/internal/auth,将 logic 下沉到 services/schedule/core/planning
- 将迁移期 runtime 桥接实现统一收拢到 services/runtime/{conv,dao,eventsvc,model},删除 shared/legacy 与未再被 import 的旧 service 实现
- 将 gateway/shared/respond 收口为 HTTP/Gin 错误写回适配,shared/respond 仅保留共享错误语义与状态映射
- 将 HTTP IdempotencyMiddleware 与 RateLimitMiddleware 收口到 gateway/middleware
- 将 GormCachePlugin 下沉到 shared/infra/gormcache,将共享 RateLimiter 下沉到 shared/infra/ratelimit,将 agent token budget 下沉到 services/agent/shared
- 删除 InitEino 兼容壳,收缩 cmd/internal/coreinit 仅保留旧组合壳残留域初始化语义
- 更新微服务迁移计划与桌面 checklist,补齐 CP4/CP5 当前切流点、目录终态与验证结果
- 完成 go test ./...、git diff --check 与最终真实 smoke;health、register/login、task/create+get、schedule/today、task-class/list、memory/items、agent chat/meta/timeline/context-stats 全部 200,SSE 合并结果为 CP5_OK 且 [DONE] 只有 1 个
This commit is contained in:
83
backend/shared/infra/kafka/admin.go
Normal file
83
backend/shared/infra/kafka/admin.go
Normal file
@@ -0,0 +1,83 @@
|
||||
package kafka
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
segmentkafka "github.com/segmentio/kafka-go"
|
||||
)
|
||||
|
||||
// WaitTopicReady 在指定超时时间内等待 Kafka topic 可用。
|
||||
// 背景:初次部署时 broker 可能已启动,但 topic/partition 还没就绪。
|
||||
// 这里启动前先探测,可减少“应用已启动但实际无法消费”的静默窗口。
|
||||
func WaitTopicReady(parent context.Context, brokers []string, topic string, timeout time.Duration) error {
|
||||
if len(brokers) == 0 {
|
||||
return errors.New("kafka brokers is empty")
|
||||
}
|
||||
if topic == "" {
|
||||
return errors.New("kafka topic is empty")
|
||||
}
|
||||
if timeout <= 0 {
|
||||
timeout = 30 * time.Second
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(parent, timeout)
|
||||
defer cancel()
|
||||
|
||||
ticker := time.NewTicker(1 * time.Second)
|
||||
defer ticker.Stop()
|
||||
|
||||
var lastErr error
|
||||
for {
|
||||
if err := probeTopic(ctx, brokers, topic); err == nil {
|
||||
return nil
|
||||
} else {
|
||||
lastErr = err
|
||||
}
|
||||
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
if lastErr != nil {
|
||||
return fmt.Errorf("wait topic ready timeout, topic=%s: %w", topic, lastErr)
|
||||
}
|
||||
return fmt.Errorf("wait topic ready timeout, topic=%s", topic)
|
||||
case <-ticker.C:
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// probeTopic 轮询所有 broker,只要任一 broker 能读到 topic 分区信息即视为就绪。
|
||||
func probeTopic(ctx context.Context, brokers []string, topic string) error {
|
||||
var lastErr error
|
||||
for _, broker := range brokers {
|
||||
conn, err := segmentkafka.DialContext(ctx, "tcp", broker)
|
||||
if err != nil {
|
||||
lastErr = err
|
||||
continue
|
||||
}
|
||||
|
||||
// 1. segmentio/kafka-go 的 ReadPartitions 不直接接收 context。
|
||||
// 2. 这里必须给底层连接设置 I/O deadline,避免 broker 已接受连接但 metadata 响应卡住时,
|
||||
// 上层 WaitTopicReady 永远阻塞,导致 outbox dispatch / consume 循环无法启动。
|
||||
// 3. deadline 命中后本轮探测失败,外层 ticker 会继续重试直到总 timeout 到期。
|
||||
_ = conn.SetDeadline(time.Now().Add(2 * time.Second))
|
||||
partitions, readErr := conn.ReadPartitions(topic)
|
||||
_ = conn.Close()
|
||||
if readErr != nil {
|
||||
lastErr = readErr
|
||||
continue
|
||||
}
|
||||
if len(partitions) == 0 {
|
||||
lastErr = fmt.Errorf("topic %s has no partitions yet", topic)
|
||||
continue
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
if lastErr != nil {
|
||||
return lastErr
|
||||
}
|
||||
return errors.New("unable to probe topic readiness")
|
||||
}
|
||||
69
backend/shared/infra/kafka/config.go
Normal file
69
backend/shared/infra/kafka/config.go
Normal file
@@ -0,0 +1,69 @@
|
||||
package kafka
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/spf13/viper"
|
||||
)
|
||||
|
||||
const (
|
||||
DefaultTopic = "smartflow.agent.outbox"
|
||||
DefaultGroup = "smartflow-agent-outbox-consumer"
|
||||
)
|
||||
|
||||
// Config 描述 outbox 异步链路所需的 Kafka 配置。
|
||||
// 说明:这些参数同时影响“发送端(producer)”与“消费端(consumer)”。
|
||||
type Config struct {
|
||||
Enabled bool
|
||||
Brokers []string
|
||||
Topic string
|
||||
GroupID string
|
||||
// ServiceName 表示当前进程所属的 outbox 服务;为空时保持单体全量模式。
|
||||
ServiceName string
|
||||
// RetryScanInterval/RetryBatchSize/MaxRetry 作用于 outbox 扫描与失败重试。
|
||||
RetryScanInterval time.Duration
|
||||
RetryBatchSize int
|
||||
MaxRetry int
|
||||
}
|
||||
|
||||
// LoadConfig 从配置中心读取 Kafka 配置,并做兜底默认值。
|
||||
// 兼容性:优先读取 kafka.brokers(数组),为空时降级读取 kafka.broker(单值)。
|
||||
func LoadConfig() Config {
|
||||
brokers := viper.GetStringSlice("kafka.brokers")
|
||||
if len(brokers) == 0 {
|
||||
single := strings.TrimSpace(viper.GetString("kafka.broker"))
|
||||
if single != "" {
|
||||
brokers = []string{single}
|
||||
}
|
||||
}
|
||||
cfg := Config{
|
||||
Enabled: viper.GetBool("kafka.enabled"),
|
||||
Brokers: brokers,
|
||||
Topic: strings.TrimSpace(viper.GetString("kafka.topic")),
|
||||
GroupID: strings.TrimSpace(viper.GetString("kafka.groupID")),
|
||||
ServiceName: strings.TrimSpace(viper.GetString("outbox.serviceName")),
|
||||
RetryScanInterval: viper.GetDuration("kafka.retryScanInterval"),
|
||||
RetryBatchSize: viper.GetInt("kafka.retryBatchSize"),
|
||||
MaxRetry: viper.GetInt("kafka.maxRetry"),
|
||||
}
|
||||
if cfg.ServiceName == "" {
|
||||
cfg.ServiceName = strings.TrimSpace(viper.GetString("kafka.serviceName"))
|
||||
}
|
||||
if cfg.Topic == "" {
|
||||
cfg.Topic = DefaultTopic
|
||||
}
|
||||
if cfg.GroupID == "" {
|
||||
cfg.GroupID = DefaultGroup
|
||||
}
|
||||
if cfg.RetryScanInterval <= 0 {
|
||||
cfg.RetryScanInterval = time.Second
|
||||
}
|
||||
if cfg.RetryBatchSize <= 0 {
|
||||
cfg.RetryBatchSize = 100
|
||||
}
|
||||
if cfg.MaxRetry <= 0 {
|
||||
cfg.MaxRetry = 20
|
||||
}
|
||||
return cfg
|
||||
}
|
||||
57
backend/shared/infra/kafka/consumer.go
Normal file
57
backend/shared/infra/kafka/consumer.go
Normal file
@@ -0,0 +1,57 @@
|
||||
package kafka
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"strings"
|
||||
|
||||
segmentkafka "github.com/segmentio/kafka-go"
|
||||
)
|
||||
|
||||
type Consumer struct {
|
||||
reader *segmentkafka.Reader
|
||||
}
|
||||
|
||||
func NewConsumer(cfg Config) (*Consumer, error) {
|
||||
if len(cfg.Brokers) == 0 {
|
||||
return nil, errors.New("kafka brokers not configured")
|
||||
}
|
||||
if strings.TrimSpace(cfg.Topic) == "" {
|
||||
return nil, errors.New("kafka topic not configured")
|
||||
}
|
||||
if strings.TrimSpace(cfg.GroupID) == "" {
|
||||
return nil, errors.New("kafka groupID not configured")
|
||||
}
|
||||
reader := segmentkafka.NewReader(segmentkafka.ReaderConfig{
|
||||
Brokers: cfg.Brokers,
|
||||
Topic: cfg.Topic,
|
||||
GroupID: cfg.GroupID,
|
||||
MinBytes: 1,
|
||||
MaxBytes: 10e6,
|
||||
CommitInterval: 0,
|
||||
StartOffset: segmentkafka.FirstOffset,
|
||||
})
|
||||
return &Consumer{reader: reader}, nil
|
||||
}
|
||||
|
||||
// Dequeue 从 Kafka 拉取一条消息(不自动提交 offset)。
|
||||
func (c *Consumer) Dequeue(ctx context.Context) (segmentkafka.Message, error) {
|
||||
if c == nil || c.reader == nil {
|
||||
return segmentkafka.Message{}, errors.New("kafka consumer not initialized")
|
||||
}
|
||||
return c.reader.FetchMessage(ctx)
|
||||
}
|
||||
|
||||
func (c *Consumer) Commit(ctx context.Context, msg segmentkafka.Message) error {
|
||||
if c == nil || c.reader == nil {
|
||||
return errors.New("kafka consumer not initialized")
|
||||
}
|
||||
return c.reader.CommitMessages(ctx, msg)
|
||||
}
|
||||
|
||||
func (c *Consumer) Close() error {
|
||||
if c == nil || c.reader == nil {
|
||||
return nil
|
||||
}
|
||||
return c.reader.Close()
|
||||
}
|
||||
28
backend/shared/infra/kafka/envelope.go
Normal file
28
backend/shared/infra/kafka/envelope.go
Normal file
@@ -0,0 +1,28 @@
|
||||
package kafka
|
||||
|
||||
import "encoding/json"
|
||||
|
||||
// Envelope 是 outbox 投递到 Kafka 的统一协议包。
|
||||
//
|
||||
// 协议边界:
|
||||
// 1. 这是总线协议,不包含具体业务字段;
|
||||
// 2. 路由只依赖 event_type,不再保留 biz_type 兼容字段;
|
||||
// 3. payload 为原始业务 JSON,由业务 handler 决定如何反序列化。
|
||||
type Envelope struct {
|
||||
// OutboxID 是 outbox 状态机主键,用于消费者回写 consumed/retry/dead。
|
||||
OutboxID int64 `json:"outbox_id"`
|
||||
// EventID 是事件唯一标识(当前默认回退为 outbox_id 字符串)。
|
||||
EventID string `json:"event_id,omitempty"`
|
||||
|
||||
// EventType 是唯一路由键(例如 chat.history.persist.requested)。
|
||||
EventType string `json:"event_type"`
|
||||
// EventVersion 是事件版本号(默认 v1)。
|
||||
EventVersion string `json:"event_version,omitempty"`
|
||||
// ServiceName 是事件归属服务;空值通常表示旧兼容消息或全量模式。
|
||||
ServiceName string `json:"service_name,omitempty"`
|
||||
// AggregateID 是聚合主键(例如 conversation_id),用于追踪同一业务对象事件流。
|
||||
AggregateID string `json:"aggregate_id,omitempty"`
|
||||
|
||||
// Payload 是业务载荷 JSON。
|
||||
Payload json.RawMessage `json:"payload"`
|
||||
}
|
||||
45
backend/shared/infra/kafka/producer.go
Normal file
45
backend/shared/infra/kafka/producer.go
Normal file
@@ -0,0 +1,45 @@
|
||||
package kafka
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
|
||||
segmentkafka "github.com/segmentio/kafka-go"
|
||||
)
|
||||
|
||||
type Producer struct {
|
||||
writer *segmentkafka.Writer
|
||||
}
|
||||
|
||||
func NewProducer(cfg Config) (*Producer, error) {
|
||||
if len(cfg.Brokers) == 0 {
|
||||
return nil, errors.New("kafka brokers 未配置")
|
||||
}
|
||||
writer := &segmentkafka.Writer{
|
||||
Addr: segmentkafka.TCP(cfg.Brokers...),
|
||||
Balancer: &segmentkafka.Hash{},
|
||||
RequiredAcks: segmentkafka.RequireOne,
|
||||
Async: false,
|
||||
}
|
||||
return &Producer{writer: writer}, nil
|
||||
}
|
||||
|
||||
// Enqueue 将消息写入 Kafka。
|
||||
func (p *Producer) Enqueue(ctx context.Context, topic, key string, value []byte) error {
|
||||
if p == nil || p.writer == nil {
|
||||
return errors.New("kafka producer 未初始化")
|
||||
}
|
||||
msg := segmentkafka.Message{
|
||||
Topic: topic,
|
||||
Key: []byte(key),
|
||||
Value: value,
|
||||
}
|
||||
return p.writer.WriteMessages(ctx, msg)
|
||||
}
|
||||
|
||||
func (p *Producer) Close() error {
|
||||
if p == nil || p.writer == nil {
|
||||
return nil
|
||||
}
|
||||
return p.writer.Close()
|
||||
}
|
||||
Reference in New Issue
Block a user