- 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
199 lines
5.2 KiB
Go
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
|
|
}
|