♻️ refactor(outbox): 抽离通用事件总线,并完成 event_type-only 收口 - ✨ 新增 `infra` 层通用 `EventBus` / `EventContract`,统一事件发布与消费协议 - 🔄 将聊天持久化链路调整为通过 `service/events` 注册 handler 并发布事件,进一步解耦业务逻辑与异步处理流程 - 🧹 移除 `chat_history_async` 旧适配实现,以及基于 `biz_type` 的兼容分发逻辑 - 📝 更新 Outbox 异步持久化决策记录,明确保留方案 A,并正式启用方案 B - 📚 同步更新 README 中关于 Outbox + Kafka 可靠异步链路的说明 - 🚚 当前 `outbox + kafka` 已与项目业务链路完全解耦,沉淀为通用、可靠性更强的消息队列能力;后续将参考消息队列的典型使用方式,逐步扩展到更多业务场景 - ✨ 补充跨不同分类事务管理器中的 `agent dao` 注册与接入支持
221 lines
5.9 KiB
Go
221 lines
5.9 KiB
Go
package outbox
|
||
|
||
import (
|
||
"context"
|
||
"encoding/json"
|
||
"errors"
|
||
"time"
|
||
|
||
"github.com/LoveLosita/smartflow/backend/model"
|
||
"gorm.io/gorm"
|
||
"gorm.io/gorm/clause"
|
||
)
|
||
|
||
// Repository 是 outbox 状态机仓储。
|
||
//
|
||
// 职责边界:
|
||
// 1. 只负责 outbox 状态流转与通用事务编排;
|
||
// 2. 不负责任何业务语义(例如聊天/任务/标题等具体落库);
|
||
// 3. 消费成功时通过回调把业务动作注入同一事务,保证原子一致。
|
||
type Repository struct {
|
||
db *gorm.DB
|
||
}
|
||
|
||
func NewRepository(db *gorm.DB) *Repository {
|
||
return &Repository{db: db}
|
||
}
|
||
|
||
// WithTx 用外部事务句柄构造同事务仓储实例。
|
||
func (d *Repository) WithTx(tx *gorm.DB) *Repository {
|
||
return &Repository{db: tx}
|
||
}
|
||
|
||
// CreateMessage 把事件写入 outbox(入队)。
|
||
//
|
||
// 步骤:
|
||
// 1. 序列化 payload;
|
||
// 2. 初始化 pending 状态;
|
||
// 3. 写入 outbox 并返回 outbox_id。
|
||
func (d *Repository) CreateMessage(ctx context.Context, eventType, topic, messageKey string, payload any, maxRetry int) (int64, error) {
|
||
if maxRetry <= 0 {
|
||
maxRetry = 20
|
||
}
|
||
|
||
raw, err := json.Marshal(payload)
|
||
if err != nil {
|
||
return 0, err
|
||
}
|
||
|
||
now := time.Now()
|
||
msg := model.AgentOutboxMessage{
|
||
EventType: eventType,
|
||
Topic: topic,
|
||
MessageKey: messageKey,
|
||
Payload: string(raw),
|
||
Status: model.OutboxStatusPending,
|
||
RetryCount: 0,
|
||
MaxRetry: maxRetry,
|
||
NextRetryAt: &now,
|
||
}
|
||
|
||
if err = d.db.WithContext(ctx).Create(&msg).Error; err != nil {
|
||
return 0, err
|
||
}
|
||
return msg.ID, nil
|
||
}
|
||
|
||
func (d *Repository) GetByID(ctx context.Context, id int64) (*model.AgentOutboxMessage, error) {
|
||
var msg model.AgentOutboxMessage
|
||
if err := d.db.WithContext(ctx).Where("id = ?", id).First(&msg).Error; err != nil {
|
||
return nil, err
|
||
}
|
||
return &msg, nil
|
||
}
|
||
|
||
// ListDueMessages 拉取到期可投递消息。
|
||
func (d *Repository) ListDueMessages(ctx context.Context, limit int) ([]model.AgentOutboxMessage, error) {
|
||
if limit <= 0 {
|
||
limit = 100
|
||
}
|
||
now := time.Now()
|
||
var messages []model.AgentOutboxMessage
|
||
err := d.db.WithContext(ctx).
|
||
Where("status = ? AND next_retry_at IS NOT NULL AND next_retry_at <= ?", model.OutboxStatusPending, now).
|
||
Order("next_retry_at ASC, id ASC").
|
||
Limit(limit).
|
||
Find(&messages).Error
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
return messages, nil
|
||
}
|
||
|
||
// MarkPublished 标记为已投递 Kafka。
|
||
func (d *Repository) MarkPublished(ctx context.Context, id int64) error {
|
||
now := time.Now()
|
||
updates := map[string]interface{}{
|
||
"status": model.OutboxStatusPublished,
|
||
"published_at": &now,
|
||
"last_error": nil,
|
||
"next_retry_at": nil,
|
||
}
|
||
result := d.db.WithContext(ctx).
|
||
Model(&model.AgentOutboxMessage{}).
|
||
Where("id = ? AND status NOT IN (?, ?)", id, model.OutboxStatusConsumed, model.OutboxStatusDead).
|
||
Updates(updates)
|
||
return result.Error
|
||
}
|
||
|
||
// MarkDead 标记为死信。
|
||
func (d *Repository) MarkDead(ctx context.Context, id int64, reason string) error {
|
||
now := time.Now()
|
||
lastErr := truncateError(reason)
|
||
updates := map[string]interface{}{
|
||
"status": model.OutboxStatusDead,
|
||
"last_error": &lastErr,
|
||
"next_retry_at": nil,
|
||
"updated_at": now,
|
||
}
|
||
return d.db.WithContext(ctx).Model(&model.AgentOutboxMessage{}).Where("id = ?", id).Updates(updates).Error
|
||
}
|
||
|
||
// MarkFailedForRetry 记录一次可重试失败并推进重试窗口。
|
||
//
|
||
// 步骤:
|
||
// 1. 行级锁读取当前状态;
|
||
// 2. 最终态幂等短路;
|
||
// 3. retry_count+1;
|
||
// 4. 计算 next_retry_at 或 dead;
|
||
// 5. 写回状态快照。
|
||
func (d *Repository) MarkFailedForRetry(ctx context.Context, id int64, reason string) error {
|
||
return d.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||
var msg model.AgentOutboxMessage
|
||
err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).Where("id = ?", id).First(&msg).Error
|
||
if err != nil {
|
||
return err
|
||
}
|
||
|
||
if msg.Status == model.OutboxStatusConsumed || msg.Status == model.OutboxStatusDead {
|
||
return nil
|
||
}
|
||
|
||
nextRetryCount := msg.RetryCount + 1
|
||
now := time.Now()
|
||
status := model.OutboxStatusPending
|
||
var nextRetryAt *time.Time
|
||
if nextRetryCount >= msg.MaxRetry {
|
||
status = model.OutboxStatusDead
|
||
nextRetryAt = nil
|
||
} else {
|
||
t := now.Add(calcRetryBackoff(nextRetryCount))
|
||
nextRetryAt = &t
|
||
}
|
||
|
||
lastErr := truncateError(reason)
|
||
updates := map[string]interface{}{
|
||
"status": status,
|
||
"retry_count": nextRetryCount,
|
||
"last_error": &lastErr,
|
||
"next_retry_at": nextRetryAt,
|
||
"updated_at": now,
|
||
}
|
||
return tx.Model(&model.AgentOutboxMessage{}).Where("id = ?", id).Updates(updates).Error
|
||
})
|
||
}
|
||
|
||
// ConsumeAndMarkConsumed 是通用“消费成功事务入口”。
|
||
//
|
||
// 步骤:
|
||
// 1. 事务内锁定 outbox 记录;
|
||
// 2. 已 consumed/dead 时幂等返回;
|
||
// 3. 执行业务回调 fn(tx);
|
||
// 4. 业务成功后统一标记 consumed。
|
||
func (d *Repository) ConsumeAndMarkConsumed(ctx context.Context, outboxID int64, fn func(tx *gorm.DB) error) error {
|
||
return d.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||
var outboxMsg model.AgentOutboxMessage
|
||
err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).Where("id = ?", outboxID).First(&outboxMsg).Error
|
||
if err != nil {
|
||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||
return nil
|
||
}
|
||
return err
|
||
}
|
||
if outboxMsg.Status == model.OutboxStatusConsumed || outboxMsg.Status == model.OutboxStatusDead {
|
||
return nil
|
||
}
|
||
|
||
if fn != nil {
|
||
if err = fn(tx); err != nil {
|
||
return err
|
||
}
|
||
}
|
||
|
||
now := time.Now()
|
||
updates := map[string]interface{}{
|
||
"status": model.OutboxStatusConsumed,
|
||
"consumed_at": &now,
|
||
"last_error": nil,
|
||
"next_retry_at": nil,
|
||
"updated_at": now,
|
||
}
|
||
return tx.Model(&model.AgentOutboxMessage{}).Where("id = ?", outboxID).Updates(updates).Error
|
||
})
|
||
}
|
||
|
||
func calcRetryBackoff(retryCount int) time.Duration {
|
||
if retryCount <= 0 {
|
||
return time.Second
|
||
}
|
||
if retryCount > 6 {
|
||
retryCount = 6
|
||
}
|
||
return time.Second * time.Duration(1<<(retryCount-1))
|
||
}
|
||
|
||
func truncateError(reason string) string {
|
||
if len(reason) <= 2000 {
|
||
return reason
|
||
}
|
||
return reason[:2000]
|
||
}
|