Version: 0.9.68.dev.260504

后端:
1. 阶段 3 notification 服务边界落地,新增 `cmd/notification`、`services/notification`、`gateway/notification`、`shared/contracts/notification` 和 notification port,按 userauth 同款最小手搓 zrpc 样板收口
2. notification outbox consumer、relay 和 retry loop 迁入独立服务入口,处理 `notification.feishu.requested`,gateway 改为通过 zrpc client 调用 notification
3. 清退旧单体 notification DAO/model/service/provider/runner 和 `service/events/notification_feishu.go`,旧实现不再作为活跃编译路径
4. 修复 outbox 路由归属、dispatch 启动扫描、Kafka topic 探测/投递超时、sending 租约恢复、毒消息 MarkDead 错误回传和 RPC timeout 边界
5. 同步调整 active-scheduler 触发通知事件、核心 outbox handler、MySQL 迁移边界和 notification 配置

文档:
1. 更新微服务迁移计划,将阶段 3 notification 标记为已完成,并明确下一阶段从 active-scheduler 开始
This commit is contained in:
Losita
2026-05-04 18:40:39 +08:00
parent 9742dc8b1c
commit abe3b4960e
41 changed files with 2178 additions and 889 deletions

View File

@@ -13,7 +13,7 @@ import (
// ActiveScheduleDAO 管理主动调度阶段 1 的自有表。
//
// 职责边界:
// 1. 只负责 active_schedule_jobs / triggers / previews / notification_records 的基础读写;
// 1. 只负责 active_schedule_jobs / triggers / previews 的基础读写;
// 2. 不负责构造候选、调用 LLM、投递 provider 或写正式日程;
// 3. 幂等查询只按持久化键读取事实,是否复用结果由上层状态机判断。
type ActiveScheduleDAO struct {
@@ -308,88 +308,3 @@ func (d *ActiveScheduleDAO) FindPreviewByApplyIdempotencyKey(ctx context.Context
}
return &preview, nil
}
func (d *ActiveScheduleDAO) CreateNotificationRecord(ctx context.Context, record *model.NotificationRecord) error {
if err := d.ensureDB(); err != nil {
return err
}
if record == nil {
return errors.New("notification record 不能为空")
}
return d.db.WithContext(ctx).Create(record).Error
}
func (d *ActiveScheduleDAO) UpdateNotificationRecordFields(ctx context.Context, notificationID int64, updates map[string]any) error {
if err := d.ensureDB(); err != nil {
return err
}
if notificationID <= 0 {
return errors.New("notification record id 不能为空")
}
if len(updates) == 0 {
return nil
}
return d.db.WithContext(ctx).
Model(&model.NotificationRecord{}).
Where("id = ?", notificationID).
Updates(updates).Error
}
func (d *ActiveScheduleDAO) GetNotificationRecordByID(ctx context.Context, notificationID int64) (*model.NotificationRecord, error) {
if err := d.ensureDB(); err != nil {
return nil, err
}
if notificationID <= 0 {
return nil, gorm.ErrRecordNotFound
}
var record model.NotificationRecord
err := d.db.WithContext(ctx).Where("id = ?", notificationID).First(&record).Error
if err != nil {
return nil, err
}
return &record, nil
}
// FindNotificationRecordByDedupeKey 查询通知去重记录。
//
// 说明:
// 1. notification 第一版按 channel + dedupe_key 聚合去重;
// 2. 若返回 pending/sending/sent上层应避免重复投递
// 3. 若返回 failed上层可以复用同一条记录进入 provider retry。
func (d *ActiveScheduleDAO) FindNotificationRecordByDedupeKey(ctx context.Context, channel string, dedupeKey string) (*model.NotificationRecord, error) {
if err := d.ensureDB(); err != nil {
return nil, err
}
if channel == "" || dedupeKey == "" {
return nil, gorm.ErrRecordNotFound
}
var record model.NotificationRecord
err := d.db.WithContext(ctx).
Where("channel = ? AND dedupe_key = ?", channel, dedupeKey).
Order("created_at DESC, id DESC").
First(&record).Error
if err != nil {
return nil, err
}
return &record, nil
}
// ListRetryableNotificationRecords 查询到达重试时间的通知记录。
func (d *ActiveScheduleDAO) ListRetryableNotificationRecords(ctx context.Context, now time.Time, limit int) ([]model.NotificationRecord, error) {
if err := d.ensureDB(); err != nil {
return nil, err
}
if limit <= 0 || now.IsZero() {
return []model.NotificationRecord{}, nil
}
var records []model.NotificationRecord
err := d.db.WithContext(ctx).
Where("status = ? AND next_retry_at IS NOT NULL AND next_retry_at <= ?", model.NotificationRecordStatusFailed, now).
Order("next_retry_at ASC, id ASC").
Limit(limit).
Find(&records).Error
if err != nil {
return nil, err
}
return records, nil
}

View File

@@ -16,7 +16,6 @@ type RepoManager struct {
Agent *AgentDAO
ActiveSchedule *ActiveScheduleDAO
ActiveScheduleSession *ActiveScheduleSessionDAO
Notification *NotificationChannelDAO
}
func NewManager(db *gorm.DB) *RepoManager {
@@ -29,7 +28,6 @@ func NewManager(db *gorm.DB) *RepoManager {
Agent: NewAgentDAO(db),
ActiveSchedule: NewActiveScheduleDAO(db),
ActiveScheduleSession: NewActiveScheduleSessionDAO(db),
Notification: NewNotificationChannelDAO(db),
}
}
@@ -49,7 +47,6 @@ func (m *RepoManager) WithTx(tx *gorm.DB) *RepoManager {
Agent: m.Agent.WithTx(tx),
ActiveSchedule: m.ActiveSchedule.WithTx(tx),
ActiveScheduleSession: m.ActiveScheduleSession.WithTx(tx),
Notification: m.Notification.WithTx(tx),
}
}

