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:
127
backend/services/notification/dao/channel.go
Normal file
127
backend/services/notification/dao/channel.go
Normal file
@@ -0,0 +1,127 @@
|
||||
package dao
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"time"
|
||||
|
||||
notificationmodel "github.com/LoveLosita/smartflow/backend/services/notification/model"
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/clause"
|
||||
)
|
||||
|
||||
// ChannelDAO 管理用户外部通知通道配置。
|
||||
//
|
||||
// 职责边界:
|
||||
// 1. 只负责 user_notification_channels 的基础读写;
|
||||
// 2. 不负责 webhook 请求发送、notification_records 状态机或 outbox 消费;
|
||||
// 3. webhook_url / bearer_token 的脱敏由 service 层处理,DAO 保持真实持久化值。
|
||||
type ChannelDAO struct {
|
||||
db *gorm.DB
|
||||
}
|
||||
|
||||
func NewChannelDAO(db *gorm.DB) *ChannelDAO {
|
||||
return &ChannelDAO{db: db}
|
||||
}
|
||||
|
||||
func (d *ChannelDAO) WithTx(tx *gorm.DB) *ChannelDAO {
|
||||
return &ChannelDAO{db: tx}
|
||||
}
|
||||
|
||||
func (d *ChannelDAO) 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 *ChannelDAO) UpsertUserNotificationChannel(ctx context.Context, channel *notificationmodel.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(¬ificationmodel.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 *ChannelDAO) GetUserNotificationChannel(ctx context.Context, userID int, channel string) (*notificationmodel.UserNotificationChannel, error) {
|
||||
if err := d.ensureDB(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if userID <= 0 || channel == "" {
|
||||
return nil, gorm.ErrRecordNotFound
|
||||
}
|
||||
var row notificationmodel.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 *ChannelDAO) 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(¬ificationmodel.UserNotificationChannel{}).Error
|
||||
}
|
||||
|
||||
// UpdateUserNotificationChannelTestResult 回写用户 webhook 测试结果。
|
||||
func (d *ChannelDAO) 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(¬ificationmodel.UserNotificationChannel{}).
|
||||
Where("user_id = ? AND channel = ?", userID, channel).
|
||||
Updates(updates).Error
|
||||
}
|
||||
60
backend/services/notification/dao/connect.go
Normal file
60
backend/services/notification/dao/connect.go
Normal file
@@ -0,0 +1,60 @@
|
||||
package dao
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
outboxinfra "github.com/LoveLosita/smartflow/backend/infra/outbox"
|
||||
coremodel "github.com/LoveLosita/smartflow/backend/model"
|
||||
notificationmodel "github.com/LoveLosita/smartflow/backend/services/notification/model"
|
||||
"github.com/spf13/viper"
|
||||
"gorm.io/driver/mysql"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// OpenDBFromConfig 创建 notification 服务自己的数据库句柄。
|
||||
//
|
||||
// 职责边界:
|
||||
// 1. 只迁移 notification_records 与 user_notification_channels;
|
||||
// 2. 不迁移主动调度、agent、userauth 或其它服务表;
|
||||
// 3. 返回的 *gorm.DB 供 notification 服务内 DAO 和 outbox consumer 复用。
|
||||
func OpenDBFromConfig() (*gorm.DB, error) {
|
||||
host := viper.GetString("database.host")
|
||||
port := viper.GetString("database.port")
|
||||
user := viper.GetString("database.user")
|
||||
password := viper.GetString("database.password")
|
||||
dbname := viper.GetString("database.dbname")
|
||||
|
||||
dsn := fmt.Sprintf(
|
||||
"%s:%s@tcp(%s:%s)/%s?charset=utf8mb4&parseTime=True&loc=Local",
|
||||
user, password, host, port, dbname,
|
||||
)
|
||||
|
||||
db, err := gorm.Open(mysql.Open(dsn), &gorm.Config{})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err = db.AutoMigrate(¬ificationmodel.NotificationRecord{}, ¬ificationmodel.UserNotificationChannel{}); err != nil {
|
||||
return nil, fmt.Errorf("auto migrate notification tables failed: %w", err)
|
||||
}
|
||||
if err = autoMigrateNotificationOutboxTable(db); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return db, nil
|
||||
}
|
||||
|
||||
// autoMigrateNotificationOutboxTable 只迁移 notification 服务自己的 outbox 物理表。
|
||||
//
|
||||
// 职责边界:
|
||||
// 1. 只负责 notification.outbox 对应表,不碰单体残留的其他业务表;
|
||||
// 2. 让独立 notification 服务可以单独启动和消费 outbox,不依赖 backend/inits 的全量迁移;
|
||||
// 3. 若后续调整 outbox 表名,只改 service catalog,不在这里硬编码。
|
||||
func autoMigrateNotificationOutboxTable(db *gorm.DB) error {
|
||||
cfg, ok := outboxinfra.ResolveServiceConfig(outboxinfra.ServiceNotification)
|
||||
if !ok {
|
||||
return fmt.Errorf("resolve notification outbox config failed")
|
||||
}
|
||||
if err := db.Table(cfg.TableName).AutoMigrate(&coremodel.AgentOutboxMessage{}); err != nil {
|
||||
return fmt.Errorf("auto migrate notification outbox table failed for %s (%s): %w", cfg.Name, cfg.TableName, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
170
backend/services/notification/dao/record.go
Normal file
170
backend/services/notification/dao/record.go
Normal file
@@ -0,0 +1,170 @@
|
||||
package dao
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"time"
|
||||
|
||||
notificationmodel "github.com/LoveLosita/smartflow/backend/services/notification/model"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// RecordDAO 管理 notification_records 投递状态机持久化。
|
||||
//
|
||||
// 职责边界:
|
||||
// 1. 只负责通知记录的创建、去重查询、状态更新和重试扫描;
|
||||
// 2. 不负责 provider 发送、幂等锁或 outbox consumed 标记;
|
||||
// 3. 不读写 active_schedule_* 表,避免 notification 服务反向持有主动调度内部状态。
|
||||
type RecordDAO struct {
|
||||
db *gorm.DB
|
||||
}
|
||||
|
||||
func NewRecordDAO(db *gorm.DB) *RecordDAO {
|
||||
return &RecordDAO{db: db}
|
||||
}
|
||||
|
||||
func (d *RecordDAO) WithTx(tx *gorm.DB) *RecordDAO {
|
||||
return &RecordDAO{db: tx}
|
||||
}
|
||||
|
||||
func (d *RecordDAO) ensureDB() error {
|
||||
if d == nil || d.db == nil {
|
||||
return errors.New("notification record dao 未初始化")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (d *RecordDAO) CreateNotificationRecord(ctx context.Context, record *notificationmodel.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 *RecordDAO) 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(¬ificationmodel.NotificationRecord{}).
|
||||
Where("id = ?", notificationID).
|
||||
Updates(updates).Error
|
||||
}
|
||||
|
||||
func (d *RecordDAO) GetNotificationRecordByID(ctx context.Context, notificationID int64) (*notificationmodel.NotificationRecord, error) {
|
||||
if err := d.ensureDB(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if notificationID <= 0 {
|
||||
return nil, gorm.ErrRecordNotFound
|
||||
}
|
||||
var record notificationmodel.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 *RecordDAO) FindNotificationRecordByDedupeKey(ctx context.Context, channel string, dedupeKey string) (*notificationmodel.NotificationRecord, error) {
|
||||
if err := d.ensureDB(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if channel == "" || dedupeKey == "" {
|
||||
return nil, gorm.ErrRecordNotFound
|
||||
}
|
||||
var record notificationmodel.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 查询到达重试时间的通知记录。
|
||||
//
|
||||
// 1. failed 记录按 next_retry_at 进入重试队列;
|
||||
// 2. sending 记录只有超过租约才会回收,避免仍在执行的 provider 调用被重复放大;
|
||||
// 3. 这让 retry scanner 同时覆盖显式失败重试和“发送中崩溃恢复”。
|
||||
func (d *RecordDAO) ListRetryableNotificationRecords(ctx context.Context, now time.Time, sendingStaleBefore time.Time, limit int) ([]notificationmodel.NotificationRecord, error) {
|
||||
if err := d.ensureDB(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if limit <= 0 || now.IsZero() {
|
||||
return []notificationmodel.NotificationRecord{}, nil
|
||||
}
|
||||
if sendingStaleBefore.IsZero() {
|
||||
sendingStaleBefore = now.Add(-10 * time.Minute)
|
||||
}
|
||||
var records []notificationmodel.NotificationRecord
|
||||
err := d.db.WithContext(ctx).
|
||||
Where(
|
||||
"(status = ? AND next_retry_at IS NOT NULL AND next_retry_at <= ?) OR (status = ? AND updated_at <= ?)",
|
||||
notificationmodel.RecordStatusFailed,
|
||||
now,
|
||||
notificationmodel.RecordStatusSending,
|
||||
sendingStaleBefore,
|
||||
).
|
||||
Order("next_retry_at ASC, id ASC").
|
||||
Limit(limit).
|
||||
Find(&records).Error
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return records, nil
|
||||
}
|
||||
|
||||
// ClaimRetryableNotificationRecord 抢占一条到期失败通知,避免多实例重复调用 provider。
|
||||
//
|
||||
// 职责边界:
|
||||
// 1. 只做跨进程 claim,不发送通知、不推进最终投递状态;
|
||||
// 2. failed 到期记录和 stale sending 记录都可以被回收为 sending;
|
||||
// 3. 返回 claimed=false 表示记录已被其它实例抢走或状态已变化,调用方应跳过本次重试。
|
||||
func (d *RecordDAO) ClaimRetryableNotificationRecord(ctx context.Context, notificationID int64, now time.Time, sendingStaleBefore time.Time) (bool, error) {
|
||||
if err := d.ensureDB(); err != nil {
|
||||
return false, err
|
||||
}
|
||||
if notificationID <= 0 || now.IsZero() {
|
||||
return false, nil
|
||||
}
|
||||
if sendingStaleBefore.IsZero() {
|
||||
sendingStaleBefore = now.Add(-10 * time.Minute)
|
||||
}
|
||||
result := d.db.WithContext(ctx).
|
||||
Model(¬ificationmodel.NotificationRecord{}).
|
||||
Where(
|
||||
"id = ? AND ((status = ? AND next_retry_at IS NOT NULL AND next_retry_at <= ?) OR (status = ? AND updated_at <= ?))",
|
||||
notificationID,
|
||||
notificationmodel.RecordStatusFailed,
|
||||
now,
|
||||
notificationmodel.RecordStatusSending,
|
||||
sendingStaleBefore,
|
||||
).
|
||||
Updates(map[string]any{
|
||||
"status": notificationmodel.RecordStatusSending,
|
||||
"next_retry_at": nil,
|
||||
"updated_at": now,
|
||||
})
|
||||
if result.Error != nil {
|
||||
return false, result.Error
|
||||
}
|
||||
return result.RowsAffected == 1, nil
|
||||
}
|
||||
Reference in New Issue
Block a user