You've already forked agentic-coding-workflow
bugfix
This commit is contained in:
@@ -21,6 +21,8 @@ type Dispatcher struct {
|
||||
log *slog.Logger
|
||||
now func() time.Time
|
||||
wg sync.WaitGroup
|
||||
// hub 是可选的实时推送中心;落库成功后 Publish。nil 时仅持久化 + 外发通道。
|
||||
hub StreamHub
|
||||
}
|
||||
|
||||
// NewDispatcher 用 Repository 与 Logger 构造 Dispatcher。注册 Notifier 通过
|
||||
@@ -35,6 +37,11 @@ func (d *Dispatcher) Register(n Notifier) {
|
||||
d.notifiers = append(d.notifiers, n)
|
||||
}
|
||||
|
||||
// SetStreamHub 注入实时推送中心(装配期)。落库成功后会向其 Publish。
|
||||
func (d *Dispatcher) SetStreamHub(h StreamHub) {
|
||||
d.hub = h
|
||||
}
|
||||
|
||||
// Dispatch 同步落库;落库失败返回错误,不再异步投递。落库成功后,
|
||||
// fire-and-forget 派发给所有外部 notifier,单个 notifier 的失败仅记日志,
|
||||
// 不阻塞其它通道;调用方无法感知异步投递结果。
|
||||
@@ -54,6 +61,10 @@ func (d *Dispatcher) Dispatch(ctx context.Context, msg Message) error {
|
||||
if err := d.repo.Insert(persistCtx, msg); err != nil {
|
||||
return err
|
||||
}
|
||||
// 落库成功后才推送实时事件(推送失败 / 慢订阅者不影响落库的权威 inbox)。
|
||||
if d.hub != nil {
|
||||
d.hub.Publish(msg.UserID, msg)
|
||||
}
|
||||
for _, n := range d.notifiers {
|
||||
d.wg.Add(1)
|
||||
go func(n Notifier) {
|
||||
|
||||
@@ -11,6 +11,8 @@
|
||||
package notify
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"time"
|
||||
@@ -23,12 +25,23 @@ import (
|
||||
"github.com/yan1h/agent-coding-workflow/internal/transport/http/middleware"
|
||||
)
|
||||
|
||||
// Encryptor 是 prefs handler 加密 im_webhook_url 所需的窄接口,由 crypto.Encryptor
|
||||
// 满足。以接口注入避免 notify 依赖 crypto 包。
|
||||
type Encryptor interface {
|
||||
Encrypt(plaintext []byte) ([]byte, error)
|
||||
}
|
||||
|
||||
// Handler 暴露 notify 模块的 HTTP 接口;依赖 Repository 与 SessionResolver。
|
||||
// 不直接持有 Dispatcher——发送通知由其它模块通过 Dispatcher 完成,handler
|
||||
// 仅服务 inbox 读取与状态变更。
|
||||
type Handler struct {
|
||||
repo Repository
|
||||
resolver middleware.SessionResolver
|
||||
// hub 为可选的实时推送中心;非 nil 时暴露 GET /stream SSE 端点。
|
||||
hub StreamHub
|
||||
// prefs/enc 为可选的偏好仓库与加密器;二者均非 nil 时暴露 GET/PUT /prefs。
|
||||
prefs PrefsRepository
|
||||
enc Encryptor
|
||||
}
|
||||
|
||||
// NewHandler 构造 notify 模块的 HTTP handler。
|
||||
@@ -36,6 +49,19 @@ func NewHandler(repo Repository, resolver middleware.SessionResolver) *Handler {
|
||||
return &Handler{repo: repo, resolver: resolver}
|
||||
}
|
||||
|
||||
// WithStreamHub 注入实时推送中心,启用 GET /api/v1/notifications/stream SSE。
|
||||
func (h *Handler) WithStreamHub(hub StreamHub) *Handler {
|
||||
h.hub = hub
|
||||
return h
|
||||
}
|
||||
|
||||
// WithPrefs 注入偏好仓库与加密器,启用 GET/PUT /api/v1/notifications/prefs。
|
||||
func (h *Handler) WithPrefs(prefs PrefsRepository, enc Encryptor) *Handler {
|
||||
h.prefs = prefs
|
||||
h.enc = enc
|
||||
return h
|
||||
}
|
||||
|
||||
// Mount 把 inbox 路由挂到给定 chi.Router 上。Auth 中间件在子路由内统一注入。
|
||||
func (h *Handler) Mount(r chi.Router) {
|
||||
r.Route("/api/v1/notifications", func(r chi.Router) {
|
||||
@@ -44,9 +70,74 @@ func (h *Handler) Mount(r chi.Router) {
|
||||
r.Get("/unread-count", h.unreadCount)
|
||||
r.Patch("/read-all", h.readAll)
|
||||
r.Patch("/{id}/read", h.markRead)
|
||||
if h.hub != nil {
|
||||
r.Get("/stream", h.stream)
|
||||
}
|
||||
if h.prefs != nil && h.enc != nil {
|
||||
r.Get("/prefs", h.getPrefs)
|
||||
r.Put("/prefs", h.putPrefs)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// stream 实现 GET /api/v1/notifications/stream:把该用户的实时通知以 SSE 推送。
|
||||
// 客户端用 EventSource 订阅;连接关闭时(r.Context().Done)自动退订。
|
||||
func (h *Handler) stream(w http.ResponseWriter, r *http.Request) {
|
||||
uid, ok := middleware.UserIDFromContext(r.Context())
|
||||
if !ok {
|
||||
httpx.WriteError(w, middleware.RequestIDFromContext(r.Context()),
|
||||
errs.New(errs.CodeUnauthorized, "未认证"))
|
||||
return
|
||||
}
|
||||
flusher, ok := w.(http.Flusher)
|
||||
if !ok {
|
||||
http.Error(w, "streaming unsupported", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
ch, cancel := h.hub.Subscribe(uid)
|
||||
defer cancel()
|
||||
|
||||
w.Header().Set("Content-Type", "text/event-stream")
|
||||
w.Header().Set("Cache-Control", "no-cache")
|
||||
w.Header().Set("Connection", "keep-alive")
|
||||
w.Header().Set("X-Accel-Buffering", "no")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
flusher.Flush()
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-r.Context().Done():
|
||||
return
|
||||
case m, ok := <-ch:
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
writeNotificationSSE(w, flusher, m)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// writeNotificationSSE 把一条通知序列化为 SSE 帧并 flush。
|
||||
func writeNotificationSSE(w http.ResponseWriter, f http.Flusher, m Message) {
|
||||
dto := notificationDTO{
|
||||
ID: m.ID.String(),
|
||||
Topic: m.Topic,
|
||||
Severity: string(m.Severity),
|
||||
Title: m.Title,
|
||||
Body: m.Body,
|
||||
Link: m.Link,
|
||||
Metadata: m.Metadata,
|
||||
CreatedAt: m.CreatedAt.UTC().Format(time.RFC3339),
|
||||
}
|
||||
if m.ReadAt != nil {
|
||||
s := m.ReadAt.UTC().Format(time.RFC3339)
|
||||
dto.ReadAt = &s
|
||||
}
|
||||
data, _ := json.Marshal(dto)
|
||||
fmt.Fprintf(w, "event: notification\ndata: %s\n\n", data)
|
||||
f.Flush()
|
||||
}
|
||||
|
||||
// notificationDTO 是 inbox 单条通知的对外 JSON 形态。created_at/read_at
|
||||
// 统一序列化为 RFC3339 字符串;read_at 为 nil 时省略(omitempty)。
|
||||
type notificationDTO struct {
|
||||
|
||||
@@ -0,0 +1,141 @@
|
||||
// Package notify 内的 notifier_email.go 实现 EmailNotifier:通过 SMTP(net/smtp)
|
||||
// 把通知投递为邮件。它实现 Notifier 接口,由 Dispatcher 在配置启用时注册。
|
||||
//
|
||||
// 投递前置条件(任一不满足则静默跳过,返回 nil——per-channel 失败/跳过对
|
||||
// Dispatcher 非致命):
|
||||
// - 全局 cfg.Enabled 为 true;
|
||||
// - 用户 notification_prefs.email_enabled 为 true;
|
||||
// - 消息 severity 达到用户 min_severity;
|
||||
// - 能解析出该用户的收件邮箱。
|
||||
//
|
||||
// SMTP 调用经 transport 抽象注入,测试用 fake transport 断言收件人/正文形态,
|
||||
// 绝不真的发邮件;生产用 smtpTransport(net/smtp.SendMail)。
|
||||
package notify
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"net/smtp"
|
||||
"strings"
|
||||
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
// EmailConfig 是 EmailNotifier 的配置,对应 cfg.Notify.Email。
|
||||
type EmailConfig struct {
|
||||
Enabled bool
|
||||
SMTPHost string
|
||||
Port int
|
||||
From string
|
||||
Username string
|
||||
Password string
|
||||
}
|
||||
|
||||
// addr 返回 "host:port" 形式的 SMTP 地址。
|
||||
func (c EmailConfig) addr() string {
|
||||
return fmt.Sprintf("%s:%d", c.SMTPHost, c.Port)
|
||||
}
|
||||
|
||||
// EmailLookup 把 userID 解析为收件邮箱。由 user.Service 适配注入,避免 notify
|
||||
// 反向依赖 user 包造成的循环导入。
|
||||
type EmailLookup interface {
|
||||
EmailForUser(ctx context.Context, userID uuid.UUID) (string, error)
|
||||
}
|
||||
|
||||
// SMTPTransport 抽象一次 SMTP 发送,便于测试注入 fake。参数与 net/smtp.SendMail
|
||||
// 对齐:addr 形如 "host:port",auth 可为 nil(无认证),from 为发件人,to 为收件人
|
||||
// 列表,msg 为完整 RFC 822 报文(含头)。
|
||||
type SMTPTransport interface {
|
||||
Send(addr string, auth smtp.Auth, from string, to []string, msg []byte) error
|
||||
}
|
||||
|
||||
// smtpTransport 是 SMTPTransport 的生产实现,直接委托 net/smtp.SendMail。
|
||||
type smtpTransport struct{}
|
||||
|
||||
func (smtpTransport) Send(addr string, auth smtp.Auth, from string, to []string, msg []byte) error {
|
||||
return smtp.SendMail(addr, auth, from, to, msg)
|
||||
}
|
||||
|
||||
// EmailNotifier 通过 SMTP 投递邮件,实现 Notifier。
|
||||
type EmailNotifier struct {
|
||||
cfg EmailConfig
|
||||
prefs PrefsRepository
|
||||
lookup EmailLookup
|
||||
tr SMTPTransport
|
||||
log *slog.Logger
|
||||
}
|
||||
|
||||
// NewEmailNotifier 构造 EmailNotifier。tr 为 nil 时用生产 net/smtp 实现;
|
||||
// log 为 nil 时回落到 slog.Default。
|
||||
func NewEmailNotifier(cfg EmailConfig, prefs PrefsRepository, lookup EmailLookup, tr SMTPTransport, log *slog.Logger) *EmailNotifier {
|
||||
if tr == nil {
|
||||
tr = smtpTransport{}
|
||||
}
|
||||
if log == nil {
|
||||
log = slog.Default()
|
||||
}
|
||||
return &EmailNotifier{cfg: cfg, prefs: prefs, lookup: lookup, tr: tr, log: log}
|
||||
}
|
||||
|
||||
// Name 返回 ChannelEmail。
|
||||
func (n *EmailNotifier) Name() Channel { return ChannelEmail }
|
||||
|
||||
// Send 在满足全部前置条件时发送一封邮件;任一条件不满足静默返回 nil。
|
||||
// SMTP 调用失败返回错误(Dispatcher 仅记日志,不影响其它通道)。
|
||||
func (n *EmailNotifier) Send(ctx context.Context, msg Message) error {
|
||||
if !n.cfg.Enabled {
|
||||
return nil
|
||||
}
|
||||
p, err := n.prefs.Get(ctx, msg.UserID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !p.EmailEnabled {
|
||||
return nil
|
||||
}
|
||||
if !meetsMinSeverity(msg.Severity, p.MinSeverity) {
|
||||
return nil
|
||||
}
|
||||
to, err := n.lookup.EmailForUser(ctx, msg.UserID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if strings.TrimSpace(to) == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
var auth smtp.Auth
|
||||
if n.cfg.Username != "" {
|
||||
auth = smtp.PlainAuth("", n.cfg.Username, n.cfg.Password, n.cfg.SMTPHost)
|
||||
}
|
||||
body := buildEmailMessage(n.cfg.From, to, msg)
|
||||
if err := n.tr.Send(n.cfg.addr(), auth, n.cfg.From, []string{to}, body); err != nil {
|
||||
return fmt.Errorf("smtp send: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// buildEmailMessage 组装一封最小 RFC 822 纯文本邮件报文(含 From/To/Subject 头)。
|
||||
// 主题前缀用 severity 便于客户端过滤;正文带标题、正文与可选链接。
|
||||
func buildEmailMessage(from, to string, msg Message) []byte {
|
||||
subject := msg.Title
|
||||
if subject == "" {
|
||||
subject = msg.Topic
|
||||
}
|
||||
var b strings.Builder
|
||||
fmt.Fprintf(&b, "From: %s\r\n", from)
|
||||
fmt.Fprintf(&b, "To: %s\r\n", to)
|
||||
fmt.Fprintf(&b, "Subject: [%s] %s\r\n", strings.ToUpper(string(msg.Severity)), subject)
|
||||
b.WriteString("MIME-Version: 1.0\r\n")
|
||||
b.WriteString("Content-Type: text/plain; charset=\"UTF-8\"\r\n")
|
||||
b.WriteString("\r\n")
|
||||
if msg.Body != "" {
|
||||
b.WriteString(msg.Body)
|
||||
b.WriteString("\r\n")
|
||||
}
|
||||
if msg.Link != "" {
|
||||
fmt.Fprintf(&b, "\r\n%s\r\n", msg.Link)
|
||||
}
|
||||
return []byte(b.String())
|
||||
}
|
||||
@@ -0,0 +1,195 @@
|
||||
package notify
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"net/smtp"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// fakePrefsRepo 是 PrefsRepository 的内存实现,供 notifier 测试断言偏好过滤。
|
||||
type fakePrefsRepo struct {
|
||||
mu sync.Mutex
|
||||
m map[uuid.UUID]Prefs
|
||||
err error
|
||||
}
|
||||
|
||||
func newFakePrefs() *fakePrefsRepo { return &fakePrefsRepo{m: map[uuid.UUID]Prefs{}} }
|
||||
|
||||
func (f *fakePrefsRepo) set(p Prefs) {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
f.m[p.UserID] = p
|
||||
}
|
||||
|
||||
func (f *fakePrefsRepo) Get(_ context.Context, userID uuid.UUID) (Prefs, error) {
|
||||
if f.err != nil {
|
||||
return Prefs{}, f.err
|
||||
}
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
if p, ok := f.m[userID]; ok {
|
||||
return p, nil
|
||||
}
|
||||
return DefaultPrefs(userID), nil
|
||||
}
|
||||
|
||||
func (f *fakePrefsRepo) Upsert(_ context.Context, p Prefs) error {
|
||||
if f.err != nil {
|
||||
return f.err
|
||||
}
|
||||
f.set(p)
|
||||
return nil
|
||||
}
|
||||
|
||||
// captureTransport 记录最后一次 SMTP 发送,便于断言收件人 / 报文形态。
|
||||
type captureTransport struct {
|
||||
mu sync.Mutex
|
||||
calls int
|
||||
addr string
|
||||
from string
|
||||
to []string
|
||||
msg []byte
|
||||
auth smtp.Auth
|
||||
}
|
||||
|
||||
func (c *captureTransport) Send(addr string, auth smtp.Auth, from string, to []string, msg []byte) error {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
c.calls++
|
||||
c.addr = addr
|
||||
c.from = from
|
||||
c.to = to
|
||||
c.msg = msg
|
||||
c.auth = auth
|
||||
return nil
|
||||
}
|
||||
|
||||
// staticLookup 总是返回固定收件邮箱。
|
||||
type staticLookup struct {
|
||||
email string
|
||||
err error
|
||||
}
|
||||
|
||||
func (s staticLookup) EmailForUser(context.Context, uuid.UUID) (string, error) {
|
||||
return s.email, s.err
|
||||
}
|
||||
|
||||
func emailCfg() EmailConfig {
|
||||
return EmailConfig{Enabled: true, SMTPHost: "smtp.test", Port: 587, From: "noreply@test"}
|
||||
}
|
||||
|
||||
func TestEmailNotifier_SendsWhenEnabledAndSeverityMet(t *testing.T) {
|
||||
uid := uuid.New()
|
||||
prefs := newFakePrefs()
|
||||
prefs.set(Prefs{UserID: uid, EmailEnabled: true, MinSeverity: SeverityWarning})
|
||||
tr := &captureTransport{}
|
||||
n := NewEmailNotifier(emailCfg(), prefs, staticLookup{email: "user@test"}, tr, newTestLogger())
|
||||
|
||||
err := n.Send(context.Background(), Message{
|
||||
UserID: uid, Topic: "acp.permission_pending", Severity: SeverityWarning,
|
||||
Title: "Approval needed", Body: "session X wants to run rm", Link: "/approvals",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, 1, tr.calls)
|
||||
require.Equal(t, "smtp.test:587", tr.addr)
|
||||
require.Equal(t, "noreply@test", tr.from)
|
||||
require.Equal(t, []string{"user@test"}, tr.to)
|
||||
require.Nil(t, tr.auth, "无 username 时不应携带 auth")
|
||||
|
||||
body := string(tr.msg)
|
||||
require.Contains(t, body, "To: user@test")
|
||||
require.Contains(t, body, "Subject: [WARNING] Approval needed")
|
||||
require.Contains(t, body, "session X wants to run rm")
|
||||
require.Contains(t, body, "/approvals")
|
||||
}
|
||||
|
||||
func TestEmailNotifier_DisabledChannelSkipped(t *testing.T) {
|
||||
uid := uuid.New()
|
||||
prefs := newFakePrefs()
|
||||
prefs.set(Prefs{UserID: uid, EmailEnabled: false, MinSeverity: SeverityInfo})
|
||||
tr := &captureTransport{}
|
||||
n := NewEmailNotifier(emailCfg(), prefs, staticLookup{email: "user@test"}, tr, newTestLogger())
|
||||
|
||||
require.NoError(t, n.Send(context.Background(), Message{UserID: uid, Severity: SeverityError}))
|
||||
require.Equal(t, 0, tr.calls, "email_enabled=false 应跳过发送")
|
||||
}
|
||||
|
||||
func TestEmailNotifier_BelowMinSeveritySkipped(t *testing.T) {
|
||||
uid := uuid.New()
|
||||
prefs := newFakePrefs()
|
||||
prefs.set(Prefs{UserID: uid, EmailEnabled: true, MinSeverity: SeverityError})
|
||||
tr := &captureTransport{}
|
||||
n := NewEmailNotifier(emailCfg(), prefs, staticLookup{email: "user@test"}, tr, newTestLogger())
|
||||
|
||||
require.NoError(t, n.Send(context.Background(), Message{UserID: uid, Severity: SeverityWarning}))
|
||||
require.Equal(t, 0, tr.calls, "低于 min_severity 应跳过")
|
||||
|
||||
require.NoError(t, n.Send(context.Background(), Message{UserID: uid, Severity: SeverityError}))
|
||||
require.Equal(t, 1, tr.calls, "达到 min_severity 应发送")
|
||||
}
|
||||
|
||||
func TestEmailNotifier_GloballyDisabledSkipped(t *testing.T) {
|
||||
uid := uuid.New()
|
||||
prefs := newFakePrefs()
|
||||
prefs.set(Prefs{UserID: uid, EmailEnabled: true, MinSeverity: SeverityInfo})
|
||||
tr := &captureTransport{}
|
||||
cfg := emailCfg()
|
||||
cfg.Enabled = false
|
||||
n := NewEmailNotifier(cfg, prefs, staticLookup{email: "user@test"}, tr, newTestLogger())
|
||||
|
||||
require.NoError(t, n.Send(context.Background(), Message{UserID: uid, Severity: SeverityError}))
|
||||
require.Equal(t, 0, tr.calls)
|
||||
}
|
||||
|
||||
func TestEmailNotifier_NoRecipientSkipped(t *testing.T) {
|
||||
uid := uuid.New()
|
||||
prefs := newFakePrefs()
|
||||
prefs.set(Prefs{UserID: uid, EmailEnabled: true, MinSeverity: SeverityInfo})
|
||||
tr := &captureTransport{}
|
||||
n := NewEmailNotifier(emailCfg(), prefs, staticLookup{email: " "}, tr, newTestLogger())
|
||||
|
||||
require.NoError(t, n.Send(context.Background(), Message{UserID: uid, Severity: SeverityError}))
|
||||
require.Equal(t, 0, tr.calls, "空收件人应静默跳过")
|
||||
}
|
||||
|
||||
func TestEmailNotifier_UsesPlainAuthWhenUsernameSet(t *testing.T) {
|
||||
uid := uuid.New()
|
||||
prefs := newFakePrefs()
|
||||
prefs.set(Prefs{UserID: uid, EmailEnabled: true, MinSeverity: SeverityInfo})
|
||||
tr := &captureTransport{}
|
||||
cfg := emailCfg()
|
||||
cfg.Username = "u"
|
||||
cfg.Password = "p"
|
||||
n := NewEmailNotifier(cfg, prefs, staticLookup{email: "user@test"}, tr, newTestLogger())
|
||||
|
||||
require.NoError(t, n.Send(context.Background(), Message{UserID: uid, Severity: SeverityError, Title: "x"}))
|
||||
require.Equal(t, 1, tr.calls)
|
||||
require.NotNil(t, tr.auth, "配置 username 时应携带 PlainAuth")
|
||||
}
|
||||
|
||||
func TestEmailNotifier_NameIsEmailChannel(t *testing.T) {
|
||||
n := NewEmailNotifier(emailCfg(), newFakePrefs(), staticLookup{}, &captureTransport{}, newTestLogger())
|
||||
require.Equal(t, ChannelEmail, n.Name())
|
||||
}
|
||||
|
||||
func TestBuildEmailMessage_Headers(t *testing.T) {
|
||||
body := string(buildEmailMessage("from@test", "to@test", Message{
|
||||
Topic: "t", Severity: SeverityError, Title: "Title", Body: "Body", Link: "http://l",
|
||||
}))
|
||||
require.True(t, strings.HasPrefix(body, "From: from@test\r\n"))
|
||||
require.Contains(t, body, "Subject: [ERROR] Title")
|
||||
require.Contains(t, body, "Content-Type: text/plain")
|
||||
}
|
||||
|
||||
func TestEmailNotifier_PrefsErrorPropagates(t *testing.T) {
|
||||
prefs := newFakePrefs()
|
||||
prefs.err = errors.New("db down")
|
||||
n := NewEmailNotifier(emailCfg(), prefs, staticLookup{email: "u@t"}, &captureTransport{}, newTestLogger())
|
||||
require.Error(t, n.Send(context.Background(), Message{UserID: uuid.New(), Severity: SeverityError}))
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
// Package notify 内的 notifier_im.go 实现 IMNotifier:以通用 webhook POST
|
||||
// (Slack incoming-webhook 兼容形态)把通知投递到用户配置的 IM 地址。
|
||||
//
|
||||
// 投递地址是 per-user 的 im_webhook_url,以密文落库于 notification_prefs
|
||||
// (im_webhook_url_enc);发送时经 Decryptor 解密,明文 URL 不在领域内存层
|
||||
// 长期驻留。投递前置条件与 EmailNotifier 对称:
|
||||
// - 全局 cfg.Enabled 为 true;
|
||||
// - 用户 im_enabled 为 true 且配置了 webhook URL;
|
||||
// - 消息 severity 达到用户 min_severity。
|
||||
//
|
||||
// 任一不满足静默返回 nil;HTTP/解密失败返回错误(Dispatcher 仅记日志)。
|
||||
package notify
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// IMConfig 是 IMNotifier 的全局配置,对应 cfg.Notify.IM。
|
||||
type IMConfig struct {
|
||||
Enabled bool
|
||||
}
|
||||
|
||||
// Decryptor 抽象 im_webhook_url 密文的解密。由 crypto.Encryptor 满足
|
||||
// (Decrypt([]byte) ([]byte, error)),以窄接口注入避免 notify 依赖 crypto 包。
|
||||
type Decryptor interface {
|
||||
Decrypt(ciphertext []byte) ([]byte, error)
|
||||
}
|
||||
|
||||
// httpDoer 抽象单次 HTTP 调用,便于测试注入 fake transport。
|
||||
type httpDoer interface {
|
||||
Do(req *http.Request) (*http.Response, error)
|
||||
}
|
||||
|
||||
// IMNotifier 通过 webhook POST 投递 IM 消息,实现 Notifier。
|
||||
type IMNotifier struct {
|
||||
cfg IMConfig
|
||||
prefs PrefsRepository
|
||||
dec Decryptor
|
||||
cl httpDoer
|
||||
log *slog.Logger
|
||||
}
|
||||
|
||||
// NewIMNotifier 构造 IMNotifier。cl 为 nil 时用默认 http.Client(含 30s 超时
|
||||
// 由 Dispatcher 的 sendCtx 兜底);log 为 nil 时回落 slog.Default。
|
||||
func NewIMNotifier(cfg IMConfig, prefs PrefsRepository, dec Decryptor, cl httpDoer, log *slog.Logger) *IMNotifier {
|
||||
if cl == nil {
|
||||
cl = http.DefaultClient
|
||||
}
|
||||
if log == nil {
|
||||
log = slog.Default()
|
||||
}
|
||||
return &IMNotifier{cfg: cfg, prefs: prefs, dec: dec, cl: cl, log: log}
|
||||
}
|
||||
|
||||
// Name 返回 ChannelIM。
|
||||
func (n *IMNotifier) Name() Channel { return ChannelIM }
|
||||
|
||||
// Send 在满足全部前置条件时向用户的 IM webhook POST 一条消息。
|
||||
func (n *IMNotifier) Send(ctx context.Context, msg Message) error {
|
||||
if !n.cfg.Enabled {
|
||||
return nil
|
||||
}
|
||||
p, err := n.prefs.Get(ctx, msg.UserID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !p.IMEnabled || len(p.ImWebhookURLEnc) == 0 {
|
||||
return nil
|
||||
}
|
||||
if !meetsMinSeverity(msg.Severity, p.MinSeverity) {
|
||||
return nil
|
||||
}
|
||||
urlBytes, err := n.dec.Decrypt(p.ImWebhookURLEnc)
|
||||
if err != nil {
|
||||
return fmt.Errorf("decrypt im webhook url: %w", err)
|
||||
}
|
||||
url := strings.TrimSpace(string(urlBytes))
|
||||
if url == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
payload, err := json.Marshal(buildIMPayload(msg))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(payload))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
resp, err := n.cl.Do(req)
|
||||
if err != nil {
|
||||
return fmt.Errorf("im webhook post: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
_, _ = io.Copy(io.Discard, resp.Body)
|
||||
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||
return fmt.Errorf("im webhook status %d", resp.StatusCode)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// buildIMPayload 把 Message 渲染为 Slack incoming-webhook 兼容的 JSON 体。
|
||||
// 主要字段是 text(含级别、标题、正文、可选链接);附加结构化字段方便富客户端。
|
||||
func buildIMPayload(msg Message) map[string]any {
|
||||
title := msg.Title
|
||||
if title == "" {
|
||||
title = msg.Topic
|
||||
}
|
||||
var b strings.Builder
|
||||
fmt.Fprintf(&b, "[%s] %s", strings.ToUpper(string(msg.Severity)), title)
|
||||
if msg.Body != "" {
|
||||
fmt.Fprintf(&b, "\n%s", msg.Body)
|
||||
}
|
||||
if msg.Link != "" {
|
||||
fmt.Fprintf(&b, "\n%s", msg.Link)
|
||||
}
|
||||
return map[string]any{
|
||||
"text": b.String(),
|
||||
"topic": msg.Topic,
|
||||
"severity": string(msg.Severity),
|
||||
"title": title,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,177 @@
|
||||
package notify
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// reverseDecryptor 是测试用 Decryptor:把密文逐字节取反作为 "解密",与
|
||||
// reverseEncrypt 配对,确保 IMNotifier 走解密路径而非裸读明文。
|
||||
type reverseDecryptor struct{ err error }
|
||||
|
||||
func reverseBytes(b []byte) []byte {
|
||||
out := make([]byte, len(b))
|
||||
for i, c := range b {
|
||||
out[i] = ^c
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func (d reverseDecryptor) Decrypt(ct []byte) ([]byte, error) {
|
||||
if d.err != nil {
|
||||
return nil, d.err
|
||||
}
|
||||
return reverseBytes(ct), nil
|
||||
}
|
||||
|
||||
// captureDoer 记录最后一次 HTTP 请求并返回固定响应码。
|
||||
type captureDoer struct {
|
||||
mu sync.Mutex
|
||||
calls int
|
||||
url string
|
||||
body []byte
|
||||
ctype string
|
||||
status int
|
||||
err error
|
||||
}
|
||||
|
||||
func (c *captureDoer) Do(req *http.Request) (*http.Response, error) {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
if c.err != nil {
|
||||
return nil, c.err
|
||||
}
|
||||
c.calls++
|
||||
c.url = req.URL.String()
|
||||
c.ctype = req.Header.Get("Content-Type")
|
||||
if req.Body != nil {
|
||||
c.body, _ = io.ReadAll(req.Body)
|
||||
}
|
||||
st := c.status
|
||||
if st == 0 {
|
||||
st = http.StatusOK
|
||||
}
|
||||
return &http.Response{StatusCode: st, Body: io.NopCloser(strings.NewReader("ok"))}, nil
|
||||
}
|
||||
|
||||
func imPrefs(uid uuid.UUID, enabled bool, sev Severity, webhookEnc []byte) Prefs {
|
||||
return Prefs{UserID: uid, IMEnabled: enabled, MinSeverity: sev, ImWebhookURLEnc: webhookEnc}
|
||||
}
|
||||
|
||||
func TestIMNotifier_PostsDecryptedWebhookWithSlackPayload(t *testing.T) {
|
||||
uid := uuid.New()
|
||||
enc := reverseBytes([]byte("https://hooks.slack.test/abc"))
|
||||
prefs := newFakePrefs()
|
||||
prefs.set(imPrefs(uid, true, SeverityWarning, enc))
|
||||
doer := &captureDoer{}
|
||||
n := NewIMNotifier(IMConfig{Enabled: true}, prefs, reverseDecryptor{}, doer, newTestLogger())
|
||||
|
||||
err := n.Send(context.Background(), Message{
|
||||
UserID: uid, Topic: "acp.permission_pending", Severity: SeverityWarning,
|
||||
Title: "Approval needed", Body: "run rm", Link: "/approvals",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, 1, doer.calls)
|
||||
require.Equal(t, "https://hooks.slack.test/abc", doer.url, "应 POST 到解密后的 URL")
|
||||
require.Equal(t, "application/json", doer.ctype)
|
||||
|
||||
var payload map[string]any
|
||||
require.NoError(t, json.Unmarshal(doer.body, &payload))
|
||||
require.Equal(t, "acp.permission_pending", payload["topic"])
|
||||
require.Equal(t, "warning", payload["severity"])
|
||||
require.Equal(t, "Approval needed", payload["title"])
|
||||
text, _ := payload["text"].(string)
|
||||
require.Contains(t, text, "[WARNING] Approval needed")
|
||||
require.Contains(t, text, "run rm")
|
||||
require.Contains(t, text, "/approvals")
|
||||
}
|
||||
|
||||
func TestIMNotifier_DisabledChannelSkipped(t *testing.T) {
|
||||
uid := uuid.New()
|
||||
prefs := newFakePrefs()
|
||||
prefs.set(imPrefs(uid, false, SeverityInfo, reverseBytes([]byte("https://x"))))
|
||||
doer := &captureDoer{}
|
||||
n := NewIMNotifier(IMConfig{Enabled: true}, prefs, reverseDecryptor{}, doer, newTestLogger())
|
||||
|
||||
require.NoError(t, n.Send(context.Background(), Message{UserID: uid, Severity: SeverityError}))
|
||||
require.Equal(t, 0, doer.calls)
|
||||
}
|
||||
|
||||
func TestIMNotifier_NoWebhookSkipped(t *testing.T) {
|
||||
uid := uuid.New()
|
||||
prefs := newFakePrefs()
|
||||
prefs.set(imPrefs(uid, true, SeverityInfo, nil))
|
||||
doer := &captureDoer{}
|
||||
n := NewIMNotifier(IMConfig{Enabled: true}, prefs, reverseDecryptor{}, doer, newTestLogger())
|
||||
|
||||
require.NoError(t, n.Send(context.Background(), Message{UserID: uid, Severity: SeverityError}))
|
||||
require.Equal(t, 0, doer.calls, "未配置 webhook 应跳过")
|
||||
}
|
||||
|
||||
func TestIMNotifier_BelowMinSeveritySkipped(t *testing.T) {
|
||||
uid := uuid.New()
|
||||
prefs := newFakePrefs()
|
||||
prefs.set(imPrefs(uid, true, SeverityError, reverseBytes([]byte("https://x"))))
|
||||
doer := &captureDoer{}
|
||||
n := NewIMNotifier(IMConfig{Enabled: true}, prefs, reverseDecryptor{}, doer, newTestLogger())
|
||||
|
||||
require.NoError(t, n.Send(context.Background(), Message{UserID: uid, Severity: SeverityWarning}))
|
||||
require.Equal(t, 0, doer.calls)
|
||||
}
|
||||
|
||||
func TestIMNotifier_GloballyDisabledSkipped(t *testing.T) {
|
||||
uid := uuid.New()
|
||||
prefs := newFakePrefs()
|
||||
prefs.set(imPrefs(uid, true, SeverityInfo, reverseBytes([]byte("https://x"))))
|
||||
doer := &captureDoer{}
|
||||
n := NewIMNotifier(IMConfig{Enabled: false}, prefs, reverseDecryptor{}, doer, newTestLogger())
|
||||
|
||||
require.NoError(t, n.Send(context.Background(), Message{UserID: uid, Severity: SeverityError}))
|
||||
require.Equal(t, 0, doer.calls)
|
||||
}
|
||||
|
||||
func TestIMNotifier_DecryptErrorReturnsErr(t *testing.T) {
|
||||
uid := uuid.New()
|
||||
prefs := newFakePrefs()
|
||||
prefs.set(imPrefs(uid, true, SeverityInfo, []byte("garbage")))
|
||||
doer := &captureDoer{}
|
||||
n := NewIMNotifier(IMConfig{Enabled: true}, prefs, reverseDecryptor{err: errors.New("bad key")}, doer, newTestLogger())
|
||||
|
||||
require.Error(t, n.Send(context.Background(), Message{UserID: uid, Severity: SeverityError}))
|
||||
require.Equal(t, 0, doer.calls)
|
||||
}
|
||||
|
||||
func TestIMNotifier_Non2xxReturnsErr(t *testing.T) {
|
||||
uid := uuid.New()
|
||||
prefs := newFakePrefs()
|
||||
prefs.set(imPrefs(uid, true, SeverityInfo, reverseBytes([]byte("https://x"))))
|
||||
doer := &captureDoer{status: http.StatusInternalServerError}
|
||||
n := NewIMNotifier(IMConfig{Enabled: true}, prefs, reverseDecryptor{}, doer, newTestLogger())
|
||||
|
||||
require.Error(t, n.Send(context.Background(), Message{UserID: uid, Severity: SeverityError}))
|
||||
require.Equal(t, 1, doer.calls)
|
||||
}
|
||||
|
||||
func TestIMNotifier_HTTPErrorReturnsErr(t *testing.T) {
|
||||
uid := uuid.New()
|
||||
prefs := newFakePrefs()
|
||||
prefs.set(imPrefs(uid, true, SeverityInfo, reverseBytes([]byte("https://x"))))
|
||||
doer := &captureDoer{err: errors.New("dial fail")}
|
||||
n := NewIMNotifier(IMConfig{Enabled: true}, prefs, reverseDecryptor{}, doer, newTestLogger())
|
||||
|
||||
require.Error(t, n.Send(context.Background(), Message{UserID: uid, Severity: SeverityError}))
|
||||
}
|
||||
|
||||
func TestIMNotifier_NameIsIMChannel(t *testing.T) {
|
||||
n := NewIMNotifier(IMConfig{Enabled: true}, newFakePrefs(), reverseDecryptor{}, &captureDoer{}, newTestLogger())
|
||||
require.Equal(t, ChannelIM, n.Name())
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
// 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)
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
// Package notify 内的 prefs_handler.go 实现 per-user 通知偏好的 HTTP 接口:
|
||||
//
|
||||
// GET /api/v1/notifications/prefs → 当前用户的偏好(im_webhook_url 不回显明文,
|
||||
// 仅返回 im_webhook_url_set 布尔标记是否已配置)。
|
||||
// PUT /api/v1/notifications/prefs → 整体更新偏好。im_webhook_url 提供非空字符串
|
||||
// 则加密落库;提供空字符串则清除;字段省略则保留原值。
|
||||
//
|
||||
// im_webhook_url 经 Encryptor 加密为 im_webhook_url_enc BYTEA。偏好只在 Handler
|
||||
// 注入了 prefsRepo + enc 后才挂载(WithPrefs),未配置 crypto 时该路由不暴露。
|
||||
package notify
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/yan1h/agent-coding-workflow/internal/infra/errs"
|
||||
httpx "github.com/yan1h/agent-coding-workflow/internal/transport/http"
|
||||
"github.com/yan1h/agent-coding-workflow/internal/transport/http/middleware"
|
||||
)
|
||||
|
||||
// prefsDTO 是偏好的对外 JSON 形态。im_webhook_url 绝不回显明文:读取时只暴露
|
||||
// im_webhook_url_set 标记是否已配置,写入时通过单独的可空字段提交。
|
||||
type prefsDTO struct {
|
||||
EmailEnabled bool `json:"email_enabled"`
|
||||
IMEnabled bool `json:"im_enabled"`
|
||||
IMWebhookURLSet bool `json:"im_webhook_url_set"`
|
||||
MinSeverity string `json:"min_severity"`
|
||||
}
|
||||
|
||||
// prefsUpdateReq 是 PUT 请求体。IMWebhookURL 用指针区分三态:
|
||||
// - nil(字段省略) → 保留原密文不变;
|
||||
// - 指向空字符串 "" → 清除已配置的 webhook;
|
||||
// - 指向非空字符串 → 加密为新密文。
|
||||
type prefsUpdateReq struct {
|
||||
EmailEnabled *bool `json:"email_enabled"`
|
||||
IMEnabled *bool `json:"im_enabled"`
|
||||
IMWebhookURL *string `json:"im_webhook_url"`
|
||||
MinSeverity *string `json:"min_severity"`
|
||||
}
|
||||
|
||||
// getPrefs 处理 GET /api/v1/notifications/prefs。
|
||||
func (h *Handler) getPrefs(w http.ResponseWriter, r *http.Request) {
|
||||
uid, ok := middleware.UserIDFromContext(r.Context())
|
||||
if !ok {
|
||||
httpx.WriteError(w, middleware.RequestIDFromContext(r.Context()),
|
||||
errs.New(errs.CodeUnauthorized, "未认证"))
|
||||
return
|
||||
}
|
||||
p, err := h.prefs.Get(r.Context(), uid)
|
||||
if err != nil {
|
||||
httpx.WriteError(w, middleware.RequestIDFromContext(r.Context()), err)
|
||||
return
|
||||
}
|
||||
httpx.WriteJSON(w, http.StatusOK, prefsDTO{
|
||||
EmailEnabled: p.EmailEnabled,
|
||||
IMEnabled: p.IMEnabled,
|
||||
IMWebhookURLSet: len(p.ImWebhookURLEnc) > 0,
|
||||
MinSeverity: string(p.MinSeverity),
|
||||
})
|
||||
}
|
||||
|
||||
// putPrefs 处理 PUT /api/v1/notifications/prefs。
|
||||
func (h *Handler) putPrefs(w http.ResponseWriter, r *http.Request) {
|
||||
uid, ok := middleware.UserIDFromContext(r.Context())
|
||||
if !ok {
|
||||
httpx.WriteError(w, middleware.RequestIDFromContext(r.Context()),
|
||||
errs.New(errs.CodeUnauthorized, "未认证"))
|
||||
return
|
||||
}
|
||||
var req prefsUpdateReq
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
httpx.WriteError(w, middleware.RequestIDFromContext(r.Context()),
|
||||
errs.New(errs.CodeInvalidInput, "请求体格式错误"))
|
||||
return
|
||||
}
|
||||
|
||||
cur, err := h.prefs.Get(r.Context(), uid)
|
||||
if err != nil {
|
||||
httpx.WriteError(w, middleware.RequestIDFromContext(r.Context()), err)
|
||||
return
|
||||
}
|
||||
if req.EmailEnabled != nil {
|
||||
cur.EmailEnabled = *req.EmailEnabled
|
||||
}
|
||||
if req.IMEnabled != nil {
|
||||
cur.IMEnabled = *req.IMEnabled
|
||||
}
|
||||
if req.MinSeverity != nil {
|
||||
sev := Severity(strings.TrimSpace(*req.MinSeverity))
|
||||
if !validSeverity(sev) {
|
||||
httpx.WriteError(w, middleware.RequestIDFromContext(r.Context()),
|
||||
errs.New(errs.CodeInvalidInput, "min_severity 取值非法(info|warning|error)"))
|
||||
return
|
||||
}
|
||||
cur.MinSeverity = sev
|
||||
}
|
||||
if req.IMWebhookURL != nil {
|
||||
url := strings.TrimSpace(*req.IMWebhookURL)
|
||||
if url == "" {
|
||||
cur.ImWebhookURLEnc = nil
|
||||
} else {
|
||||
ct, err := h.enc.Encrypt([]byte(url))
|
||||
if err != nil {
|
||||
httpx.WriteError(w, middleware.RequestIDFromContext(r.Context()),
|
||||
errs.Wrap(err, errs.CodeInternal, "加密 im_webhook_url"))
|
||||
return
|
||||
}
|
||||
cur.ImWebhookURLEnc = ct
|
||||
}
|
||||
}
|
||||
|
||||
if err := h.prefs.Upsert(r.Context(), cur); err != nil {
|
||||
httpx.WriteError(w, middleware.RequestIDFromContext(r.Context()), err)
|
||||
return
|
||||
}
|
||||
httpx.WriteJSON(w, http.StatusOK, prefsDTO{
|
||||
EmailEnabled: cur.EmailEnabled,
|
||||
IMEnabled: cur.IMEnabled,
|
||||
IMWebhookURLSet: len(cur.ImWebhookURLEnc) > 0,
|
||||
MinSeverity: string(cur.MinSeverity),
|
||||
})
|
||||
}
|
||||
|
||||
// validSeverity 报告 s 是否为合法的 min_severity 取值。
|
||||
func validSeverity(s Severity) bool {
|
||||
switch s {
|
||||
case SeverityInfo, SeverityWarning, SeverityError:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,168 @@
|
||||
package notify
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/google/uuid"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
type prefsFakeResolver struct{ uid uuid.UUID }
|
||||
|
||||
func (f *prefsFakeResolver) ResolveSession(_ context.Context, token string) (uuid.UUID, bool, error) {
|
||||
if token == "" {
|
||||
return uuid.Nil, false, nil
|
||||
}
|
||||
return f.uid, true, nil
|
||||
}
|
||||
|
||||
// reverseEncryptor 是测试用 Encryptor:取反作为 "加密",与 reverseDecryptor 配对。
|
||||
type reverseEncryptor struct{}
|
||||
|
||||
func (reverseEncryptor) Encrypt(pt []byte) ([]byte, error) { return reverseBytes(pt), nil }
|
||||
|
||||
func mountPrefs(t *testing.T, uid uuid.UUID, prefs PrefsRepository) http.Handler {
|
||||
t.Helper()
|
||||
r := chi.NewRouter()
|
||||
NewHandler(&noopRepo{}, &prefsFakeResolver{uid: uid}).
|
||||
WithPrefs(prefs, reverseEncryptor{}).Mount(r)
|
||||
return r
|
||||
}
|
||||
|
||||
// noopRepo 满足 inbox Repository(prefs 测试不触达 inbox 端点)。
|
||||
type noopRepo struct{}
|
||||
|
||||
func (noopRepo) Insert(context.Context, Message) error { return nil }
|
||||
func (noopRepo) List(context.Context, uuid.UUID, bool, int) ([]Message, error) {
|
||||
return nil, nil
|
||||
}
|
||||
func (noopRepo) CountUnread(context.Context, uuid.UUID) (int, error) { return 0, nil }
|
||||
func (noopRepo) MarkRead(context.Context, uuid.UUID, uuid.UUID) error { return nil }
|
||||
func (noopRepo) MarkAllRead(context.Context, uuid.UUID) error { return nil }
|
||||
|
||||
func TestPrefsHandler_GetReturnsDefaultsForNewUser(t *testing.T) {
|
||||
uid := uuid.New()
|
||||
h := mountPrefs(t, uid, newFakePrefs())
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/v1/notifications/prefs", nil)
|
||||
req.Header.Set("Authorization", "Bearer tok")
|
||||
rec := httptest.NewRecorder()
|
||||
h.ServeHTTP(rec, req)
|
||||
|
||||
require.Equal(t, http.StatusOK, rec.Code)
|
||||
var dto prefsDTO
|
||||
require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &dto))
|
||||
require.False(t, dto.EmailEnabled)
|
||||
require.False(t, dto.IMEnabled)
|
||||
require.False(t, dto.IMWebhookURLSet)
|
||||
require.Equal(t, "warning", dto.MinSeverity)
|
||||
}
|
||||
|
||||
func TestPrefsHandler_GetRequiresAuth(t *testing.T) {
|
||||
h := mountPrefs(t, uuid.New(), newFakePrefs())
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/v1/notifications/prefs", nil) // no token
|
||||
rec := httptest.NewRecorder()
|
||||
h.ServeHTTP(rec, req)
|
||||
require.Equal(t, http.StatusUnauthorized, rec.Code)
|
||||
}
|
||||
|
||||
func TestPrefsHandler_PutPersistsAndEncryptsWebhook(t *testing.T) {
|
||||
uid := uuid.New()
|
||||
prefs := newFakePrefs()
|
||||
h := mountPrefs(t, uid, prefs)
|
||||
|
||||
body, _ := json.Marshal(prefsUpdateReq{
|
||||
EmailEnabled: boolPtr(true),
|
||||
IMEnabled: boolPtr(true),
|
||||
IMWebhookURL: strPtr("https://hooks.slack.test/xyz"),
|
||||
MinSeverity: strPtr("error"),
|
||||
})
|
||||
req := httptest.NewRequest(http.MethodPut, "/api/v1/notifications/prefs", bytes.NewReader(body))
|
||||
req.Header.Set("Authorization", "Bearer tok")
|
||||
rec := httptest.NewRecorder()
|
||||
h.ServeHTTP(rec, req)
|
||||
|
||||
require.Equal(t, http.StatusOK, rec.Code)
|
||||
var dto prefsDTO
|
||||
require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &dto))
|
||||
require.True(t, dto.EmailEnabled)
|
||||
require.True(t, dto.IMEnabled)
|
||||
require.True(t, dto.IMWebhookURLSet)
|
||||
require.Equal(t, "error", dto.MinSeverity)
|
||||
|
||||
// 落库的 webhook 应为密文(取反),解回应得回原 URL;明文绝不直接存。
|
||||
saved, err := prefs.Get(context.Background(), uid)
|
||||
require.NoError(t, err)
|
||||
require.NotEmpty(t, saved.ImWebhookURLEnc)
|
||||
require.NotEqual(t, []byte("https://hooks.slack.test/xyz"), saved.ImWebhookURLEnc)
|
||||
require.Equal(t, "https://hooks.slack.test/xyz", string(reverseBytes(saved.ImWebhookURLEnc)))
|
||||
}
|
||||
|
||||
func TestPrefsHandler_PutPartialUpdatePreservesWebhook(t *testing.T) {
|
||||
uid := uuid.New()
|
||||
prefs := newFakePrefs()
|
||||
prefs.set(Prefs{UserID: uid, IMEnabled: true, MinSeverity: SeverityWarning,
|
||||
ImWebhookURLEnc: reverseBytes([]byte("https://existing"))})
|
||||
h := mountPrefs(t, uid, prefs)
|
||||
|
||||
// 仅改 email_enabled,省略 im_webhook_url -> 保留原密文。
|
||||
body, _ := json.Marshal(prefsUpdateReq{EmailEnabled: boolPtr(true)})
|
||||
req := httptest.NewRequest(http.MethodPut, "/api/v1/notifications/prefs", bytes.NewReader(body))
|
||||
req.Header.Set("Authorization", "Bearer tok")
|
||||
rec := httptest.NewRecorder()
|
||||
h.ServeHTTP(rec, req)
|
||||
|
||||
require.Equal(t, http.StatusOK, rec.Code)
|
||||
saved, _ := prefs.Get(context.Background(), uid)
|
||||
require.Equal(t, "https://existing", string(reverseBytes(saved.ImWebhookURLEnc)))
|
||||
require.True(t, saved.EmailEnabled)
|
||||
}
|
||||
|
||||
func TestPrefsHandler_PutEmptyWebhookClearsIt(t *testing.T) {
|
||||
uid := uuid.New()
|
||||
prefs := newFakePrefs()
|
||||
prefs.set(Prefs{UserID: uid, IMEnabled: true, MinSeverity: SeverityWarning,
|
||||
ImWebhookURLEnc: reverseBytes([]byte("https://existing"))})
|
||||
h := mountPrefs(t, uid, prefs)
|
||||
|
||||
body, _ := json.Marshal(prefsUpdateReq{IMWebhookURL: strPtr("")})
|
||||
req := httptest.NewRequest(http.MethodPut, "/api/v1/notifications/prefs", bytes.NewReader(body))
|
||||
req.Header.Set("Authorization", "Bearer tok")
|
||||
rec := httptest.NewRecorder()
|
||||
h.ServeHTTP(rec, req)
|
||||
|
||||
require.Equal(t, http.StatusOK, rec.Code)
|
||||
saved, _ := prefs.Get(context.Background(), uid)
|
||||
require.Empty(t, saved.ImWebhookURLEnc)
|
||||
}
|
||||
|
||||
func TestPrefsHandler_PutRejectsBadSeverity(t *testing.T) {
|
||||
uid := uuid.New()
|
||||
h := mountPrefs(t, uid, newFakePrefs())
|
||||
body, _ := json.Marshal(prefsUpdateReq{MinSeverity: strPtr("catastrophic")})
|
||||
req := httptest.NewRequest(http.MethodPut, "/api/v1/notifications/prefs", bytes.NewReader(body))
|
||||
req.Header.Set("Authorization", "Bearer tok")
|
||||
rec := httptest.NewRecorder()
|
||||
h.ServeHTTP(rec, req)
|
||||
require.Equal(t, http.StatusBadRequest, rec.Code)
|
||||
}
|
||||
|
||||
func TestPrefsHandler_NotMountedWithoutEncryptor(t *testing.T) {
|
||||
uid := uuid.New()
|
||||
r := chi.NewRouter()
|
||||
// WithPrefs 未调用 -> /prefs 不应被挂载。
|
||||
NewHandler(&noopRepo{}, &prefsFakeResolver{uid: uid}).Mount(r)
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/v1/notifications/prefs", nil)
|
||||
req.Header.Set("Authorization", "Bearer tok")
|
||||
rec := httptest.NewRecorder()
|
||||
r.ServeHTTP(rec, req)
|
||||
require.Equal(t, http.StatusNotFound, rec.Code)
|
||||
}
|
||||
|
||||
func boolPtr(b bool) *bool { return &b }
|
||||
func strPtr(s string) *string { return &s }
|
||||
@@ -0,0 +1,14 @@
|
||||
-- name: GetNotificationPrefs :one
|
||||
SELECT user_id, email_enabled, im_enabled, im_webhook_url_enc, min_severity, updated_at
|
||||
FROM notification_prefs
|
||||
WHERE user_id = $1;
|
||||
|
||||
-- name: UpsertNotificationPrefs :exec
|
||||
INSERT INTO notification_prefs (user_id, email_enabled, im_enabled, im_webhook_url_enc, min_severity, updated_at)
|
||||
VALUES ($1, $2, $3, $4, $5, now())
|
||||
ON CONFLICT (user_id) DO UPDATE SET
|
||||
email_enabled = EXCLUDED.email_enabled,
|
||||
im_enabled = EXCLUDED.im_enabled,
|
||||
im_webhook_url_enc = EXCLUDED.im_webhook_url_enc,
|
||||
min_severity = EXCLUDED.min_severity,
|
||||
updated_at = now();
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
"net/netip"
|
||||
|
||||
"github.com/jackc/pgx/v5/pgtype"
|
||||
"github.com/pgvector/pgvector-go"
|
||||
)
|
||||
|
||||
type AcpAgentKind struct {
|
||||
@@ -25,6 +26,11 @@ type AcpAgentKind struct {
|
||||
ToolAllowlist []string `json:"tool_allowlist"`
|
||||
ClientType string `json:"client_type"`
|
||||
EncryptedMcpServers []byte `json:"encrypted_mcp_servers"`
|
||||
ModelID pgtype.UUID `json:"model_id"`
|
||||
MaxCostUsd pgtype.Numeric `json:"max_cost_usd"`
|
||||
MaxTokens *int64 `json:"max_tokens"`
|
||||
MaxWallClockSeconds *int32 `json:"max_wall_clock_seconds"`
|
||||
KeyVersion int16 `json:"key_version"`
|
||||
}
|
||||
|
||||
type AcpAgentKindConfigFile struct {
|
||||
@@ -35,6 +41,7 @@ type AcpAgentKindConfigFile struct {
|
||||
UpdatedBy pgtype.UUID `json:"updated_by"`
|
||||
CreatedAt pgtype.Timestamptz `json:"created_at"`
|
||||
UpdatedAt pgtype.Timestamptz `json:"updated_at"`
|
||||
KeyVersion int16 `json:"key_version"`
|
||||
}
|
||||
|
||||
type AcpEvent struct {
|
||||
@@ -64,23 +71,64 @@ type AcpPermissionRequest struct {
|
||||
}
|
||||
|
||||
type AcpSession struct {
|
||||
ID pgtype.UUID `json:"id"`
|
||||
WorkspaceID pgtype.UUID `json:"workspace_id"`
|
||||
ProjectID pgtype.UUID `json:"project_id"`
|
||||
AgentKindID pgtype.UUID `json:"agent_kind_id"`
|
||||
UserID pgtype.UUID `json:"user_id"`
|
||||
IssueID pgtype.UUID `json:"issue_id"`
|
||||
RequirementID pgtype.UUID `json:"requirement_id"`
|
||||
AgentSessionID *string `json:"agent_session_id"`
|
||||
Branch string `json:"branch"`
|
||||
CwdPath string `json:"cwd_path"`
|
||||
IsMainWorktree bool `json:"is_main_worktree"`
|
||||
Status string `json:"status"`
|
||||
Pid *int32 `json:"pid"`
|
||||
ExitCode *int32 `json:"exit_code"`
|
||||
LastError *string `json:"last_error"`
|
||||
StartedAt pgtype.Timestamptz `json:"started_at"`
|
||||
EndedAt pgtype.Timestamptz `json:"ended_at"`
|
||||
ID pgtype.UUID `json:"id"`
|
||||
WorkspaceID pgtype.UUID `json:"workspace_id"`
|
||||
ProjectID pgtype.UUID `json:"project_id"`
|
||||
AgentKindID pgtype.UUID `json:"agent_kind_id"`
|
||||
UserID pgtype.UUID `json:"user_id"`
|
||||
IssueID pgtype.UUID `json:"issue_id"`
|
||||
RequirementID pgtype.UUID `json:"requirement_id"`
|
||||
AgentSessionID *string `json:"agent_session_id"`
|
||||
Branch string `json:"branch"`
|
||||
CwdPath string `json:"cwd_path"`
|
||||
IsMainWorktree bool `json:"is_main_worktree"`
|
||||
Status string `json:"status"`
|
||||
Pid *int32 `json:"pid"`
|
||||
ExitCode *int32 `json:"exit_code"`
|
||||
LastError *string `json:"last_error"`
|
||||
StartedAt pgtype.Timestamptz `json:"started_at"`
|
||||
EndedAt pgtype.Timestamptz `json:"ended_at"`
|
||||
LastStopReason *string `json:"last_stop_reason"`
|
||||
OrchestratorStepID pgtype.UUID `json:"orchestrator_step_id"`
|
||||
PromptTokens int64 `json:"prompt_tokens"`
|
||||
CompletionTokens int64 `json:"completion_tokens"`
|
||||
ThinkingTokens int64 `json:"thinking_tokens"`
|
||||
TotalCostUsd pgtype.Numeric `json:"total_cost_usd"`
|
||||
LastActivityAt pgtype.Timestamptz `json:"last_activity_at"`
|
||||
BudgetMaxCostUsd pgtype.Numeric `json:"budget_max_cost_usd"`
|
||||
BudgetMaxTokens *int64 `json:"budget_max_tokens"`
|
||||
BudgetMaxWallClockSeconds *int32 `json:"budget_max_wall_clock_seconds"`
|
||||
TerminatedReason *string `json:"terminated_reason"`
|
||||
SandboxMode *string `json:"sandbox_mode"`
|
||||
CostUsd pgtype.Numeric `json:"cost_usd"`
|
||||
TokensTotal int64 `json:"tokens_total"`
|
||||
}
|
||||
|
||||
type AcpSessionUsage struct {
|
||||
ID int64 `json:"id"`
|
||||
SessionID pgtype.UUID `json:"session_id"`
|
||||
UserID pgtype.UUID `json:"user_id"`
|
||||
ProjectID pgtype.UUID `json:"project_id"`
|
||||
AgentKindID pgtype.UUID `json:"agent_kind_id"`
|
||||
ModelID pgtype.UUID `json:"model_id"`
|
||||
PromptTokens int64 `json:"prompt_tokens"`
|
||||
CompletionTokens int64 `json:"completion_tokens"`
|
||||
ThinkingTokens int64 `json:"thinking_tokens"`
|
||||
CostUsd pgtype.Numeric `json:"cost_usd"`
|
||||
SourceEventID *int64 `json:"source_event_id"`
|
||||
CreatedAt pgtype.Timestamptz `json:"created_at"`
|
||||
}
|
||||
|
||||
type AcpTurn struct {
|
||||
ID int64 `json:"id"`
|
||||
SessionID pgtype.UUID `json:"session_id"`
|
||||
TurnIndex int32 `json:"turn_index"`
|
||||
PromptRequestID *string `json:"prompt_request_id"`
|
||||
Status string `json:"status"`
|
||||
StopReason *string `json:"stop_reason"`
|
||||
UpdateCount int32 `json:"update_count"`
|
||||
StartedAt pgtype.Timestamptz `json:"started_at"`
|
||||
CompletedAt pgtype.Timestamptz `json:"completed_at"`
|
||||
}
|
||||
|
||||
type AuditLog struct {
|
||||
@@ -94,6 +142,81 @@ type AuditLog struct {
|
||||
CreatedAt pgtype.Timestamptz `json:"created_at"`
|
||||
}
|
||||
|
||||
type ChangeRequest struct {
|
||||
ID pgtype.UUID `json:"id"`
|
||||
ProjectID pgtype.UUID `json:"project_id"`
|
||||
WorkspaceID pgtype.UUID `json:"workspace_id"`
|
||||
RequirementID pgtype.UUID `json:"requirement_id"`
|
||||
IssueID pgtype.UUID `json:"issue_id"`
|
||||
Number int32 `json:"number"`
|
||||
Title string `json:"title"`
|
||||
Description string `json:"description"`
|
||||
SourceBranch string `json:"source_branch"`
|
||||
TargetBranch string `json:"target_branch"`
|
||||
State string `json:"state"`
|
||||
ReviewVerdict string `json:"review_verdict"`
|
||||
CiState string `json:"ci_state"`
|
||||
Provider string `json:"provider"`
|
||||
ExternalID *int64 `json:"external_id"`
|
||||
ExternalUrl string `json:"external_url"`
|
||||
MergeCommitSha string `json:"merge_commit_sha"`
|
||||
ReviewedBy pgtype.UUID `json:"reviewed_by"`
|
||||
ReviewedAt pgtype.Timestamptz `json:"reviewed_at"`
|
||||
CreatedBy pgtype.UUID `json:"created_by"`
|
||||
MergedAt pgtype.Timestamptz `json:"merged_at"`
|
||||
CreatedAt pgtype.Timestamptz `json:"created_at"`
|
||||
UpdatedAt pgtype.Timestamptz `json:"updated_at"`
|
||||
}
|
||||
|
||||
type CodeChunk struct {
|
||||
ID pgtype.UUID `json:"id"`
|
||||
RunID pgtype.UUID `json:"run_id"`
|
||||
WorkspaceID pgtype.UUID `json:"workspace_id"`
|
||||
CommitSha string `json:"commit_sha"`
|
||||
FilePath string `json:"file_path"`
|
||||
Lang *string `json:"lang"`
|
||||
StartLine int32 `json:"start_line"`
|
||||
EndLine int32 `json:"end_line"`
|
||||
Content string `json:"content"`
|
||||
ContentSha []byte `json:"content_sha"`
|
||||
TokenCount *int32 `json:"token_count"`
|
||||
Embedding *pgvector.Vector `json:"embedding"`
|
||||
CreatedAt pgtype.Timestamptz `json:"created_at"`
|
||||
}
|
||||
|
||||
type CodeIndexRun struct {
|
||||
ID pgtype.UUID `json:"id"`
|
||||
WorkspaceID pgtype.UUID `json:"workspace_id"`
|
||||
WorktreeID pgtype.UUID `json:"worktree_id"`
|
||||
Branch string `json:"branch"`
|
||||
CommitSha string `json:"commit_sha"`
|
||||
Status string `json:"status"`
|
||||
EmbeddingEndpointID pgtype.UUID `json:"embedding_endpoint_id"`
|
||||
EmbeddingModel string `json:"embedding_model"`
|
||||
Dims int32 `json:"dims"`
|
||||
FilesIndexed int32 `json:"files_indexed"`
|
||||
ChunksIndexed int32 `json:"chunks_indexed"`
|
||||
Error *string `json:"error"`
|
||||
StartedAt pgtype.Timestamptz `json:"started_at"`
|
||||
FinishedAt pgtype.Timestamptz `json:"finished_at"`
|
||||
CreatedAt pgtype.Timestamptz `json:"created_at"`
|
||||
}
|
||||
|
||||
type CommitSummary struct {
|
||||
ID pgtype.UUID `json:"id"`
|
||||
WorkspaceID pgtype.UUID `json:"workspace_id"`
|
||||
WorktreeID pgtype.UUID `json:"worktree_id"`
|
||||
Branch string `json:"branch"`
|
||||
CommitSha string `json:"commit_sha"`
|
||||
Kind string `json:"kind"`
|
||||
Title *string `json:"title"`
|
||||
BodyMd string `json:"body_md"`
|
||||
Model *string `json:"model"`
|
||||
Status string `json:"status"`
|
||||
Error *string `json:"error"`
|
||||
CreatedAt pgtype.Timestamptz `json:"created_at"`
|
||||
}
|
||||
|
||||
type Conversation struct {
|
||||
ID pgtype.UUID `json:"id"`
|
||||
UserID pgtype.UUID `json:"user_id"`
|
||||
@@ -110,6 +233,14 @@ type Conversation struct {
|
||||
RequirementID pgtype.UUID `json:"requirement_id"`
|
||||
}
|
||||
|
||||
type CryptoKey struct {
|
||||
Version int32 `json:"version"`
|
||||
Provider string `json:"provider"`
|
||||
KeyRef *string `json:"key_ref"`
|
||||
Status string `json:"status"`
|
||||
CreatedAt pgtype.Timestamptz `json:"created_at"`
|
||||
}
|
||||
|
||||
type GitCredential struct {
|
||||
ID pgtype.UUID `json:"id"`
|
||||
WorkspaceID pgtype.UUID `json:"workspace_id"`
|
||||
@@ -119,6 +250,7 @@ type GitCredential struct {
|
||||
Fingerprint *string `json:"fingerprint"`
|
||||
CreatedAt pgtype.Timestamptz `json:"created_at"`
|
||||
UpdatedAt pgtype.Timestamptz `json:"updated_at"`
|
||||
KeyVersion int16 `json:"key_version"`
|
||||
}
|
||||
|
||||
type Issue struct {
|
||||
@@ -135,6 +267,17 @@ type Issue struct {
|
||||
CreatedAt pgtype.Timestamptz `json:"created_at"`
|
||||
UpdatedAt pgtype.Timestamptz `json:"updated_at"`
|
||||
WorkspaceID pgtype.UUID `json:"workspace_id"`
|
||||
ParentID pgtype.UUID `json:"parent_id"`
|
||||
Priority int32 `json:"priority"`
|
||||
}
|
||||
|
||||
type IssueDependency struct {
|
||||
ID pgtype.UUID `json:"id"`
|
||||
ProjectID pgtype.UUID `json:"project_id"`
|
||||
BlockedID pgtype.UUID `json:"blocked_id"`
|
||||
BlockerID pgtype.UUID `json:"blocker_id"`
|
||||
CreatedBy pgtype.UUID `json:"created_by"`
|
||||
CreatedAt pgtype.Timestamptz `json:"created_at"`
|
||||
}
|
||||
|
||||
type Job struct {
|
||||
@@ -162,6 +305,7 @@ type LlmEndpoint struct {
|
||||
CreatedBy pgtype.UUID `json:"created_by"`
|
||||
CreatedAt pgtype.Timestamptz `json:"created_at"`
|
||||
UpdatedAt pgtype.Timestamptz `json:"updated_at"`
|
||||
KeyVersion int16 `json:"key_version"`
|
||||
}
|
||||
|
||||
type LlmModel struct {
|
||||
@@ -252,6 +396,50 @@ type Notification struct {
|
||||
CreatedAt pgtype.Timestamptz `json:"created_at"`
|
||||
}
|
||||
|
||||
type NotificationPref struct {
|
||||
UserID pgtype.UUID `json:"user_id"`
|
||||
EmailEnabled bool `json:"email_enabled"`
|
||||
ImEnabled bool `json:"im_enabled"`
|
||||
ImWebhookUrlEnc []byte `json:"im_webhook_url_enc"`
|
||||
MinSeverity string `json:"min_severity"`
|
||||
UpdatedAt pgtype.Timestamptz `json:"updated_at"`
|
||||
}
|
||||
|
||||
type OrchestratorRun struct {
|
||||
ID pgtype.UUID `json:"id"`
|
||||
ProjectID pgtype.UUID `json:"project_id"`
|
||||
RequirementID pgtype.UUID `json:"requirement_id"`
|
||||
WorkspaceID pgtype.UUID `json:"workspace_id"`
|
||||
OwnerID pgtype.UUID `json:"owner_id"`
|
||||
Status string `json:"status"`
|
||||
CurrentPhase string `json:"current_phase"`
|
||||
Config []byte `json:"config"`
|
||||
LastError *string `json:"last_error"`
|
||||
CreatedAt pgtype.Timestamptz `json:"created_at"`
|
||||
UpdatedAt pgtype.Timestamptz `json:"updated_at"`
|
||||
EndedAt pgtype.Timestamptz `json:"ended_at"`
|
||||
}
|
||||
|
||||
type OrchestratorStep struct {
|
||||
ID pgtype.UUID `json:"id"`
|
||||
RunID pgtype.UUID `json:"run_id"`
|
||||
Phase string `json:"phase"`
|
||||
Attempt int32 `json:"attempt"`
|
||||
ParentStepID pgtype.UUID `json:"parent_step_id"`
|
||||
Depth int32 `json:"depth"`
|
||||
Status string `json:"status"`
|
||||
AgentKindID pgtype.UUID `json:"agent_kind_id"`
|
||||
AcpSessionID pgtype.UUID `json:"acp_session_id"`
|
||||
Prompt string `json:"prompt"`
|
||||
StopReason *string `json:"stop_reason"`
|
||||
GateResult []byte `json:"gate_result"`
|
||||
LastError *string `json:"last_error"`
|
||||
JobID pgtype.UUID `json:"job_id"`
|
||||
CreatedAt pgtype.Timestamptz `json:"created_at"`
|
||||
UpdatedAt pgtype.Timestamptz `json:"updated_at"`
|
||||
EndedAt pgtype.Timestamptz `json:"ended_at"`
|
||||
}
|
||||
|
||||
type Project struct {
|
||||
ID pgtype.UUID `json:"id"`
|
||||
Slug string `json:"slug"`
|
||||
@@ -264,6 +452,30 @@ type Project struct {
|
||||
UpdatedAt pgtype.Timestamptz `json:"updated_at"`
|
||||
}
|
||||
|
||||
type ProjectMember struct {
|
||||
ProjectID pgtype.UUID `json:"project_id"`
|
||||
UserID pgtype.UUID `json:"user_id"`
|
||||
Role string `json:"role"`
|
||||
CreatedAt pgtype.Timestamptz `json:"created_at"`
|
||||
AddedBy pgtype.UUID `json:"added_by"`
|
||||
}
|
||||
|
||||
type ProjectMemory struct {
|
||||
ID pgtype.UUID `json:"id"`
|
||||
ProjectID pgtype.UUID `json:"project_id"`
|
||||
WorkspaceID pgtype.UUID `json:"workspace_id"`
|
||||
Kind string `json:"kind"`
|
||||
Title string `json:"title"`
|
||||
Body string `json:"body"`
|
||||
Tags []string `json:"tags"`
|
||||
Source string `json:"source"`
|
||||
Embedding *pgvector.Vector `json:"embedding"`
|
||||
EmbeddingModel *string `json:"embedding_model"`
|
||||
CreatedBy pgtype.UUID `json:"created_by"`
|
||||
CreatedAt pgtype.Timestamptz `json:"created_at"`
|
||||
UpdatedAt pgtype.Timestamptz `json:"updated_at"`
|
||||
}
|
||||
|
||||
type PromptTemplate struct {
|
||||
ID pgtype.UUID `json:"id"`
|
||||
Name string `json:"name"`
|
||||
@@ -299,6 +511,16 @@ type RequirementArtifact struct {
|
||||
SourceMessageID *int64 `json:"source_message_id"`
|
||||
CreatedBy pgtype.UUID `json:"created_by"`
|
||||
CreatedAt pgtype.Timestamptz `json:"created_at"`
|
||||
Verdict string `json:"verdict"`
|
||||
}
|
||||
|
||||
type RequirementPhasePointer struct {
|
||||
RequirementID pgtype.UUID `json:"requirement_id"`
|
||||
Phase string `json:"phase"`
|
||||
ApprovedArtifactID pgtype.UUID `json:"approved_artifact_id"`
|
||||
ApprovedBy pgtype.UUID `json:"approved_by"`
|
||||
ApprovedAt pgtype.Timestamptz `json:"approved_at"`
|
||||
UpdatedAt pgtype.Timestamptz `json:"updated_at"`
|
||||
}
|
||||
|
||||
type User struct {
|
||||
@@ -354,6 +576,7 @@ type WorkspaceRunProfile struct {
|
||||
CreatedBy pgtype.UUID `json:"created_by"`
|
||||
CreatedAt pgtype.Timestamptz `json:"created_at"`
|
||||
UpdatedAt pgtype.Timestamptz `json:"updated_at"`
|
||||
KeyVersion int16 `json:"key_version"`
|
||||
}
|
||||
|
||||
type WorkspaceWorktree struct {
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
// Code generated by sqlc. DO NOT EDIT.
|
||||
// versions:
|
||||
// sqlc v1.31.1
|
||||
// source: prefs.sql
|
||||
|
||||
package notifysqlc
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/jackc/pgx/v5/pgtype"
|
||||
)
|
||||
|
||||
const getNotificationPrefs = `-- name: GetNotificationPrefs :one
|
||||
SELECT user_id, email_enabled, im_enabled, im_webhook_url_enc, min_severity, updated_at
|
||||
FROM notification_prefs
|
||||
WHERE user_id = $1
|
||||
`
|
||||
|
||||
func (q *Queries) GetNotificationPrefs(ctx context.Context, userID pgtype.UUID) (NotificationPref, error) {
|
||||
row := q.db.QueryRow(ctx, getNotificationPrefs, userID)
|
||||
var i NotificationPref
|
||||
err := row.Scan(
|
||||
&i.UserID,
|
||||
&i.EmailEnabled,
|
||||
&i.ImEnabled,
|
||||
&i.ImWebhookUrlEnc,
|
||||
&i.MinSeverity,
|
||||
&i.UpdatedAt,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
|
||||
const upsertNotificationPrefs = `-- name: UpsertNotificationPrefs :exec
|
||||
INSERT INTO notification_prefs (user_id, email_enabled, im_enabled, im_webhook_url_enc, min_severity, updated_at)
|
||||
VALUES ($1, $2, $3, $4, $5, now())
|
||||
ON CONFLICT (user_id) DO UPDATE SET
|
||||
email_enabled = EXCLUDED.email_enabled,
|
||||
im_enabled = EXCLUDED.im_enabled,
|
||||
im_webhook_url_enc = EXCLUDED.im_webhook_url_enc,
|
||||
min_severity = EXCLUDED.min_severity,
|
||||
updated_at = now()
|
||||
`
|
||||
|
||||
type UpsertNotificationPrefsParams struct {
|
||||
UserID pgtype.UUID `json:"user_id"`
|
||||
EmailEnabled bool `json:"email_enabled"`
|
||||
ImEnabled bool `json:"im_enabled"`
|
||||
ImWebhookUrlEnc []byte `json:"im_webhook_url_enc"`
|
||||
MinSeverity string `json:"min_severity"`
|
||||
}
|
||||
|
||||
func (q *Queries) UpsertNotificationPrefs(ctx context.Context, arg UpsertNotificationPrefsParams) error {
|
||||
_, err := q.db.Exec(ctx, upsertNotificationPrefs,
|
||||
arg.UserID,
|
||||
arg.EmailEnabled,
|
||||
arg.ImEnabled,
|
||||
arg.ImWebhookUrlEnc,
|
||||
arg.MinSeverity,
|
||||
)
|
||||
return err
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
// stream.go 实现通知 inbox 的实时推送(spec §11 步骤8)。
|
||||
//
|
||||
// StreamHub 是 per-user 的订阅扇出中心:每个浏览器标签页 Subscribe 一个有界缓冲
|
||||
// channel;Dispatcher 落库成功后 Publish 到该用户的全部订阅者。慢订阅者(缓冲满)
|
||||
// 直接丢弃该消息(drop-on-slow),绝不阻塞 Publish / Dispatch,避免 goroutine /
|
||||
// 内存泄漏(镜像 chat StreamerHub 的丢弃纪律)。
|
||||
package notify
|
||||
|
||||
import (
|
||||
"sync"
|
||||
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
// StreamHub 是 Dispatcher 推送实时通知所需的窄接口。
|
||||
type StreamHub interface {
|
||||
Subscribe(userID uuid.UUID) (<-chan Message, func())
|
||||
Publish(userID uuid.UUID, m Message)
|
||||
}
|
||||
|
||||
// streamSubBuffer 是单个订阅者的缓冲深度。满时新消息被丢弃(客户端可用 inbox
|
||||
// 列表接口补偿,与 chat WS onopen 主动拉取同理)。
|
||||
const streamSubBuffer = 16
|
||||
|
||||
// memHub 是 StreamHub 的进程内实现。
|
||||
type memHub struct {
|
||||
mu sync.Mutex
|
||||
subs map[uuid.UUID]map[int]chan Message
|
||||
next int
|
||||
}
|
||||
|
||||
// NewStreamHub 构造进程内的 per-user 通知推送中心。
|
||||
func NewStreamHub() StreamHub {
|
||||
return &memHub{subs: map[uuid.UUID]map[int]chan Message{}}
|
||||
}
|
||||
|
||||
// Subscribe 为某用户注册一个订阅者,返回只读 channel 与取消函数。取消函数幂等:
|
||||
// 关闭 channel 并从订阅表移除;调用方应在请求结束时 defer 调用。
|
||||
func (h *memHub) Subscribe(userID uuid.UUID) (<-chan Message, func()) {
|
||||
ch := make(chan Message, streamSubBuffer)
|
||||
h.mu.Lock()
|
||||
if h.subs[userID] == nil {
|
||||
h.subs[userID] = map[int]chan Message{}
|
||||
}
|
||||
id := h.next
|
||||
h.next++
|
||||
h.subs[userID][id] = ch
|
||||
h.mu.Unlock()
|
||||
|
||||
var once sync.Once
|
||||
cancel := func() {
|
||||
once.Do(func() {
|
||||
h.mu.Lock()
|
||||
if m, ok := h.subs[userID]; ok {
|
||||
if c, ok := m[id]; ok {
|
||||
delete(m, id)
|
||||
close(c)
|
||||
}
|
||||
if len(m) == 0 {
|
||||
delete(h.subs, userID)
|
||||
}
|
||||
}
|
||||
h.mu.Unlock()
|
||||
})
|
||||
}
|
||||
return ch, cancel
|
||||
}
|
||||
|
||||
// Publish 向某用户的全部订阅者非阻塞投递一条消息。缓冲满的订阅者被跳过。
|
||||
//
|
||||
// 关键:整个投递循环持有 h.mu。因为发送是非阻塞的(select default),持锁开销极小,
|
||||
// 但这让 Publish 与 cancel()(同样持 h.mu 后 close channel)互斥——否则快照 channel
|
||||
// 释放锁后、cancel 并发 close,会 panic "send on closed channel"。
|
||||
func (h *memHub) Publish(userID uuid.UUID, m Message) {
|
||||
h.mu.Lock()
|
||||
defer h.mu.Unlock()
|
||||
for _, c := range h.subs[userID] {
|
||||
select {
|
||||
case c <- m:
|
||||
default:
|
||||
// drop-on-slow:缓冲满,丢弃,避免阻塞 Dispatch。
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
package notify
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
func TestStreamHubSubscribeReceivesPublish(t *testing.T) {
|
||||
hub := NewStreamHub()
|
||||
uid := uuid.New()
|
||||
ch, cancel := hub.Subscribe(uid)
|
||||
defer cancel()
|
||||
|
||||
want := Message{ID: uuid.New(), UserID: uid, Topic: "t", Title: "hi"}
|
||||
hub.Publish(uid, want)
|
||||
|
||||
select {
|
||||
case got := <-ch:
|
||||
if got.ID != want.ID {
|
||||
t.Fatalf("got %v, want %v", got.ID, want.ID)
|
||||
}
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("did not receive published message")
|
||||
}
|
||||
}
|
||||
|
||||
func TestStreamHubUnsubscribeStopsDelivery(t *testing.T) {
|
||||
hub := NewStreamHub()
|
||||
uid := uuid.New()
|
||||
ch, cancel := hub.Subscribe(uid)
|
||||
cancel() // immediate unsubscribe closes the channel
|
||||
|
||||
// channel should be closed, draining returns zero value + !ok.
|
||||
if _, ok := <-ch; ok {
|
||||
t.Fatal("expected channel closed after cancel")
|
||||
}
|
||||
// Publishing to an unsubscribed user must not panic.
|
||||
hub.Publish(uid, Message{UserID: uid})
|
||||
}
|
||||
|
||||
func TestStreamHubDropsSlowSubscriber(t *testing.T) {
|
||||
hub := NewStreamHub()
|
||||
uid := uuid.New()
|
||||
ch, cancel := hub.Subscribe(uid)
|
||||
defer cancel()
|
||||
|
||||
// Flood beyond buffer; Publish must not block / deadlock.
|
||||
done := make(chan struct{})
|
||||
go func() {
|
||||
for i := 0; i < streamSubBuffer*4; i++ {
|
||||
hub.Publish(uid, Message{UserID: uid, Topic: "flood"})
|
||||
}
|
||||
close(done)
|
||||
}()
|
||||
select {
|
||||
case <-done:
|
||||
case <-time.After(2 * time.Second):
|
||||
t.Fatal("Publish deadlocked on slow subscriber")
|
||||
}
|
||||
// Buffer holds at most streamSubBuffer; the rest were dropped.
|
||||
count := 0
|
||||
for {
|
||||
select {
|
||||
case <-ch:
|
||||
count++
|
||||
default:
|
||||
if count > streamSubBuffer {
|
||||
t.Fatalf("buffered %d > cap %d (drop-on-slow violated)", count, streamSubBuffer)
|
||||
}
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestDispatcherPublishesAfterInsert(t *testing.T) {
|
||||
repo := &fakeRepo{}
|
||||
d := NewDispatcher(repo, newTestLogger())
|
||||
hub := NewStreamHub()
|
||||
d.SetStreamHub(hub)
|
||||
|
||||
uid := uuid.New()
|
||||
ch, cancel := hub.Subscribe(uid)
|
||||
defer cancel()
|
||||
|
||||
if err := d.Dispatch(context.Background(), Message{UserID: uid, Topic: "x", Title: "y"}); err != nil {
|
||||
t.Fatalf("dispatch: %v", err)
|
||||
}
|
||||
select {
|
||||
case got := <-ch:
|
||||
if got.Topic != "x" {
|
||||
t.Fatalf("topic = %q", got.Topic)
|
||||
}
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("hub did not receive dispatched message")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDispatcherNoPublishOnInsertFailure(t *testing.T) {
|
||||
repo := &fakeRepo{failNext: true}
|
||||
d := NewDispatcher(repo, newTestLogger())
|
||||
hub := NewStreamHub()
|
||||
d.SetStreamHub(hub)
|
||||
|
||||
uid := uuid.New()
|
||||
ch, cancel := hub.Subscribe(uid)
|
||||
defer cancel()
|
||||
|
||||
if err := d.Dispatch(context.Background(), Message{UserID: uid}); err == nil {
|
||||
t.Fatal("expected dispatch error on insert failure")
|
||||
}
|
||||
select {
|
||||
case <-ch:
|
||||
t.Fatal("hub received message despite insert failure")
|
||||
case <-time.After(100 * time.Millisecond):
|
||||
// expected: no publish.
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user