Version: 0.6.1.dev.260316

♻️ 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` 注册与接入支持
This commit is contained in:
Losita
2026-03-16 13:00:26 +08:00
parent 712bcd3605
commit 626fc700d2
17 changed files with 782 additions and 422 deletions

View File

@@ -19,41 +19,68 @@ func NewAgentDAO(db *gorm.DB) *AgentDAO {
return &AgentDAO{db: db}
}
func (a *AgentDAO) SaveChatHistory(ctx context.Context, userID int, conversationID string, role, message string) error {
// 1. 同步落库路径也要保证“消息写入”和“会话计数更新”原子一致。
// 因此这里使用事务,避免出现“有消息但 message_count 没加”或反过来的不一致状态。
return a.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
// 1.1 先写 chat_histories。
userChat := model.ChatHistory{
UserID: userID,
MessageContent: &message,
Role: &role,
ChatID: conversationID,
}
if err := tx.Create(&userChat).Error; err != nil {
return err
}
func (r *AgentDAO) WithTx(tx *gorm.DB) *AgentDAO {
return &AgentDAO{db: tx}
}
// 1.2 再原子更新 agent_chats 的统计字段:
// - message_count: +1
// - last_message_at: 当前时间
// 这样 message_count 语义就稳定等于“已成功落库的消息条数”。
now := time.Now()
updates := map[string]interface{}{
"message_count": gorm.Expr("message_count + ?", 1),
"last_message_at": &now,
}
result := tx.Model(&model.AgentChat{}).
Where("user_id = ? AND chat_id = ?", userID, conversationID).
Updates(updates)
if result.Error != nil {
return result.Error
}
if result.RowsAffected == 0 {
// 会话不存在视为数据不一致,回滚事务,防止产生“孤儿历史记录”。
return fmt.Errorf("conversation not found when updating stats: user_id=%d chat_id=%s", userID, conversationID)
}
return nil
// saveChatHistoryCore 是“聊天消息落库 + 会话统计更新”的核心实现。
//
// 职责边界:
// 1. 只执行当前 DAO 句柄上的数据库写入动作;
// 2. 不主动开启事务(事务由调用方决定);
// 3. 保证 chat_histories 与 agent_chats.message_count 的一致性口径。
//
// 失败处理:
// 1. 任一步骤失败都返回 error
// 2. 若调用方处于事务中,返回 error 会触发事务回滚。
func (a *AgentDAO) saveChatHistoryCore(ctx context.Context, userID int, conversationID string, role, message string) error {
// 1. 先写 chat_histories 原始消息。
userChat := model.ChatHistory{
UserID: userID,
MessageContent: &message,
Role: &role,
ChatID: conversationID,
}
if err := a.db.WithContext(ctx).Create(&userChat).Error; err != nil {
return err
}
// 2. 再更新会话统计message_count +1, last_message_at=now
now := time.Now()
updates := map[string]interface{}{
"message_count": gorm.Expr("message_count + ?", 1),
"last_message_at": &now,
}
result := a.db.WithContext(ctx).Model(&model.AgentChat{}).
Where("user_id = ? AND chat_id = ?", userID, conversationID).
Updates(updates)
if result.Error != nil {
return result.Error
}
if result.RowsAffected == 0 {
// 会话不存在时直接失败,避免出现“孤儿历史消息”。
return fmt.Errorf("conversation not found when updating stats: user_id=%d chat_id=%s", userID, conversationID)
}
return nil
}
// SaveChatHistoryInTx 在调用方“已开启事务”的场景下写入聊天历史。
//
// 设计目的:
// 1. 给服务层组合多个 DAO 操作时复用,避免嵌套事务;
// 2. 让 outbox 消费处理器可以和业务写入共享同一个 tx。
func (a *AgentDAO) SaveChatHistoryInTx(ctx context.Context, userID int, conversationID string, role, message string) error {
return a.saveChatHistoryCore(ctx, userID, conversationID, role, message)
}
// SaveChatHistory 在同步直写路径下写入聊天历史。
//
// 说明:
// 1. 该方法会自行开启事务;
// 2. 内部复用 saveChatHistoryCore确保和 SaveChatHistoryInTx 的业务口径完全一致。
func (a *AgentDAO) SaveChatHistory(ctx context.Context, userID int, conversationID string, role, message string) error {
return a.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
return a.WithTx(tx).saveChatHistoryCore(ctx, userID, conversationID, role, message)
})
}
@@ -80,7 +107,7 @@ func (a *AgentDAO) GetUserChatHistories(ctx context.Context, userID, limit int,
if err != nil {
return nil, err
}
// 保留“最近 N 条”的前提下,反转时间正序,便模型消费
// 保留“最近 N 条”,反转时间正序,便模型消费
for i, j := 0, len(histories)-1; i < j; i, j = i+1, j-1 {
histories[i], histories[j] = histories[j], histories[i]
}
@@ -92,17 +119,14 @@ func (a *AgentDAO) IfChatExists(ctx context.Context, userID int, chatID string)
err := a.db.WithContext(ctx).Where("user_id = ? AND chat_id = ?", userID, chatID).First(&chat).Error
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return false, nil // 没有找到记录,表示会话不存在
return false, nil
}
return false, err
}
return true, nil
}
// GetConversationMeta 查询单个会话元信息。
// 用途:
// 1) 给前端提供“当前会话标题/消息数/最近消息时间”等展示字段;
// 2) 与流式聊天接口解耦,避免在 SSE 头部里塞动态标题。
// GetConversationMeta 查询单个会话元信息。
func (a *AgentDAO) GetConversationMeta(ctx context.Context, userID int, chatID string) (*model.AgentChat, error) {
var chat model.AgentChat
err := a.db.WithContext(ctx).
@@ -116,10 +140,6 @@ func (a *AgentDAO) GetConversationMeta(ctx context.Context, userID int, chatID s
}
// GetConversationTitle 读取当前会话标题。
// 返回值说明:
// 1) title标题内容若为空表示尚未生成
// 2) exists会话是否存在
// 3) err数据库错误。
func (a *AgentDAO) GetConversationTitle(ctx context.Context, userID int, chatID string) (title string, exists bool, err error) {
var chat model.AgentChat
queryErr := a.db.WithContext(ctx).
@@ -138,10 +158,7 @@ func (a *AgentDAO) GetConversationTitle(ctx context.Context, userID int, chatID
return strings.TrimSpace(*chat.Title), true, nil
}
// UpdateConversationTitleIfEmpty 仅在标题为空时写入会话标题。
// 设计目的:
// 1) 避免每轮对话都覆盖已有标题;
// 2) 并发下保持幂等:多个 goroutine 同时尝试写标题,最终只会成功一次。
// UpdateConversationTitleIfEmpty 仅在标题为空时更新会话标题。
func (a *AgentDAO) UpdateConversationTitleIfEmpty(ctx context.Context, userID int, chatID, title string) error {
normalized := strings.TrimSpace(title)
if normalized == "" {

View File

@@ -6,7 +6,7 @@ import (
"gorm.io/gorm"
)
// RepoManager 囊括了所有的 Repo
// RepoManager 聚合所有 DAO供服务层做跨仓储事务编排。
type RepoManager struct {
db *gorm.DB
Schedule *ScheduleDAO
@@ -14,6 +14,7 @@ type RepoManager struct {
Course *CourseDAO
TaskClass *TaskClassDAO
User *UserDAO
Agent *AgentDAO
}
func NewManager(db *gorm.DB) *RepoManager {
@@ -24,21 +25,37 @@ func NewManager(db *gorm.DB) *RepoManager {
Course: NewCourseDAO(db),
TaskClass: NewTaskClassDAO(db),
User: NewUserDAO(db),
Agent: NewAgentDAO(db),
}
}
// Transaction 核心函数:开启一个带事务的“新管理器”
// WithTx 基于外部事务句柄构造“同事务 RepoManager”。
//
// 职责边界:
// 1. 只做 DAO 依赖重绑定,不开启/提交/回滚事务;
// 2. 让服务层在一个 tx 内调用多个 DAO 方法;
// 3. 适用于 outbox 消费处理器这类“基础设施事务 + 业务事务合并”的场景。
func (m *RepoManager) WithTx(tx *gorm.DB) *RepoManager {
return &RepoManager{
db: tx,
Schedule: m.Schedule.WithTx(tx),
Task: m.Task.WithTx(tx),
TaskClass: m.TaskClass.WithTx(tx),
Course: m.Course.WithTx(tx),
User: m.User.WithTx(tx),
Agent: m.Agent.WithTx(tx),
}
}
// Transaction 开启事务并把“同事务 RepoManager”传给回调。
//
// 使用约束:
// 1. 回调里应只使用 txM 下挂 DAO避免混入事务外句柄
// 2. 回调返回 error 会触发整体回滚;
// 3. 回调返回 nil 表示提交事务。
func (m *RepoManager) Transaction(ctx context.Context, fn func(txM *RepoManager) error) error {
return m.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
// 💡 关键:创建一个新的 RepoManager里面的 Repo 全部注入这个 tx 句柄
txM := &RepoManager{
db: tx,
Schedule: m.Schedule.WithTx(tx),
Task: m.Task.WithTx(tx),
TaskClass: m.TaskClass.WithTx(tx),
Course: m.Course.WithTx(tx),
User: m.User.WithTx(tx),
}
txM := m.WithTx(tx)
return fn(txM)
})
}