View File

@@ -1,127 +0,0 @@
package dao
import (
"context"
"errors"
"time"
"github.com/LoveLosita/smartflow/backend/model"
"gorm.io/gorm"
"gorm.io/gorm/clause"
)
// NotificationChannelDAO 管理用户外部通知通道配置。
//
// 职责边界:
// 1. 只负责 user_notification_channels 的基础读写;
// 2. 不负责 webhook 请求发送、notification_records 状态机或 outbox 消费;
// 3. webhook_url / bearer_token 的脱敏由 API/service 层处理DAO 保持真实持久化值。
type NotificationChannelDAO struct {
db *gorm.DB
}
func NewNotificationChannelDAO(db *gorm.DB) *NotificationChannelDAO {
return &NotificationChannelDAO{db: db}
}
func (d *NotificationChannelDAO) WithTx(tx *gorm.DB) *NotificationChannelDAO {
return &NotificationChannelDAO{db: tx}
}
func (d *NotificationChannelDAO) ensureDB() error {
if d == nil || d.db == nil {
return errors.New("notification channel dao 未初始化")
}
return nil
}
// UpsertUserNotificationChannel 按 user_id + channel 幂等保存用户通知配置。
//
// 说明:
// 1. 只覆盖开关、webhook、鉴权配置和 updated_at
// 2. 不清空 last_test_*,避免用户保存配置后丢掉最近一次测试结果;
// 3. channel.ID 由数据库自增,调用方不应依赖传入 ID。
func (d *NotificationChannelDAO) UpsertUserNotificationChannel(ctx context.Context, channel *model.UserNotificationChannel) error {
if err := d.ensureDB(); err != nil {
return err
}
if channel == nil || channel.UserID <= 0 || channel.Channel == "" {
return errors.New("notification channel 必须包含 user_id 和 channel")
}
now := time.Now()
values := map[string]any{
"user_id": channel.UserID,
"channel": channel.Channel,
"enabled": channel.Enabled,
"webhook_url": channel.WebhookURL,
"auth_type": channel.AuthType,
"bearer_token": channel.BearerToken,
"created_at": now,
"updated_at": now,
}
return d.db.WithContext(ctx).
Model(&model.UserNotificationChannel{}).
Clauses(clause.OnConflict{
Columns: []clause.Column{{Name: "user_id"}, {Name: "channel"}},
DoUpdates: clause.Assignments(map[string]any{
"enabled": channel.Enabled,
"webhook_url": channel.WebhookURL,
"auth_type": channel.AuthType,
"bearer_token": channel.BearerToken,
"updated_at": now,
}),
}).
Create(values).Error
}
// GetUserNotificationChannel 查询用户指定通知通道配置。
func (d *NotificationChannelDAO) GetUserNotificationChannel(ctx context.Context, userID int, channel string) (*model.UserNotificationChannel, error) {
if err := d.ensureDB(); err != nil {
return nil, err
}
if userID <= 0 || channel == "" {
return nil, gorm.ErrRecordNotFound
}
var row model.UserNotificationChannel
err := d.db.WithContext(ctx).
Where("user_id = ? AND channel = ?", userID, channel).
First(&row).Error
if err != nil {
return nil, err
}
return &row, nil
}
// DeleteUserNotificationChannel 删除用户指定通知通道配置。
//
// 说明:当前表不保留软删除列;删除后再次保存会重新创建配置。
func (d *NotificationChannelDAO) DeleteUserNotificationChannel(ctx context.Context, userID int, channel string) error {
if err := d.ensureDB(); err != nil {
return err
}
if userID <= 0 || channel == "" {
return nil
}
return d.db.WithContext(ctx).
Where("user_id = ? AND channel = ?", userID, channel).
Delete(&model.UserNotificationChannel{}).Error
}
// UpdateUserNotificationChannelTestResult 回写用户 webhook 测试结果。
func (d *NotificationChannelDAO) UpdateUserNotificationChannelTestResult(ctx context.Context, userID int, channel string, status string, testErr string, testedAt time.Time) error {
if err := d.ensureDB(); err != nil {
return err
}
if userID <= 0 || channel == "" {
return errors.New("user_id 和 channel 不能为空")
}
updates := map[string]any{
"last_test_status": status,
"last_test_error": testErr,
"last_test_at": &testedAt,
}
return d.db.WithContext(ctx).
Model(&model.UserNotificationChannel{}).
Where("user_id = ? AND channel = ?", userID, channel).
Updates(updates).Error
}