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
|
||||
}
|
||||
139
backend/services/notification/internal/feishu/mock.go
Normal file
139
backend/services/notification/internal/feishu/mock.go
Normal file
@@ -0,0 +1,139 @@
|
||||
package feishu
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// MockMode 描述 mock provider 下一次返回哪类结果。
|
||||
type MockMode string
|
||||
|
||||
const (
|
||||
MockModeSuccess MockMode = "success"
|
||||
MockModeTemporaryFail MockMode = "temporary_fail"
|
||||
MockModePermanentFail MockMode = "permanent_fail"
|
||||
)
|
||||
|
||||
// MockProvider 是进程内 mock provider。
|
||||
//
|
||||
// 职责边界:
|
||||
// 1. 只用于本地联调、单元测试和阶段性验收;
|
||||
// 2. 不做真实 HTTP 调用,直接根据预设 mode 返回 success / temporary_fail / permanent_fail;
|
||||
// 3. 保留调用历史,方便测试断言“有没有重复发飞书”。
|
||||
type MockProvider struct {
|
||||
mu sync.Mutex
|
||||
defaultMode MockMode
|
||||
queuedModes []MockMode
|
||||
calls []SendRequest
|
||||
}
|
||||
|
||||
func NewMockProvider(defaultMode MockMode) *MockProvider {
|
||||
if defaultMode == "" {
|
||||
defaultMode = MockModeSuccess
|
||||
}
|
||||
return &MockProvider{defaultMode: defaultMode}
|
||||
}
|
||||
|
||||
func (p *MockProvider) SetDefaultMode(mode MockMode) {
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
if mode == "" {
|
||||
mode = MockModeSuccess
|
||||
}
|
||||
p.defaultMode = mode
|
||||
}
|
||||
|
||||
// PushModes 追加一组“一次性模式”。
|
||||
//
|
||||
// 说明:
|
||||
// 1. 先进先出消费,便于测试“先失败再成功”的重试路径;
|
||||
// 2. 队列用尽后回退到 defaultMode;
|
||||
// 3. 空模式会被自动忽略,避免测试代码误塞脏数据。
|
||||
func (p *MockProvider) PushModes(modes ...MockMode) {
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
for _, mode := range modes {
|
||||
if mode == "" {
|
||||
continue
|
||||
}
|
||||
p.queuedModes = append(p.queuedModes, mode)
|
||||
}
|
||||
}
|
||||
|
||||
func (p *MockProvider) Calls() []SendRequest {
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
copied := make([]SendRequest, len(p.calls))
|
||||
copy(copied, p.calls)
|
||||
return copied
|
||||
}
|
||||
|
||||
// Send 按预设模式返回模拟结果。
|
||||
//
|
||||
// 步骤说明:
|
||||
// 1. 先记录本次请求,方便测试校验是否发生重复投递;
|
||||
// 2. 再按 queuedModes -> defaultMode 的顺序决定 outcome;
|
||||
// 3. 最后返回可落库审计的 request/response 摘要。
|
||||
func (p *MockProvider) Send(_ context.Context, req SendRequest) (SendResult, error) {
|
||||
p.mu.Lock()
|
||||
p.calls = append(p.calls, req)
|
||||
|
||||
mode := p.defaultMode
|
||||
if len(p.queuedModes) > 0 {
|
||||
mode = p.queuedModes[0]
|
||||
p.queuedModes = p.queuedModes[1:]
|
||||
}
|
||||
p.mu.Unlock()
|
||||
|
||||
switch mode {
|
||||
case MockModeTemporaryFail:
|
||||
return SendResult{
|
||||
Outcome: SendOutcomeTemporaryFail,
|
||||
ErrorCode: ErrorCodeProviderTimeout,
|
||||
ErrorMessage: "mock feishu provider temporary failure",
|
||||
RequestPayload: map[string]any{
|
||||
"notification_id": req.NotificationID,
|
||||
"user_id": req.UserID,
|
||||
"preview_id": req.PreviewID,
|
||||
"target_url": req.TargetURL,
|
||||
},
|
||||
ResponsePayload: map[string]any{
|
||||
"mode": string(mode),
|
||||
"reason": "mock temporary failure",
|
||||
},
|
||||
}, nil
|
||||
case MockModePermanentFail:
|
||||
return SendResult{
|
||||
Outcome: SendOutcomePermanentFail,
|
||||
ErrorCode: ErrorCodePayloadInvalid,
|
||||
ErrorMessage: "mock feishu provider permanent failure",
|
||||
RequestPayload: map[string]any{
|
||||
"notification_id": req.NotificationID,
|
||||
"user_id": req.UserID,
|
||||
"preview_id": req.PreviewID,
|
||||
"target_url": req.TargetURL,
|
||||
},
|
||||
ResponsePayload: map[string]any{
|
||||
"mode": string(mode),
|
||||
"reason": "mock permanent failure",
|
||||
},
|
||||
}, nil
|
||||
default:
|
||||
return SendResult{
|
||||
Outcome: SendOutcomeSuccess,
|
||||
ProviderMessageID: fmt.Sprintf("mock_feishu_%d", time.Now().UnixNano()),
|
||||
RequestPayload: map[string]any{
|
||||
"notification_id": req.NotificationID,
|
||||
"user_id": req.UserID,
|
||||
"preview_id": req.PreviewID,
|
||||
"target_url": req.TargetURL,
|
||||
},
|
||||
ResponsePayload: map[string]any{
|
||||
"mode": string(MockModeSuccess),
|
||||
"status": "ok",
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
}
|
||||
88
backend/services/notification/internal/feishu/types.go
Normal file
88
backend/services/notification/internal/feishu/types.go
Normal file
@@ -0,0 +1,88 @@
|
||||
package feishu
|
||||
|
||||
import "context"
|
||||
|
||||
const (
|
||||
// Channel 表示当前通知记录走飞书通道。
|
||||
Channel = "feishu"
|
||||
)
|
||||
|
||||
const (
|
||||
// ErrorCodeProviderTimeout 表示 provider 超时,属于可重试错误。
|
||||
ErrorCodeProviderTimeout = "provider_timeout"
|
||||
// ErrorCodeProviderRateLimited 表示 provider 限流,属于可重试错误。
|
||||
ErrorCodeProviderRateLimited = "provider_rate_limited"
|
||||
// ErrorCodeProvider5xx 表示 provider 服务端异常,属于可重试错误。
|
||||
ErrorCodeProvider5xx = "provider_5xx"
|
||||
// ErrorCodeNetworkError 表示网络层异常,属于可重试错误。
|
||||
ErrorCodeNetworkError = "network_error"
|
||||
// ErrorCodeRecipientMissing 表示缺少接收方,属于不可恢复错误。
|
||||
ErrorCodeRecipientMissing = "recipient_missing"
|
||||
// ErrorCodeInvalidURL 表示目标链接非法,属于不可恢复错误。
|
||||
ErrorCodeInvalidURL = "invalid_url"
|
||||
// ErrorCodeProviderAuthFailed 表示 provider 认证失败,属于不可恢复错误。
|
||||
ErrorCodeProviderAuthFailed = "provider_auth_failed"
|
||||
// ErrorCodePayloadInvalid 表示请求体非法,属于不可恢复错误。
|
||||
ErrorCodePayloadInvalid = "payload_invalid"
|
||||
)
|
||||
|
||||
// SendOutcome 表示 provider 对一次投递尝试的分类结果。
|
||||
//
|
||||
// 职责边界:
|
||||
// 1. 只表达 provider 层对“这次投递”是否成功、是否可重试的判断;
|
||||
// 2. 不直接承载 notification_records 的状态机,状态流转由 service 决定;
|
||||
// 3. 后续新增 Webhook / OpenID provider 时,只需返回同一套枚举。
|
||||
type SendOutcome string
|
||||
|
||||
const (
|
||||
SendOutcomeSuccess SendOutcome = "success"
|
||||
SendOutcomeTemporaryFail SendOutcome = "temporary_fail"
|
||||
SendOutcomePermanentFail SendOutcome = "permanent_fail"
|
||||
SendOutcomeSkipped SendOutcome = "skipped"
|
||||
)
|
||||
|
||||
// SendRequest 是通知服务传给 provider 的稳定输入。
|
||||
//
|
||||
// 职责边界:
|
||||
// 1. 只描述 provider 真正发消息所需的信息;
|
||||
// 2. 不暴露 GORM model,避免 provider 依赖数据库细节;
|
||||
// 3. 同时保留审计字段,方便 mock/webhook provider 记录请求摘要。
|
||||
type SendRequest struct {
|
||||
NotificationID int64 `json:"notification_id"`
|
||||
UserID int `json:"user_id"`
|
||||
TriggerID string `json:"trigger_id"`
|
||||
PreviewID string `json:"preview_id"`
|
||||
TriggerType string `json:"trigger_type"`
|
||||
TargetType string `json:"target_type"`
|
||||
TargetID int `json:"target_id"`
|
||||
TargetURL string `json:"target_url"`
|
||||
MessageText string `json:"message_text"`
|
||||
FallbackUsed bool `json:"fallback_used"`
|
||||
TraceID string `json:"trace_id,omitempty"`
|
||||
AttemptCount int `json:"attempt_count"`
|
||||
}
|
||||
|
||||
// SendResult 是 provider 对外返回的投递结果。
|
||||
//
|
||||
// 职责边界:
|
||||
// 1. outcome 决定 service 应该进入 sent / failed / dead 中哪一条路径;
|
||||
// 2. request/response payload 仅用于落库审计,不要求与任意具体 SDK 强绑定;
|
||||
// 3. error_code 需要尽量稳定,便于后续按错误码做告警和排障。
|
||||
type SendResult struct {
|
||||
Outcome SendOutcome `json:"outcome"`
|
||||
ProviderMessageID string `json:"provider_message_id,omitempty"`
|
||||
ErrorCode string `json:"error_code,omitempty"`
|
||||
ErrorMessage string `json:"error_message,omitempty"`
|
||||
RequestPayload any `json:"request_payload,omitempty"`
|
||||
ResponsePayload any `json:"response_payload,omitempty"`
|
||||
}
|
||||
|
||||
// Provider 是飞书投递能力的抽象边界。
|
||||
//
|
||||
// 职责边界:
|
||||
// 1. 负责把最终文案发给具体 provider;
|
||||
// 2. 不负责 notification_records 的创建、去重、状态机和重试节奏;
|
||||
// 3. 调用方只根据 SendResult.Outcome 推进自己的状态机。
|
||||
type Provider interface {
|
||||
Send(ctx context.Context, req SendRequest) (SendResult, error)
|
||||
}
|
||||
361
backend/services/notification/internal/feishu/webhook.go
Normal file
361
backend/services/notification/internal/feishu/webhook.go
Normal file
@@ -0,0 +1,361 @@
|
||||
package feishu
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
notificationmodel "github.com/LoveLosita/smartflow/backend/services/notification/model"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
const (
|
||||
defaultWebhookTimeout = 5 * time.Second
|
||||
defaultFrontendBaseURL = "https://smartflow.example.com"
|
||||
webhookPayloadEvent = "smartflow.schedule_adjustment_ready"
|
||||
webhookPayloadVersion = "1"
|
||||
webhookMessageTitle = "SmartFlow 日程调整建议"
|
||||
webhookMessageActionText = "查看并确认调整"
|
||||
maxWebhookResponseBodyLen = 64 * 1024
|
||||
)
|
||||
|
||||
// ChannelReader 描述 webhook provider 读取用户通知配置所需的最小能力。
|
||||
//
|
||||
// 职责边界:
|
||||
// 1. 只读取 user_id + channel 对应的配置;
|
||||
// 2. 不负责保存配置和测试结果;
|
||||
// 3. 生产环境由 notification/dao.ChannelDAO 实现,测试可替换为内存 fake。
|
||||
type ChannelReader interface {
|
||||
GetUserNotificationChannel(ctx context.Context, userID int, channel string) (*notificationmodel.UserNotificationChannel, error)
|
||||
}
|
||||
|
||||
type WebhookProviderOptions struct {
|
||||
HTTPClient *http.Client
|
||||
FrontendBaseURL string
|
||||
Timeout time.Duration
|
||||
Now func() time.Time
|
||||
}
|
||||
|
||||
// WebhookProvider 把 SmartFlow 通知事件发送到用户配置的飞书 Webhook 触发器。
|
||||
//
|
||||
// 职责边界:
|
||||
// 1. 只负责读取用户 webhook 配置、拼装极简业务 JSON 并执行 HTTP POST;
|
||||
// 2. 不负责 notification_records 的创建、重试节奏和幂等;
|
||||
// 3. 不实现飞书群自定义机器人 msg_type 协议,私聊/群发由飞书流程自行编排。
|
||||
type WebhookProvider struct {
|
||||
store ChannelReader
|
||||
client *http.Client
|
||||
frontendBaseURL string
|
||||
now func() time.Time
|
||||
}
|
||||
|
||||
type WebhookPayload struct {
|
||||
Event string `json:"event"`
|
||||
Version string `json:"version"`
|
||||
NotificationID int64 `json:"notification_id"`
|
||||
UserID int `json:"user_id"`
|
||||
PreviewID string `json:"preview_id"`
|
||||
TriggerID string `json:"trigger_id"`
|
||||
TriggerType string `json:"trigger_type"`
|
||||
TargetType string `json:"target_type"`
|
||||
TargetID int `json:"target_id"`
|
||||
Message WebhookMessage `json:"message"`
|
||||
TraceID string `json:"trace_id,omitempty"`
|
||||
SentAt string `json:"sent_at"`
|
||||
}
|
||||
|
||||
type WebhookMessage struct {
|
||||
Title string `json:"title"`
|
||||
Summary string `json:"summary"`
|
||||
ActionText string `json:"action_text"`
|
||||
ActionURL string `json:"action_url"`
|
||||
}
|
||||
|
||||
func NewWebhookProvider(store ChannelReader, opts WebhookProviderOptions) (*WebhookProvider, error) {
|
||||
if store == nil {
|
||||
return nil, errors.New("user notification channel store is nil")
|
||||
}
|
||||
timeout := opts.Timeout
|
||||
if timeout <= 0 {
|
||||
timeout = defaultWebhookTimeout
|
||||
}
|
||||
client := opts.HTTPClient
|
||||
if client == nil {
|
||||
client = &http.Client{Timeout: timeout}
|
||||
}
|
||||
now := opts.Now
|
||||
if now == nil {
|
||||
now = time.Now
|
||||
}
|
||||
return &WebhookProvider{
|
||||
store: store,
|
||||
client: client,
|
||||
frontendBaseURL: normalizeFrontendBaseURL(opts.FrontendBaseURL),
|
||||
now: now,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// BuildWebhookPayload 生成飞书 Webhook 触发器消费的极简业务 JSON。
|
||||
//
|
||||
// 说明:
|
||||
// 1. 该结构不包含飞书群机器人 msg_type 字段;
|
||||
// 2. message 四个字段是飞书流程拼私聊消息的稳定输入;
|
||||
// 3. 其它字段用于用户流程分支、SmartFlow 排障和审计。
|
||||
func BuildWebhookPayload(req SendRequest, frontendBaseURL string, sentAt time.Time) WebhookPayload {
|
||||
if sentAt.IsZero() {
|
||||
sentAt = time.Now()
|
||||
}
|
||||
summary := strings.TrimSpace(req.MessageText)
|
||||
if summary == "" {
|
||||
summary = "我为你生成了一份日程调整建议,请回到系统确认是否应用。"
|
||||
}
|
||||
return WebhookPayload{
|
||||
Event: webhookPayloadEvent,
|
||||
Version: webhookPayloadVersion,
|
||||
NotificationID: req.NotificationID,
|
||||
UserID: req.UserID,
|
||||
PreviewID: strings.TrimSpace(req.PreviewID),
|
||||
TriggerID: strings.TrimSpace(req.TriggerID),
|
||||
TriggerType: strings.TrimSpace(req.TriggerType),
|
||||
TargetType: strings.TrimSpace(req.TargetType),
|
||||
TargetID: req.TargetID,
|
||||
Message: WebhookMessage{
|
||||
Title: webhookMessageTitle,
|
||||
Summary: summary,
|
||||
ActionText: webhookMessageActionText,
|
||||
ActionURL: buildActionURL(frontendBaseURL, req.TargetURL),
|
||||
},
|
||||
TraceID: strings.TrimSpace(req.TraceID),
|
||||
SentAt: sentAt.Format(time.RFC3339),
|
||||
}
|
||||
}
|
||||
|
||||
// Send 向用户配置的飞书 Webhook 触发器投递一次 SmartFlow 通知事件。
|
||||
func (p *WebhookProvider) Send(ctx context.Context, req SendRequest) (SendResult, error) {
|
||||
if p == nil || p.store == nil || p.client == nil {
|
||||
return SendResult{}, errors.New("webhook feishu provider 未初始化")
|
||||
}
|
||||
config, err := p.store.GetUserNotificationChannel(ctx, req.UserID, notificationmodel.ChannelFeishuWebhook)
|
||||
if err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return skippedResult(req, "用户未配置飞书 Webhook 触发器"), nil
|
||||
}
|
||||
return SendResult{}, err
|
||||
}
|
||||
if config == nil || !config.Enabled || strings.TrimSpace(config.WebhookURL) == "" {
|
||||
return skippedResult(req, "用户未启用飞书 Webhook 触发器"), nil
|
||||
}
|
||||
if err = ValidateWebhookURL(config.WebhookURL); err != nil {
|
||||
return SendResult{
|
||||
Outcome: SendOutcomePermanentFail,
|
||||
ErrorCode: ErrorCodeInvalidURL,
|
||||
ErrorMessage: err.Error(),
|
||||
RequestPayload: map[string]any{
|
||||
"notification_id": req.NotificationID,
|
||||
"user_id": req.UserID,
|
||||
"webhook": MaskWebhookURL(config.WebhookURL),
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
|
||||
payload := BuildWebhookPayload(req, p.frontendBaseURL, p.now())
|
||||
raw, err := json.Marshal(payload)
|
||||
if err != nil {
|
||||
return SendResult{}, err
|
||||
}
|
||||
|
||||
httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, strings.TrimSpace(config.WebhookURL), bytes.NewReader(raw))
|
||||
if err != nil {
|
||||
return permanentWebhookResult(req, payload, nil, ErrorCodeInvalidURL, err.Error()), nil
|
||||
}
|
||||
httpReq.Header.Set("Content-Type", "application/json; charset=utf-8")
|
||||
if strings.EqualFold(strings.TrimSpace(config.AuthType), notificationmodel.AuthTypeBearer) && strings.TrimSpace(config.BearerToken) != "" {
|
||||
httpReq.Header.Set("Authorization", "Bearer "+strings.TrimSpace(config.BearerToken))
|
||||
}
|
||||
|
||||
resp, err := p.client.Do(httpReq)
|
||||
if err != nil {
|
||||
return temporaryWebhookResult(req, payload, nil, classifyNetworkError(err), err.Error()), nil
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
body, readErr := io.ReadAll(io.LimitReader(resp.Body, maxWebhookResponseBodyLen))
|
||||
responsePayload := buildWebhookResponsePayload(resp.StatusCode, body, readErr)
|
||||
if readErr != nil {
|
||||
return temporaryWebhookResult(req, payload, responsePayload, ErrorCodeNetworkError, readErr.Error()), nil
|
||||
}
|
||||
return classifyWebhookHTTPResult(req, payload, responsePayload, resp.StatusCode, body), nil
|
||||
}
|
||||
|
||||
func classifyWebhookHTTPResult(req SendRequest, payload WebhookPayload, responsePayload map[string]any, statusCode int, body []byte) SendResult {
|
||||
if statusCode >= 200 && statusCode < 300 {
|
||||
if len(strings.TrimSpace(string(body))) > 0 {
|
||||
var parsed struct {
|
||||
Code *int `json:"code"`
|
||||
Msg string `json:"msg"`
|
||||
}
|
||||
if err := json.Unmarshal(body, &parsed); err == nil && parsed.Code != nil && *parsed.Code != 0 {
|
||||
return permanentWebhookResult(req, payload, responsePayload, ErrorCodePayloadInvalid, firstNonEmpty(parsed.Msg, fmt.Sprintf("飞书 webhook 返回 code=%d", *parsed.Code)))
|
||||
}
|
||||
}
|
||||
return SendResult{
|
||||
Outcome: SendOutcomeSuccess,
|
||||
ProviderMessageID: fmt.Sprintf("feishu_webhook_%d_%d", req.NotificationID, time.Now().UnixNano()),
|
||||
RequestPayload: payload,
|
||||
ResponsePayload: responsePayload,
|
||||
}
|
||||
}
|
||||
switch {
|
||||
case statusCode == http.StatusTooManyRequests:
|
||||
return temporaryWebhookResult(req, payload, responsePayload, ErrorCodeProviderRateLimited, fmt.Sprintf("飞书 webhook HTTP %d", statusCode))
|
||||
case statusCode >= 500:
|
||||
return temporaryWebhookResult(req, payload, responsePayload, ErrorCodeProvider5xx, fmt.Sprintf("飞书 webhook HTTP %d", statusCode))
|
||||
case statusCode == http.StatusUnauthorized || statusCode == http.StatusForbidden:
|
||||
return permanentWebhookResult(req, payload, responsePayload, ErrorCodeProviderAuthFailed, fmt.Sprintf("飞书 webhook 鉴权失败 HTTP %d", statusCode))
|
||||
default:
|
||||
return permanentWebhookResult(req, payload, responsePayload, ErrorCodePayloadInvalid, fmt.Sprintf("飞书 webhook HTTP %d", statusCode))
|
||||
}
|
||||
}
|
||||
|
||||
func skippedResult(req SendRequest, reason string) SendResult {
|
||||
return SendResult{
|
||||
Outcome: SendOutcomeSkipped,
|
||||
ErrorCode: ErrorCodeRecipientMissing,
|
||||
ErrorMessage: reason,
|
||||
RequestPayload: map[string]any{
|
||||
"notification_id": req.NotificationID,
|
||||
"user_id": req.UserID,
|
||||
"preview_id": req.PreviewID,
|
||||
},
|
||||
ResponsePayload: map[string]any{
|
||||
"skipped": true,
|
||||
"reason": reason,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func temporaryWebhookResult(_ SendRequest, payload WebhookPayload, responsePayload any, code string, message string) SendResult {
|
||||
return SendResult{
|
||||
Outcome: SendOutcomeTemporaryFail,
|
||||
ErrorCode: code,
|
||||
ErrorMessage: message,
|
||||
RequestPayload: payload,
|
||||
ResponsePayload: responsePayload,
|
||||
}
|
||||
}
|
||||
|
||||
func permanentWebhookResult(_ SendRequest, payload WebhookPayload, responsePayload any, code string, message string) SendResult {
|
||||
return SendResult{
|
||||
Outcome: SendOutcomePermanentFail,
|
||||
ErrorCode: code,
|
||||
ErrorMessage: message,
|
||||
RequestPayload: payload,
|
||||
ResponsePayload: responsePayload,
|
||||
}
|
||||
}
|
||||
|
||||
func buildWebhookResponsePayload(statusCode int, body []byte, readErr error) map[string]any {
|
||||
payload := map[string]any{
|
||||
"status_code": statusCode,
|
||||
}
|
||||
if len(body) > 0 {
|
||||
payload["body"] = string(body)
|
||||
}
|
||||
if readErr != nil {
|
||||
payload["read_error"] = readErr.Error()
|
||||
}
|
||||
return payload
|
||||
}
|
||||
|
||||
func classifyNetworkError(err error) string {
|
||||
if errors.Is(err, context.DeadlineExceeded) {
|
||||
return ErrorCodeProviderTimeout
|
||||
}
|
||||
return ErrorCodeNetworkError
|
||||
}
|
||||
|
||||
func normalizeFrontendBaseURL(value string) string {
|
||||
trimmed := strings.TrimRight(strings.TrimSpace(value), "/")
|
||||
if trimmed == "" {
|
||||
return defaultFrontendBaseURL
|
||||
}
|
||||
return trimmed
|
||||
}
|
||||
|
||||
func buildActionURL(frontendBaseURL string, targetURL string) string {
|
||||
targetURL = strings.TrimSpace(targetURL)
|
||||
if strings.HasPrefix(targetURL, "https://") || strings.HasPrefix(targetURL, "http://") {
|
||||
return targetURL
|
||||
}
|
||||
base := normalizeFrontendBaseURL(frontendBaseURL)
|
||||
return base + "/" + strings.TrimLeft(targetURL, "/")
|
||||
}
|
||||
|
||||
// ValidateWebhookURL 校验第一版允许保存的飞书 Webhook 触发器地址。
|
||||
func ValidateWebhookURL(rawURL string) error {
|
||||
parsed, err := url.Parse(strings.TrimSpace(rawURL))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if parsed.Scheme != "https" {
|
||||
return errors.New("飞书 webhook 必须使用 https")
|
||||
}
|
||||
host := strings.ToLower(parsed.Hostname())
|
||||
if host != "www.feishu.cn" && host != "feishu.cn" {
|
||||
return errors.New("飞书 webhook 域名必须是 feishu.cn")
|
||||
}
|
||||
if !strings.HasPrefix(parsed.EscapedPath(), "/flow/api/trigger-webhook/") {
|
||||
return errors.New("飞书 webhook 路径必须是 /flow/api/trigger-webhook/{key}")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// MaskWebhookURL 对 webhook URL 做脱敏,避免接口和日志泄露完整密钥。
|
||||
func MaskWebhookURL(rawURL string) string {
|
||||
trimmed := strings.TrimSpace(rawURL)
|
||||
parsed, err := url.Parse(trimmed)
|
||||
if err != nil || parsed.Host == "" {
|
||||
return maskMiddle(trimmed)
|
||||
}
|
||||
parts := strings.Split(strings.Trim(parsed.Path, "/"), "/")
|
||||
if len(parts) == 0 {
|
||||
return parsed.Scheme + "://" + parsed.Host
|
||||
}
|
||||
last := parts[len(parts)-1]
|
||||
parts[len(parts)-1] = maskMiddle(last)
|
||||
parsed.Path = "/" + strings.Join(parts, "/")
|
||||
parsed.RawQuery = ""
|
||||
parsed.Fragment = ""
|
||||
return parsed.String()
|
||||
}
|
||||
|
||||
func MaskSecret(value string) string {
|
||||
return maskMiddle(strings.TrimSpace(value))
|
||||
}
|
||||
|
||||
func maskMiddle(value string) string {
|
||||
if value == "" {
|
||||
return ""
|
||||
}
|
||||
runes := []rune(value)
|
||||
if len(runes) <= 8 {
|
||||
return "****"
|
||||
}
|
||||
return string(runes[:4]) + "..." + string(runes[len(runes)-4:])
|
||||
}
|
||||
|
||||
func firstNonEmpty(values ...string) string {
|
||||
for _, value := range values {
|
||||
if trimmed := strings.TrimSpace(value); trimmed != "" {
|
||||
return trimmed
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
41
backend/services/notification/model/channel.go
Normal file
41
backend/services/notification/model/channel.go
Normal file
@@ -0,0 +1,41 @@
|
||||
package model
|
||||
|
||||
import "time"
|
||||
|
||||
const (
|
||||
// ChannelFeishuWebhook 表示用户配置的是飞书 Webhook 触发器。
|
||||
ChannelFeishuWebhook = "feishu_webhook"
|
||||
)
|
||||
|
||||
const (
|
||||
// AuthTypeNone 表示 webhook 不需要额外鉴权头。
|
||||
AuthTypeNone = "none"
|
||||
// AuthTypeBearer 表示 webhook 需要 Authorization: Bearer token。
|
||||
AuthTypeBearer = "bearer"
|
||||
)
|
||||
|
||||
// UserNotificationChannel 保存单个用户的外部通知通道配置。
|
||||
//
|
||||
// 职责边界:
|
||||
// 1. 只记录 user_id 到具体通知 provider 配置的映射;
|
||||
// 2. 不记录 notification_records 投递状态,投递状态属于 NotificationRecord;
|
||||
// 3. 当前 webhook_url / bearer_token 暂以明文字段承载,接口和日志必须脱敏。
|
||||
type UserNotificationChannel struct {
|
||||
ID int64 `gorm:"column:id;primaryKey;autoIncrement" json:"id"`
|
||||
|
||||
UserID int `gorm:"column:user_id;not null;uniqueIndex:uk_user_notification_channel,priority:1;index:idx_user_notification_channel_user"`
|
||||
Channel string `gorm:"column:channel;type:varchar(32);not null;uniqueIndex:uk_user_notification_channel,priority:2"`
|
||||
Enabled bool `gorm:"column:enabled;not null;default:true"`
|
||||
WebhookURL string `gorm:"column:webhook_url;type:text;not null"`
|
||||
AuthType string `gorm:"column:auth_type;type:varchar(32);not null;default:'none'"`
|
||||
BearerToken string `gorm:"column:bearer_token;type:text;not null"`
|
||||
|
||||
LastTestStatus string `gorm:"column:last_test_status;type:varchar(32)"`
|
||||
LastTestError string `gorm:"column:last_test_error;type:text"`
|
||||
LastTestAt *time.Time `gorm:"column:last_test_at"`
|
||||
|
||||
CreatedAt time.Time `gorm:"column:created_at;autoCreateTime"`
|
||||
UpdatedAt time.Time `gorm:"column:updated_at;autoUpdateTime"`
|
||||
}
|
||||
|
||||
func (UserNotificationChannel) TableName() string { return "user_notification_channels" }
|
||||
59
backend/services/notification/model/record.go
Normal file
59
backend/services/notification/model/record.go
Normal file
@@ -0,0 +1,59 @@
|
||||
package model
|
||||
|
||||
import "time"
|
||||
|
||||
const (
|
||||
// RecordStatusPending 表示通知记录已落库,等待投递。
|
||||
RecordStatusPending = "pending"
|
||||
// RecordStatusSending 表示当前 worker 正在调用 provider。
|
||||
RecordStatusSending = "sending"
|
||||
// RecordStatusSent 表示 provider 明确返回成功。
|
||||
RecordStatusSent = "sent"
|
||||
// RecordStatusFailed 表示本次投递失败,但仍可重试。
|
||||
RecordStatusFailed = "failed"
|
||||
// RecordStatusDead 表示达到重试上限或不可恢复错误。
|
||||
RecordStatusDead = "dead"
|
||||
// RecordStatusSkipped 表示命中去重或配置关闭,本次不投递。
|
||||
RecordStatusSkipped = "skipped"
|
||||
)
|
||||
|
||||
// NotificationRecord 是通知投递记录表模型。
|
||||
//
|
||||
// 职责边界:
|
||||
// 1. 记录一次通知请求的幂等键、投递状态、provider 请求和响应审计;
|
||||
// 2. 不保存用户 webhook 配置,配置由 UserNotificationChannel 维护;
|
||||
// 3. 不承担主动调度 preview 或正式日程状态,二者只通过 trigger_id/preview_id 关联排障。
|
||||
type NotificationRecord struct {
|
||||
ID int64 `gorm:"column:id;primaryKey;autoIncrement"`
|
||||
|
||||
Channel string `gorm:"column:channel;type:varchar(32);not null;uniqueIndex:uk_notification_dedupe,priority:1;comment:通知渠道"`
|
||||
UserID int `gorm:"column:user_id;not null;index:idx_notification_user_created,priority:1"`
|
||||
TriggerID string `gorm:"column:trigger_id;type:varchar(64);not null;index:idx_notification_trigger"`
|
||||
PreviewID string `gorm:"column:preview_id;type:varchar(64);not null;index:idx_notification_preview"`
|
||||
TriggerType string `gorm:"column:trigger_type;type:varchar(64);not null"`
|
||||
TargetType string `gorm:"column:target_type;type:varchar(64);not null"`
|
||||
TargetID int `gorm:"column:target_id;not null"`
|
||||
DedupeKey string `gorm:"column:dedupe_key;type:varchar(191);not null;uniqueIndex:uk_notification_dedupe,priority:2"`
|
||||
TargetURL string `gorm:"column:target_url;type:varchar(255);not null;comment:站内预览链接"`
|
||||
SummaryText string `gorm:"column:summary_text;type:text"`
|
||||
FallbackText string `gorm:"column:fallback_text;type:text"`
|
||||
FallbackUsed bool `gorm:"column:fallback_used;not null;default:false"`
|
||||
|
||||
Status string `gorm:"column:status;type:varchar(32);not null;default:'pending';index:idx_notification_status_retry,priority:1;comment:pending/sending/sent/failed/dead/skipped"`
|
||||
AttemptCount int `gorm:"column:attempt_count;not null;default:0"`
|
||||
MaxAttempts int `gorm:"column:max_attempts;not null;default:5"`
|
||||
NextRetryAt *time.Time `gorm:"column:next_retry_at;index:idx_notification_status_retry,priority:2"`
|
||||
LastErrorCode *string `gorm:"column:last_error_code;type:varchar(64)"`
|
||||
LastError *string `gorm:"column:last_error;type:text"`
|
||||
|
||||
ProviderMessageID *string `gorm:"column:provider_message_id;type:varchar(128)"`
|
||||
ProviderRequestJSON *string `gorm:"column:provider_request_json;type:json"`
|
||||
ProviderResponseJSON *string `gorm:"column:provider_response_json;type:json"`
|
||||
SentAt *time.Time `gorm:"column:sent_at"`
|
||||
TraceID string `gorm:"column:trace_id;type:varchar(128)"`
|
||||
|
||||
CreatedAt time.Time `gorm:"column:created_at;autoCreateTime;index:idx_notification_user_created,priority:2"`
|
||||
UpdatedAt time.Time `gorm:"column:updated_at;autoUpdateTime"`
|
||||
}
|
||||
|
||||
func (NotificationRecord) TableName() string { return "notification_records" }
|
||||
76
backend/services/notification/rpc/errors.go
Normal file
76
backend/services/notification/rpc/errors.go
Normal file
@@ -0,0 +1,76 @@
|
||||
package rpc
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"log"
|
||||
"strings"
|
||||
|
||||
"github.com/LoveLosita/smartflow/backend/respond"
|
||||
"google.golang.org/genproto/googleapis/rpc/errdetails"
|
||||
"google.golang.org/grpc/codes"
|
||||
"google.golang.org/grpc/status"
|
||||
)
|
||||
|
||||
const notificationErrorDomain = "smartflow.notification"
|
||||
|
||||
// grpcErrorFromServiceError 负责把 notification 内部错误收口成 gRPC status。
|
||||
//
|
||||
// 职责边界:
|
||||
// 1. 只负责把本服务内部的 respond.Response / 普通 error 转成 gRPC 可传输错误;
|
||||
// 2. 不负责决定 HTTP 语义,也不负责写回前端响应体;
|
||||
// 3. 上层 handler 只要直接 return 这个结果,就能让 client 侧按 `res, err :=` 的方式接收。
|
||||
func grpcErrorFromServiceError(err error) error {
|
||||
if err == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
var resp respond.Response
|
||||
if errors.As(err, &resp) {
|
||||
return grpcErrorFromResponse(resp)
|
||||
}
|
||||
log.Printf("notification rpc internal error: %v", err)
|
||||
return status.Error(codes.Internal, "notification service internal error")
|
||||
}
|
||||
|
||||
// grpcErrorFromResponse 负责把项目内业务响应映射成 gRPC status。
|
||||
//
|
||||
// 职责边界:
|
||||
// 1. 只处理 notification 这组响应码到 gRPC code 的映射;
|
||||
// 2. 业务码和业务文案通过 ErrorInfo 附带,方便 gateway 再反解回 respond.Response;
|
||||
// 3. 失败时退化为普通 gRPC status,不阻断请求链路。
|
||||
func grpcErrorFromResponse(resp respond.Response) error {
|
||||
code := grpcCodeFromRespondStatus(resp.Status)
|
||||
message := strings.TrimSpace(resp.Info)
|
||||
if message == "" {
|
||||
message = strings.TrimSpace(resp.Status)
|
||||
}
|
||||
|
||||
st := status.New(code, message)
|
||||
detail := &errdetails.ErrorInfo{
|
||||
Domain: notificationErrorDomain,
|
||||
Reason: resp.Status,
|
||||
Metadata: map[string]string{
|
||||
"info": resp.Info,
|
||||
},
|
||||
}
|
||||
withDetails, err := st.WithDetails(detail)
|
||||
if err != nil {
|
||||
return st.Err()
|
||||
}
|
||||
return withDetails.Err()
|
||||
}
|
||||
|
||||
func grpcCodeFromRespondStatus(statusValue string) codes.Code {
|
||||
switch strings.TrimSpace(statusValue) {
|
||||
case respond.MissingToken.Status, respond.InvalidToken.Status, respond.InvalidClaims.Status,
|
||||
respond.ErrUnauthorized.Status, respond.WrongTokenType.Status, respond.UserLoggedOut.Status:
|
||||
return codes.Unauthenticated
|
||||
case respond.MissingParam.Status, respond.WrongParamType.Status, respond.ParamTooLong.Status:
|
||||
return codes.InvalidArgument
|
||||
}
|
||||
|
||||
if strings.HasPrefix(strings.TrimSpace(statusValue), "5") {
|
||||
return codes.Internal
|
||||
}
|
||||
return codes.InvalidArgument
|
||||
}
|
||||
133
backend/services/notification/rpc/handler.go
Normal file
133
backend/services/notification/rpc/handler.go
Normal file
@@ -0,0 +1,133 @@
|
||||
package rpc
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"time"
|
||||
|
||||
"github.com/LoveLosita/smartflow/backend/respond"
|
||||
"github.com/LoveLosita/smartflow/backend/services/notification/rpc/pb"
|
||||
notificationsv "github.com/LoveLosita/smartflow/backend/services/notification/sv"
|
||||
contracts "github.com/LoveLosita/smartflow/backend/shared/contracts/notification"
|
||||
)
|
||||
|
||||
type Handler struct {
|
||||
pb.UnimplementedNotificationServer
|
||||
svc *notificationsv.Service
|
||||
}
|
||||
|
||||
func NewHandler(svc *notificationsv.Service) *Handler {
|
||||
return &Handler{svc: svc}
|
||||
}
|
||||
|
||||
// GetFeishuWebhook 负责把配置查询请求从 gRPC 协议转成内部服务调用。
|
||||
//
|
||||
// 职责边界:
|
||||
// 1. 只做 transport -> service 的参数搬运,不碰 DAO/provider/outbox 细节;
|
||||
// 2. 业务错误统一转成 gRPC status,让 client 侧继续使用 `res, err :=`;
|
||||
// 3. 成功时只回传业务数据,不在 payload 里塞 status/info。
|
||||
func (h *Handler) GetFeishuWebhook(ctx context.Context, req *pb.GetFeishuWebhookRequest) (*pb.ChannelResponse, error) {
|
||||
if h == nil || h.svc == nil {
|
||||
return nil, grpcErrorFromServiceError(errors.New("notification service dependency not initialized"))
|
||||
}
|
||||
if req == nil {
|
||||
return nil, grpcErrorFromServiceError(respond.MissingParam)
|
||||
}
|
||||
|
||||
resp, err := h.svc.GetFeishuWebhook(ctx, int(req.UserId))
|
||||
if err != nil {
|
||||
return nil, grpcErrorFromServiceError(err)
|
||||
}
|
||||
return channelToPB(resp), nil
|
||||
}
|
||||
|
||||
func (h *Handler) SaveFeishuWebhook(ctx context.Context, req *pb.SaveFeishuWebhookRequest) (*pb.ChannelResponse, error) {
|
||||
if h == nil || h.svc == nil {
|
||||
return nil, grpcErrorFromServiceError(errors.New("notification service dependency not initialized"))
|
||||
}
|
||||
if req == nil {
|
||||
return nil, grpcErrorFromServiceError(respond.MissingParam)
|
||||
}
|
||||
|
||||
resp, err := h.svc.SaveFeishuWebhook(ctx, int(req.UserId), contracts.SaveFeishuWebhookRequest{
|
||||
UserID: int(req.UserId),
|
||||
Enabled: req.Enabled,
|
||||
WebhookURL: req.WebhookUrl,
|
||||
AuthType: req.AuthType,
|
||||
BearerToken: req.BearerToken,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, grpcErrorFromServiceError(err)
|
||||
}
|
||||
return channelToPB(resp), nil
|
||||
}
|
||||
|
||||
func (h *Handler) DeleteFeishuWebhook(ctx context.Context, req *pb.DeleteFeishuWebhookRequest) (*pb.StatusResponse, error) {
|
||||
if h == nil || h.svc == nil {
|
||||
return nil, grpcErrorFromServiceError(errors.New("notification service dependency not initialized"))
|
||||
}
|
||||
if req == nil {
|
||||
return nil, grpcErrorFromServiceError(respond.MissingParam)
|
||||
}
|
||||
|
||||
if err := h.svc.DeleteFeishuWebhook(ctx, int(req.UserId)); err != nil {
|
||||
return nil, grpcErrorFromServiceError(err)
|
||||
}
|
||||
return &pb.StatusResponse{}, nil
|
||||
}
|
||||
|
||||
func (h *Handler) TestFeishuWebhook(ctx context.Context, req *pb.TestFeishuWebhookRequest) (*pb.TestResult, error) {
|
||||
if h == nil || h.svc == nil {
|
||||
return nil, grpcErrorFromServiceError(errors.New("notification service dependency not initialized"))
|
||||
}
|
||||
if req == nil {
|
||||
return nil, grpcErrorFromServiceError(respond.MissingParam)
|
||||
}
|
||||
|
||||
resp, err := h.svc.TestFeishuWebhook(ctx, int(req.UserId))
|
||||
if err != nil {
|
||||
return nil, grpcErrorFromServiceError(err)
|
||||
}
|
||||
return testResultToPB(resp), nil
|
||||
}
|
||||
|
||||
func channelToPB(resp contracts.ChannelResponse) *pb.ChannelResponse {
|
||||
return &pb.ChannelResponse{
|
||||
Channel: resp.Channel,
|
||||
Enabled: resp.Enabled,
|
||||
Configured: resp.Configured,
|
||||
WebhookUrlMask: resp.WebhookURLMask,
|
||||
AuthType: resp.AuthType,
|
||||
HasBearerToken: resp.HasBearerToken,
|
||||
LastTestStatus: resp.LastTestStatus,
|
||||
LastTestError: resp.LastTestError,
|
||||
LastTestAtUnixNano: timePtrToUnixNano(resp.LastTestAt),
|
||||
}
|
||||
}
|
||||
|
||||
func testResultToPB(resp contracts.TestResult) *pb.TestResult {
|
||||
return &pb.TestResult{
|
||||
Channel: channelToPB(resp.Channel),
|
||||
Status: resp.Status,
|
||||
Outcome: resp.Outcome,
|
||||
Message: resp.Message,
|
||||
TraceId: resp.TraceID,
|
||||
SentAtUnixNano: timeToUnixNano(resp.SentAt),
|
||||
Skipped: resp.Skipped,
|
||||
Provider: resp.Provider,
|
||||
}
|
||||
}
|
||||
|
||||
func timePtrToUnixNano(value *time.Time) int64 {
|
||||
if value == nil || value.IsZero() {
|
||||
return 0
|
||||
}
|
||||
return value.UnixNano()
|
||||
}
|
||||
|
||||
func timeToUnixNano(value time.Time) int64 {
|
||||
if value.IsZero() {
|
||||
return 0
|
||||
}
|
||||
return value.UnixNano()
|
||||
}
|
||||
58
backend/services/notification/rpc/notification.proto
Normal file
58
backend/services/notification/rpc/notification.proto
Normal file
@@ -0,0 +1,58 @@
|
||||
syntax = "proto3";
|
||||
|
||||
package smartflow.notification;
|
||||
|
||||
option go_package = "github.com/LoveLosita/smartflow/backend/services/notification/rpc/pb";
|
||||
|
||||
service Notification {
|
||||
rpc GetFeishuWebhook(GetFeishuWebhookRequest) returns (ChannelResponse);
|
||||
rpc SaveFeishuWebhook(SaveFeishuWebhookRequest) returns (ChannelResponse);
|
||||
rpc DeleteFeishuWebhook(DeleteFeishuWebhookRequest) returns (StatusResponse);
|
||||
rpc TestFeishuWebhook(TestFeishuWebhookRequest) returns (TestResult);
|
||||
}
|
||||
|
||||
message GetFeishuWebhookRequest {
|
||||
int64 user_id = 1;
|
||||
}
|
||||
|
||||
message SaveFeishuWebhookRequest {
|
||||
int64 user_id = 1;
|
||||
bool enabled = 2;
|
||||
string webhook_url = 3;
|
||||
string auth_type = 4;
|
||||
string bearer_token = 5;
|
||||
}
|
||||
|
||||
message DeleteFeishuWebhookRequest {
|
||||
int64 user_id = 1;
|
||||
}
|
||||
|
||||
message TestFeishuWebhookRequest {
|
||||
int64 user_id = 1;
|
||||
}
|
||||
|
||||
message StatusResponse {
|
||||
}
|
||||
|
||||
message ChannelResponse {
|
||||
string channel = 1;
|
||||
bool enabled = 2;
|
||||
bool configured = 3;
|
||||
string webhook_url_mask = 4;
|
||||
string auth_type = 5;
|
||||
bool has_bearer_token = 6;
|
||||
string last_test_status = 7;
|
||||
string last_test_error = 8;
|
||||
int64 last_test_at_unix_nano = 9;
|
||||
}
|
||||
|
||||
message TestResult {
|
||||
ChannelResponse channel = 1;
|
||||
string status = 2;
|
||||
string outcome = 3;
|
||||
string message = 4;
|
||||
string trace_id = 5;
|
||||
int64 sent_at_unix_nano = 6;
|
||||
bool skipped = 7;
|
||||
string provider = 8;
|
||||
}
|
||||
102
backend/services/notification/rpc/pb/notification.pb.go
Normal file
102
backend/services/notification/rpc/pb/notification.pb.go
Normal file
@@ -0,0 +1,102 @@
|
||||
package pb
|
||||
|
||||
import proto "github.com/golang/protobuf/proto"
|
||||
|
||||
var _ = proto.Marshal
|
||||
|
||||
const _ = proto.ProtoPackageIsVersion3
|
||||
|
||||
type GetFeishuWebhookRequest struct {
|
||||
UserId int64 `protobuf:"varint,1,opt,name=user_id,json=userId,proto3" json:"user_id,omitempty"`
|
||||
XXX_NoUnkeyedLiteral struct{} `json:"-"`
|
||||
XXX_unrecognized []byte `json:"-"`
|
||||
XXX_sizecache int32 `json:"-"`
|
||||
}
|
||||
|
||||
func (m *GetFeishuWebhookRequest) Reset() { *m = GetFeishuWebhookRequest{} }
|
||||
func (m *GetFeishuWebhookRequest) String() string { return proto.CompactTextString(m) }
|
||||
func (*GetFeishuWebhookRequest) ProtoMessage() {}
|
||||
|
||||
type SaveFeishuWebhookRequest struct {
|
||||
UserId int64 `protobuf:"varint,1,opt,name=user_id,json=userId,proto3" json:"user_id,omitempty"`
|
||||
Enabled bool `protobuf:"varint,2,opt,name=enabled,proto3" json:"enabled,omitempty"`
|
||||
WebhookUrl string `protobuf:"bytes,3,opt,name=webhook_url,json=webhookUrl,proto3" json:"webhook_url,omitempty"`
|
||||
AuthType string `protobuf:"bytes,4,opt,name=auth_type,json=authType,proto3" json:"auth_type,omitempty"`
|
||||
BearerToken string `protobuf:"bytes,5,opt,name=bearer_token,json=bearerToken,proto3" json:"bearer_token,omitempty"`
|
||||
XXX_NoUnkeyedLiteral struct{} `json:"-"`
|
||||
XXX_unrecognized []byte `json:"-"`
|
||||
XXX_sizecache int32 `json:"-"`
|
||||
}
|
||||
|
||||
func (m *SaveFeishuWebhookRequest) Reset() { *m = SaveFeishuWebhookRequest{} }
|
||||
func (m *SaveFeishuWebhookRequest) String() string { return proto.CompactTextString(m) }
|
||||
func (*SaveFeishuWebhookRequest) ProtoMessage() {}
|
||||
|
||||
type DeleteFeishuWebhookRequest struct {
|
||||
UserId int64 `protobuf:"varint,1,opt,name=user_id,json=userId,proto3" json:"user_id,omitempty"`
|
||||
XXX_NoUnkeyedLiteral struct{} `json:"-"`
|
||||
XXX_unrecognized []byte `json:"-"`
|
||||
XXX_sizecache int32 `json:"-"`
|
||||
}
|
||||
|
||||
func (m *DeleteFeishuWebhookRequest) Reset() { *m = DeleteFeishuWebhookRequest{} }
|
||||
func (m *DeleteFeishuWebhookRequest) String() string { return proto.CompactTextString(m) }
|
||||
func (*DeleteFeishuWebhookRequest) ProtoMessage() {}
|
||||
|
||||
type TestFeishuWebhookRequest struct {
|
||||
UserId int64 `protobuf:"varint,1,opt,name=user_id,json=userId,proto3" json:"user_id,omitempty"`
|
||||
XXX_NoUnkeyedLiteral struct{} `json:"-"`
|
||||
XXX_unrecognized []byte `json:"-"`
|
||||
XXX_sizecache int32 `json:"-"`
|
||||
}
|
||||
|
||||
func (m *TestFeishuWebhookRequest) Reset() { *m = TestFeishuWebhookRequest{} }
|
||||
func (m *TestFeishuWebhookRequest) String() string { return proto.CompactTextString(m) }
|
||||
func (*TestFeishuWebhookRequest) ProtoMessage() {}
|
||||
|
||||
type StatusResponse struct {
|
||||
XXX_NoUnkeyedLiteral struct{} `json:"-"`
|
||||
XXX_unrecognized []byte `json:"-"`
|
||||
XXX_sizecache int32 `json:"-"`
|
||||
}
|
||||
|
||||
func (m *StatusResponse) Reset() { *m = StatusResponse{} }
|
||||
func (m *StatusResponse) String() string { return proto.CompactTextString(m) }
|
||||
func (*StatusResponse) ProtoMessage() {}
|
||||
|
||||
type ChannelResponse struct {
|
||||
Channel string `protobuf:"bytes,1,opt,name=channel,proto3" json:"channel,omitempty"`
|
||||
Enabled bool `protobuf:"varint,2,opt,name=enabled,proto3" json:"enabled,omitempty"`
|
||||
Configured bool `protobuf:"varint,3,opt,name=configured,proto3" json:"configured,omitempty"`
|
||||
WebhookUrlMask string `protobuf:"bytes,4,opt,name=webhook_url_mask,json=webhookUrlMask,proto3" json:"webhook_url_mask,omitempty"`
|
||||
AuthType string `protobuf:"bytes,5,opt,name=auth_type,json=authType,proto3" json:"auth_type,omitempty"`
|
||||
HasBearerToken bool `protobuf:"varint,6,opt,name=has_bearer_token,json=hasBearerToken,proto3" json:"has_bearer_token,omitempty"`
|
||||
LastTestStatus string `protobuf:"bytes,7,opt,name=last_test_status,json=lastTestStatus,proto3" json:"last_test_status,omitempty"`
|
||||
LastTestError string `protobuf:"bytes,8,opt,name=last_test_error,json=lastTestError,proto3" json:"last_test_error,omitempty"`
|
||||
LastTestAtUnixNano int64 `protobuf:"varint,9,opt,name=last_test_at_unix_nano,json=lastTestAtUnixNano,proto3" json:"last_test_at_unix_nano,omitempty"`
|
||||
XXX_NoUnkeyedLiteral struct{} `json:"-"`
|
||||
XXX_unrecognized []byte `json:"-"`
|
||||
XXX_sizecache int32 `json:"-"`
|
||||
}
|
||||
|
||||
func (m *ChannelResponse) Reset() { *m = ChannelResponse{} }
|
||||
func (m *ChannelResponse) String() string { return proto.CompactTextString(m) }
|
||||
func (*ChannelResponse) ProtoMessage() {}
|
||||
|
||||
type TestResult struct {
|
||||
Channel *ChannelResponse `protobuf:"bytes,1,opt,name=channel,proto3" json:"channel,omitempty"`
|
||||
Status string `protobuf:"bytes,2,opt,name=status,proto3" json:"status,omitempty"`
|
||||
Outcome string `protobuf:"bytes,3,opt,name=outcome,proto3" json:"outcome,omitempty"`
|
||||
Message string `protobuf:"bytes,4,opt,name=message,proto3" json:"message,omitempty"`
|
||||
TraceId string `protobuf:"bytes,5,opt,name=trace_id,json=traceId,proto3" json:"trace_id,omitempty"`
|
||||
SentAtUnixNano int64 `protobuf:"varint,6,opt,name=sent_at_unix_nano,json=sentAtUnixNano,proto3" json:"sent_at_unix_nano,omitempty"`
|
||||
Skipped bool `protobuf:"varint,7,opt,name=skipped,proto3" json:"skipped,omitempty"`
|
||||
Provider string `protobuf:"bytes,8,opt,name=provider,proto3" json:"provider,omitempty"`
|
||||
XXX_NoUnkeyedLiteral struct{} `json:"-"`
|
||||
XXX_unrecognized []byte `json:"-"`
|
||||
XXX_sizecache int32 `json:"-"`
|
||||
}
|
||||
|
||||
func (m *TestResult) Reset() { *m = TestResult{} }
|
||||
func (m *TestResult) String() string { return proto.CompactTextString(m) }
|
||||
func (*TestResult) ProtoMessage() {}
|
||||
193
backend/services/notification/rpc/pb/notification_grpc.pb.go
Normal file
193
backend/services/notification/rpc/pb/notification_grpc.pb.go
Normal file
@@ -0,0 +1,193 @@
|
||||
package pb
|
||||
|
||||
import (
|
||||
context "context"
|
||||
|
||||
grpc "google.golang.org/grpc"
|
||||
codes "google.golang.org/grpc/codes"
|
||||
status "google.golang.org/grpc/status"
|
||||
)
|
||||
|
||||
const (
|
||||
Notification_GetFeishuWebhook_FullMethodName = "/smartflow.notification.Notification/GetFeishuWebhook"
|
||||
Notification_SaveFeishuWebhook_FullMethodName = "/smartflow.notification.Notification/SaveFeishuWebhook"
|
||||
Notification_DeleteFeishuWebhook_FullMethodName = "/smartflow.notification.Notification/DeleteFeishuWebhook"
|
||||
Notification_TestFeishuWebhook_FullMethodName = "/smartflow.notification.Notification/TestFeishuWebhook"
|
||||
)
|
||||
|
||||
type NotificationClient interface {
|
||||
GetFeishuWebhook(ctx context.Context, in *GetFeishuWebhookRequest, opts ...grpc.CallOption) (*ChannelResponse, error)
|
||||
SaveFeishuWebhook(ctx context.Context, in *SaveFeishuWebhookRequest, opts ...grpc.CallOption) (*ChannelResponse, error)
|
||||
DeleteFeishuWebhook(ctx context.Context, in *DeleteFeishuWebhookRequest, opts ...grpc.CallOption) (*StatusResponse, error)
|
||||
TestFeishuWebhook(ctx context.Context, in *TestFeishuWebhookRequest, opts ...grpc.CallOption) (*TestResult, error)
|
||||
}
|
||||
|
||||
type notificationClient struct {
|
||||
cc grpc.ClientConnInterface
|
||||
}
|
||||
|
||||
func NewNotificationClient(cc grpc.ClientConnInterface) NotificationClient {
|
||||
return ¬ificationClient{cc}
|
||||
}
|
||||
|
||||
func (c *notificationClient) GetFeishuWebhook(ctx context.Context, in *GetFeishuWebhookRequest, opts ...grpc.CallOption) (*ChannelResponse, error) {
|
||||
out := new(ChannelResponse)
|
||||
err := c.cc.Invoke(ctx, Notification_GetFeishuWebhook_FullMethodName, in, out, opts...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (c *notificationClient) SaveFeishuWebhook(ctx context.Context, in *SaveFeishuWebhookRequest, opts ...grpc.CallOption) (*ChannelResponse, error) {
|
||||
out := new(ChannelResponse)
|
||||
err := c.cc.Invoke(ctx, Notification_SaveFeishuWebhook_FullMethodName, in, out, opts...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (c *notificationClient) DeleteFeishuWebhook(ctx context.Context, in *DeleteFeishuWebhookRequest, opts ...grpc.CallOption) (*StatusResponse, error) {
|
||||
out := new(StatusResponse)
|
||||
err := c.cc.Invoke(ctx, Notification_DeleteFeishuWebhook_FullMethodName, in, out, opts...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (c *notificationClient) TestFeishuWebhook(ctx context.Context, in *TestFeishuWebhookRequest, opts ...grpc.CallOption) (*TestResult, error) {
|
||||
out := new(TestResult)
|
||||
err := c.cc.Invoke(ctx, Notification_TestFeishuWebhook_FullMethodName, in, out, opts...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
type NotificationServer interface {
|
||||
GetFeishuWebhook(context.Context, *GetFeishuWebhookRequest) (*ChannelResponse, error)
|
||||
SaveFeishuWebhook(context.Context, *SaveFeishuWebhookRequest) (*ChannelResponse, error)
|
||||
DeleteFeishuWebhook(context.Context, *DeleteFeishuWebhookRequest) (*StatusResponse, error)
|
||||
TestFeishuWebhook(context.Context, *TestFeishuWebhookRequest) (*TestResult, error)
|
||||
}
|
||||
|
||||
type UnimplementedNotificationServer struct{}
|
||||
|
||||
func (UnimplementedNotificationServer) GetFeishuWebhook(context.Context, *GetFeishuWebhookRequest) (*ChannelResponse, error) {
|
||||
return nil, status.Errorf(codes.Unimplemented, "method GetFeishuWebhook not implemented")
|
||||
}
|
||||
|
||||
func (UnimplementedNotificationServer) SaveFeishuWebhook(context.Context, *SaveFeishuWebhookRequest) (*ChannelResponse, error) {
|
||||
return nil, status.Errorf(codes.Unimplemented, "method SaveFeishuWebhook not implemented")
|
||||
}
|
||||
|
||||
func (UnimplementedNotificationServer) DeleteFeishuWebhook(context.Context, *DeleteFeishuWebhookRequest) (*StatusResponse, error) {
|
||||
return nil, status.Errorf(codes.Unimplemented, "method DeleteFeishuWebhook not implemented")
|
||||
}
|
||||
|
||||
func (UnimplementedNotificationServer) TestFeishuWebhook(context.Context, *TestFeishuWebhookRequest) (*TestResult, error) {
|
||||
return nil, status.Errorf(codes.Unimplemented, "method TestFeishuWebhook not implemented")
|
||||
}
|
||||
|
||||
func RegisterNotificationServer(s grpc.ServiceRegistrar, srv NotificationServer) {
|
||||
s.RegisterService(&Notification_ServiceDesc, srv)
|
||||
}
|
||||
|
||||
func _Notification_GetFeishuWebhook_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
|
||||
in := new(GetFeishuWebhookRequest)
|
||||
if err := dec(in); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if interceptor == nil {
|
||||
return srv.(NotificationServer).GetFeishuWebhook(ctx, in)
|
||||
}
|
||||
info := &grpc.UnaryServerInfo{
|
||||
Server: srv,
|
||||
FullMethod: Notification_GetFeishuWebhook_FullMethodName,
|
||||
}
|
||||
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
|
||||
return srv.(NotificationServer).GetFeishuWebhook(ctx, req.(*GetFeishuWebhookRequest))
|
||||
}
|
||||
return interceptor(ctx, in, info, handler)
|
||||
}
|
||||
|
||||
func _Notification_SaveFeishuWebhook_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
|
||||
in := new(SaveFeishuWebhookRequest)
|
||||
if err := dec(in); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if interceptor == nil {
|
||||
return srv.(NotificationServer).SaveFeishuWebhook(ctx, in)
|
||||
}
|
||||
info := &grpc.UnaryServerInfo{
|
||||
Server: srv,
|
||||
FullMethod: Notification_SaveFeishuWebhook_FullMethodName,
|
||||
}
|
||||
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
|
||||
return srv.(NotificationServer).SaveFeishuWebhook(ctx, req.(*SaveFeishuWebhookRequest))
|
||||
}
|
||||
return interceptor(ctx, in, info, handler)
|
||||
}
|
||||
|
||||
func _Notification_DeleteFeishuWebhook_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
|
||||
in := new(DeleteFeishuWebhookRequest)
|
||||
if err := dec(in); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if interceptor == nil {
|
||||
return srv.(NotificationServer).DeleteFeishuWebhook(ctx, in)
|
||||
}
|
||||
info := &grpc.UnaryServerInfo{
|
||||
Server: srv,
|
||||
FullMethod: Notification_DeleteFeishuWebhook_FullMethodName,
|
||||
}
|
||||
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
|
||||
return srv.(NotificationServer).DeleteFeishuWebhook(ctx, req.(*DeleteFeishuWebhookRequest))
|
||||
}
|
||||
return interceptor(ctx, in, info, handler)
|
||||
}
|
||||
|
||||
func _Notification_TestFeishuWebhook_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
|
||||
in := new(TestFeishuWebhookRequest)
|
||||
if err := dec(in); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if interceptor == nil {
|
||||
return srv.(NotificationServer).TestFeishuWebhook(ctx, in)
|
||||
}
|
||||
info := &grpc.UnaryServerInfo{
|
||||
Server: srv,
|
||||
FullMethod: Notification_TestFeishuWebhook_FullMethodName,
|
||||
}
|
||||
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
|
||||
return srv.(NotificationServer).TestFeishuWebhook(ctx, req.(*TestFeishuWebhookRequest))
|
||||
}
|
||||
return interceptor(ctx, in, info, handler)
|
||||
}
|
||||
|
||||
var Notification_ServiceDesc = grpc.ServiceDesc{
|
||||
ServiceName: "smartflow.notification.Notification",
|
||||
HandlerType: (*NotificationServer)(nil),
|
||||
Methods: []grpc.MethodDesc{
|
||||
{
|
||||
MethodName: "GetFeishuWebhook",
|
||||
Handler: _Notification_GetFeishuWebhook_Handler,
|
||||
},
|
||||
{
|
||||
MethodName: "SaveFeishuWebhook",
|
||||
Handler: _Notification_SaveFeishuWebhook_Handler,
|
||||
},
|
||||
{
|
||||
MethodName: "DeleteFeishuWebhook",
|
||||
Handler: _Notification_DeleteFeishuWebhook_Handler,
|
||||
},
|
||||
{
|
||||
MethodName: "TestFeishuWebhook",
|
||||
Handler: _Notification_TestFeishuWebhook_Handler,
|
||||
},
|
||||
},
|
||||
Streams: []grpc.StreamDesc{},
|
||||
Metadata: "services/notification/rpc/notification.proto",
|
||||
}
|
||||
54
backend/services/notification/rpc/server.go
Normal file
54
backend/services/notification/rpc/server.go
Normal file
@@ -0,0 +1,54 @@
|
||||
package rpc
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/LoveLosita/smartflow/backend/services/notification/rpc/pb"
|
||||
notificationsv "github.com/LoveLosita/smartflow/backend/services/notification/sv"
|
||||
"github.com/zeromicro/go-zero/core/service"
|
||||
"github.com/zeromicro/go-zero/zrpc"
|
||||
"google.golang.org/grpc"
|
||||
)
|
||||
|
||||
const (
|
||||
defaultListenOn = "0.0.0.0:9082"
|
||||
defaultTimeout = 6 * time.Second
|
||||
)
|
||||
|
||||
type ServerOptions struct {
|
||||
ListenOn string
|
||||
Timeout time.Duration
|
||||
Service *notificationsv.Service
|
||||
}
|
||||
|
||||
func NewServer(opts ServerOptions) (*zrpc.RpcServer, string, error) {
|
||||
if opts.Service == nil {
|
||||
return nil, "", errors.New("notification service dependency not initialized")
|
||||
}
|
||||
|
||||
listenOn := strings.TrimSpace(opts.ListenOn)
|
||||
if listenOn == "" {
|
||||
listenOn = defaultListenOn
|
||||
}
|
||||
timeout := opts.Timeout
|
||||
if timeout <= 0 {
|
||||
timeout = defaultTimeout
|
||||
}
|
||||
|
||||
server, err := zrpc.NewServer(zrpc.RpcServerConf{
|
||||
ServiceConf: service.ServiceConf{
|
||||
Name: "notification.rpc",
|
||||
Mode: service.DevMode,
|
||||
},
|
||||
ListenOn: listenOn,
|
||||
Timeout: int64(timeout / time.Millisecond),
|
||||
}, func(grpcServer *grpc.Server) {
|
||||
pb.RegisterNotificationServer(grpcServer, NewHandler(opts.Service))
|
||||
})
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
return server, listenOn, nil
|
||||
}
|
||||
158
backend/services/notification/sv/channel.go
Normal file
158
backend/services/notification/sv/channel.go
Normal file
@@ -0,0 +1,158 @@
|
||||
package sv
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"strings"
|
||||
|
||||
"github.com/LoveLosita/smartflow/backend/respond"
|
||||
notificationfeishu "github.com/LoveLosita/smartflow/backend/services/notification/internal/feishu"
|
||||
notificationmodel "github.com/LoveLosita/smartflow/backend/services/notification/model"
|
||||
contracts "github.com/LoveLosita/smartflow/backend/shared/contracts/notification"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
const (
|
||||
channelTestStatusSuccess = "success"
|
||||
channelTestStatusFailed = "failed"
|
||||
)
|
||||
|
||||
// GetFeishuWebhook 查询当前用户的飞书 Webhook 触发器配置。
|
||||
func (s *Service) GetFeishuWebhook(ctx context.Context, userID int) (contracts.ChannelResponse, error) {
|
||||
if userID <= 0 {
|
||||
return contracts.ChannelResponse{}, respond.ErrUnauthorized
|
||||
}
|
||||
row, err := s.channelStore.GetUserNotificationChannel(ctx, userID, notificationmodel.ChannelFeishuWebhook)
|
||||
if err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return contracts.ChannelResponse{
|
||||
Channel: notificationmodel.ChannelFeishuWebhook,
|
||||
AuthType: notificationmodel.AuthTypeNone,
|
||||
Configured: false,
|
||||
}, nil
|
||||
}
|
||||
return contracts.ChannelResponse{}, err
|
||||
}
|
||||
return responseFromChannel(row), nil
|
||||
}
|
||||
|
||||
// SaveFeishuWebhook 幂等保存当前用户的飞书 Webhook 触发器配置。
|
||||
func (s *Service) SaveFeishuWebhook(ctx context.Context, userID int, req contracts.SaveFeishuWebhookRequest) (contracts.ChannelResponse, error) {
|
||||
if userID <= 0 {
|
||||
return contracts.ChannelResponse{}, respond.ErrUnauthorized
|
||||
}
|
||||
webhookURL := strings.TrimSpace(req.WebhookURL)
|
||||
if webhookURL == "" {
|
||||
return contracts.ChannelResponse{}, respond.MissingParam
|
||||
}
|
||||
if err := notificationfeishu.ValidateWebhookURL(webhookURL); err != nil {
|
||||
return contracts.ChannelResponse{}, respond.WrongParamType
|
||||
}
|
||||
authType := normalizeAuthType(req.AuthType)
|
||||
bearerToken := strings.TrimSpace(req.BearerToken)
|
||||
if authType == notificationmodel.AuthTypeBearer && bearerToken == "" {
|
||||
return contracts.ChannelResponse{}, respond.MissingParam
|
||||
}
|
||||
row := ¬ificationmodel.UserNotificationChannel{
|
||||
UserID: userID,
|
||||
Channel: notificationmodel.ChannelFeishuWebhook,
|
||||
Enabled: req.Enabled,
|
||||
WebhookURL: webhookURL,
|
||||
AuthType: authType,
|
||||
BearerToken: bearerToken,
|
||||
}
|
||||
if err := s.channelStore.UpsertUserNotificationChannel(ctx, row); err != nil {
|
||||
return contracts.ChannelResponse{}, err
|
||||
}
|
||||
return s.GetFeishuWebhook(ctx, userID)
|
||||
}
|
||||
|
||||
// DeleteFeishuWebhook 删除当前用户的飞书 Webhook 触发器配置。
|
||||
func (s *Service) DeleteFeishuWebhook(ctx context.Context, userID int) error {
|
||||
if userID <= 0 {
|
||||
return respond.ErrUnauthorized
|
||||
}
|
||||
return s.channelStore.DeleteUserNotificationChannel(ctx, userID, notificationmodel.ChannelFeishuWebhook)
|
||||
}
|
||||
|
||||
// TestFeishuWebhook 发送一条最小业务 JSON 到当前用户配置的飞书 Webhook。
|
||||
func (s *Service) TestFeishuWebhook(ctx context.Context, userID int) (contracts.TestResult, error) {
|
||||
if userID <= 0 {
|
||||
return contracts.TestResult{}, respond.ErrUnauthorized
|
||||
}
|
||||
now := s.options.Now()
|
||||
traceID := "trace_feishu_webhook_test"
|
||||
sendResult, sendErr := s.provider.Send(ctx, notificationfeishu.SendRequest{
|
||||
NotificationID: 0,
|
||||
UserID: userID,
|
||||
TriggerID: "ast_test_webhook",
|
||||
PreviewID: "asp_test_webhook",
|
||||
TriggerType: "manual_test",
|
||||
TargetType: "notification_channel",
|
||||
TargetID: 0,
|
||||
TargetURL: "/assistant/00000000-0000-0000-0000-000000000000",
|
||||
MessageText: "这是一条 SmartFlow 飞书 Webhook 测试消息。",
|
||||
TraceID: traceID,
|
||||
AttemptCount: 1,
|
||||
})
|
||||
if sendErr != nil {
|
||||
return contracts.TestResult{}, sendErr
|
||||
}
|
||||
|
||||
status := channelTestStatusFailed
|
||||
testErr := strings.TrimSpace(sendResult.ErrorMessage)
|
||||
if sendResult.Outcome == notificationfeishu.SendOutcomeSuccess {
|
||||
status = channelTestStatusSuccess
|
||||
testErr = ""
|
||||
}
|
||||
if sendResult.Outcome == notificationfeishu.SendOutcomeSkipped && testErr == "" {
|
||||
testErr = "飞书 webhook 未配置或未启用"
|
||||
}
|
||||
if err := s.channelStore.UpdateUserNotificationChannelTestResult(ctx, userID, notificationmodel.ChannelFeishuWebhook, status, testErr, now); err != nil {
|
||||
return contracts.TestResult{}, err
|
||||
}
|
||||
channel, err := s.GetFeishuWebhook(ctx, userID)
|
||||
if err != nil {
|
||||
return contracts.TestResult{}, err
|
||||
}
|
||||
return contracts.TestResult{
|
||||
Channel: channel,
|
||||
Status: status,
|
||||
Outcome: string(sendResult.Outcome),
|
||||
Message: testErr,
|
||||
TraceID: traceID,
|
||||
SentAt: now,
|
||||
Skipped: sendResult.Outcome == notificationfeishu.SendOutcomeSkipped,
|
||||
Provider: notificationfeishu.Channel,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func responseFromChannel(row *notificationmodel.UserNotificationChannel) contracts.ChannelResponse {
|
||||
if row == nil {
|
||||
return contracts.ChannelResponse{
|
||||
Channel: notificationmodel.ChannelFeishuWebhook,
|
||||
AuthType: notificationmodel.AuthTypeNone,
|
||||
Configured: false,
|
||||
}
|
||||
}
|
||||
return contracts.ChannelResponse{
|
||||
Channel: row.Channel,
|
||||
Enabled: row.Enabled,
|
||||
Configured: strings.TrimSpace(row.WebhookURL) != "",
|
||||
WebhookURLMask: notificationfeishu.MaskWebhookURL(row.WebhookURL),
|
||||
AuthType: normalizeAuthType(row.AuthType),
|
||||
HasBearerToken: strings.TrimSpace(row.BearerToken) != "",
|
||||
LastTestStatus: row.LastTestStatus,
|
||||
LastTestError: row.LastTestError,
|
||||
LastTestAt: row.LastTestAt,
|
||||
}
|
||||
}
|
||||
|
||||
func normalizeAuthType(authType string) string {
|
||||
switch strings.ToLower(strings.TrimSpace(authType)) {
|
||||
case notificationmodel.AuthTypeBearer:
|
||||
return notificationmodel.AuthTypeBearer
|
||||
default:
|
||||
return notificationmodel.AuthTypeNone
|
||||
}
|
||||
}
|
||||
29
backend/services/notification/sv/factory.go
Normal file
29
backend/services/notification/sv/factory.go
Normal file
@@ -0,0 +1,29 @@
|
||||
package sv
|
||||
|
||||
import notificationfeishu "github.com/LoveLosita/smartflow/backend/services/notification/internal/feishu"
|
||||
|
||||
// FeishuWebhookProviderOptions 定义生产默认飞书 Webhook provider 的启动参数。
|
||||
//
|
||||
// 职责边界:
|
||||
// 1. 只承载 provider 初始化需要的外部配置,不暴露 internal/feishu 的具体实现;
|
||||
// 2. 不负责 notification 状态机参数,重试次数和扫描批量仍由 ServiceOptions 管理;
|
||||
// 3. 后续若新增 OpenID 等 provider,应新增对应构造器,避免把多 provider 分支堆进 cmd 入口。
|
||||
type FeishuWebhookProviderOptions struct {
|
||||
FrontendBaseURL string
|
||||
}
|
||||
|
||||
// NewNotificationServiceWithFeishuWebhook 创建生产默认的飞书 Webhook notification 服务。
|
||||
//
|
||||
// 职责边界:
|
||||
// 1. 在 sv 层完成 internal/feishu provider 装配,cmd 入口不直接依赖 internal 包;
|
||||
// 2. 只组合 notification 领域内部依赖,不连接数据库、不读取配置;
|
||||
// 3. provider 构造失败时直接返回 error,避免启动出半初始化服务。
|
||||
func NewNotificationServiceWithFeishuWebhook(recordStore RecordStore, channelStore ChannelStore, providerOpts FeishuWebhookProviderOptions, serviceOpts ServiceOptions) (*Service, error) {
|
||||
provider, err := notificationfeishu.NewWebhookProvider(channelStore, notificationfeishu.WebhookProviderOptions{
|
||||
FrontendBaseURL: providerOpts.FrontendBaseURL,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return NewNotificationService(recordStore, channelStore, provider, serviceOpts)
|
||||
}
|
||||
94
backend/services/notification/sv/outbox.go
Normal file
94
backend/services/notification/sv/outbox.go
Normal file
@@ -0,0 +1,94 @@
|
||||
package sv
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"log"
|
||||
"strings"
|
||||
|
||||
kafkabus "github.com/LoveLosita/smartflow/backend/infra/kafka"
|
||||
outboxinfra "github.com/LoveLosita/smartflow/backend/infra/outbox"
|
||||
sharedevents "github.com/LoveLosita/smartflow/backend/shared/events"
|
||||
)
|
||||
|
||||
// OutboxBus 是 notification 服务注册消费 handler 需要的最小总线接口。
|
||||
//
|
||||
// 职责边界:只要求具备 handler 注册能力,启动、关闭和发布由进程入口自己编排。
|
||||
type OutboxBus interface {
|
||||
RegisterEventHandler(eventType string, handler outboxinfra.MessageHandler) error
|
||||
}
|
||||
|
||||
// RegisterFeishuRequestedHandler 注册 `notification.feishu.requested` 消费 handler。
|
||||
//
|
||||
// 职责边界:
|
||||
// 1. 只负责事件解析、协议校验、调用 NotificationService 和推进 outbox consumed;
|
||||
// 2. 不承担 notification_records 状态机细节,状态流转全部下沉到 notification 服务;
|
||||
// 3. 不在 handler 内部创建 provider/service,避免事件消费与 retry loop 使用两套不同配置。
|
||||
func RegisterFeishuRequestedHandler(bus OutboxBus, outboxRepo *outboxinfra.Repository, svc *Service) error {
|
||||
if bus == nil {
|
||||
return errors.New("event bus is nil")
|
||||
}
|
||||
if outboxRepo == nil {
|
||||
return errors.New("outbox repository is nil")
|
||||
}
|
||||
if svc == nil {
|
||||
return errors.New("notification service is nil")
|
||||
}
|
||||
if err := outboxinfra.RegisterEventService(sharedevents.NotificationFeishuRequestedEventType, outboxinfra.ServiceNotification); err != nil {
|
||||
return err
|
||||
}
|
||||
route, ok := outboxinfra.ResolveEventRoute(sharedevents.NotificationFeishuRequestedEventType)
|
||||
if !ok {
|
||||
return errors.New("notification.feishu.requested route is missing")
|
||||
}
|
||||
eventOutboxRepo := outboxRepo.WithRoute(route)
|
||||
|
||||
handler := func(ctx context.Context, envelope kafkabus.Envelope) error {
|
||||
// 1. 先校验 event_version,避免未来协议破坏性升级后旧 handler 误吃新消息。
|
||||
// 2. 当前阶段只接受 v1;版本不匹配属于不可恢复协议错误,直接标记 dead。
|
||||
eventVersion := strings.TrimSpace(envelope.EventVersion)
|
||||
if eventVersion != "" && eventVersion != sharedevents.NotificationFeishuRequestedEventVersion {
|
||||
if err := eventOutboxRepo.MarkDead(ctx, envelope.OutboxID, "notification.feishu.requested event_version 不匹配: "+eventVersion); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
var payload sharedevents.FeishuNotificationRequestedPayload
|
||||
if unmarshalErr := json.Unmarshal(envelope.Payload, &payload); unmarshalErr != nil {
|
||||
if err := eventOutboxRepo.MarkDead(ctx, envelope.OutboxID, "解析 notification.feishu.requested 载荷失败: "+unmarshalErr.Error()); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
if validateErr := payload.Validate(); validateErr != nil {
|
||||
if err := eventOutboxRepo.MarkDead(ctx, envelope.OutboxID, "notification.feishu.requested 载荷非法: "+validateErr.Error()); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
result, handleErr := svc.HandleFeishuRequested(ctx, payload)
|
||||
if handleErr != nil {
|
||||
return handleErr
|
||||
}
|
||||
|
||||
if consumeErr := eventOutboxRepo.ConsumeAndMarkConsumed(ctx, envelope.OutboxID, nil); consumeErr != nil {
|
||||
return consumeErr
|
||||
}
|
||||
|
||||
log.Printf(
|
||||
"notification.feishu.requested 消费完成: outbox_id=%d notification_id=%d status=%s delivered=%t reused=%t attempt_count=%d",
|
||||
envelope.OutboxID,
|
||||
result.RecordID,
|
||||
result.Status,
|
||||
result.Delivered,
|
||||
result.Reused,
|
||||
result.AttemptCount,
|
||||
)
|
||||
return nil
|
||||
}
|
||||
|
||||
return bus.RegisterEventHandler(sharedevents.NotificationFeishuRequestedEventType, handler)
|
||||
}
|
||||
747
backend/services/notification/sv/service.go
Normal file
747
backend/services/notification/sv/service.go
Normal file
@@ -0,0 +1,747 @@
|
||||
package sv
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
notificationfeishu "github.com/LoveLosita/smartflow/backend/services/notification/internal/feishu"
|
||||
notificationmodel "github.com/LoveLosita/smartflow/backend/services/notification/model"
|
||||
sharedevents "github.com/LoveLosita/smartflow/backend/shared/events"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
const (
|
||||
defaultMaxAttempts = 5
|
||||
defaultRetryBaseDelay = 5 * time.Minute
|
||||
defaultRetryMaxDelay = 30 * time.Minute
|
||||
defaultSendingLease = 10 * time.Minute
|
||||
defaultSummaryMaxRunes = 180
|
||||
defaultRetryScanBatch = 100
|
||||
sendingLeaseExpiredCode = "sending_lease_expired"
|
||||
defaultFallbackTemplate = "我为你生成了一份日程调整建议,请回到系统确认是否应用。"
|
||||
)
|
||||
|
||||
// RecordStore 抽象出 notification_records 真正依赖的持久化能力。
|
||||
//
|
||||
// 职责边界:
|
||||
// 1. 只描述 notification_records 读写所需的最小接口;
|
||||
// 2. 允许生产环境直接复用 notification DAO,也允许测试时替换成内存 fake;
|
||||
// 3. 不把 provider、事件总线和业务状态机耦合进存储接口。
|
||||
type RecordStore interface {
|
||||
CreateNotificationRecord(ctx context.Context, record *notificationmodel.NotificationRecord) error
|
||||
UpdateNotificationRecordFields(ctx context.Context, notificationID int64, updates map[string]any) error
|
||||
GetNotificationRecordByID(ctx context.Context, notificationID int64) (*notificationmodel.NotificationRecord, error)
|
||||
FindNotificationRecordByDedupeKey(ctx context.Context, channel string, dedupeKey string) (*notificationmodel.NotificationRecord, error)
|
||||
ListRetryableNotificationRecords(ctx context.Context, now time.Time, sendingStaleBefore time.Time, limit int) ([]notificationmodel.NotificationRecord, error)
|
||||
ClaimRetryableNotificationRecord(ctx context.Context, notificationID int64, now time.Time, sendingStaleBefore time.Time) (bool, error)
|
||||
}
|
||||
|
||||
// ChannelStore 抽象出用户通知通道配置所需的最小持久化能力。
|
||||
type ChannelStore interface {
|
||||
GetUserNotificationChannel(ctx context.Context, userID int, channel string) (*notificationmodel.UserNotificationChannel, error)
|
||||
UpsertUserNotificationChannel(ctx context.Context, channel *notificationmodel.UserNotificationChannel) error
|
||||
DeleteUserNotificationChannel(ctx context.Context, userID int, channel string) error
|
||||
UpdateUserNotificationChannelTestResult(ctx context.Context, userID int, channel string, status string, testErr string, testedAt time.Time) error
|
||||
}
|
||||
|
||||
// Service 负责 notification_records 状态机、通道配置和 provider 调用编排。
|
||||
//
|
||||
// 职责边界:
|
||||
// 1. 负责飞书 webhook 通道配置、测试、消息投递、重试和 outbox 消费;
|
||||
// 2. 不负责 active_schedule 的 dry-run / preview / trigger 状态机;
|
||||
// 3. 不负责 gateway 的响应适配、路由聚合和 JWT 鉴权。
|
||||
type Service struct {
|
||||
recordStore RecordStore
|
||||
channelStore ChannelStore
|
||||
provider notificationfeishu.Provider
|
||||
options ServiceOptions
|
||||
locks *keyedLocker
|
||||
}
|
||||
|
||||
// NotificationService 是阶段四对外暴露的语义化别名。
|
||||
type NotificationService = Service
|
||||
|
||||
// ServiceOptions 定义通知服务的可调参数。
|
||||
type ServiceOptions struct {
|
||||
Now func() time.Time
|
||||
MaxAttempts int
|
||||
RetryBaseDelay time.Duration
|
||||
RetryMaxDelay time.Duration
|
||||
SendingLease time.Duration
|
||||
SummaryMaxRunes int
|
||||
RetryScanBatch int
|
||||
}
|
||||
|
||||
// HandleResult 描述一次事件处理或一次 retry 尝试的结果。
|
||||
type HandleResult struct {
|
||||
RecordID int64
|
||||
Status string
|
||||
Reused bool
|
||||
Delivered bool
|
||||
FallbackUsed bool
|
||||
AttemptCount int
|
||||
NextRetryAt *time.Time
|
||||
ProviderError string
|
||||
}
|
||||
|
||||
// RetryResult 汇总一次批量 retry 扫描的结果。
|
||||
type RetryResult struct {
|
||||
Scanned int
|
||||
Retried int
|
||||
Sent int
|
||||
Failed int
|
||||
Dead int
|
||||
Skipped int
|
||||
Errors int
|
||||
}
|
||||
|
||||
// NewNotificationService 创建通知服务。
|
||||
func NewNotificationService(recordStore RecordStore, channelStore ChannelStore, provider notificationfeishu.Provider, opts ServiceOptions) (*Service, error) {
|
||||
if recordStore == nil {
|
||||
return nil, errors.New("notification record store is nil")
|
||||
}
|
||||
if channelStore == nil {
|
||||
return nil, errors.New("notification channel store is nil")
|
||||
}
|
||||
if provider == nil {
|
||||
return nil, errors.New("feishu provider is nil")
|
||||
}
|
||||
opts = normalizeServiceOptions(opts)
|
||||
return &Service{
|
||||
recordStore: recordStore,
|
||||
channelStore: channelStore,
|
||||
provider: provider,
|
||||
options: opts,
|
||||
locks: newKeyedLocker(),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// HandleFeishuRequested 处理一条 `notification.feishu.requested` 事件。
|
||||
//
|
||||
// 步骤说明:
|
||||
// 1. 先校验 shared/events payload,避免脏数据进入状态机;
|
||||
// 2. 再按 `channel + dedupe_key` 串行化处理,保证进程内不会并发重复发同一条飞书;
|
||||
// 3. 若已有 pending/failed,则复用同一条 record 继续投递;sending/sent/dead/skipped 则直接短路。
|
||||
func (s *Service) HandleFeishuRequested(ctx context.Context, payload sharedevents.FeishuNotificationRequestedPayload) (HandleResult, error) {
|
||||
if err := payload.Validate(); err != nil {
|
||||
return HandleResult{}, err
|
||||
}
|
||||
|
||||
lockKey := buildNotificationLockKey(notificationfeishu.Channel, payload.DedupeKey)
|
||||
unlock := s.locks.Lock(lockKey)
|
||||
defer unlock()
|
||||
|
||||
record, reused, err := s.findOrCreateRecordForPayload(ctx, payload)
|
||||
if err != nil {
|
||||
return HandleResult{}, err
|
||||
}
|
||||
|
||||
result, err := s.deliverRecord(ctx, record)
|
||||
if err != nil {
|
||||
return HandleResult{}, err
|
||||
}
|
||||
result.Reused = reused
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// RetryFeishuNotifications 扫描并重试到点的 failed 记录。
|
||||
//
|
||||
// 步骤说明:
|
||||
// 1. 先按 DAO 提供的 retry 查询口径拉取 `status=failed && next_retry_at<=now`;
|
||||
// 2. 再逐条加进程内锁并复用同一条 record 重试,避免 scanner 和事件 handler 打架;
|
||||
// 3. 单条失败不会中断整批扫描,但会在返回值中累计 Errors,并把首个错误回传给调用方。
|
||||
func (s *Service) RetryFeishuNotifications(ctx context.Context, now time.Time, limit int) (RetryResult, error) {
|
||||
if now.IsZero() {
|
||||
now = s.options.Now()
|
||||
}
|
||||
if limit <= 0 {
|
||||
limit = s.options.RetryScanBatch
|
||||
}
|
||||
|
||||
records, err := s.recordStore.ListRetryableNotificationRecords(ctx, now, s.sendingStaleBefore(now), limit)
|
||||
if err != nil {
|
||||
return RetryResult{}, err
|
||||
}
|
||||
|
||||
result := RetryResult{Scanned: len(records)}
|
||||
var firstErr error
|
||||
|
||||
for _, record := range records {
|
||||
if record.Channel != notificationfeishu.Channel {
|
||||
result.Skipped++
|
||||
continue
|
||||
}
|
||||
|
||||
handleResult, retryErr := s.retryOneRecord(ctx, record.ID)
|
||||
if retryErr != nil {
|
||||
result.Errors++
|
||||
if firstErr == nil {
|
||||
firstErr = retryErr
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
if handleResult.Delivered {
|
||||
result.Retried++
|
||||
}
|
||||
switch handleResult.Status {
|
||||
case notificationmodel.RecordStatusSent:
|
||||
if handleResult.Delivered {
|
||||
result.Sent++
|
||||
} else {
|
||||
result.Skipped++
|
||||
}
|
||||
case notificationmodel.RecordStatusFailed:
|
||||
result.Failed++
|
||||
case notificationmodel.RecordStatusDead:
|
||||
result.Dead++
|
||||
default:
|
||||
result.Skipped++
|
||||
}
|
||||
}
|
||||
|
||||
return result, firstErr
|
||||
}
|
||||
|
||||
func (s *Service) RetryDue(ctx context.Context, now time.Time, limit int) (int, error) {
|
||||
result, err := s.RetryFeishuNotifications(ctx, now, limit)
|
||||
if err != nil {
|
||||
return result.Retried, err
|
||||
}
|
||||
return result.Retried, nil
|
||||
}
|
||||
|
||||
func (s *Service) retryOneRecord(ctx context.Context, notificationID int64) (HandleResult, error) {
|
||||
record, err := s.recordStore.GetNotificationRecordByID(ctx, notificationID)
|
||||
if err != nil {
|
||||
return HandleResult{}, err
|
||||
}
|
||||
|
||||
lockKey := buildNotificationLockKey(record.Channel, record.DedupeKey)
|
||||
unlock := s.locks.Lock(lockKey)
|
||||
defer unlock()
|
||||
|
||||
// 1. retry scanner 可能在滚动发布或多实例场景下并行运行,进程内锁只能保护当前进程。
|
||||
// 2. 这里先用条件 UPDATE 把 failed 且到期的记录 claim 成 sending;只有抢到 claim 的实例才能调用 provider。
|
||||
// 3. 未抢到说明记录已被其它实例处理或状态已变化,直接回读当前状态用于统计,不再重复发送。
|
||||
now := s.options.Now()
|
||||
claimed, err := s.recordStore.ClaimRetryableNotificationRecord(ctx, notificationID, now, s.sendingStaleBefore(now))
|
||||
if err != nil {
|
||||
return HandleResult{}, err
|
||||
}
|
||||
|
||||
current, err := s.recordStore.GetNotificationRecordByID(ctx, notificationID)
|
||||
if err != nil {
|
||||
return HandleResult{}, err
|
||||
}
|
||||
if !claimed {
|
||||
return HandleResult{
|
||||
RecordID: current.ID,
|
||||
Status: current.Status,
|
||||
FallbackUsed: current.FallbackUsed,
|
||||
AttemptCount: current.AttemptCount,
|
||||
NextRetryAt: current.NextRetryAt,
|
||||
}, nil
|
||||
}
|
||||
return s.sendRecordNow(ctx, current)
|
||||
}
|
||||
|
||||
func (s *Service) findOrCreateRecordForPayload(ctx context.Context, payload sharedevents.FeishuNotificationRequestedPayload) (*notificationmodel.NotificationRecord, bool, error) {
|
||||
// 1. 若 payload 已携带 notification_id,先尝试命中现有记录,便于后续扩展“指定 record 重放”场景。
|
||||
// 2. 若 id 未命中或字段不一致,再退回到 channel + dedupe_key 这一版稳定幂等口径。
|
||||
if payload.NotificationID > 0 {
|
||||
record, err := s.recordStore.GetNotificationRecordByID(ctx, payload.NotificationID)
|
||||
if err == nil && record != nil && record.Channel == notificationfeishu.Channel && record.DedupeKey == strings.TrimSpace(payload.DedupeKey) {
|
||||
return record, true, nil
|
||||
}
|
||||
if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return nil, false, err
|
||||
}
|
||||
}
|
||||
|
||||
record, err := s.recordStore.FindNotificationRecordByDedupeKey(ctx, notificationfeishu.Channel, strings.TrimSpace(payload.DedupeKey))
|
||||
if err == nil {
|
||||
return record, true, nil
|
||||
}
|
||||
if !errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return nil, false, err
|
||||
}
|
||||
|
||||
summaryText, fallbackText, fallbackUsed := s.normalizeMessageTemplate(payload.SummaryText, payload.FallbackText)
|
||||
record = ¬ificationmodel.NotificationRecord{
|
||||
Channel: notificationfeishu.Channel,
|
||||
UserID: payload.UserID,
|
||||
TriggerID: strings.TrimSpace(payload.TriggerID),
|
||||
PreviewID: strings.TrimSpace(payload.PreviewID),
|
||||
TriggerType: strings.TrimSpace(payload.TriggerType),
|
||||
TargetType: strings.TrimSpace(payload.TargetType),
|
||||
TargetID: payload.TargetID,
|
||||
DedupeKey: strings.TrimSpace(payload.DedupeKey),
|
||||
TargetURL: strings.TrimSpace(payload.TargetURL),
|
||||
SummaryText: summaryText,
|
||||
FallbackText: fallbackText,
|
||||
FallbackUsed: fallbackUsed,
|
||||
Status: notificationmodel.RecordStatusPending,
|
||||
MaxAttempts: s.options.MaxAttempts,
|
||||
TraceID: strings.TrimSpace(payload.TraceID),
|
||||
}
|
||||
|
||||
if err = s.recordStore.CreateNotificationRecord(ctx, record); err != nil {
|
||||
// 1. 并发场景下若唯一索引已被别的协程抢先创建,这里回查 dedupe 记录即可;
|
||||
// 2. 若回查仍失败,说明不是幂等竞争而是真正落库异常,应交给上层重试。
|
||||
existing, findErr := s.recordStore.FindNotificationRecordByDedupeKey(ctx, notificationfeishu.Channel, record.DedupeKey)
|
||||
if findErr == nil {
|
||||
return existing, true, nil
|
||||
}
|
||||
return nil, false, err
|
||||
}
|
||||
return record, false, nil
|
||||
}
|
||||
|
||||
func (s *Service) deliverRecord(ctx context.Context, record *notificationmodel.NotificationRecord) (HandleResult, error) {
|
||||
if record == nil {
|
||||
return HandleResult{}, errors.New("notification record is nil")
|
||||
}
|
||||
|
||||
switch record.Status {
|
||||
case notificationmodel.RecordStatusSending:
|
||||
if !s.isSendingLeaseExpired(record) {
|
||||
return HandleResult{}, errors.New("notification record 正在发送中,等待租约过期后再重试")
|
||||
}
|
||||
if err := s.claimStaleSendingRecord(ctx, record); err != nil {
|
||||
return HandleResult{}, err
|
||||
}
|
||||
case notificationmodel.RecordStatusSent,
|
||||
notificationmodel.RecordStatusDead,
|
||||
notificationmodel.RecordStatusSkipped:
|
||||
return HandleResult{
|
||||
RecordID: record.ID,
|
||||
Status: record.Status,
|
||||
FallbackUsed: record.FallbackUsed,
|
||||
AttemptCount: record.AttemptCount,
|
||||
NextRetryAt: record.NextRetryAt,
|
||||
}, nil
|
||||
case notificationmodel.RecordStatusPending, notificationmodel.RecordStatusFailed:
|
||||
// 继续向下走真正投递流程。
|
||||
default:
|
||||
// 1. 未识别状态先保守短路,避免把未知脏数据继续推进到 provider。
|
||||
// 2. 后续若新增新状态,应显式扩展这里的状态机分支。
|
||||
return HandleResult{
|
||||
RecordID: record.ID,
|
||||
Status: record.Status,
|
||||
FallbackUsed: record.FallbackUsed,
|
||||
AttemptCount: record.AttemptCount,
|
||||
NextRetryAt: record.NextRetryAt,
|
||||
}, nil
|
||||
}
|
||||
|
||||
return s.sendRecordNow(ctx, record)
|
||||
}
|
||||
|
||||
func (s *Service) sendRecordNow(ctx context.Context, record *notificationmodel.NotificationRecord) (HandleResult, error) {
|
||||
requestPayload := s.buildSendRequest(record)
|
||||
requestJSON, err := marshalJSONPointer(requestPayload)
|
||||
if err != nil {
|
||||
return HandleResult{}, err
|
||||
}
|
||||
|
||||
nextAttemptCount := record.AttemptCount + 1
|
||||
updates := map[string]any{
|
||||
"status": notificationmodel.RecordStatusSending,
|
||||
"attempt_count": nextAttemptCount,
|
||||
"next_retry_at": nil,
|
||||
"last_error_code": nil,
|
||||
"last_error": nil,
|
||||
"provider_request_json": requestJSON,
|
||||
}
|
||||
if record.MaxAttempts <= 0 {
|
||||
updates["max_attempts"] = s.options.MaxAttempts
|
||||
record.MaxAttempts = s.options.MaxAttempts
|
||||
}
|
||||
if err = s.recordStore.UpdateNotificationRecordFields(ctx, record.ID, updates); err != nil {
|
||||
return HandleResult{}, err
|
||||
}
|
||||
|
||||
record.Status = notificationmodel.RecordStatusSending
|
||||
record.AttemptCount = nextAttemptCount
|
||||
record.NextRetryAt = nil
|
||||
record.ProviderRequestJSON = requestJSON
|
||||
|
||||
sendResult, sendErr := s.provider.Send(ctx, requestPayload)
|
||||
if sendErr != nil && sendResult.Outcome == "" {
|
||||
sendResult = notificationfeishu.SendResult{
|
||||
Outcome: notificationfeishu.SendOutcomeTemporaryFail,
|
||||
ErrorCode: notificationfeishu.ErrorCodeNetworkError,
|
||||
ErrorMessage: sendErr.Error(),
|
||||
}
|
||||
}
|
||||
if sendResult.Outcome == "" {
|
||||
sendResult.Outcome = notificationfeishu.SendOutcomeTemporaryFail
|
||||
if sendResult.ErrorCode == "" {
|
||||
sendResult.ErrorCode = notificationfeishu.ErrorCodeNetworkError
|
||||
}
|
||||
if sendResult.ErrorMessage == "" && sendErr != nil {
|
||||
sendResult.ErrorMessage = sendErr.Error()
|
||||
}
|
||||
}
|
||||
|
||||
return s.applySendResult(ctx, record, sendResult)
|
||||
}
|
||||
|
||||
func (s *Service) claimStaleSendingRecord(ctx context.Context, record *notificationmodel.NotificationRecord) error {
|
||||
now := s.options.Now()
|
||||
// 1. sending 只在超过租约后回收,避免多实例把仍在执行的 provider 调用重复发送。
|
||||
// 2. claim 使用条件 UPDATE,抢不到说明状态已被其它实例推进,本次交给 outbox/retry 下轮重试。
|
||||
// 3. 抢到后复用 sendRecordNow 重新进入统一投递状态机,不额外分叉 provider 调用路径。
|
||||
claimed, err := s.recordStore.ClaimRetryableNotificationRecord(ctx, record.ID, now, s.sendingStaleBefore(now))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !claimed {
|
||||
return errors.New("notification record sending 租约已被其它实例处理")
|
||||
}
|
||||
record.Status = notificationmodel.RecordStatusFailed
|
||||
record.NextRetryAt = &now
|
||||
record.LastErrorCode = stringPtrOrNil(sendingLeaseExpiredCode)
|
||||
record.LastError = stringPtrOrNil("上一次发送停留在 sending,租约过期后自动恢复重试")
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Service) isSendingLeaseExpired(record *notificationmodel.NotificationRecord) bool {
|
||||
if record == nil || record.Status != notificationmodel.RecordStatusSending {
|
||||
return false
|
||||
}
|
||||
if record.UpdatedAt.IsZero() {
|
||||
return true
|
||||
}
|
||||
return !record.UpdatedAt.After(s.sendingStaleBefore(s.options.Now()))
|
||||
}
|
||||
|
||||
func (s *Service) sendingStaleBefore(now time.Time) time.Time {
|
||||
if now.IsZero() {
|
||||
now = time.Now()
|
||||
}
|
||||
lease := s.options.SendingLease
|
||||
if lease <= 0 {
|
||||
lease = defaultSendingLease
|
||||
}
|
||||
return now.Add(-lease)
|
||||
}
|
||||
|
||||
func (s *Service) applySendResult(ctx context.Context, record *notificationmodel.NotificationRecord, sendResult notificationfeishu.SendResult) (HandleResult, error) {
|
||||
now := s.options.Now()
|
||||
responseJSON, err := marshalJSONPointer(sendResult.ResponsePayload)
|
||||
if err != nil {
|
||||
return HandleResult{}, err
|
||||
}
|
||||
requestJSON, err := marshalJSONPointer(sendResult.RequestPayload)
|
||||
if err != nil {
|
||||
return HandleResult{}, err
|
||||
}
|
||||
if requestJSON == nil {
|
||||
requestJSON = record.ProviderRequestJSON
|
||||
}
|
||||
|
||||
errorCode := stringPtrOrNil(sendResult.ErrorCode)
|
||||
errorMessage := stringPtrOrNil(truncateText(sendResult.ErrorMessage, 2000))
|
||||
providerMessageID := stringPtrOrNil(sendResult.ProviderMessageID)
|
||||
|
||||
switch sendResult.Outcome {
|
||||
case notificationfeishu.SendOutcomeSuccess:
|
||||
sentAt := now
|
||||
updates := map[string]any{
|
||||
"status": notificationmodel.RecordStatusSent,
|
||||
"provider_message_id": providerMessageID,
|
||||
"provider_request_json": requestJSON,
|
||||
"provider_response_json": responseJSON,
|
||||
"last_error_code": nil,
|
||||
"last_error": nil,
|
||||
"next_retry_at": nil,
|
||||
"sent_at": &sentAt,
|
||||
}
|
||||
if err = s.recordStore.UpdateNotificationRecordFields(ctx, record.ID, updates); err != nil {
|
||||
return HandleResult{}, err
|
||||
}
|
||||
return HandleResult{
|
||||
RecordID: record.ID,
|
||||
Status: notificationmodel.RecordStatusSent,
|
||||
Delivered: true,
|
||||
FallbackUsed: record.FallbackUsed,
|
||||
AttemptCount: record.AttemptCount,
|
||||
}, nil
|
||||
case notificationfeishu.SendOutcomeSkipped:
|
||||
updates := map[string]any{
|
||||
"status": notificationmodel.RecordStatusSkipped,
|
||||
"provider_message_id": providerMessageID,
|
||||
"provider_request_json": requestJSON,
|
||||
"provider_response_json": responseJSON,
|
||||
"last_error_code": errorCode,
|
||||
"last_error": errorMessage,
|
||||
"next_retry_at": nil,
|
||||
}
|
||||
if err = s.recordStore.UpdateNotificationRecordFields(ctx, record.ID, updates); err != nil {
|
||||
return HandleResult{}, err
|
||||
}
|
||||
return HandleResult{
|
||||
RecordID: record.ID,
|
||||
Status: notificationmodel.RecordStatusSkipped,
|
||||
Delivered: true,
|
||||
FallbackUsed: record.FallbackUsed,
|
||||
AttemptCount: record.AttemptCount,
|
||||
ProviderError: strings.TrimSpace(sendResult.ErrorCode),
|
||||
}, nil
|
||||
case notificationfeishu.SendOutcomePermanentFail:
|
||||
updates := map[string]any{
|
||||
"status": notificationmodel.RecordStatusDead,
|
||||
"provider_message_id": providerMessageID,
|
||||
"provider_request_json": requestJSON,
|
||||
"provider_response_json": responseJSON,
|
||||
"last_error_code": errorCode,
|
||||
"last_error": errorMessage,
|
||||
"next_retry_at": nil,
|
||||
}
|
||||
if err = s.recordStore.UpdateNotificationRecordFields(ctx, record.ID, updates); err != nil {
|
||||
return HandleResult{}, err
|
||||
}
|
||||
return HandleResult{
|
||||
RecordID: record.ID,
|
||||
Status: notificationmodel.RecordStatusDead,
|
||||
Delivered: true,
|
||||
FallbackUsed: record.FallbackUsed,
|
||||
AttemptCount: record.AttemptCount,
|
||||
ProviderError: strings.TrimSpace(sendResult.ErrorCode),
|
||||
}, nil
|
||||
default:
|
||||
if record.AttemptCount >= s.effectiveMaxAttempts(record) {
|
||||
updates := map[string]any{
|
||||
"status": notificationmodel.RecordStatusDead,
|
||||
"provider_message_id": providerMessageID,
|
||||
"provider_request_json": requestJSON,
|
||||
"provider_response_json": responseJSON,
|
||||
"last_error_code": errorCode,
|
||||
"last_error": errorMessage,
|
||||
"next_retry_at": nil,
|
||||
}
|
||||
if err = s.recordStore.UpdateNotificationRecordFields(ctx, record.ID, updates); err != nil {
|
||||
return HandleResult{}, err
|
||||
}
|
||||
return HandleResult{
|
||||
RecordID: record.ID,
|
||||
Status: notificationmodel.RecordStatusDead,
|
||||
Delivered: true,
|
||||
FallbackUsed: record.FallbackUsed,
|
||||
AttemptCount: record.AttemptCount,
|
||||
ProviderError: strings.TrimSpace(sendResult.ErrorCode),
|
||||
}, nil
|
||||
}
|
||||
|
||||
nextRetryAt := s.calcNextRetryAt(now, record.AttemptCount)
|
||||
updates := map[string]any{
|
||||
"status": notificationmodel.RecordStatusFailed,
|
||||
"provider_message_id": providerMessageID,
|
||||
"provider_request_json": requestJSON,
|
||||
"provider_response_json": responseJSON,
|
||||
"last_error_code": errorCode,
|
||||
"last_error": errorMessage,
|
||||
"next_retry_at": &nextRetryAt,
|
||||
}
|
||||
if err = s.recordStore.UpdateNotificationRecordFields(ctx, record.ID, updates); err != nil {
|
||||
return HandleResult{}, err
|
||||
}
|
||||
return HandleResult{
|
||||
RecordID: record.ID,
|
||||
Status: notificationmodel.RecordStatusFailed,
|
||||
Delivered: true,
|
||||
FallbackUsed: record.FallbackUsed,
|
||||
AttemptCount: record.AttemptCount,
|
||||
NextRetryAt: &nextRetryAt,
|
||||
ProviderError: strings.TrimSpace(sendResult.ErrorCode),
|
||||
}, nil
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Service) buildSendRequest(record *notificationmodel.NotificationRecord) notificationfeishu.SendRequest {
|
||||
messageText := strings.TrimSpace(record.SummaryText)
|
||||
if record.FallbackUsed || messageText == "" {
|
||||
messageText = strings.TrimSpace(record.FallbackText)
|
||||
}
|
||||
if messageText == "" {
|
||||
messageText = defaultFallbackTemplate
|
||||
}
|
||||
if !strings.Contains(messageText, strings.TrimSpace(record.TargetURL)) {
|
||||
messageText = strings.TrimSpace(messageText) + "\n" + strings.TrimSpace(record.TargetURL)
|
||||
}
|
||||
|
||||
return notificationfeishu.SendRequest{
|
||||
NotificationID: record.ID,
|
||||
UserID: record.UserID,
|
||||
TriggerID: record.TriggerID,
|
||||
PreviewID: record.PreviewID,
|
||||
TriggerType: record.TriggerType,
|
||||
TargetType: record.TargetType,
|
||||
TargetID: record.TargetID,
|
||||
TargetURL: record.TargetURL,
|
||||
MessageText: strings.TrimSpace(messageText),
|
||||
FallbackUsed: record.FallbackUsed,
|
||||
TraceID: record.TraceID,
|
||||
AttemptCount: record.AttemptCount + 1,
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Service) normalizeMessageTemplate(summaryText, fallbackText string) (string, string, bool) {
|
||||
normalizedFallback := strings.TrimSpace(fallbackText)
|
||||
if normalizedFallback == "" {
|
||||
normalizedFallback = defaultFallbackTemplate
|
||||
}
|
||||
|
||||
normalizedSummary := strings.TrimSpace(summaryText)
|
||||
if normalizedSummary == "" {
|
||||
return "", normalizedFallback, true
|
||||
}
|
||||
if containsExternalLink(normalizedSummary) {
|
||||
return "", normalizedFallback, true
|
||||
}
|
||||
|
||||
runes := []rune(normalizedSummary)
|
||||
if len(runes) > s.options.SummaryMaxRunes {
|
||||
normalizedSummary = string(runes[:s.options.SummaryMaxRunes])
|
||||
}
|
||||
return strings.TrimSpace(normalizedSummary), normalizedFallback, false
|
||||
}
|
||||
|
||||
func (s *Service) calcNextRetryAt(now time.Time, attemptCount int) time.Time {
|
||||
if attemptCount <= 0 {
|
||||
attemptCount = 1
|
||||
}
|
||||
|
||||
delay := s.options.RetryBaseDelay
|
||||
for idx := 1; idx < attemptCount; idx++ {
|
||||
delay *= 2
|
||||
if delay >= s.options.RetryMaxDelay {
|
||||
delay = s.options.RetryMaxDelay
|
||||
break
|
||||
}
|
||||
}
|
||||
if delay > s.options.RetryMaxDelay {
|
||||
delay = s.options.RetryMaxDelay
|
||||
}
|
||||
return now.Add(delay)
|
||||
}
|
||||
|
||||
func (s *Service) effectiveMaxAttempts(record *notificationmodel.NotificationRecord) int {
|
||||
if record != nil && record.MaxAttempts > 0 {
|
||||
return record.MaxAttempts
|
||||
}
|
||||
return s.options.MaxAttempts
|
||||
}
|
||||
|
||||
func normalizeServiceOptions(opts ServiceOptions) ServiceOptions {
|
||||
if opts.Now == nil {
|
||||
opts.Now = time.Now
|
||||
}
|
||||
if opts.MaxAttempts <= 0 {
|
||||
opts.MaxAttempts = defaultMaxAttempts
|
||||
}
|
||||
if opts.RetryBaseDelay <= 0 {
|
||||
opts.RetryBaseDelay = defaultRetryBaseDelay
|
||||
}
|
||||
if opts.RetryMaxDelay <= 0 {
|
||||
opts.RetryMaxDelay = defaultRetryMaxDelay
|
||||
}
|
||||
if opts.RetryMaxDelay < opts.RetryBaseDelay {
|
||||
opts.RetryMaxDelay = opts.RetryBaseDelay
|
||||
}
|
||||
if opts.SendingLease <= 0 {
|
||||
opts.SendingLease = defaultSendingLease
|
||||
}
|
||||
if opts.SummaryMaxRunes <= 0 {
|
||||
opts.SummaryMaxRunes = defaultSummaryMaxRunes
|
||||
}
|
||||
if opts.RetryScanBatch <= 0 {
|
||||
opts.RetryScanBatch = defaultRetryScanBatch
|
||||
}
|
||||
return opts
|
||||
}
|
||||
|
||||
func buildNotificationLockKey(channel, dedupeKey string) string {
|
||||
return strings.TrimSpace(channel) + "|" + strings.TrimSpace(dedupeKey)
|
||||
}
|
||||
|
||||
func marshalJSONPointer(value any) (*string, error) {
|
||||
if value == nil {
|
||||
return nil, nil
|
||||
}
|
||||
raw, err := json.Marshal(value)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
text := string(raw)
|
||||
return &text, nil
|
||||
}
|
||||
|
||||
func stringPtrOrNil(value string) *string {
|
||||
trimmed := strings.TrimSpace(value)
|
||||
if trimmed == "" {
|
||||
return nil
|
||||
}
|
||||
return &trimmed
|
||||
}
|
||||
|
||||
func truncateText(value string, limit int) string {
|
||||
if limit <= 0 {
|
||||
return ""
|
||||
}
|
||||
runes := []rune(strings.TrimSpace(value))
|
||||
if len(runes) <= limit {
|
||||
return string(runes)
|
||||
}
|
||||
return string(runes[:limit])
|
||||
}
|
||||
|
||||
func containsExternalLink(text string) bool {
|
||||
lowered := strings.ToLower(strings.TrimSpace(text))
|
||||
return strings.Contains(lowered, "://") || strings.Contains(lowered, "www.")
|
||||
}
|
||||
|
||||
type keyedLocker struct {
|
||||
mu sync.Mutex
|
||||
locks map[string]*keyedLockEntry
|
||||
}
|
||||
|
||||
type keyedLockEntry struct {
|
||||
mu sync.Mutex
|
||||
refs int
|
||||
}
|
||||
|
||||
func newKeyedLocker() *keyedLocker {
|
||||
return &keyedLocker{
|
||||
locks: make(map[string]*keyedLockEntry),
|
||||
}
|
||||
}
|
||||
|
||||
func (l *keyedLocker) Lock(key string) func() {
|
||||
l.mu.Lock()
|
||||
entry := l.locks[key]
|
||||
if entry == nil {
|
||||
entry = &keyedLockEntry{}
|
||||
l.locks[key] = entry
|
||||
}
|
||||
entry.refs++
|
||||
l.mu.Unlock()
|
||||
|
||||
entry.mu.Lock()
|
||||
|
||||
return func() {
|
||||
entry.mu.Unlock()
|
||||
l.mu.Lock()
|
||||
entry.refs--
|
||||
if entry.refs == 0 {
|
||||
delete(l.locks, key)
|
||||
}
|
||||
l.mu.Unlock()
|
||||
}
|
||||
}
|
||||
44
backend/services/notification/sv/worker.go
Normal file
44
backend/services/notification/sv/worker.go
Normal file
@@ -0,0 +1,44 @@
|
||||
package sv
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log"
|
||||
"time"
|
||||
)
|
||||
|
||||
// StartRetryLoop 启动 notification_records 重试扫描器。
|
||||
//
|
||||
// 说明:
|
||||
// 1. 只在 worker/all 或独立 notification 进程启动;API / RPC 入口不主动扫重试;
|
||||
// 2. provider 失败后的重试由本循环负责,避免通用 outbox 被外部服务慢失败拖住;
|
||||
// 3. 每轮失败只写日志,下一轮继续扫描。
|
||||
func (s *Service) StartRetryLoop(ctx context.Context, every time.Duration, limit int) {
|
||||
if s == nil {
|
||||
return
|
||||
}
|
||||
if every <= 0 {
|
||||
every = time.Minute
|
||||
}
|
||||
if limit <= 0 {
|
||||
limit = 50
|
||||
}
|
||||
go func() {
|
||||
ticker := time.NewTicker(every)
|
||||
defer ticker.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-ticker.C:
|
||||
result, err := s.RetryFeishuNotifications(ctx, time.Now(), limit)
|
||||
if err != nil {
|
||||
log.Printf("飞书通知重试扫描失败: err=%v", err)
|
||||
continue
|
||||
}
|
||||
if result.Scanned > 0 {
|
||||
log.Printf("飞书通知重试扫描完成: scanned=%d sent=%d failed=%d dead=%d skipped=%d", result.Scanned, result.Sent, result.Failed, result.Dead, result.Skipped)
|
||||
}
|
||||
}
|
||||
}
|
||||
}()
|
||||
}
|
||||
Reference in New Issue
Block a user