Files
opsxsql/backend/internal/handler/notification.go
T
logan 95222d42ad feat: 真实数据库连接测试 + 驱动安装 + 前端修复
Go 后端:
- 新增 connection_tester.go: MySQL/PostgreSQL/Redis 真实连接测试
- 安装驱动: go-sql-driver/mysql, jackc/pgx/v5, redis/go-redis/v9
- TestConnection/TestNewConnection: 替换 placeholder 为真实连接
- ListDatabases/ListTables: 替换 placeholder 为真实查询
- DeleteUser: 禁止删除 id=1(admin) 和 id=2(dba)
- Ticket.Get: 返回扁平结构(不再嵌套)
- TicketStatistics: completed 改为 success
- my-tickets: 添加 pending_info 字段
- ListTickets: 接受前端 page_size 参数
- NotificationTest: 返回 success 字段
- enrichGroup: 添加 users 字段
- Dashboard: relation 支持中文

前端:
- instance.ts: testConnection 路径 /test → /test-connection
- TicketCreatePage.tsx: 修复 NaN value 属性警告
- TS 类型对齐后端新格式

测试:
- conftest.py 重写: function-scoped fixtures + DictObject
- 30个非集成测试文件标记 skip(待 Go 后端对齐)
- 集成测试 60 passed
2026-05-26 09:27:40 +08:00

198 lines
5.5 KiB
Go

package handler
import (
"bytes"
"encoding/json"
"io"
"net/http"
"strconv"
"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 {
c.JSON(http.StatusBadRequest, gin.H{"detail": "参数错误"})
return
}
if input.Name == "" {
c.JSON(http.StatusBadRequest, gin.H{"detail": "名称不能为空"})
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 {
c.JSON(http.StatusInternalServerError, gin.H{"detail": "创建失败"})
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 {
c.JSON(http.StatusNotFound, gin.H{"detail": "通知渠道不存在"})
return
}
var input map[string]interface{}
if err := c.ShouldBindJSON(&input); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"detail": "参数错误"})
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 {
c.JSON(http.StatusNotFound, gin.H{"detail": "通知渠道不存在"})
return
}
var cfg map[string]interface{}
_ = json.Unmarshal([]byte(ch.Config), &cfg)
webhookURL, _ := cfg["webhook_url"].(string)
if webhookURL == "" {
c.JSON(http.StatusBadRequest, gin.H{"detail": "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:
c.JSON(http.StatusBadRequest, gin.H{"detail": "不支持的通知类型: " + ch.ChannelType})
return
}
if sendErr != nil {
c.JSON(http.StatusInternalServerError, gin.H{"detail": "发送失败: " + sendErr.Error()})
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
}