// 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 } }