// Package notify 内的 prefs.go 实现 per-user 通知偏好(notification_prefs 表)。 // // 偏好控制 email / im 两条外发通道的开关与最低投递级别(min_severity)。in-app // inbox(持久化 + SSE)始终投递,不受偏好影响。im_webhook_url 以密文落库 // (im_webhook_url_enc BYTEA,经 crypto.Encryptor 加密),repo 层只搬运密文, // 解密由 IMNotifier 在发送时按需进行——避免明文 webhook URL 进入领域内存层。 package notify import ( "context" "errors" "github.com/google/uuid" "github.com/jackc/pgx/v5" "github.com/jackc/pgx/v5/pgxpool" "github.com/yan1h/agent-coding-workflow/internal/infra/errs" notifysqlc "github.com/yan1h/agent-coding-workflow/internal/infra/notify/sqlc" ) // Prefs 是一个用户的通知偏好领域形态。ImWebhookURLEnc 保持密文(BYTEA 原样), // 解密延迟到 IMNotifier.Send。MinSeverity 取值与 Severity 枚举一致。 type Prefs struct { UserID uuid.UUID EmailEnabled bool IMEnabled bool ImWebhookURLEnc []byte MinSeverity Severity } // DefaultPrefs 是用户尚未显式设置偏好时的兜底:email / im 均关闭,最低级别 // warning(与 notification_prefs.min_severity 列默认值一致)。 func DefaultPrefs(userID uuid.UUID) Prefs { return Prefs{ UserID: userID, EmailEnabled: false, IMEnabled: false, MinSeverity: SeverityWarning, } } // PrefsRepository 抽象 notification_prefs 的读写。Get 在无记录时返回 // DefaultPrefs(不报 not-found),让上层无需区分 "未设置" 与 "全关"。 type PrefsRepository interface { Get(ctx context.Context, userID uuid.UUID) (Prefs, error) Upsert(ctx context.Context, p Prefs) error } // pgPrefsRepo 是 PrefsRepository 的 Postgres 实现。 type pgPrefsRepo struct { q *notifysqlc.Queries } // NewPostgresPrefsRepository 用现有 pgxpool 构造偏好仓库。 func NewPostgresPrefsRepository(pool *pgxpool.Pool) PrefsRepository { return &pgPrefsRepo{q: notifysqlc.New(pool)} } // Get 读取用户偏好。无记录时返回 DefaultPrefs(userID) 与 nil error。 func (r *pgPrefsRepo) Get(ctx context.Context, userID uuid.UUID) (Prefs, error) { row, err := r.q.GetNotificationPrefs(ctx, toPgUUID(userID)) if err != nil { if errors.Is(err, pgx.ErrNoRows) { return DefaultPrefs(userID), nil } return Prefs{}, errs.Wrap(err, errs.CodeInternal, "get notification prefs") } return Prefs{ UserID: fromPgUUID(row.UserID), EmailEnabled: row.EmailEnabled, IMEnabled: row.ImEnabled, ImWebhookURLEnc: row.ImWebhookUrlEnc, MinSeverity: Severity(row.MinSeverity), }, nil } // Upsert 写入(或更新)用户偏好。ImWebhookURLEnc 为 nil 时写 NULL。 func (r *pgPrefsRepo) Upsert(ctx context.Context, p Prefs) error { if err := r.q.UpsertNotificationPrefs(ctx, notifysqlc.UpsertNotificationPrefsParams{ UserID: toPgUUID(p.UserID), EmailEnabled: p.EmailEnabled, ImEnabled: p.IMEnabled, ImWebhookUrlEnc: p.ImWebhookURLEnc, MinSeverity: string(p.MinSeverity), }); err != nil { return errs.Wrap(err, errs.CodeInternal, "upsert notification prefs") } return nil } // severityRank 把 Severity 映射为可比较的等级序数,用于 min_severity 过滤。 // 未知值按最低(info)处理,确保 "至少投递" 而非误丢。 func severityRank(s Severity) int { switch s { case SeverityError: return 2 case SeverityWarning: return 1 default: return 0 } } // meetsMinSeverity 报告消息级别是否达到(>=)用户设定的最低投递级别。 func meetsMinSeverity(msg Severity, min Severity) bool { return severityRank(msg) >= severityRank(min) }