First Commit
ci / go (push) Waiting to run
ci / go-db (agent) (push) Waiting to run
ci / go-db (config) (push) Waiting to run
ci / go-db (db) (push) Waiting to run
ci / go-db (evidence) (push) Waiting to run
ci / go-db (llmrec) (push) Waiting to run
ci / go-db (server) (push) Waiting to run
web / web (push) Waiting to run
docs / links (push) Canceled after 0s
detections / detections (push) Canceled after 0s
ci / go (push) Waiting to run
ci / go-db (agent) (push) Waiting to run
ci / go-db (config) (push) Waiting to run
ci / go-db (db) (push) Waiting to run
ci / go-db (evidence) (push) Waiting to run
ci / go-db (llmrec) (push) Waiting to run
ci / go-db (server) (push) Waiting to run
web / web (push) Waiting to run
docs / links (push) Canceled after 0s
detections / detections (push) Canceled after 0s
This commit is contained in:
@@ -0,0 +1,542 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/Autumn-27/artex/db"
|
||||
"github.com/Autumn-27/artex/notify"
|
||||
)
|
||||
|
||||
// 本文件是推送功能的 HTTP 接口。全部路由挂在 requireAuth 之后(见 Handler()),
|
||||
// 与其它管理接口一致。
|
||||
|
||||
// notify_api.go 가 HTTP 응답으로 돌려주는 사용자 노출 오류 문구다. 한국어 UI 에서 알림
|
||||
// 설정 요청이 실패하면 이 문구가 그대로 토스트로 뜨므로 한국어로 둔다. 식별자(id)·JSON
|
||||
// 필드명·enum 값(realtime·digest)·채널 종류 키는 사용자가 설정을 고치는 데 쓰는 값이라
|
||||
// 원문 그대로 둔다. %s·%q 가 든 상수는 fmt.Sprintf 형식 문자열이다. 用語: 渠道→채널,
|
||||
// 通知推送→알림(크롬 nav "알림 발송"), 投递→전송, 推送模式→발송 모드. 로그·주석은 BRIEF
|
||||
// 방침상 최하위라 이 묶음 밖이다.
|
||||
const (
|
||||
notifyErrBadJSON = "요청 본문이 올바른 JSON 형식이 아닙니다: "
|
||||
notifyErrKindInvalidFmt = "채널 유형이 올바르지 않습니다. 가능한 값: %s"
|
||||
notifyErrNameMissing = "채널 이름을 입력하세요"
|
||||
notifyErrNameEmpty = "채널 이름은 비워 둘 수 없습니다"
|
||||
notifyErrModeInvalid = "발송 모드가 올바르지 않습니다. 가능한 값: realtime / digest"
|
||||
notifyErrRateNegative = "전송 제한 값은 음수일 수 없습니다"
|
||||
notifyErrChannelID = "채널 id 가 올바르지 않습니다"
|
||||
notifyErrKindUnregFmt = "채널 유형 %q 는 등록되어 있지 않습니다"
|
||||
notifyErrDeliveryID = "전송 id 가 올바르지 않습니다"
|
||||
notifyErrChannelNotFound = "알림 채널을 찾을 수 없습니다"
|
||||
)
|
||||
|
||||
// 채널 연결을 점검할 때 보내는 테스트 메시지 본문이다. 사용자가 등록한 채널로 실제 발송되므로
|
||||
// 한국어로 두되, 받는 사람이 실제 취약점으로 오해하지 않도록 한눈에 테스트임이 드러나게 한다.
|
||||
const (
|
||||
notifyTestName = "테스트 메시지 · 채널 설정 정상"
|
||||
notifyTestClass = "연결 테스트"
|
||||
notifyTestSummary = "ARTEX 알림 채널 테스트 메시지입니다. 이 메시지를 받으셨다면 채널 설정이 정상입니다."
|
||||
)
|
||||
|
||||
// notifyChannelDTO 是渠道的对外表述。
|
||||
//
|
||||
// Config 是**掩码后**的配置:凭据字段被替换成 notify.MaskedPrefix 开头的值。
|
||||
// 前端把掩码值原样提交回来即表示「这个字段没改」,服务端据此保留库中原值
|
||||
// (见 notify.MergeConfig)。
|
||||
type notifyChannelDTO struct {
|
||||
ID int64 `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Kind string `json:"kind"`
|
||||
Enabled bool `json:"enabled"`
|
||||
Mode string `json:"mode"`
|
||||
Config map[string]any `json:"config"`
|
||||
Filter notify.Filter `json:"filter"`
|
||||
RatePerMin int `json:"rate_per_min"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
// SecretKeys 告知前端哪些字段是凭据,据此渲染密码框与「留空即不改」的提示。
|
||||
// 由渠道自己声明(notify.Channel.SecretKeys),前端不硬编码渠道知识。
|
||||
SecretKeys []string `json:"secret_keys"`
|
||||
}
|
||||
|
||||
// notifyDeliveryDTO 是投递历史的对外表述。
|
||||
type notifyDeliveryDTO struct {
|
||||
ID int64 `json:"id"`
|
||||
FindingID int64 `json:"finding_id,string"`
|
||||
EventKind string `json:"event_kind"`
|
||||
ChannelID int64 `json:"channel_id"`
|
||||
ChannelName string `json:"channel_name"`
|
||||
ChannelKind string `json:"channel_kind"`
|
||||
State string `json:"state"`
|
||||
Attempts int `json:"attempts"`
|
||||
LastError string `json:"last_error"`
|
||||
BatchID *int64 `json:"batch_id,omitempty"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
SentAt *time.Time `json:"sent_at,omitempty"`
|
||||
NextAttempt time.Time `json:"next_attempt_at"`
|
||||
// 消息标题摘要,让历史列表不必展开就能看出这条推的是什么。
|
||||
Title string `json:"title"`
|
||||
Severity string `json:"severity"`
|
||||
}
|
||||
|
||||
func toNotifyChannelDTO(ch *db.NotificationChannel) notifyChannelDTO {
|
||||
var cfg map[string]any
|
||||
if len(ch.Config) > 0 {
|
||||
_ = json.Unmarshal(ch.Config, &cfg)
|
||||
}
|
||||
if cfg == nil {
|
||||
cfg = map[string]any{}
|
||||
}
|
||||
secrets := []string{}
|
||||
if c, ok := notify.Get(ch.Kind); ok {
|
||||
secrets = c.SecretKeys()
|
||||
}
|
||||
return notifyChannelDTO{
|
||||
ID: ch.ID,
|
||||
Name: ch.Name,
|
||||
Kind: ch.Kind,
|
||||
Enabled: ch.IsEnabled(),
|
||||
Mode: ch.Mode,
|
||||
Config: notify.MaskConfig(ch.Kind, cfg),
|
||||
Filter: notify.ParseFilter(ch.Filter),
|
||||
RatePerMin: ch.RatePerMin,
|
||||
CreatedAt: ch.CreatedAt,
|
||||
UpdatedAt: ch.UpdatedAt,
|
||||
SecretKeys: secrets,
|
||||
}
|
||||
}
|
||||
|
||||
func toNotifyDeliveryDTO(dl *db.NotificationDelivery) notifyDeliveryDTO {
|
||||
snap, _ := parseSnapshot(dl)
|
||||
dto := notifyDeliveryDTO{
|
||||
ID: dl.ID,
|
||||
FindingID: dl.FindingID,
|
||||
EventKind: dl.EventKind,
|
||||
ChannelID: dl.ChannelID,
|
||||
ChannelName: dl.ChannelName,
|
||||
ChannelKind: dl.ChannelKind,
|
||||
State: dl.State,
|
||||
Attempts: dl.Attempts,
|
||||
LastError: dl.LastError,
|
||||
BatchID: dl.BatchID,
|
||||
CreatedAt: dl.CreatedAt,
|
||||
SentAt: dl.SentAt,
|
||||
NextAttempt: dl.NextAttemptAt,
|
||||
Severity: snap.Severity,
|
||||
}
|
||||
if snap.Name != "" {
|
||||
dto.Title = snap.Name
|
||||
} else {
|
||||
dto.Title = snap.VulnClass
|
||||
}
|
||||
return dto
|
||||
}
|
||||
|
||||
// notifyMeta 返回通知页需要的静态元数据与全局设置,一次请求拿全,
|
||||
// 避免前端为了渲染一个下拉框发三次请求。
|
||||
func (s *Server) notifyMeta(w http.ResponseWriter, r *http.Request) {
|
||||
pg := s.pg(w)
|
||||
if pg == nil {
|
||||
return
|
||||
}
|
||||
kinds := make([]map[string]any, 0, len(notify.Kinds()))
|
||||
for _, k := range notify.Kinds() {
|
||||
ch, _ := notify.Get(k)
|
||||
kinds = append(kinds, map[string]any{
|
||||
"kind": k,
|
||||
"default_rate_per_min": ch.DefaultRatePerMin(),
|
||||
"secret_keys": ch.SecretKeys(),
|
||||
})
|
||||
}
|
||||
baseURL, _, _ := pg.GetSetting(settingNotifyPublicBaseURL)
|
||||
digest, _, _ := pg.GetSetting(settingNotifyDigestMinutes)
|
||||
stats, err := pg.NotificationStatsSnapshot(r.Context())
|
||||
if err != nil {
|
||||
writeErr(w, 500, err.Error())
|
||||
return
|
||||
}
|
||||
writeJSON(w, 200, map[string]any{
|
||||
"kinds": kinds,
|
||||
"enabled": pg.GetBool(settingNotifyEnabled, true),
|
||||
"public_base_url": baseURL,
|
||||
"digest_interval_min": digest,
|
||||
"defaults": map[string]any{
|
||||
"digest_interval_min": notifyDefaultDigestMinutes,
|
||||
},
|
||||
"stats": stats,
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Server) notifyListChannels(w http.ResponseWriter, r *http.Request) {
|
||||
pg := s.pg(w)
|
||||
if pg == nil {
|
||||
return
|
||||
}
|
||||
channels, err := pg.ListNotificationChannels(r.Context())
|
||||
if err != nil {
|
||||
writeErr(w, 500, err.Error())
|
||||
return
|
||||
}
|
||||
out := make([]notifyChannelDTO, 0, len(channels))
|
||||
for _, ch := range channels {
|
||||
out = append(out, toNotifyChannelDTO(ch))
|
||||
}
|
||||
writeJSON(w, 200, map[string]any{"channels": out})
|
||||
}
|
||||
|
||||
// notifyChannelRequest 是新建/更新渠道的请求体。
|
||||
//
|
||||
// 全部业务字段用指针,以便区分「没传」与「传了零值」:PATCH 语义下,
|
||||
// 没传的字段必须保留库中原值。
|
||||
type notifyChannelRequest struct {
|
||||
Name *string `json:"name"`
|
||||
Kind *string `json:"kind"`
|
||||
Enabled *bool `json:"enabled"`
|
||||
Mode *string `json:"mode"`
|
||||
Config map[string]any `json:"config"`
|
||||
Filter *notify.Filter `json:"filter"`
|
||||
RatePerMin *int `json:"rate_per_min"`
|
||||
}
|
||||
|
||||
func (s *Server) notifyCreateChannel(w http.ResponseWriter, r *http.Request) {
|
||||
pg := s.pg(w)
|
||||
if pg == nil {
|
||||
return
|
||||
}
|
||||
var req notifyChannelRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
writeErr(w, 400, notifyErrBadJSON+err.Error())
|
||||
return
|
||||
}
|
||||
if req.Kind == nil || !notify.ValidKind(*req.Kind) {
|
||||
writeErr(w, 400, fmt.Sprintf(notifyErrKindInvalidFmt, strings.Join(notify.Kinds(), " / ")))
|
||||
return
|
||||
}
|
||||
name := ""
|
||||
if req.Name != nil {
|
||||
name = strings.TrimSpace(*req.Name)
|
||||
}
|
||||
if name == "" {
|
||||
writeErr(w, 400, notifyErrNameMissing)
|
||||
return
|
||||
}
|
||||
channel, _ := notify.Get(*req.Kind)
|
||||
if err := channel.Validate(req.Config); err != nil {
|
||||
writeErr(w, 400, err.Error())
|
||||
return
|
||||
}
|
||||
ch := &db.NotificationChannel{
|
||||
Name: name,
|
||||
Kind: *req.Kind,
|
||||
Enabled: req.Enabled,
|
||||
Mode: db.NotifyModeRealtime,
|
||||
RatePerMin: channel.DefaultRatePerMin(),
|
||||
}
|
||||
if req.Mode != nil {
|
||||
if !db.ValidNotifyMode(*req.Mode) {
|
||||
writeErr(w, 400, notifyErrModeInvalid)
|
||||
return
|
||||
}
|
||||
ch.Mode = *req.Mode
|
||||
}
|
||||
if req.RatePerMin != nil {
|
||||
// 显式给值就照用——包括 0,它表示「不限流」,是合法配置。
|
||||
if *req.RatePerMin < 0 {
|
||||
writeErr(w, 400, notifyErrRateNegative)
|
||||
return
|
||||
}
|
||||
ch.RatePerMin = *req.RatePerMin
|
||||
}
|
||||
// 只有「字段缺省」才套用渠道默认值。默认值必须在这里决定而不是在 db 层:
|
||||
// 只有请求体能区分「没传这个字段」与「显式传了 0」,而两者的含义完全不同
|
||||
// (前者=用默认,后者=不限流)。db 层把 0 也当未指定,会让不限流配置不可达。
|
||||
if req.RatePerMin == nil {
|
||||
ch.RatePerMin = channel.DefaultRatePerMin()
|
||||
}
|
||||
if req.Filter != nil {
|
||||
// 写入时校验取值受限的过滤字段(如 min_severity)。详见 notify.Filter.Validate:
|
||||
// 门槛打错字会让过滤器静默失效变成全推,必须在入口拦掉。
|
||||
if err := req.Filter.Validate(); err != nil {
|
||||
writeErr(w, 400, err.Error())
|
||||
return
|
||||
}
|
||||
raw, _ := json.Marshal(req.Filter)
|
||||
ch.Filter = raw
|
||||
}
|
||||
rawCfg, _ := json.Marshal(req.Config)
|
||||
ch.Config = rawCfg
|
||||
|
||||
id, err := pg.SaveNotificationChannel(r.Context(), ch)
|
||||
if err != nil {
|
||||
writeErr(w, 500, err.Error())
|
||||
return
|
||||
}
|
||||
writeJSON(w, 200, map[string]any{"id": id})
|
||||
}
|
||||
|
||||
func (s *Server) notifyUpdateChannel(w http.ResponseWriter, r *http.Request) {
|
||||
pg := s.pg(w)
|
||||
if pg == nil {
|
||||
return
|
||||
}
|
||||
id, ok := pathInt(r, "id")
|
||||
if !ok {
|
||||
writeErr(w, 400, notifyErrChannelID)
|
||||
return
|
||||
}
|
||||
current, err := pg.NotificationChannelByID(r.Context(), id)
|
||||
if err != nil {
|
||||
notifyChannelLookupErr(w, err)
|
||||
return
|
||||
}
|
||||
var req notifyChannelRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
writeErr(w, 400, notifyErrBadJSON+err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
// kind 允许修改,但改类型意味着凭据字段整套替换,不能与旧配置合并。
|
||||
kind := current.Kind
|
||||
if req.Kind != nil {
|
||||
if !notify.ValidKind(*req.Kind) {
|
||||
writeErr(w, 400, fmt.Sprintf(notifyErrKindInvalidFmt, strings.Join(notify.Kinds(), " / ")))
|
||||
return
|
||||
}
|
||||
kind = *req.Kind
|
||||
}
|
||||
channel, _ := notify.Get(kind)
|
||||
|
||||
var stored map[string]any
|
||||
if kind == current.Kind {
|
||||
if len(current.Config) > 0 {
|
||||
_ = json.Unmarshal(current.Config, &stored)
|
||||
}
|
||||
}
|
||||
if stored == nil {
|
||||
stored = map[string]any{}
|
||||
}
|
||||
// 用 PrepareConfigUpdate 而不是裸的 MergeConfig:目标地址变更时必须让操作者
|
||||
// 对凭据字段重新表态,否则「只改地址、凭据沿用」会把库里的真凭据发到新地址。
|
||||
merged, err := notify.PrepareConfigUpdate(kind, stored, req.Config)
|
||||
if err != nil {
|
||||
writeErr(w, 400, err.Error())
|
||||
return
|
||||
}
|
||||
if err := channel.Validate(merged); err != nil {
|
||||
writeErr(w, 400, err.Error())
|
||||
return
|
||||
}
|
||||
rawCfg, _ := json.Marshal(merged)
|
||||
|
||||
ch := &db.NotificationChannel{
|
||||
ID: id,
|
||||
Name: current.Name,
|
||||
Kind: kind,
|
||||
Enabled: current.Enabled,
|
||||
Mode: current.Mode,
|
||||
Config: rawCfg,
|
||||
Filter: current.Filter,
|
||||
RatePerMin: current.RatePerMin,
|
||||
}
|
||||
if req.Name != nil {
|
||||
if ch.Name = strings.TrimSpace(*req.Name); ch.Name == "" {
|
||||
writeErr(w, 400, notifyErrNameEmpty)
|
||||
return
|
||||
}
|
||||
}
|
||||
if req.Enabled != nil {
|
||||
ch.Enabled = req.Enabled
|
||||
}
|
||||
if req.Mode != nil {
|
||||
if !db.ValidNotifyMode(*req.Mode) {
|
||||
writeErr(w, 400, notifyErrModeInvalid)
|
||||
return
|
||||
}
|
||||
ch.Mode = *req.Mode
|
||||
}
|
||||
if req.RatePerMin != nil {
|
||||
if *req.RatePerMin < 0 {
|
||||
writeErr(w, 400, notifyErrRateNegative)
|
||||
return
|
||||
}
|
||||
ch.RatePerMin = *req.RatePerMin
|
||||
}
|
||||
if req.Filter != nil {
|
||||
if err := req.Filter.Validate(); err != nil {
|
||||
writeErr(w, 400, err.Error())
|
||||
return
|
||||
}
|
||||
raw, _ := json.Marshal(req.Filter)
|
||||
ch.Filter = raw
|
||||
}
|
||||
|
||||
// 走 SetNotificationChannelEnabled 而非 SaveNotificationChannel 的路径,
|
||||
// 是为了让「停用」同时把存量待发投递标记为 skipped,避免重新启用时收到
|
||||
// 一批已过时的积压消息。
|
||||
enabledChanged := ch.Enabled != nil && current.Enabled != nil && *ch.Enabled != *current.Enabled
|
||||
if enabledChanged {
|
||||
// 先把配置更新落库(此时 enabled 用旧值,避免提前触发跳过逻辑),
|
||||
// 再单独切开关。两步之间没有并发窗口:本接口是唯一改这两个字段的入口。
|
||||
prev := ch.Enabled
|
||||
ch.Enabled = current.Enabled
|
||||
if _, err := pg.SaveNotificationChannel(r.Context(), ch); err != nil {
|
||||
writeErr(w, 500, err.Error())
|
||||
return
|
||||
}
|
||||
if err := pg.SetNotificationChannelEnabled(r.Context(), id, *prev); err != nil {
|
||||
writeErr(w, 500, err.Error())
|
||||
return
|
||||
}
|
||||
writeJSON(w, 200, map[string]any{"id": id})
|
||||
return
|
||||
}
|
||||
if _, err := pg.SaveNotificationChannel(r.Context(), ch); err != nil {
|
||||
writeErr(w, 500, err.Error())
|
||||
return
|
||||
}
|
||||
writeJSON(w, 200, map[string]any{"id": id})
|
||||
}
|
||||
|
||||
func (s *Server) notifyDeleteChannel(w http.ResponseWriter, r *http.Request) {
|
||||
pg := s.pg(w)
|
||||
if pg == nil {
|
||||
return
|
||||
}
|
||||
id, ok := pathInt(r, "id")
|
||||
if !ok {
|
||||
writeErr(w, 400, notifyErrChannelID)
|
||||
return
|
||||
}
|
||||
if err := pg.DeleteNotificationChannel(r.Context(), id); err != nil {
|
||||
notifyChannelLookupErr(w, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, 200, map[string]any{"ok": true})
|
||||
}
|
||||
|
||||
// notifyTestChannel 用当前保存的配置发一条测试消息。
|
||||
//
|
||||
// 直接调用渠道 Send 而不经投递队列:测试的目的是立刻告诉用户「这套配置能不能
|
||||
// 发出去」,走队列会把结果藏进投递历史,用户得再去翻一遍才知道成没成。
|
||||
// 因此本接口是**同步**的,超时上限由 notify 包的 HTTP 客户端决定(15 秒)。
|
||||
func (s *Server) notifyTestChannel(w http.ResponseWriter, r *http.Request) {
|
||||
pg := s.pg(w)
|
||||
if pg == nil {
|
||||
return
|
||||
}
|
||||
id, ok := pathInt(r, "id")
|
||||
if !ok {
|
||||
writeErr(w, 400, notifyErrChannelID)
|
||||
return
|
||||
}
|
||||
ch, err := pg.NotificationChannelByID(r.Context(), id)
|
||||
if err != nil {
|
||||
notifyChannelLookupErr(w, err)
|
||||
return
|
||||
}
|
||||
channel, ok := notify.Get(ch.Kind)
|
||||
if !ok {
|
||||
writeErr(w, 400, fmt.Sprintf(notifyErrKindUnregFmt, ch.Kind))
|
||||
return
|
||||
}
|
||||
var cfg map[string]any
|
||||
if len(ch.Config) > 0 {
|
||||
_ = json.Unmarshal(ch.Config, &cfg)
|
||||
}
|
||||
if err := channel.Validate(cfg); err != nil {
|
||||
writeErr(w, 400, err.Error())
|
||||
return
|
||||
}
|
||||
msg := notifyTestMessage(s.notifierBaseURL(pg))
|
||||
start := time.Now()
|
||||
// 测试消息只有一条,送达条数这里不需要(渠道长度上限对单条消息而言
|
||||
// 由截断兜底,不涉及分段)。
|
||||
if _, err := channel.Send(r.Context(), cfg, msg); err != nil {
|
||||
// 把渠道返回的原始错误如实回给用户——这是他们调试配置的唯一线索。
|
||||
writeErr(w, 502, err.Error())
|
||||
return
|
||||
}
|
||||
writeJSON(w, 200, map[string]any{
|
||||
"ok": true,
|
||||
"latency_ms": time.Since(start).Milliseconds(),
|
||||
})
|
||||
}
|
||||
|
||||
// notifyTestMessage 构造测试消息。刻意用一眼能看出是测试的内容:
|
||||
// 收到的人不应该把它误判成真实漏洞。
|
||||
func notifyTestMessage(baseURL string) notify.Message {
|
||||
return notify.Message{
|
||||
Items: []notify.Item{{
|
||||
FindingID: 0,
|
||||
Name: notifyTestName,
|
||||
VulnClass: notifyTestClass,
|
||||
Severity: "low",
|
||||
Summary: notifyTestSummary,
|
||||
Assets: []string{"artex.example.com"},
|
||||
DetailURL: baseURL,
|
||||
}},
|
||||
HomeURL: baseURL,
|
||||
}
|
||||
}
|
||||
|
||||
// notifierBaseURL 读回链用的外部地址。
|
||||
func (s *Server) notifierBaseURL(pg *db.DB) string {
|
||||
v, _, _ := pg.GetSetting(settingNotifyPublicBaseURL)
|
||||
return trimTrailingSlash(v)
|
||||
}
|
||||
|
||||
func (s *Server) notifyListDeliveries(w http.ResponseWriter, r *http.Request) {
|
||||
pg := s.pg(w)
|
||||
if pg == nil {
|
||||
return
|
||||
}
|
||||
f := db.NotificationDeliveryFilter{
|
||||
State: r.URL.Query().Get("state"),
|
||||
EventKind: r.URL.Query().Get("event_kind"),
|
||||
}
|
||||
if v := r.URL.Query().Get("channel_id"); v != "" {
|
||||
f.ChannelID = int64(atoiDefault(v, 0))
|
||||
}
|
||||
page := queryInt(r, "page", 1)
|
||||
pageSize := queryInt(r, "page_size", 50)
|
||||
items, total, err := pg.ListNotificationDeliveries(r.Context(), f, page, pageSize)
|
||||
if err != nil {
|
||||
writeErr(w, 500, err.Error())
|
||||
return
|
||||
}
|
||||
out := make([]notifyDeliveryDTO, 0, len(items))
|
||||
for _, dl := range items {
|
||||
out = append(out, toNotifyDeliveryDTO(dl))
|
||||
}
|
||||
writeJSON(w, 200, map[string]any{"deliveries": out, "total": total, "page": page, "page_size": pageSize})
|
||||
}
|
||||
|
||||
func (s *Server) notifyRetryDelivery(w http.ResponseWriter, r *http.Request) {
|
||||
pg := s.pg(w)
|
||||
if pg == nil {
|
||||
return
|
||||
}
|
||||
id, ok := pathInt(r, "id")
|
||||
if !ok {
|
||||
writeErr(w, 400, notifyErrDeliveryID)
|
||||
return
|
||||
}
|
||||
if err := pg.RetryNotificationDelivery(r.Context(), id); err != nil {
|
||||
writeErr(w, 400, err.Error())
|
||||
return
|
||||
}
|
||||
writeJSON(w, 200, map[string]any{"ok": true})
|
||||
}
|
||||
|
||||
// notifyChannelLookupErr 把「渠道不存在」翻译成 404,其余错误 500。
|
||||
func notifyChannelLookupErr(w http.ResponseWriter, err error) {
|
||||
if errors.Is(err, db.ErrNotificationChannelNotFound) {
|
||||
writeErr(w, 404, notifyErrChannelNotFound)
|
||||
return
|
||||
}
|
||||
writeErr(w, 500, err.Error())
|
||||
}
|
||||
Reference in New Issue
Block a user