This commit is contained in:
2026-06-22 08:55:57 +08:00
parent dbb87823e8
commit 6ade6e8fa9
325 changed files with 41131 additions and 855 deletions
+11
View File
@@ -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) {
+91
View File
@@ -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 {
+141
View File
@@ -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}))
}
+131
View File
@@ -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,
}
}
+177
View File
@@ -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())
}
+107
View File
@@ -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)
}
+133
View File
@@ -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
}
}
+168
View File
@@ -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 }
+14
View File
@@ -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();
+240 -17
View File
@@ -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 {
+62
View File
@@ -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
}
+84
View File
@@ -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。
}
}
}
+120
View File
@@ -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.
}
}