Files
opsxsql/backend/internal/handler/notification.go
T
Logan.Li 16f1f7d65f feat(backend): unified response helpers, refresh token, security hardening
- new resp package (OK/Page/Fail/BadRequest/.../InternalErr): unified error
  envelope {detail, code?}, sanitized 500s (full error logged server-side,
  generic message to client - stops DB error leakage), pagination envelope
  {items,total,page,page_size}; migrate 200+ inline c.JSON error sites
- refresh token: 7d rotation tokens (SHA-256 hashed at rest), public
  POST /auth/refresh with single-use rotation, POST /auth/logout revokes
  all user refresh tokens (endpoint previously missing - frontend 404)
- security: gh-ost command line password masked in task status payload;
  Instance.MarshalJSON guarantees encrypted passwords never leave the API
  (removes reliance on per-handler manual masking); production (DEBUG=false)
  with default JWT secret now auto-generates and persists a random secret
- safe.Go recover wrapper for background goroutines (instance probe,
  gh-ost async)
- delete dead service layer (zero callers)
- fix pre-existing go vet unkeyed bson.E literals
2026-10-26 17:21:00 +08:00

199 lines
5.2 KiB
Go

package handler
import (
"bytes"
"encoding/json"
"io"
"net/http"
"strconv"
"sql-platform/internal/handler/resp"
"sql-platform/internal/model"
"github.com/gin-gonic/gin"
)
// ChannelResponse — config as JSON object for frontend
type ChannelResponse struct {
ID int `json:"id"`
Name string `json:"name"`
ChannelType string `json:"channel_type"`
Config map[string]interface{} `json:"config"`
IsEnabled bool `json:"is_enabled"`
CreatedAt string `json:"created_at"`
UpdatedAt string `json:"updated_at"`
}
func toChannelResponse(ch model.NotificationChannel) ChannelResponse {
var cfg map[string]interface{}
if ch.Config != "" {
_ = json.Unmarshal([]byte(ch.Config), &cfg)
}
if cfg == nil {
cfg = map[string]interface{}{}
}
enabled := ch.IsEnabled != nil && *ch.IsEnabled
return ChannelResponse{
ID: ch.ID,
Name: ch.Name,
ChannelType: ch.ChannelType,
Config: cfg,
IsEnabled: enabled,
CreatedAt: ch.CreatedAt.Format("2006-01-02T15:04:05Z"),
UpdatedAt: ch.UpdatedAt.Format("2006-01-02T15:04:05Z"),
}
}
// GET /api/v1/notifications
func ListNotifications(c *gin.Context) {
var channels []model.NotificationChannel
model.DB.Order("id").Find(&channels)
result := make([]ChannelResponse, 0, len(channels))
for _, ch := range channels {
result = append(result, toChannelResponse(ch))
}
c.JSON(http.StatusOK, result)
}
// POST /api/v1/notifications
func CreateNotification(c *gin.Context) {
var input struct {
Name string `json:"name"`
ChannelType string `json:"channel_type"`
Config map[string]interface{} `json:"config"`
IsEnabled bool `json:"is_enabled"`
}
if err := c.ShouldBindJSON(&input); err != nil {
resp.BadRequest(c, "参数错误")
return
}
if input.Name == "" {
resp.BadRequest(c, "名称不能为空")
return
}
cfgBytes, _ := json.Marshal(input.Config)
enabled := input.IsEnabled
ch := model.NotificationChannel{
Name: input.Name,
ChannelType: input.ChannelType,
Config: string(cfgBytes),
IsEnabled: &enabled,
}
if err := model.DB.Create(&ch).Error; err != nil {
resp.InternalMsg(c, "创建失败")
return
}
c.JSON(http.StatusOK, toChannelResponse(ch))
}
// PUT /api/v1/notifications/:id
func UpdateNotification(c *gin.Context) {
id, _ := strconv.Atoi(c.Param("id"))
var ch model.NotificationChannel
if err := model.DB.First(&ch, id).Error; err != nil {
resp.NotFound(c, "通知渠道不存在")
return
}
var input map[string]interface{}
if err := c.ShouldBindJSON(&input); err != nil {
resp.BadRequest(c, "参数错误")
return
}
if name, ok := input["name"].(string); ok {
ch.Name = name
}
if ct, ok := input["channel_type"].(string); ok {
ch.ChannelType = ct
}
if cfg, ok := input["config"].(map[string]interface{}); ok {
cfgBytes, _ := json.Marshal(cfg)
ch.Config = string(cfgBytes)
}
if enabled, ok := input["is_enabled"].(bool); ok {
ch.IsEnabled = &enabled
}
model.DB.Save(&ch)
c.JSON(http.StatusOK, toChannelResponse(ch))
}
// DELETE /api/v1/notifications/:id
func DeleteNotification(c *gin.Context) {
id, _ := strconv.Atoi(c.Param("id"))
model.DB.Delete(&model.NotificationChannel{}, id)
c.JSON(http.StatusOK, gin.H{"message": "已删除"})
}
// POST /api/v1/notifications/:id/test
func TestNotificationSend(c *gin.Context) {
id, _ := strconv.Atoi(c.Param("id"))
var ch model.NotificationChannel
if err := model.DB.First(&ch, id).Error; err != nil {
resp.NotFound(c, "通知渠道不存在")
return
}
var cfg map[string]interface{}
_ = json.Unmarshal([]byte(ch.Config), &cfg)
webhookURL, _ := cfg["webhook_url"].(string)
if webhookURL == "" {
resp.BadRequest(c, "Webhook URL 未配置")
return
}
title := "SQL审核平台 - 通知测试"
body := "这是一条测试消息,如果收到说明通知渠道配置正确。"
var sendErr error
switch ch.ChannelType {
case "webhook_feishu":
sendErr = sendFeishuWebhook(webhookURL, title, body)
case "dingtalk":
secret, _ := cfg["secret"].(string)
sendErr = sendDingtalkWebhook(webhookURL, secret, title, body)
default:
resp.BadRequest(c, "不支持的通知类型: "+ch.ChannelType)
return
}
if sendErr != nil {
resp.InternalErr(c, sendErr)
return
}
c.JSON(http.StatusOK, gin.H{"success": true, "message": "测试消息已发送"})
}
func sendFeishuWebhook(webhookURL, title, body string) error {
payload := map[string]interface{}{
"msg_type": "interactive",
"card": map[string]interface{}{
"elements": []map[string]interface{}{
{"tag": "markdown", "content": body},
},
},
}
return postJSON(webhookURL, payload)
}
func sendDingtalkWebhook(webhookURL, secret, title, body string) error {
dingBody := title + "\n\n" + body
payload := map[string]interface{}{
"msgtype": "markdown",
"markdown": map[string]string{
"title": title,
"text": dingBody,
},
}
return postJSON(webhookURL, payload)
}
func postJSON(url string, payload interface{}) error {
data, _ := json.Marshal(payload)
resp, err := http.Post(url, "application/json", bytes.NewReader(data))
if err != nil {
return err
}
defer resp.Body.Close()
io.Copy(io.Discard, resp.Body)
return nil
}